Files
buzz-sheet/components/comments/comment-card.tsx
T
2026-10-08 18:11:09 +07:00

371 lines
29 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { BanIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, EllipsisVerticalIcon, HeartIcon, PencilIcon, ShieldCheckIcon, ThumbsDownIcon, ThumbsUpIcon, Trash2Icon } 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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-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 { TableCell, TableRow } from "@/components/ui/table";
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<typeof setInterval> | 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 <time dateTime={value} title={value} className="text-xs text-muted-foreground">{now > 0
? relativeTime()
: `${value.slice(0, 16).replace("T", " ")} UTC`}</time>;
}
export function CommentImages({ images }: { images: CommentImage[] }) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const selected = selectedIndex === null ? null : images[selectedIndex];
return images.length ? <>
<div className="flex flex-wrap gap-2">{images.map((image, index) =>
<Button key={image.id} variant="ghost" className="h-auto rounded-lg p-0" aria-label={`เปิดรูปภาพที่ ${index + 1}`} onClick={() => setSelectedIndex(index)}>
<CommentImageView image={image} alt={`รูปภาพประกอบความคิดเห็นที่ ${index + 1}`} className="size-28 rounded-lg border object-cover sm:size-36" />
</Button>)}</div>
<Dialog open={Boolean(selected)} onOpenChange={(open) => { if (!open) setSelectedIndex(null); }}>
<DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-5xl" onKeyDown={(event) => {
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))));
}
}}>
<DialogHeader><DialogTitle>รูปภาพประกอบความคิดเห็น</DialogTitle><DialogDescription>รูปที่ {(selectedIndex ?? 0) + 1} จาก {images.length}</DialogDescription></DialogHeader>
{selected && <>
<CommentImageView key={selected.id} image={selected} alt={`รูปภาพประกอบความคิดเห็นที่ ${(selectedIndex ?? 0) + 1}`} className="max-h-[65svh] w-full object-contain" />
{images.length > 1 && <div className="flex justify-center gap-2">
<Button variant="outline" size="icon" aria-label="รูปก่อนหน้า" disabled={selectedIndex === 0} onClick={() => setSelectedIndex((current) => (current ?? 0) - 1)}><ChevronLeftIcon /></Button>
<Button variant="outline" size="icon" aria-label="รูปถัดไป" disabled={selectedIndex === images.length - 1} onClick={() => setSelectedIndex((current) => (current ?? 0) + 1)}><ChevronRightIcon /></Button>
</div>}
</>}
</DialogContent>
</Dialog>
</> : null;
}
function CommentText({ text }: { text: string }) {
return <p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{text.split(/(https?:\/\/[^\s<>]+)/g).map((part, i) =>
/^https?:\/\//.test(part) ? <a key={i} href={part} rel="nofollow ugc noopener noreferrer" target="_blank" className="text-primary underline underline-offset-4">{part}</a> : part)}</p>;
}
function RevisionHistory({ id }: { id: string }) {
const [items, setItems] = useState<CommentHistoryItem[]>([]);
const [cursor, setCursor] = useState<string | null>(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 <div className="flex max-h-[65vh] flex-col gap-4 overflow-y-auto">
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
{items.map((revision) => <section key={revision.id} className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">เวอร์ชัน {revision.version} · {new Date(revision.createdAt).toLocaleString("th-TH")}</p>
<CommentText text={revision.text} /><CommentImages images={revision.images} /><Separator />
</section>)}
{busy && <CommentSkeleton count={2} />}
{cursor && <Button variant="outline" disabled={busy} onClick={() => void load(cursor)}>ดูเวอร์ชันก่อนหน้า</Button>}
{error && <Button variant="outline" disabled={busy} onClick={() => void load()}>ลองใหม่</Button>}
</div>;
}
export function CommentCard({ item, viewer, onChange, revision = 0, inbox = false, inboxReply = false, moderationDetails = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false, overview }: {
item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; revision?: number; inbox?: boolean; inboxReply?: boolean; moderationDetails?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean; overview?: ReactNode;
}) {
const router = useRouter();
const fullWidthMobile = inbox || moderationDetails;
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<HTMLButtonElement>(null);
const [history, setHistory] = useState(false);
const [deleting, setDeleting] = useState(false);
const [banning, setBanning] = useState(false);
const [pending, setPending] = 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<void>>(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" | "ban" | "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({ banned: value }) }),
});
setDeleting(false); setBanning(false); await onChange();
} catch (cause) { toast.error(cause instanceof Error ? cause.message : "ไม่สามารถอัปเดตความคิดเห็นได้"); }
finally { setPending(false); }
}
function compose(next: "reply" | "edit") {
if (!viewer) return router.push(commentSignInHref());
setMode(next);
}
const canReadHistory = (!item.deleted && !item.hidden) || Boolean(viewer?.admin);
const optionsMenu = (item.canEdit || viewer?.admin) && <DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" className="shrink-0 rounded-full" aria-label="ตัวเลือกความคิดเห็น" disabled={pending} />}><EllipsisVerticalIcon /></DropdownMenuTrigger>
<DropdownMenuContent align="end"><DropdownMenuGroup>
{item.canEdit && <DropdownMenuItem onClick={() => compose("edit")}><PencilIcon />แก้ไข</DropdownMenuItem>}
{(item.canEdit || viewer?.admin) && <DropdownMenuItem variant="destructive" disabled={pending || (viewer?.admin && !item.canModerate)} onClick={() => setDeleting(true)}><Trash2Icon />ลบ</DropdownMenuItem>}
{viewer?.admin && <DropdownMenuItem disabled={pending || !item.canBanAuthor} onClick={() => item.authorBanned ? void mutate("ban", false) : setBanning(true)}>
{item.authorBanned ? <ShieldCheckIcon /> : <BanIcon />}{item.authorBanned ? "ยกเลิกแบนผู้เขียน" : "แบนผู้เขียน"}
</DropdownMenuItem>}
</DropdownMenuGroup></DropdownMenuContent>
</DropdownMenu>;
const Container = inbox ? TableRow : "article";
const Content = inbox ? TableCell : "div";
return <Container id={`comment-${item.id}`} tabIndex={highlighted ? -1 : undefined} className={cn("scroll-mt-36", inbox ? "max-md:block" : "flex flex-col gap-2 py-2", inboxReply && "relative", highlighted && "rounded-lg bg-accent ring-2 ring-ring", expanded && !item.rootId && !inbox && "relative before:absolute before:bottom-8 before:left-5 before:top-12 before:border-l before:border-border", fullWidthMobile && "max-sm:before:hidden")}>
<Content className={cn("min-w-0", inbox && "px-0 py-5 align-top whitespace-normal max-md:block max-md:pb-3 md:pr-6", inbox && expanded && !item.rootId && "relative before:absolute before:bottom-8 before:left-5 before:top-12 before:border-l before:border-border max-md:before:hidden")}>
<div className={cn("flex min-w-0 items-start gap-3", fullWidthMobile && "grid grid-cols-[auto_minmax(0,1fr)] sm:flex")}>
<Avatar className={item.rootId ? "size-8 shrink-0" : "size-10 shrink-0"}><AvatarImage src={item.authorImage ?? undefined} alt={item.authorName} /><AvatarFallback>{item.authorName.slice(0, 2)}</AvatarFallback></Avatar>
<div className={cn("flex min-w-0 flex-1 flex-col gap-2", fullWidthMobile && "max-sm:contents")}>
<div className="flex min-w-0 items-start justify-between gap-2"><div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"><span className="text-sm font-semibold">{item.authorName}</span>{item.authorAdmin && <Badge variant="secondary">ผู้ดูแล</Badge>}
<CommentTimestamp value={item.createdAt} />
{inbox && item.rootId && <Badge variant="outline">ตอบกลับ</Badge>}
{(inbox || moderationDetails) && item.hidden && <Badge variant="secondary">{item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"}</Badge>}
{(inbox || moderationDetails) && item.deleted && <Badge variant="secondary">ลบแล้ว</Badge>}
{item.version > 1 && canReadHistory && <Button variant="link" size="xs" onClick={() => setHistory(true)}>แก้ไขแล้ว</Button>}
</div>
{/* Inbox replies reach across the 280px content column and 24px cell padding. */}
{!inbox && optionsMenu && <div className={cn("shrink-0", inboxReply && "md:absolute md:top-2 md:right-[-304px]")}>{optionsMenu}</div>}
</div>
<div className={cn("contents", fullWidthMobile && "max-sm:col-span-2 max-sm:flex max-sm:min-w-0 max-sm:flex-col max-sm:gap-2")}>
{item.replyToName && <p className="text-xs text-muted-foreground">ตอบกลับ {item.replyToName}</p>}
{mode === "edit" ? <CommentComposer key="edit" target={item.target} editing={item}
onCancel={() => setMode(null)} onSaved={() => { setMode(null); onChange(); }} /> : <>
{(item.deleted || item.hidden) && !viewer?.admin ? <p className="text-sm text-muted-foreground">{item.deleted ? "ความคิดเห็นถูกลบแล้ว" : "ความคิดเห็นถูกซ่อนโดยผู้ดูแล"}</p> : <CommentText text={item.text} />}
<CommentImages images={item.images} />
<div className="flex flex-wrap items-center gap-1">
<Button variant={optimistic.reaction === 1 ? "secondary" : "ghost"} size="sm" className="rounded-full" aria-label={`ถูกใจความคิดเห็น มี ${optimistic.likes} ถูกใจ`} aria-pressed={optimistic.reaction === 1} disabled={pending || !item.canInteract}
onClick={() => void mutate("reaction", desired.current.reaction === 1 ? 0 : 1)}><ThumbsUpIcon data-icon="inline-start" fill={optimistic.reaction === 1 ? "currentColor" : "none"} />{optimistic.likes > 0 && optimistic.likes.toLocaleString("th-TH")}</Button>
<Button variant={optimistic.reaction === -1 ? "secondary" : "ghost"} size="icon-sm" className="rounded-full" aria-label="ไม่ถูกใจความคิดเห็น" aria-pressed={optimistic.reaction === -1} disabled={pending || !item.canInteract}
onClick={() => void mutate("reaction", desired.current.reaction === -1 ? 0 : -1)}><ThumbsDownIcon fill={optimistic.reaction === -1 ? "currentColor" : "none"} /></Button>
{item.canInteract && <Button variant="ghost" size="sm" className="rounded-full" onClick={() => compose("reply")} disabled={pending}>ตอบกลับ</Button>}
{viewer?.admin ? <Button variant={optimistic.hearted ? "secondary" : "ghost"} size="sm" aria-pressed={optimistic.hearted} disabled={pending || !item.canInteract} onClick={() => void mutate("heart", !desired.current.hearted)}>
<HeartIcon data-icon="inline-start" className={cn(optimistic.hearted && "text-destructive")} fill={optimistic.hearted ? "currentColor" : "none"} />{optimistic.hearted ? "ผู้ดูแลถูกใจ" : null}</Button>
: optimistic.hearted && !item.deleted && !item.hidden && <Badge variant="secondary"><HeartIcon data-icon="inline-start" className="text-destructive" fill="currentColor" />ผู้ดูแลถูกใจ</Badge>}
</div>
</>}
{mode === "reply" && <CommentComposer key="reply" target={item.target} replyTo={item}
onCancel={() => setMode(null)} onSaved={() => { setMode(null); expandReplies(true); onChange(); }} />}
{replies && !item.rootId && (item.replyCount > 0 || expanded) && <Accordion value={expanded ? ["replies"] : []} onValueChange={(value) => expandReplies(value.includes("replies"))}>
<AccordionItem value="replies"><AccordionTrigger ref={replyToggleRef} className="flex-none gap-2 rounded-full px-3" headerClassName={expanded ? "hidden" : "justify-start"}>{`การตอบกลับ ${item.replyCount.toLocaleString("th-TH")} รายการ`}</AccordionTrigger>
<AccordionContent keepMounted panelClassName="data-open:overflow-visible" className="[&_p]:mb-0">{hasOpenedReplies && <div className="flex flex-col gap-3">
<CommentReplies rootId={item.id} focusReplyId={focusReplyId} viewer={viewer} onChange={onChange} revision={revision} moderationDetails={inbox || moderationDetails} inbox={inbox} />
<div className={cn("relative self-start before:absolute before:-left-8 before:top-0 before:h-5 before:w-8 before:rounded-bl-2xl before:border-b before:border-l before:border-border", fullWidthMobile && "max-md:before:hidden")}>
<Button variant="ghost" size="sm" className="rounded-full" onClick={() => { setExpanded(false); requestAnimationFrame(() => replyToggleRef.current?.focus()); }}><ChevronUpIcon data-icon="inline-start" />Show less</Button>
</div>
</div>}</AccordionContent>
</AccordionItem>
</Accordion>}
</div>
</div>
</div>
</Content>
{inbox && <TableCell className="px-0 py-5 align-top whitespace-normal max-md:block max-md:pt-0">
<div className="flex items-start gap-2 md:justify-end">{optionsMenu}{overview}</div>
</TableCell>}
<Dialog open={history} onOpenChange={setHistory}><DialogContent className="sm:max-w-2xl"><DialogHeader><DialogTitle>ประวัติการแก้ไข</DialogTitle><DialogDescription>ดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น</DialogDescription></DialogHeader>
{history && <RevisionHistory id={item.id} />}</DialogContent></Dialog>
<Dialog open={deleting} onOpenChange={setDeleting}><DialogContent><DialogHeader><DialogTitle>ลบความคิดเห็นนี้?</DialogTitle><DialogDescription>ความคิดเห็นนี้ คำตอบกลับทุกระดับ ประวัติการแก้ไข และรูปภาพจะถูกลบอย่างถาวร ไม่สามารถกู้คืนได้</DialogDescription></DialogHeader>
<div className="flex justify-end gap-2"><Button variant="ghost" disabled={pending} onClick={() => setDeleting(false)}>ยกเลิก</Button><Button variant="destructive" disabled={pending} onClick={() => void mutate("delete")}>ลบ</Button></div>
</DialogContent></Dialog>
<AlertDialog open={banning} onOpenChange={(open) => { if (!pending) setBanning(open); }}><AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>แบน {item.authorName}?</AlertDialogTitle><AlertDialogDescription>ผู้เขียนจะถูกออกจากระบบและไม่สามารถเข้าสู่ระบบ แสดงความคิดเห็น หรือใช้งานบัญชีได้ จนกว่าผู้ดูแลจะยกเลิกแบน ความคิดเห็นเดิมจะยังอยู่</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel className="max-md:min-h-11" disabled={pending}>ยกเลิก</AlertDialogCancel><AlertDialogAction variant="destructive" className="max-md:min-h-11" disabled={pending} onClick={() => void mutate("ban", true)}><BanIcon data-icon="inline-start" />แบนผู้เขียน</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent></AlertDialog>
</Container>;
}
function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDetails = false, inbox = false }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise<unknown>; moderationDetails?: boolean; inbox?: 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<string | null>(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 <div id={`replies-${rootId}`} className="flex flex-col gap-3 pt-2">
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
<CommentReplyTree focusReplyId={focusReplyId} items={feed.page.items} viewer={feed.page.viewer} moderationDetails={moderationDetails} inbox={inbox} onChange={async () => { await refresh({ silent: true }); await onChange(); }} />
{feed.busy && <CommentSkeleton count={2} />}
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>ดูคำตอบเพิ่มเติม</Button>}
{feed.error && <Button variant="outline" disabled={feed.busy} onClick={() => void refresh()}>ลองใหม่</Button>}
</div>;
}
function CommentReplyTree({ items, viewer, onChange, focusReplyId, moderationDetails = false, inbox = false }: { focusReplyId?: string; items: CommentItem[]; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; moderationDetails?: boolean; inbox?: boolean }) {
const loaded = new Set(items.map((item) => item.id));
const groups = new Map<string | null, CommentItem[]>();
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) => <div key={reply.id} className={cn(
"relative flex min-w-0 flex-col gap-3 before:absolute before:-left-8 before:top-0 before:h-6 before:w-6 before:rounded-bl-2xl before:border-b before:border-l before:border-border",
parent !== null && index < siblings.length - 1 && "after:absolute after:-bottom-3 after:-left-8 after:top-0 after:border-l after:border-border",
moderationDetails && "max-md:before:hidden max-md:after:hidden",
moderationDetails && parent !== null && "before:hidden after:hidden",
)}>
<div className={cn(!moderationDetails && groups.has(reply.id) && "relative after:absolute after:-bottom-3 after:left-4 after:top-10 after:border-l after:border-border")}>
<CommentCard item={reply} highlighted={reply.id === focusReplyId} viewer={viewer} moderationDetails={moderationDetails} inboxReply={inbox} replies={false} onChange={onChange} />
</div>
{groups.has(reply.id) && <div className={cn("ml-4 flex min-w-0 flex-col gap-3 pl-8", moderationDetails && "ml-0 pl-0")}>
{branch(reply.id)}
</div>}
</div>);
}
return <div className="flex flex-col gap-4">{branch(null)}</div>;
}
export function CommentThread({ rootId, expectedTarget, onChange, focusReplyId }: { focusReplyId?: string; rootId: string; expectedTarget: string; onChange: () => void | Promise<unknown> }) {
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 <div className="flex min-w-0 flex-col gap-3" aria-busy={!data && !error}>
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
{data && <CommentCard item={data.item} viewer={data.viewer} moderationDetails={Boolean(data.viewer?.admin)} defaultExpanded focusReplyId={focusReplyId} revision={revision} onChange={async () => { await load(true); await onChange(); }} />}
{!data && !error && <CommentThreadSkeleton />}
{error && <Button variant="outline" onClick={() => void load()}>ลองใหม่</Button>}
</div>;
}