"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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; 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 COMMENTS_PAGE_SIZE = 20; type CommentsResponse = { data: Comment[]; total: number; limit: number; offset: number; }; 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 [confirmOpen, setConfirmOpen] = 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 && ( setConfirmOpen(true)} className="text-xs text-muted-foreground hover:text-destructive flex items-center gap-1" > {tCommon("delete")} )} {t("deleteCommentTitle")} {t("deleteCommentDescription")} {tCommon("cancel")} { void deleteComment(); }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {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 [loadingMore, setLoadingMore] = useState(false); const [topLevelOffset, setTopLevelOffset] = useState(0); const [topLevelTotal, setTopLevelTotal] = useState(0); const t = useTranslations("social"); const tCommon = useTranslations("common"); // Full reload from the first page — used on mount and after any mutation (post/reply/delete) // so the thread stays consistent rather than trying to patch pagination state in place. const load = useCallback(async () => { const res = await fetch(`/api/v1/recipes/${recipeId}/comments?limit=${COMMENTS_PAGE_SIZE}&offset=0`); if (res.ok) { const json = await res.json() as CommentsResponse; setComments(json.data); setTopLevelTotal(json.total); setTopLevelOffset(json.data.filter((c) => !c.parentId).length); } setLoading(false); }, [recipeId]); const loadMore = useCallback(async () => { setLoadingMore(true); try { const res = await fetch(`/api/v1/recipes/${recipeId}/comments?limit=${COMMENTS_PAGE_SIZE}&offset=${topLevelOffset}`); if (res.ok) { const json = await res.json() as CommentsResponse; setComments((prev) => [...prev, ...json.data]); setTopLevelTotal(json.total); setTopLevelOffset((prev) => prev + json.data.filter((c) => !c.parentId).length); } } finally { setLoadingMore(false); } }, [recipeId, topLevelOffset]); 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 && } ))} {topLevelOffset < topLevelTotal && ( void loadMore()} disabled={loadingMore}> {loadingMore ? t("loadingMoreComments") : t("loadMoreComments")} )} )} ); }
{renderContentWithMentions(comment.content)}
{tCommon("loading")}
{t("noCommentsYet")}