"use client"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, EllipsisVerticalIcon, HeartIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react"; import { toast } from "sonner"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"; import { cn } from "@/lib/utils"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { CommentSkeleton, CommentThreadSkeleton } from "./comment-skeleton"; import { Separator } from "@/components/ui/separator"; import type { CommentHistoryItem, CommentImage, CommentItem, CommentViewer } from "@/lib/comments/types"; import { CommentImageView } from "./comment-image"; import { CommentComposer } from "./comment-composer"; import { commentRequest, commentSignInHref } from "./client"; import { useCommentEvents } from "./use-comment-events"; import { useCommentFeed } from "./use-comment-feed"; // A single minute clock for all timestamps, with a stable SSR snapshot. let clockTime = 0; let clockTimer: ReturnType | undefined; const clockListeners = new Set<() => void>(); function subscribeClock(listener: () => void) { clockListeners.add(listener); clockTime = Date.now(); if (!clockTimer) clockTimer = setInterval(() => { clockTime = Date.now(); for (const notify of clockListeners) notify(); }, 60_000); return () => { clockListeners.delete(listener); if (!clockListeners.size) { clearInterval(clockTimer); clockTimer = undefined; } }; } const clockSnapshot = () => clockTime; const serverClockSnapshot = () => 0; function CommentTimestamp({ value }: { value: string }) { const now = useSyncExternalStore(subscribeClock, clockSnapshot, serverClockSnapshot); function relativeTime() { const seconds = Math.round((new Date(value).getTime() - now) / 1000); const absolute = Math.abs(seconds); if (absolute < 60) return "เมื่อสักครู่"; const formatter = new Intl.RelativeTimeFormat("th-TH", { numeric: "auto" }); const units: [number, Intl.RelativeTimeFormatUnit][] = [[31536000, "year"], [2592000, "month"], [86400, "day"], [3600, "hour"], [60, "minute"]]; const [size, unit] = units.find(([size]) => absolute >= size)!; return formatter.format(Math.round(seconds / size), unit); } return ; } export function CommentImages({ images }: { images: CommentImage[] }) { const [selectedIndex, setSelectedIndex] = useState(null); const selected = selectedIndex === null ? null : images[selectedIndex]; return images.length ? <>
{images.map((image, index) => )}
{ if (!open) setSelectedIndex(null); }}> { if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); setSelectedIndex((current) => Math.max(0, Math.min(images.length - 1, (current ?? 0) + (event.key === "ArrowLeft" ? -1 : 1)))); } }}> รูปภาพประกอบความคิดเห็นรูปที่ {(selectedIndex ?? 0) + 1} จาก {images.length} {selected && <> {images.length > 1 &&
} }
: null; } function CommentText({ text }: { text: string }) { return

{text.split(/(https?:\/\/[^\s<>]+)/g).map((part, i) => /^https?:\/\//.test(part) ? {part} : part)}

; } function RevisionHistory({ id }: { id: string }) { const [items, setItems] = useState([]); const [cursor, setCursor] = useState(null); const [error, setError] = useState(""); const [busy, setBusy] = useState(true); const load = useCallback(async (next?: string) => { setBusy(true); try { const result = await commentRequest<{ items: CommentHistoryItem[]; nextCursor: string | null }>(`/api/comments/${id}/history${next ? `?cursor=${next}` : ""}`); setItems((current) => next ? [...current, ...result.items] : result.items); setCursor(result.nextCursor); setError(""); } catch (cause) { setError(cause instanceof Error ? cause.message : "ไม่สามารถโหลดประวัติการแก้ไขได้"); } finally { setBusy(false); } }, [id]); useEffect(() => { void Promise.resolve().then(() => load()); }, [load]); return
{error && {error}} {items.map((revision) =>

เวอร์ชัน {revision.version} · {new Date(revision.createdAt).toLocaleString("th-TH")}

)} {busy && } {cursor && } {error && }
; } export function CommentCard({ item, viewer, onChange, revision = 0, inbox = false, moderationDetails = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false }: { item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise; revision?: number; inbox?: boolean; moderationDetails?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean; }) { const router = useRouter(); const fullWidthMobile = inbox || moderationDetails; const isMobileInbox = () => inbox && window.matchMedia("(max-width: 639px)").matches; const [mode, setMode] = useState<"reply" | "edit" | null>(null); const [expanded, setExpanded] = useState(defaultExpanded); const [hasOpenedReplies, setHasOpenedReplies] = useState(defaultExpanded); function expandReplies(open: boolean) { setExpanded(open); if (open) setHasOpenedReplies(true); } const replyToggleRef = useRef(null); const [history, setHistory] = useState(false); const [deleting, setDeleting] = useState(false); const [pending, setPending] = useState(false); const [thread, setThread] = useState(false); const [reactionOverride, setReactionOverride] = useState<{ reaction: number; likes: number; hearted: boolean } | null>(null); const optimistic = reactionOverride ?? { reaction: item.reaction, likes: item.likes, hearted: item.hearted }; const reactionQueue = useRef>(Promise.resolve()); const reactionSequence = useRef(0); const queuedReactions = useRef(0); const baseReaction = useRef({ reaction: item.reaction, likes: item.likes, hearted: item.hearted }); const desired = useRef({ reaction: item.reaction, likes: item.likes, hearted: item.hearted }); const confirmedReaction = useRef({ reaction: item.reaction, likes: item.likes, hearted: item.hearted }); useEffect(() => { baseReaction.current = { reaction: item.reaction, likes: item.likes, hearted: item.hearted }; if (!queuedReactions.current && (!reactionOverride || (item.reaction === reactionOverride.reaction && item.hearted === reactionOverride.hearted))) { desired.current = baseReaction.current; confirmedReaction.current = baseReaction.current; if (reactionOverride) void Promise.resolve().then(() => setReactionOverride((current) => current === reactionOverride ? null : current)); } }, [item.reaction, item.likes, item.hearted, reactionOverride]); async function mutate(action: "reaction" | "heart" | "moderation" | "delete", value?: number | boolean) { if (!viewer) return router.push(commentSignInHref()); if (action === "reaction" || action === "heart") { const sequence = ++reactionSequence.current; const next = action === "heart" ? { ...desired.current, hearted: Boolean(value) } : { ...desired.current, reaction: Number(value), likes: Math.max(0, desired.current.likes + (value === 1 ? 1 : 0) - (desired.current.reaction === 1 ? 1 : 0)), }; desired.current = next; setReactionOverride(next); queuedReactions.current++; const save = reactionQueue.current.then(async () => { await commentRequest(`/api/comments/${item.id}/${action}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(action === "reaction" ? { value } : { hearted: value }), }); confirmedReaction.current = action === "heart" ? { ...confirmedReaction.current, hearted: Boolean(value) } : { ...confirmedReaction.current, reaction: Number(value), likes: Math.max(0, confirmedReaction.current.likes + (value === 1 ? 1 : 0) - (confirmedReaction.current.reaction === 1 ? 1 : 0)), }; await onChange(); }); reactionQueue.current = save.catch(() => undefined); try { await save; } catch (cause) { if (sequence === reactionSequence.current) { desired.current = confirmedReaction.current; setReactionOverride(confirmedReaction.current); } toast.error(cause instanceof Error ? cause.message : "ไม่สามารถบันทึกได้ กรุณาลองใหม่"); } finally { queuedReactions.current--; if (!queuedReactions.current && sequence === reactionSequence.current) { const saved = confirmedReaction.current; desired.current = saved; setReactionOverride(baseReaction.current.reaction === saved.reaction && baseReaction.current.hearted === saved.hearted ? null : saved); } } return; } setPending(true); try { await commentRequest(`/api/comments/${item.id}${action === "delete" ? "" : `/${action}`}`, { method: action === "delete" ? "DELETE" : "PUT", ...(action === "delete" ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ hidden: value }) }), }); setDeleting(false); onChange(); } catch (cause) { toast.error(cause instanceof Error ? cause.message : "ไม่สามารถอัปเดตความคิดเห็นได้"); } finally { setPending(false); } } function compose(next: "reply" | "edit") { if (!viewer) return router.push(commentSignInHref()); if (next === "reply" && isMobileInbox()) { setThread(true); return; } setMode(next); } const canReadHistory = (!item.deleted && !item.hidden) || Boolean(viewer?.admin); return
{ if (isMobileInbox() && event.currentTarget.contains(event.target as Node) && !(event.target as Element).closest("a, button, input, textarea, [role='button']")) setThread(true); }} id={`comment-${item.id}`} tabIndex={highlighted ? -1 : undefined} className={cn("flex scroll-mt-36 flex-col gap-2 py-2", inbox && "border-b pb-5", highlighted && "rounded-lg bg-accent ring-2 ring-ring", expanded && !item.rootId && "relative before:absolute before:bottom-8 before:left-5 before:top-12 before:border-l before:border-border", fullWidthMobile && "max-sm:before:hidden")}> {inbox && {item.targetImage && <> {/* eslint-disable-next-line @next/next/no-img-element */} {`ภาพ } {item.target.startsWith("stygian:") && {item.targetName}} }
{item.authorName.slice(0, 2)}
{item.authorName}{item.authorAdmin && ผู้ดูแล} {inbox && item.rootId && ตอบกลับ} {(inbox || moderationDetails) && item.hidden && {item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"}} {(inbox || moderationDetails) && item.deleted && ลบแล้ว} {item.version > 1 && canReadHistory && }
{(item.canEdit || viewer?.admin) && }> {item.canEdit && <> compose("edit")}>แก้ไข setDeleting(true)}>ลบ} {viewer?.admin && void mutate("moderation", !item.ownHidden)}>{item.ownHidden ? "แสดงอีกครั้ง" : "ซ่อน"}} }
{item.replyToName &&

ตอบกลับ {item.replyToName}

} {(item.deleted || item.hidden) && !viewer?.admin ?

{item.deleted ? "ความคิดเห็นถูกลบแล้ว" : "ความคิดเห็นถูกซ่อนโดยผู้ดูแล"}

: }
{item.canInteract && } {viewer?.admin ? : optimistic.hearted && !item.deleted && !item.hidden && ผู้ดูแลถูกใจ} {inbox && }
{mode && setMode(null)} onSaved={() => { setMode(null); expandReplies(true); onChange(); }} />} {replies && !item.rootId && (item.replyCount > 0 || expanded) && { if (isMobileInbox()) setThread(true); else expandReplies(value.includes("replies")); }}> {`การตอบกลับ ${item.replyCount.toLocaleString("th-TH")} รายการ`} {hasOpenedReplies &&
}
}
ประวัติการแก้ไขดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น {history && } ลบความคิดเห็นนี้?ความคิดเห็นนี้ คำตอบกลับทุกระดับ ประวัติการแก้ไข และรูปภาพจะถูกลบอย่างถาวร ไม่สามารถกู้คืนได้
การสนทนาความคิดเห็นต้นทางและคำตอบกลับ เรียงจากเก่าไปใหม่ {thread && }
; } function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDetails = false }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise; moderationDetails?: boolean }) { const feed = useCommentFeed(`/api/comments/${rootId}/replies?view=thread`); const { refresh, busy, error, page, more } = feed; const previousRevision = useRef(revision); useEffect(() => { if (previousRevision.current !== revision) { previousRevision.current = revision; void refresh({ silent: true }); } }, [revision, refresh]); const focused = useRef(null); useEffect(() => { if (!focusReplyId || focused.current === focusReplyId || busy || error) return; if (page.items.some((item) => item.id === focusReplyId)) { focused.current = focusReplyId; requestAnimationFrame(() => { const reply = document.getElementById(`comment-${focusReplyId}`); reply?.scrollIntoView({ block: "center" }); reply?.focus({ preventScroll: true }); }); } else if (page.nextCursor) void more(); }, [focusReplyId, busy, error, page, more]); return
{feed.error && {feed.error}} { await refresh({ silent: true }); await onChange(); }} /> {feed.busy && } {feed.page.nextCursor && } {feed.error && }
; } function CommentReplyTree({ items, viewer, onChange, focusReplyId, moderationDetails = false }: { focusReplyId?: string; items: CommentItem[]; viewer: CommentViewer | null; onChange: () => void | Promise; moderationDetails?: boolean }) { const loaded = new Set(items.map((item) => item.id)); const groups = new Map(); for (const item of items) { const parent = item.replyToId && item.replyToId !== item.rootId && loaded.has(item.replyToId) ? item.replyToId : null; const siblings = groups.get(parent) ?? []; siblings.push(item); groups.set(parent, siblings); } function branch(parent: string | null): React.ReactNode { const siblings = groups.get(parent) ?? []; return siblings.map((reply, index) =>
{groups.has(reply.id) &&
{branch(reply.id)}
}
); } return
{branch(null)}
; } export function CommentThread({ rootId, expectedTarget, onChange, focusReplyId }: { focusReplyId?: string; rootId: string; expectedTarget: string; onChange: () => void | Promise }) { const [data, setData] = useState<{ item: CommentItem; viewer: CommentViewer | null } | null>(null); const [error, setError] = useState(""); const [revision, setRevision] = useState(0); const lastThread = useRef(""); const load = useCallback(async (forceReplies = false) => { try { const result = await commentRequest<{ item: CommentItem; viewer: CommentViewer | null }>(`/api/comments/${rootId}`); if (result.item.target !== expectedTarget) throw new Error("การสนทนานี้เป็นของไกด์อื่น"); const payload = JSON.stringify(result); if (lastThread.current !== payload) { lastThread.current = payload; setData(result); setRevision((value) => value + 1); } else if (forceReplies) setRevision((value) => value + 1); setError(""); } catch (cause) { lastThread.current = ""; setData(null); setError(cause instanceof Error ? cause.message : "ไม่สามารถโหลดการสนทนาได้"); } }, [rootId, expectedTarget]); useCommentEvents({ target: expectedTarget }, load); useEffect(() => { void Promise.resolve().then(() => load()); }, [load]); return
{error && {error}} {data && { await load(true); await onChange(); }} />} {!data && !error && } {error && }
; }