Files
buzz-sheet/components/comments/comment-card.tsx
T
gunshiz fdea6c94be
CI / Verify (push) Successful in 1m51s
CI / Build immutable images and deploy (push) Successful in 2m35s
fix(comments) : improve mobile admin conversation layout
2026-10-08 13:22:14 +07:00

363 lines
28 KiB
TypeScript

"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<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, moderationDetails = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false }: {
item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; 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<HTMLButtonElement>(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<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" | "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 <article onClick={(event) => {
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 && <Link href={item.href} prefetch={false} title={item.targetName} aria-label={`เปิดไกด์ ${item.targetName}`} className="w-full self-start overflow-hidden rounded-lg sm:w-36">
{item.targetImage && <>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.targetImage} alt={`ภาพ Overview ของ ${item.targetName}`} loading="lazy" className="aspect-video w-full object-cover" />
</>}
{item.target.startsWith("stygian:") && <Badge variant="secondary" className="whitespace-normal">{item.targetName}</Badge>}
</Link>}
<div className={cn("flex 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>
{(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")}>แก้ไข</DropdownMenuItem><DropdownMenuItem variant="destructive" onClick={() => setDeleting(true)}>ลบ</DropdownMenuItem></>}
{viewer?.admin && <DropdownMenuItem disabled={!item.canModerate || (item.hidden && !item.ownHidden)} onClick={() => void mutate("moderation", !item.ownHidden)}>{item.ownHidden ? "แสดงอีกครั้ง" : "ซ่อน"}</DropdownMenuItem>}
</DropdownMenuGroup></DropdownMenuContent>
</DropdownMenu>}
</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>}
{(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>}
{inbox && <Button variant="outline" size="sm" className={cn(replies && "sm:hidden")} aria-haspopup="dialog" onClick={() => setThread(true)}>ดูการสนทนา{item.replyCount > 0 ? ` · ${item.replyCount.toLocaleString("th-TH")} คำตอบ` : ""}</Button>}
</div>
{mode && <CommentComposer key={mode} target={item.target} replyTo={mode === "reply" ? item : undefined} editing={mode === "edit" ? item : undefined}
onCancel={() => setMode(null)} onSaved={() => { setMode(null); expandReplies(true); onChange(); }} />}
{replies && !item.rootId && (item.replyCount > 0 || expanded) && <Accordion className={cn(inbox && "max-sm:hidden")} value={expanded ? ["replies"] : []} onValueChange={(value) => { if (isMobileInbox()) setThread(true); else 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} />
<div className="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">
<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>
<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>
<Dialog open={thread} onOpenChange={setThread}><DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-4xl max-sm:top-0 max-sm:left-0 max-sm:h-svh max-sm:max-h-svh max-sm:max-w-none max-sm:translate-x-0 max-sm:translate-y-0 max-sm:rounded-none max-sm:content-start">
<DialogHeader className="pr-8"><DialogTitle>การสนทนา</DialogTitle><DialogDescription>ความคิดเห็นต้นทางและคำตอบกลับ เรียงจากเก่าไปใหม่</DialogDescription></DialogHeader>
{thread && <CommentThread rootId={item.rootId ?? item.id} expectedTarget={item.target} focusReplyId={item.rootId ? item.id : undefined} onChange={onChange} />}
</DialogContent></Dialog>
</article>;
}
function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDetails = false }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise<unknown>; 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<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} 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 }: { focusReplyId?: string; items: CommentItem[]; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; moderationDetails?: 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-sm:before:hidden max-sm:after:hidden",
)}>
<div className={cn(groups.has(reply.id) && "relative after:absolute after:-bottom-3 after:left-4 after:top-10 after:border-l after:border-border", moderationDetails && "max-sm:after:hidden")}>
<CommentCard item={reply} highlighted={reply.id === focusReplyId} viewer={viewer} moderationDetails={moderationDetails} 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 && "max-sm:ml-0 max-sm: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>;
}