Files
buzz-sheet/components/comments/comment-board.tsx
T

86 lines
7.0 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { RefreshCwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
import type { CommentItem, CommentPage } from "@/lib/comments/types";
import { CommentComposer } from "./comment-composer";
import { CommentCard } from "./comment-card";
import { commentRequest, commentSignInHref } from "./client";
import { CommentSkeleton } from "./comment-skeleton";
import { useCommentEvents } from "./use-comment-events";
import { useCommentFeed } from "./use-comment-feed";
export function CommentBoard({ target, initial }: { target: string; initial: CommentPage }) {
const router = useRouter();
const [sort, setSort] = useState("top");
const [linkedReply, setLinkedReply] = useState<string | null>(null);
const [linkedThread, setLinkedThread] = useState<string | null>(null);
const [linkedResult, setLinkedResult] = useState<{ rootId: string; target: string; item: CommentItem | null; error: string } | null>(null);
const feed = useCommentFeed(`/api/comments?target=${encodeURIComponent(target)}&sort=${sort}`, initial);
const loadedThread = feed.page.items.find((item) => item.id === linkedThread && item.target === target && !item.rootId);
const threadLoaded = Boolean(loadedThread);
useEffect(() => {
if (!linkedThread || threadLoaded) return;
const controller = new AbortController();
void Promise.resolve().then(async () => {
if (controller.signal.aborted) return;
setLinkedResult((current) => ({ rootId: linkedThread, target, item: current?.rootId === linkedThread && current.target === target ? current.item : null, error: "" }));
try {
const result = await commentRequest<{ item: CommentItem }>(`/api/comments/${linkedThread}`, { signal: controller.signal });
if (!result.item || result.item.id !== linkedThread || result.item.target !== target || result.item.rootId) throw new Error("ไม่พบการสนทนาที่เลือก");
if (!controller.signal.aborted) setLinkedResult({ rootId: linkedThread, target, item: result.item, error: "" });
} catch (cause) {
if (!controller.signal.aborted) setLinkedResult({ rootId: linkedThread, target, item: null, error: cause instanceof Error ? cause.message : "ไม่สามารถโหลดการสนทนาได้" });
}
});
return () => controller.abort();
}, [linkedThread, target, threadLoaded, feed.revision]);
const result = linkedResult?.rootId === linkedThread && linkedResult.target === target ? linkedResult : null;
const selectedItem = linkedThread ? loadedThread ?? result?.item : null;
const selectedError = linkedThread && !threadLoaded ? result?.error : "";
const selectedBusy = Boolean(linkedThread && !selectedItem && !selectedError);
const items = selectedItem ? [selectedItem, ...feed.page.items.filter((item) => item.id !== linkedThread)] : feed.page.items;
useCommentEvents({ target }, (changed) => feed.refresh({ silent: true, forceRevision: changed }));
useEffect(() => {
const showThread = () => {
const match = /^#comment-([0-9a-f-]{36})$/i.exec(window.location.hash);
setLinkedThread(match?.[1] ?? null);
const reply = new URLSearchParams(window.location.search).get("reply");
setLinkedReply(match && reply && /^[0-9a-f-]{36}$/i.test(reply) ? reply : null);
};
showThread();
window.addEventListener("hashchange", showThread);
return () => window.removeEventListener("hashchange", showThread);
}, []);
return <div className="flex flex-col gap-6 py-2">
<div className="flex items-start gap-3"><Avatar className="size-10 shrink-0"><AvatarImage src={feed.page.viewer?.image ?? undefined} alt={feed.page.viewer?.name ?? "โปรไฟล์"} /><AvatarFallback>{feed.page.viewer?.name?.slice(0, 2) || "คุณ"}</AvatarFallback></Avatar><div className="min-w-0 flex-1">{feed.page.viewer ? <CommentComposer target={target} onSaved={() => void feed.refresh({ silent: true, forceRevision: true })} />
: <div className="flex flex-col items-start gap-2"><p className="text-sm text-muted-foreground">{"เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ"}</p><Button onClick={() => router.push(commentSignInHref())}>{"เข้าสู่ระบบ"}</Button></div>}</div></div>
<div className="order-first flex flex-wrap items-center gap-3"><h2 className="font-heading text-xl font-semibold">{"ความคิดเห็น"}</h2>
<ToggleGroup value={[sort]} onValueChange={(values) => { if (values[0] && values[0] !== sort) {
setLinkedThread(null); setLinkedReply(null); setSort(values[0]);
const url = new URL(window.location.href); url.hash = ""; url.searchParams.delete("reply");
window.history.replaceState(null, "", url.pathname + url.search);
} }} variant="outline" aria-label={"เรียงความคิดเห็น"}>
<ToggleGroupItem value="newest">{"ล่าสุด"}</ToggleGroupItem><ToggleGroupItem value="top">{"ยอดนิยม"}</ToggleGroupItem>
</ToggleGroup>
<Button variant="ghost" className="ml-auto rounded-full" aria-label={"รีเฟรชความคิดเห็น"} size="icon" disabled={feed.busy} onClick={() => void feed.refresh({ silent: false, forceRevision: true })}><RefreshCwIcon /></Button>
</div>
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
{selectedError && <Alert variant="destructive"><AlertDescription>{selectedError}</AlertDescription></Alert>}
{selectedBusy && <CommentSkeleton count={1} />}
{items.map((item) => <CommentCard key={item.id === linkedThread ? `${item.id}:linked:${linkedReply ?? ""}` : item.id} item={item} viewer={feed.page.viewer}
defaultExpanded={item.id === linkedThread} focusReplyId={item.id === linkedThread ? linkedReply ?? undefined : undefined}
onChange={() => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision} />)}
{!items.length && !feed.busy && !feed.error && !selectedBusy && !selectedError && <Empty><EmptyHeader><EmptyTitle>{"ยังไม่มีความคิดเห็น"}</EmptyTitle><EmptyDescription>{"เป็นคนแรกที่ร่วมพูดคุยเกี่ยวกับไกด์นี้"}</EmptyDescription></EmptyHeader></Empty>}
{feed.busy && <CommentSkeleton count={feed.page.items.length ? 1 : 3} />}
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>{"ดูความคิดเห็นเพิ่มเติม"}</Button>}
</div>;
}