fix(comments) : group admin comments by guide with inline replies
CI / Verify (push) Successful in 1m54s
CI / Build immutable images and deploy (push) Successful in 2m33s

This commit is contained in:
2026-10-08 13:44:57 +07:00 Unverified
parent fdea6c94be
commit 34c73e2f03
2 changed files with 27 additions and 28 deletions
+5 -20
View File
@@ -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>;
}