105 lines
10 KiB
TypeScript
105 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import Image from "@/components/ui/resilient-image";
|
|
import { RefreshCwIcon } from "lucide-react";
|
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
|
import { FuzzyCombobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
|
|
import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import type { CommentItem, CommentPage } from "@/lib/comments/types";
|
|
import { characterRarityGradientClass } from "@/lib/catalog/rarity";
|
|
import { cn } from "@/lib/utils";
|
|
import { useMarkCommentsRead } from "./admin-unread-comments";
|
|
import { CommentCard } from "./comment-card";
|
|
import { commentRequest } from "./client";
|
|
import { AdminCommentRowsSkeleton } from "./comment-skeleton";
|
|
import { useCommentEvents } from "./use-comment-events";
|
|
import { useCommentFeed } from "./use-comment-feed";
|
|
|
|
type TargetOption = { value: string; label: string; image: string | null; rarity: number | null; characterKey: string | null };
|
|
export function AdminCommentInbox({ initial, targets, initialTarget, initialComment }: { initial: CommentPage; targets: TargetOption[]; initialTarget?: string; initialComment?: string }) {
|
|
const [target, setTarget] = useState<TargetOption | null>(targets.find((item) => item.value === initialTarget) ?? null);
|
|
const [status, setStatus] = useState("all");
|
|
const [answered, setAnswered] = useState("all");
|
|
const [sort, setSort] = useState("newest");
|
|
const [selectedId, setSelectedId] = useState(initialTarget ? initialComment : undefined);
|
|
const [selectedResult, setSelectedResult] = useState<{ item: CommentItem | null; error: string }>({ item: null, error: "" });
|
|
const query = new URLSearchParams({ view: "inbox", grouped: "true", status, sort, unanswered: String(answered === "unanswered") });
|
|
if (target) query.set("target", target.value);
|
|
const feed = useCommentFeed(`/api/admin/comments?${query}`, initial);
|
|
const loadedSelection = feed.page.items.find((item) => item.id === selectedId && item.target === initialTarget && !item.rootId);
|
|
const selectionLoaded = Boolean(loadedSelection);
|
|
useEffect(() => {
|
|
if (!selectedId || !initialTarget || selectionLoaded) return;
|
|
const controller = new AbortController();
|
|
void Promise.resolve().then(async () => {
|
|
if (controller.signal.aborted) return;
|
|
setSelectedResult((current) => ({ ...current, error: "" }));
|
|
try {
|
|
const result = await commentRequest<{ item: CommentItem }>(`/api/comments/${selectedId}`, { signal: controller.signal });
|
|
if (!result.item || result.item.id !== selectedId || result.item.target !== initialTarget || result.item.rootId) throw new Error("ไม่พบการสนทนาที่เลือก");
|
|
if (!controller.signal.aborted) setSelectedResult({ item: result.item, error: "" });
|
|
} catch (cause) {
|
|
if (!controller.signal.aborted) setSelectedResult({ item: null, error: cause instanceof Error ? cause.message : "ไม่สามารถโหลดการสนทนาได้" });
|
|
}
|
|
});
|
|
return () => controller.abort();
|
|
}, [selectedId, initialTarget, selectionLoaded, feed.revision]);
|
|
const selectedItem = selectedId ? loadedSelection ?? selectedResult.item : null;
|
|
const selectedError = selectedId && !selectionLoaded ? selectedResult.error : "";
|
|
const selectedBusy = Boolean(selectedId && !selectedItem && !selectedError);
|
|
const items = selectedItem ? [selectedItem, ...feed.page.items.filter((item) => item.id !== selectedId)] : feed.page.items;
|
|
useCommentEvents({ admin: true }, () => feed.refresh({ silent: true, forceRevision: true }));
|
|
const markRead = useMarkCommentsRead();
|
|
useEffect(() => { void markRead(target?.value ?? "all"); }, [markRead, target, feed.revision]);
|
|
return <div className="flex min-w-0 flex-col gap-6 max-md:[&_button]:min-h-11 max-md:[&_button]:min-w-11">
|
|
<FieldGroup className="lg:grid lg:grid-cols-2 xl:grid-cols-[repeat(4,minmax(0,1fr))_auto] lg:items-end">
|
|
<Field><FieldLabel>{"ไกด์หรือรอบ Stygian"}</FieldLabel>
|
|
<FuzzyCombobox items={targets} value={target} onValueChange={(value) => { if (value?.value !== target?.value) setSelectedId(undefined); setTarget(value); }} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value}
|
|
searchNames={(item) => [item.label]} autoHighlight>
|
|
<ComboboxInput aria-label={"ค้นหาไกด์หรือรอบ Stygian"} placeholder={"ทุกไกด์และทุกรอบ"} showClear />
|
|
<ComboboxContent><ComboboxEmpty>{"ไม่พบไกด์ที่ตรงกัน"}</ComboboxEmpty><ComboboxList>{(item: TargetOption) => <ComboboxItem key={item.value} value={item}><Avatar className={cn("size-9 shrink-0", characterRarityGradientClass(item.characterKey, item.rarity))}><AvatarImage src={item.image ?? undefined} alt="" /><AvatarFallback>{item.label.slice(0, 2)}</AvatarFallback></Avatar><span className="min-w-0 flex-1 truncate">{item.label}</span></ComboboxItem>}</ComboboxList></ComboboxContent>
|
|
</FuzzyCombobox>
|
|
</Field>
|
|
<Field><FieldLabel>{"การแสดงผล"}</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[status]} onValueChange={(values) => { if (values[0] && values[0] !== status) { setSelectedId(undefined); setStatus(values[0]); } }} variant="outline" aria-label={"กรองการแสดงผลความคิดเห็น"}>
|
|
<ToggleGroupItem value="all">{"ทั้งหมด"}</ToggleGroupItem><ToggleGroupItem value="visible">{"แสดงอยู่"}</ToggleGroupItem><ToggleGroupItem value="hidden">{"ซ่อนอยู่"}</ToggleGroupItem>
|
|
</ToggleGroup></Field>
|
|
<Field><FieldLabel>{"การตอบกลับจากผู้ดูแล"}</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[answered]} onValueChange={(values) => { if (values[0] && values[0] !== answered) { setSelectedId(undefined); setAnswered(values[0]); } }} variant="outline" aria-label={"กรองการตอบกลับจากผู้ดูแล"}>
|
|
<ToggleGroupItem value="all">{"ทุกความคิดเห็น"}</ToggleGroupItem><ToggleGroupItem value="unanswered">{"ยังไม่ได้ตอบ"}</ToggleGroupItem>
|
|
</ToggleGroup></Field>
|
|
<Field><FieldLabel>{"เรียงตาม"}</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[sort]} onValueChange={(values) => { if (values[0] && values[0] !== sort) { setSelectedId(undefined); setSort(values[0]); } }} variant="outline" aria-label={"เรียงความคิดเห็น"}>
|
|
<ToggleGroupItem value="newest">{"ล่าสุด"}</ToggleGroupItem><ToggleGroupItem value="oldest">{"เก่าสุด"}</ToggleGroupItem>
|
|
</ToggleGroup></Field>
|
|
<Button variant="outline" className="self-end" disabled={feed.busy} onClick={() => void feed.refresh({ silent: false, forceRevision: true })}><RefreshCwIcon data-icon="inline-start" />{"รีเฟรช"}</Button>
|
|
</FieldGroup>
|
|
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
|
|
{selectedError && <Alert variant="destructive"><AlertDescription>{selectedError}</AlertDescription></Alert>}
|
|
{selectedBusy && <AdminCommentRowsSkeleton count={1} />}
|
|
{items.length > 0 && <section aria-label={"ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian"} className="flex min-w-0 flex-col">
|
|
<Table className="table-fixed max-md:block" aria-label={"ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian"}>
|
|
<TableHeader className="max-md:sr-only"><TableRow>
|
|
<TableHead scope="col" className="px-0">{"ความคิดเห็น"}</TableHead>
|
|
<TableHead scope="col" className="w-[280px] px-0 text-right">{"เนื้อหา"}</TableHead>
|
|
</TableRow></TableHeader>
|
|
<TableBody className="max-md:block">
|
|
{items.map((item) => <CommentCard key={item.id} item={item} viewer={feed.page.viewer} inbox defaultExpanded={item.id === selectedId} onChange={() => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision}
|
|
overview={item.target.startsWith("stygian:") ? <Link href={item.href} prefetch={false} className="min-w-0 break-words rounded-lg py-2 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
{item.targetName}
|
|
</Link> : item.targetImage && <Link href={item.href} prefetch={false} className="shrink-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
<Image src={item.targetImage} alt={`ภาพ Overview ของ ${item.targetName}`} sizes="112px" width={112} height={112} className="size-28 rounded-lg object-contain" />
|
|
</Link>} />)}
|
|
</TableBody>
|
|
</Table>
|
|
</section>}
|
|
{!items.length && !feed.busy && !feed.error && !selectedBusy && !selectedError && <Empty><EmptyHeader><EmptyTitle>{"ไม่พบความคิดเห็นที่ตรงกัน"}</EmptyTitle><EmptyDescription>{"ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian จะแสดงที่นี่"}</EmptyDescription></EmptyHeader></Empty>}
|
|
{feed.busy && <AdminCommentRowsSkeleton count={feed.page.items.length ? 1 : 3} />}
|
|
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>{"ดูความคิดเห็นเพิ่มเติม"}</Button>}
|
|
</div>;
|
|
}
|