fix(comments) : group admin comments by guide with inline replies
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -9,8 +10,7 @@ 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import type { CommentPage } from "@/lib/comments/types";
|
||||
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";
|
||||
@@ -21,7 +21,6 @@ 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 [notificationOpen, setNotificationOpen] = useState(Boolean(initialComment));
|
||||
const [target, setTarget] = useState<TargetOption | null>(targets.find((item) => item.value === initialTarget) ?? null);
|
||||
const [status, setStatus] = useState("all");
|
||||
const [answered, setAnswered] = useState("all");
|
||||
@@ -31,11 +30,17 @@ 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">
|
||||
{initialComment && initialTarget && <Dialog open={notificationOpen} onOpenChange={setNotificationOpen}><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>
|
||||
{notificationOpen && <CommentThread rootId={initialComment} expectedTarget={initialTarget} onChange={() => feed.refresh({ silent: true })} />}
|
||||
</DialogContent></Dialog>}
|
||||
{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">
|
||||
<Field><FieldLabel>ไกด์หรือรอบ Stygian</FieldLabel>
|
||||
<FuzzyCombobox items={targets} value={target} onValueChange={setTarget} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value}
|
||||
@@ -53,7 +58,16 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
|
||||
</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>}
|
||||
{feed.page.items.map((item) => <CommentCard key={item.id} item={item} viewer={feed.page.viewer} inbox onChange={() => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision} />)}
|
||||
{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 && !feed.busy && !feed.error && <Empty><EmptyHeader><EmptyTitle>ไม่พบความคิดเห็นที่ตรงกัน</EmptyTitle><EmptyDescription>ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian จะแสดงที่นี่</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>}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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";
|
||||
@@ -122,7 +121,6 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
}) {
|
||||
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);
|
||||
@@ -134,7 +132,6 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
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());
|
||||
@@ -204,20 +201,11 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
}
|
||||
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>}
|
||||
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")}>
|
||||
|
||||
<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")}>
|
||||
@@ -249,11 +237,11 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
{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")); }}>
|
||||
{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} />
|
||||
@@ -271,10 +259,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
<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>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user