fix(comments) : improve mobile admin conversation layout
CI / Verify (push) Successful in 1m51s
CI / Build immutable images and deploy (push) Successful in 2m35s

This commit is contained in:
2026-10-08 13:22:14 +07:00 Unverified
parent 20c52fac04
commit fdea6c94be
3 changed files with 31 additions and 23 deletions
+2 -2
View File
@@ -2,9 +2,9 @@
# This is NOT the Next.js you know
This version has breaking changes - APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` - verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+2 -2
View File
@@ -32,8 +32,8 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
const markRead = useMarkCommentsRead();
useEffect(() => { void markRead(target?.value ?? "all"); }, [markRead, target, feed.revision]);
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">
<DialogHeader><DialogTitle>การสนทนา</DialogTitle><DialogDescription>ความคิดเห็นจากการแจ้งเตือนและคำตอบกลับ</DialogDescription></DialogHeader>
{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>}
<FieldGroup className="md:grid md:grid-cols-3">
+27 -19
View File
@@ -121,6 +121,8 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; revision?: number; inbox?: boolean; moderationDetails?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean;
}) {
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);
@@ -202,14 +204,24 @@ 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 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")}>
<div className="flex items-start gap-3">
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>}
<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="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex 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>}
<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>}
<CommentTimestamp value={item.createdAt} />
{inbox && item.rootId && <Badge variant="outline">ตอบกลับ</Badge>}
{(inbox || moderationDetails) && item.hidden && <Badge variant="secondary">{item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"}</Badge>}
@@ -224,6 +236,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
</DropdownMenuGroup></DropdownMenuContent>
</DropdownMenu>}
</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>}
{(item.deleted || item.hidden) && !viewer?.admin ? <p className="text-sm text-muted-foreground">{item.deleted ? "ความคิดเห็นถูกลบแล้ว" : "ความคิดเห็นถูกซ่อนโดยผู้ดูแล"}</p> : <CommentText text={item.text} />}
<CommentImages images={item.images} />
@@ -236,11 +249,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 && !replies && item.hasReplies && <Button variant="outline" size="sm" aria-haspopup="dialog" onClick={() => setThread(true)}>ดูการสนทนา</Button>}
{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 value={expanded ? ["replies"] : []} onValueChange={(value) => expandReplies(value.includes("replies"))}>
{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")); }}>
<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} />
@@ -251,22 +264,16 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
</AccordionItem>
</Accordion>}
</div>
{inbox && <Link href={item.href} prefetch={false} title={item.targetName} aria-label={`เปิดไกด์ ${item.targetName}`} className="w-20 shrink-0 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>}
</div>
</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>
<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">
<DialogHeader><DialogTitle>การสนทนา</DialogTitle><DialogDescription>ความคิดเห็นต้นทางและคำตอบกลับ เรียงจากเก่าไปใหม่</DialogDescription></DialogHeader>
{thread && <CommentThread rootId={item.rootId ?? item.id} expectedTarget={item.target} onChange={onChange} />}
<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>;
}
@@ -312,11 +319,12 @@ function CommentReplyTree({ items, viewer, onChange, focusReplyId, moderationDet
return siblings.map((reply, index) => <div key={reply.id} className={cn(
"relative flex min-w-0 flex-col gap-3 before:absolute before:-left-8 before:top-0 before:h-6 before:w-6 before:rounded-bl-2xl before:border-b before:border-l before:border-border",
parent !== null && index < siblings.length - 1 && "after:absolute after:-bottom-3 after:-left-8 after:top-0 after:border-l after:border-border",
moderationDetails && "max-sm:before:hidden max-sm:after:hidden",
)}>
<div className={cn(groups.has(reply.id) && "relative after:absolute after:-bottom-3 after:left-4 after:top-10 after:border-l after:border-border")}>
<div className={cn(groups.has(reply.id) && "relative after:absolute after:-bottom-3 after:left-4 after:top-10 after:border-l after:border-border", moderationDetails && "max-sm:after:hidden")}>
<CommentCard item={reply} highlighted={reply.id === focusReplyId} viewer={viewer} moderationDetails={moderationDetails} replies={false} onChange={onChange} />
</div>
{groups.has(reply.id) && <div className="ml-4 flex min-w-0 flex-col gap-3 pl-8">
{groups.has(reply.id) && <div className={cn("ml-4 flex min-w-0 flex-col gap-3 pl-8", moderationDetails && "max-sm:ml-0 max-sm:pl-0")}>
{branch(reply.id)}
</div>}
</div>);
@@ -347,7 +355,7 @@ export function CommentThread({ rootId, expectedTarget, onChange, focusReplyId }
}, [load]);
return <div className="flex min-w-0 flex-col gap-3" aria-busy={!data && !error}>
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
{data && <CommentCard item={data.item} viewer={data.viewer} defaultExpanded focusReplyId={focusReplyId} revision={revision} onChange={async () => { await load(true); await onChange(); }} />}
{data && <CommentCard item={data.item} viewer={data.viewer} moderationDetails={Boolean(data.viewer?.admin)} defaultExpanded focusReplyId={focusReplyId} revision={revision} onChange={async () => { await load(true); await onChange(); }} />}
{!data && !error && <CommentThreadSkeleton />}
{error && <Button variant="outline" onClick={() => void load()}>ลองใหม่</Button>}
</div>;