"use client"; import { useState, useEffect, useCallback, useMemo, useRef, 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"; import { MENTION_REGEX } from "@/lib/mentions"; type MentionCandidate = { id: string; name: string; username: string | null; avatarUrl: string | null }; // Matches an in-progress "@partial" token ending at the cursor — e.g. typing // "hey @al" mid-comment matches "@al", but "hey @al there" (cursor after // "there") doesn't, since the mention token no longer ends at the cursor. const MENTION_TRIGGER_REGEX = /@([a-z0-9_-]{0,20})$/i; 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; 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); const textareaRef = useRef(null); const mentionDebounceRef = useRef | null>(null); // Where the "@" of the mention currently being typed sits in `content` — // needed to splice in the chosen username without disturbing the rest of // the text, since the cursor may have moved by the time a fetch resolves. const mentionStartRef = useRef(null); const [mentionCandidates, setMentionCandidates] = useState([]); const [mentionActiveIndex, setMentionActiveIndex] = useState(0); const mentionOpen = mentionCandidates.length > 0; 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(""); closeMentions(); onSubmit(); } finally { setSubmitting(false); } } function closeMentions() { setMentionCandidates([]); setMentionActiveIndex(0); mentionStartRef.current = null; if (mentionDebounceRef.current) clearTimeout(mentionDebounceRef.current); } function handleContentChange(e: React.ChangeEvent) { const value = e.target.value; const cursor = e.target.selectionStart; setContent(value); const beforeCursor = value.slice(0, cursor); const match = MENTION_TRIGGER_REGEX.exec(beforeCursor); if (!match) { closeMentions(); return; } const partial = match[1] ?? ""; mentionStartRef.current = cursor - match[0].length; if (mentionDebounceRef.current) clearTimeout(mentionDebounceRef.current); if (partial.length < 2) { setMentionCandidates([]); return; } mentionDebounceRef.current = setTimeout(async () => { try { const res = await fetch(`/api/v1/users/search?q=${encodeURIComponent(partial)}`); if (!res.ok) return; const data = (await res.json()) as { users: MentionCandidate[] }; setMentionCandidates(data.users); setMentionActiveIndex(0); } catch { // Silently drop — a failed autocomplete fetch shouldn't interrupt typing. } }, 250); } function selectMention(candidate: MentionCandidate) { const start = mentionStartRef.current; const textarea = textareaRef.current; if (start === null || !candidate.username || !textarea) { closeMentions(); return; } const cursor = textarea.selectionStart; const next = `${content.slice(0, start)}@${candidate.username} ${content.slice(cursor)}`; setContent(next); closeMentions(); const caretPos = start + candidate.username.length + 2; requestAnimationFrame(() => { textarea.focus(); textarea.setSelectionRange(caretPos, caretPos); }); } function handleKeyDown(e: React.KeyboardEvent) { if (!mentionOpen) return; if (e.key === "ArrowDown") { e.preventDefault(); setMentionActiveIndex((i) => (i + 1) % mentionCandidates.length); } else if (e.key === "ArrowUp") { e.preventDefault(); setMentionActiveIndex((i) => (i - 1 + mentionCandidates.length) % mentionCandidates.length); } else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); selectMention(mentionCandidates[mentionActiveIndex]!); } else if (e.key === "Escape") { e.preventDefault(); closeMentions(); } } return ( setTimeout(closeMentions, 150)} placeholder={placeholder ?? t("commentPlaceholder")} rows={2} disabled={submitting} /> {mentionOpen && ( {mentionCandidates.map((candidate, i) => ( { e.preventDefault(); selectMention(candidate); }} className={cn( "w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm", i === mentionActiveIndex ? "bg-accent" : "hover:bg-accent/50" )} > {candidate.avatarUrl && } {candidate.name.slice(0, 2).toUpperCase()} {candidate.name} @{candidate.username} ))} )} {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")}