feat : update

This commit is contained in:
2026-10-08 14:51:44 +07:00 Unverified
parent 34c73e2f03
commit 721e2e5cf8
5 changed files with 78 additions and 41 deletions
+27 -24
View File
@@ -10,12 +10,13 @@ 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 type { CommentItem, CommentPage } from "@/lib/comments/types";
import { Separator } from "@/components/ui/separator";
import type { CommentPage } from "@/lib/comments/types";
import { characterRarityGradientClass } from "@/lib/catalog/rarity";
import { cn } from "@/lib/utils";
import { useMarkCommentsRead } from "./admin-unread-comments";
import { CommentCard, CommentThread } from "./comment-card";
import { CommentSkeleton } from "./comment-skeleton";
import { AdminCommentRowsSkeleton } from "./comment-skeleton";
import { useCommentEvents } from "./use-comment-events";
import { useCommentFeed } from "./use-comment-feed";
@@ -30,18 +31,12 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
useCommentEvents({ admin: true }, () => feed.refresh({ silent: true, forceRevision: true }));
const markRead = useMarkCommentsRead();
useEffect(() => { void markRead(target?.value ?? "all"); }, [markRead, target, feed.revision]);
const groups = new Map<string, CommentItem[]>();
for (const item of feed.page.items) {
const comments = groups.get(item.target) ?? [];
comments.push(item);
groups.set(item.target, comments);
}
return <div className="flex flex-col gap-6">
return <div className="flex min-w-0 flex-col gap-6 max-md:[&_button]:min-h-11 max-md:[&_button]:min-w-11">
{initialComment && initialTarget && <section className="flex min-w-0 flex-col gap-3" aria-label="การสนทนาจากการแจ้งเตือน">
<h2 className="font-heading text-lg font-semibold">การสนทนาจากการแจ้งเตือน</h2>
<CommentThread rootId={initialComment} expectedTarget={initialTarget} onChange={() => feed.refresh({ silent: true })} />
</section>}
<FieldGroup className="md:grid md:grid-cols-3">
<FieldGroup className="lg:grid lg:grid-cols-[repeat(3,minmax(0,1fr))_auto] lg:items-end">
<Field><FieldLabel>ไกด์หรือรอบ Stygian</FieldLabel>
<FuzzyCombobox items={targets} value={target} onValueChange={setTarget} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value}
searchNames={(item) => [item.label]} autoHighlight>
@@ -49,27 +44,35 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
<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 value={[status]} onValueChange={(values) => { if (values[0]) setStatus(values[0]); }} variant="outline" aria-label="กรองการแสดงผลความคิดเห็น">
<Field><FieldLabel>การแสดงผล</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[status]} onValueChange={(values) => { if (values[0]) 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 value={[answered]} onValueChange={(values) => { if (values[0]) setAnswered(values[0]); }} variant="outline" aria-label="กรองการตอบกลับจากผู้ดูแล">
<Field><FieldLabel>การตอบกลับจากผู้ดูแล</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[answered]} onValueChange={(values) => { if (values[0]) setAnswered(values[0]); }} variant="outline" aria-label="กรองการตอบกลับจากผู้ดูแล">
<ToggleGroupItem value="all">ทุกความคิดเห็น</ToggleGroupItem><ToggleGroupItem value="unanswered">ยังไม่ได้ตอบ</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>
<Button variant="outline" className="self-end" disabled={feed.busy} onClick={() => void feed.refresh({ silent: false, forceRevision: true })}><RefreshCwIcon data-icon="inline-start" />รีเฟรช</Button>
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
{Array.from(groups, ([targetKey, items]) => <section key={targetKey} className="flex min-w-0 flex-col gap-4" aria-label={items[0].targetName}>
<Link href={items[0].href} prefetch={false} className="flex min-w-0 flex-col gap-3">
{items[0].targetImage && <>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={items[0].targetImage} alt={`ภาพ Overview ของ ${items[0].targetName}`} loading="lazy" className="aspect-video w-full rounded-lg object-cover sm:max-w-md" />
</>}
<h2 className="font-heading text-xl font-semibold">{items[0].targetName}</h2>
</Link>
{items.map((item) => <CommentCard key={item.id} item={item} viewer={feed.page.viewer} inbox onChange={() => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision} />)}
</section>)}
{feed.page.items.length > 0 && <section aria-label="ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian" className="flex min-w-0 flex-col">
<div className="hidden grid-cols-[minmax(0,1fr)_280px] gap-6 pb-3 text-sm text-muted-foreground md:grid">
<span>ความคิดเห็น</span><span className="text-right">เนื้อหา</span>
</div>
<Separator />
{feed.page.items.map((item) => <div key={item.id}>
<div className="py-5">
<CommentCard item={item} viewer={feed.page.viewer} inbox 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">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.targetImage} alt={`ภาพ Overview ของ ${item.targetName}`} loading="lazy" decoding="async" width={112} height={112} className="size-28 rounded-lg object-contain" />
</Link>} />
</div>
<Separator />
</div>)}
</section>}
{!feed.page.items.length && !feed.busy && !feed.error && <Empty><EmptyHeader><EmptyTitle>ไม่พบความคิดเห็นที่ตรงกัน</EmptyTitle><EmptyDescription>ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian จะแสดงที่นี่</EmptyDescription></EmptyHeader></Empty>}
{feed.busy && <CommentSkeleton count={feed.page.items.length ? 1 : 3} />}
{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>;
}
+14 -12
View File
@@ -2,7 +2,7 @@
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } 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";
@@ -116,8 +116,8 @@ function RevisionHistory({ id }: { id: string }) {
</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;
export function CommentCard({ item, viewer, onChange, revision = 0, inbox = false, moderationDetails = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false, overview }: {
item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; revision?: number; inbox?: boolean; moderationDetails?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean; overview?: ReactNode;
}) {
const router = useRouter();
const fullWidthMobile = inbox || moderationDetails;
@@ -204,9 +204,16 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
setMode(next);
}
const canReadHistory = (!item.deleted && !item.hidden) || Boolean(viewer?.admin);
return <article 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")}>
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")}>แก้ไข</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>;
return <article id={`comment-${item.id}`} tabIndex={highlighted ? -1 : undefined} className={cn("flex scroll-mt-36 flex-col gap-2 py-2", inbox && "grid grid-cols-1 items-start gap-3 md:grid-cols-[minmax(0,1fr)_280px] md:gap-6", 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")}>
<div className={cn("flex items-start gap-3", fullWidthMobile && "grid grid-cols-[auto_minmax(0,1fr)] sm:flex")}>
<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>}
@@ -216,13 +223,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
{(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>}
{!inbox && optionsMenu}
</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>}
@@ -254,6 +255,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
</div>
</div>
</div>
{inbox && <div className="flex items-start gap-2 md:justify-end">{optionsMenu}{overview}</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>
+32
View File
@@ -1,4 +1,36 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
export function AdminCommentRowsSkeleton({ count = 3 }: { count?: number }) {
return <div role="status" aria-label="กำลังโหลดความคิดเห็น" className="flex min-w-0 flex-col">
<span className="sr-only">กำลังโหลดความคิดเห็น…</span>
<div aria-hidden="true">{Array.from({ length: count }, (_, index) => <div key={index}>
<div className="grid min-w-0 grid-cols-1 items-start gap-3 py-5 md:grid-cols-[minmax(0,1fr)_280px] md:gap-6">
<div className="flex min-w-0 flex-col gap-3 py-2">
<div className="flex items-center gap-3"><Skeleton className="size-10 shrink-0 rounded-full" /><Skeleton className="h-4 w-36 max-w-full" /></div>
<Skeleton className="h-4 w-4/5" /><Skeleton className="h-4 w-3/5" />
<div className="flex gap-3"><Skeleton className="h-11 w-16 rounded-full md:h-7" /><Skeleton className="h-11 w-11 rounded-full md:h-7" /><Skeleton className="h-11 w-11 rounded-full md:h-7" /></div>
</div>
<div className="flex items-start gap-2 py-2 md:justify-end">
<Skeleton className="size-11 shrink-0 rounded-full md:size-7" />
<Skeleton className="size-28 shrink-0 rounded-lg" />
</div>
</div>
<Separator />
</div>)}</div>
</div>;
}
export function AdminCommentInboxSkeleton() {
return <div className="flex min-w-0 flex-col gap-6">
<div className="flex flex-col gap-2"><Skeleton className="h-9 w-48 max-w-full" /><Skeleton className="h-5 w-72 max-w-full" /></div>
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-[repeat(3,minmax(0,1fr))_auto] lg:items-end">{Array.from({ length: 3 }, (_, index) =>
<div key={index} className="flex min-w-0 flex-col gap-2"><Skeleton className="h-4 w-36 max-w-full" /><Skeleton className="h-11 w-full md:h-8" /></div>)}
<Skeleton className="h-11 w-24 self-end md:h-8" />
</div>
<AdminCommentRowsSkeleton />
</div>;
}
export function CommentSkeleton({ count = 3 }: { count?: number }) {
return <div role="status" aria-label="กำลังโหลดความคิดเห็น" className="flex flex-col gap-6">