Files
Epicure/apps/web/components/social/comments-section.tsx
T
Arnaud 57c29f62b4 feat: user blocking and content reporting/flagging
- user_blocks table (composite PK), Block/Unblock button on profiles.
  Blocking severs any existing follow relationship both ways and
  prevents the blocked party from following, commenting on the
  blocker's recipes, or the blocker from seeing their comments.
- reports table (recipe/comment/user targets, pending/reviewed/
  dismissed status). ReportButton on comments, admin review queue at
  /admin/reports with dismiss/mark-reviewed actions, audit-logged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:09:14 +02:00

253 lines
7.8 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { MessageCircle, Reply, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { CommentReactions } from "@/components/social/comment-reactions";
import { ReportButton } from "@/components/social/report-button";
import { cn } from "@/lib/utils";
type Comment = {
id: string;
content: string;
parentId: string | null;
createdAt: string;
userId: string;
userName: string;
userUsername: string | null;
userAvatarUrl: string | null;
};
const MAX_VISUAL_INDENT = 4;
function timeAgo(dateStr: string) {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function CommentForm({
recipeId,
parentId,
onSubmit,
placeholder = "Add a comment…",
onCancel,
}: {
recipeId: string;
parentId?: string;
onSubmit: () => void;
placeholder?: string;
onCancel?: () => void;
}) {
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
async function submit() {
if (!content.trim()) return;
setSubmitting(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: content.trim(), parentId }),
});
if (!res.ok) { toast.error("Failed to post comment"); return; }
setContent("");
onSubmit();
} finally {
setSubmitting(false);
}
}
return (
<div className="space-y-2">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder}
rows={2}
disabled={submitting}
/>
<div className="flex gap-2">
<Button size="sm" onClick={submit} disabled={!content.trim() || submitting}>
{submitting ? "Posting…" : "Post"}
</Button>
{onCancel && <Button size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>}
</div>
</div>
);
}
function CommentItem({
comment,
childrenByParent,
depth,
currentUserId,
recipeId,
onRefresh,
}: {
comment: Comment;
childrenByParent: Map<string, Comment[]>;
depth: number;
currentUserId?: string;
recipeId: string;
onRefresh: () => void;
}) {
const [showReply, setShowReply] = useState(false);
const isOwn = comment.userId === currentUserId;
const t = useTranslations("social");
const replies = childrenByParent.get(comment.id) ?? [];
const indented = depth > 0 && depth <= MAX_VISUAL_INDENT;
async function deleteComment() {
const res = await fetch(`/api/v1/comments/${comment.id}`, { method: "DELETE" });
if (res.ok) { toast.success("Deleted"); onRefresh(); }
else toast.error("Failed to delete");
}
return (
<div className={cn("space-y-3", indented && "ml-10 border-l pl-4")}>
<div className="flex gap-3">
<Avatar className={cn("shrink-0 mt-0.5", depth === 0 ? "h-7 w-7" : "h-6 w-6")}>
<AvatarImage src={comment.userAvatarUrl ?? ""} />
<AvatarFallback className="text-xs">{comment.userName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-baseline gap-2">
<span className="font-medium text-sm">{comment.userName}</span>
<span className="text-xs text-muted-foreground">{timeAgo(comment.createdAt)}</span>
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{comment.content}</p>
<CommentReactions recipeId={recipeId} commentId={comment.id} initialCounts={{}} initialUserReactions={[]} />
<div className="flex items-center gap-2">
{currentUserId && (
<button
onClick={() => setShowReply(!showReply)}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Reply className="h-3 w-3" /> Reply
</button>
)}
{currentUserId && !isOwn && (
<ReportButton targetType="comment" targetId={comment.id} />
)}
{isOwn && (
<button
onClick={deleteComment}
className="text-xs text-muted-foreground hover:text-destructive flex items-center gap-1"
>
<Trash2 className="h-3 w-3" /> Delete
</button>
)}
</div>
{showReply && (
<CommentForm
recipeId={recipeId}
parentId={comment.id}
onSubmit={() => { setShowReply(false); onRefresh(); }}
placeholder={t("replyPlaceholder")}
onCancel={() => setShowReply(false)}
/>
)}
</div>
</div>
{replies.length > 0 && (
<div className="space-y-3">
{replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
childrenByParent={childrenByParent}
depth={depth + 1}
currentUserId={currentUserId}
recipeId={recipeId}
onRefresh={onRefresh}
/>
))}
</div>
)}
</div>
);
}
export function CommentsSection({
recipeId,
currentUserId,
}: {
recipeId: string;
currentUserId?: string;
}) {
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const t = useTranslations("social");
const load = useCallback(async () => {
const res = await fetch(`/api/v1/recipes/${recipeId}/comments`);
if (res.ok) setComments(await res.json() as Comment[]);
setLoading(false);
}, [recipeId]);
useEffect(() => { void load(); }, [load]);
const { topLevel, childrenByParent } = useMemo(() => {
const byParent = new Map<string, Comment[]>();
const top: Comment[] = [];
for (const c of comments) {
if (!c.parentId) {
top.push(c);
continue;
}
const siblings = byParent.get(c.parentId) ?? [];
siblings.push(c);
byParent.set(c.parentId, siblings);
}
return { topLevel: top, childrenByParent: byParent };
}, [comments]);
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
<h2 className="text-xl font-semibold">Comments</h2>
{!loading && <span className="text-muted-foreground text-sm">({comments.length})</span>}
</div>
{currentUserId && (
<CommentForm recipeId={recipeId} onSubmit={load} placeholder={t("commentPlaceholder")} />
)}
{loading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : topLevel.length === 0 ? (
<p className="text-sm text-muted-foreground">No comments yet. Be the first!</p>
) : (
<div className="space-y-6">
{topLevel.map((comment, i) => (
<div key={comment.id}>
{i > 0 && <Separator className="mb-6" />}
<CommentItem
comment={comment}
childrenByParent={childrenByParent}
depth={0}
currentUserId={currentUserId}
recipeId={recipeId}
onRefresh={load}
/>
</div>
))}
</div>
)}
</div>
);
}