"use client"; import { useState, useEffect, useCallback, useMemo, Fragment } from "react"; import Link from "next/link"; 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; const MENTION_REGEX = /@([a-z0-9_-]{3,30})/gi; function renderContentWithMentions(content: string) { const parts = content.split(MENTION_REGEX); // split() with a capturing group interleaves [text, username, text, username, ...text] return parts.map((part, i) => i % 2 === 1 ? ( @{part} ) : ( {part} ) ); } function timeAgo(dateStr: string, t: ReturnType) { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return t("justNow"); if (mins < 60) return t("minutesAgo", { mins }); const hours = Math.floor(mins / 60); if (hours < 24) return t("hoursAgo", { hours }); return t("daysAgo", { days: Math.floor(hours / 24) }); } function CommentForm({ recipeId, parentId, onSubmit, placeholder, onCancel, }: { recipeId: string; parentId?: string; onSubmit: () => void; placeholder?: string; onCancel?: () => void; }) { const t = useTranslations("social"); const tCommon = useTranslations("common"); 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(t("commentFailed")); return; } setContent(""); onSubmit(); } finally { setSubmitting(false); } } return ( setContent(e.target.value)} placeholder={placeholder ?? t("commentPlaceholder")} rows={2} disabled={submitting} /> {submitting ? t("postingButton") : t("postButton")} {onCancel && {tCommon("cancel")}} ); } function CommentItem({ comment, childrenByParent, depth, currentUserId, recipeId, onRefresh, }: { comment: Comment; childrenByParent: Map; depth: number; currentUserId?: string; recipeId: string; onRefresh: () => void; }) { const [showReply, setShowReply] = useState(false); const isOwn = comment.userId === currentUserId; const t = useTranslations("social"); const tCommon = useTranslations("common"); 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(tCommon("deleted")); onRefresh(); } else toast.error(tCommon("deleteFailed")); } return ( {comment.userName.slice(0, 2).toUpperCase()} {comment.userName} {timeAgo(comment.createdAt, t)} {renderContentWithMentions(comment.content)} {currentUserId && ( setShowReply(!showReply)} className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1" > {t("replyButton")} )} {currentUserId && !isOwn && ( )} {isOwn && ( {tCommon("delete")} )} {showReply && ( { setShowReply(false); onRefresh(); }} placeholder={t("replyPlaceholder")} onCancel={() => setShowReply(false)} /> )} {replies.length > 0 && ( {replies.map((reply) => ( ))} )} ); } export function CommentsSection({ recipeId, currentUserId, }: { recipeId: string; currentUserId?: string; }) { const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); const t = useTranslations("social"); const tCommon = useTranslations("common"); 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(); 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 ( {t("commentsTitle")} {!loading && ({comments.length})} {currentUserId && ( )} {loading ? ( {tCommon("loading")} ) : topLevel.length === 0 ? ( {t("noCommentsYet")} ) : ( {topLevel.map((comment, i) => ( {i > 0 && } ))} )} ); }
{renderContentWithMentions(comment.content)}
{tCommon("loading")}
{t("noCommentsYet")}