fix(comments) : improve mobile admin conversation layout
This commit is contained in:
@@ -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 -->
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
@@ -250,23 +263,17 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
</div>}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>}
|
||||
</div>
|
||||
</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>
|
||||
<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>;
|
||||
|
||||
Reference in New Issue
Block a user