feat : use only thai
CI / Verify (push) Successful in 1m47s
CI / Build immutable images and deploy (push) Successful in 2m47s

This commit is contained in:
2026-10-08 23:37:19 +07:00 Unverified
parent 9580407a90
commit a04a76fd70
16 changed files with 140 additions and 221 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ export function AdminHeader() {
</Link>
<nav aria-label="เมนูแอดมิน" className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto sm:justify-end">
<Button variant={pathname.startsWith("/admin/comments") ? "secondary" : "ghost"} size="sm" nativeButton={false}
render={<Link href="/admin/comments" prefetch={false} />} aria-label="Comments">
<MessageSquareIcon data-icon="inline-start" /><span className="hidden xl:inline">Comments</span>
render={<Link href="/admin/comments" prefetch={false} />} aria-label="ความคิดเห็น">
<MessageSquareIcon data-icon="inline-start" /><span className="hidden xl:inline">ความคิดเห็น</span>
</Button>
<Button variant={pathname.startsWith("/admin/commission") ? "secondary" : "ghost"}
size="sm" nativeButton={false} render={<Link href="/admin/commission" />} aria-label="Commission">
+1 -1
View File
@@ -98,7 +98,7 @@ export function GuideSectionNav({
)}
>
<MessageSquareIcon className="size-5" aria-hidden="true" />
Comment
ความคิดเห็น
</Link>
<AddExtraButton
guideId={guideId}
+17 -20
View File
@@ -1,7 +1,5 @@
"use client";
import { useCommentLocale } from "./comment-locale";
import { useEffect, useState } from "react";
import Link from "next/link";
import Image from "@/components/ui/resilient-image";
@@ -25,7 +23,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 { t } = useCommentLocale();
const [target, setTarget] = useState<TargetOption | null>(targets.find((item) => item.value === initialTarget) ?? null);
const [status, setStatus] = useState("all");
const [answered, setAnswered] = useState("all");
@@ -36,45 +33,45 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
const markRead = useMarkCommentsRead();
useEffect(() => { void markRead(target?.value ?? "all"); }, [markRead, target, feed.revision]);
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={t("การสนทนาจากการแจ้งเตือน", "Thread from notification")}>
<h2 className="font-heading text-lg font-semibold">{t("การสนทนาจากการแจ้งเตือน", "Thread from notification")}</h2>
{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="lg:grid lg:grid-cols-[repeat(3,minmax(0,1fr))_auto] lg:items-end">
<Field><FieldLabel>{t("ไกด์หรือรอบ Stygian", "Guide or Stygian season")}</FieldLabel>
<Field><FieldLabel>{"ไกด์หรือรอบ Stygian"}</FieldLabel>
<FuzzyCombobox items={targets} value={target} onValueChange={setTarget} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value}
searchNames={(item) => [item.label]} autoHighlight>
<ComboboxInput aria-label={t("ค้นหาไกด์หรือรอบ Stygian", "Search guides or Stygian seasons")} placeholder={t("ทุกไกด์และทุกรอบ", "All guides and seasons")} showClear />
<ComboboxContent><ComboboxEmpty>{t("ไม่พบไกด์ที่ตรงกัน", "No matching guides")}</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>
<ComboboxInput aria-label={"ค้นหาไกด์หรือรอบ Stygian"} placeholder={"ทุกไกด์และทุกรอบ"} showClear />
<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>{t("การแสดงผล", "Visibility")}</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[status]} onValueChange={(values) => { if (values[0]) setStatus(values[0]); }} variant="outline" aria-label={t("กรองการแสดงผลความคิดเห็น", "Filter comment visibility")}>
<ToggleGroupItem value="all">{t("ทั้งหมด", "All")}</ToggleGroupItem><ToggleGroupItem value="visible">{t("แสดงอยู่", "Visible")}</ToggleGroupItem><ToggleGroupItem value="hidden">{t("ซ่อนอยู่", "Hidden")}</ToggleGroupItem>
<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>{t("การตอบกลับจากผู้ดูแล", "Admin replies")}</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[answered]} onValueChange={(values) => { if (values[0]) setAnswered(values[0]); }} variant="outline" aria-label={t("กรองการตอบกลับจากผู้ดูแล", "Filter admin replies")}>
<ToggleGroupItem value="all">{t("ทุกความคิดเห็น", "All comments")}</ToggleGroupItem><ToggleGroupItem value="unanswered">{t("ยังไม่ได้ตอบ", "Unanswered")}</ToggleGroupItem>
<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" />{t("รีเฟรช", "Refresh")}</Button>
<Button variant="outline" className="self-end" disabled={feed.busy} onClick={() => void feed.refresh({ silent: false, forceRevision: true })}><RefreshCwIcon data-icon="inline-start" />{"รีเฟรช"}</Button>
</FieldGroup>
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
{feed.page.items.length > 0 && <section aria-label={t("ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian", "Comments from all guides and Stygian seasons")} className="flex min-w-0 flex-col">
<Table className="table-fixed max-md:block" aria-label={t("ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian", "Comments from all guides and Stygian seasons")}>
{feed.page.items.length > 0 && <section aria-label={"ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian"} className="flex min-w-0 flex-col">
<Table className="table-fixed max-md:block" aria-label={"ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian"}>
<TableHeader className="max-md:sr-only"><TableRow>
<TableHead scope="col" className="px-0">{t("ความคิดเห็น", "Comments")}</TableHead>
<TableHead scope="col" className="w-[280px] px-0 text-right">{t("เนื้อหา", "Content")}</TableHead>
<TableHead scope="col" className="px-0">{"ความคิดเห็น"}</TableHead>
<TableHead scope="col" className="w-[280px] px-0 text-right">{"เนื้อหา"}</TableHead>
</TableRow></TableHeader>
<TableBody className="max-md:block">
{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}
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">
<Image src={item.targetImage} alt={t(`ภาพ Overview ของ ${item.targetName}`, `Overview image for ${item.targetName}`)} sizes="112px" width={112} height={112} className="size-28 rounded-lg object-contain" />
<Image src={item.targetImage} alt={`ภาพ Overview ของ ${item.targetName}`} sizes="112px" width={112} height={112} className="size-28 rounded-lg object-contain" />
</Link>} />)}
</TableBody>
</Table>
</section>}
{!feed.page.items.length && !feed.busy && !feed.error && <Empty><EmptyHeader><EmptyTitle>{t("ไม่พบความคิดเห็นที่ตรงกัน", "No matching comments")}</EmptyTitle><EmptyDescription>{t("ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian จะแสดงที่นี่", "Comments from every guide and Stygian season will appear here")}</EmptyDescription></EmptyHeader></Empty>}
{!feed.page.items.length && !feed.busy && !feed.error && <Empty><EmptyHeader><EmptyTitle>{"ไม่พบความคิดเห็นที่ตรงกัน"}</EmptyTitle><EmptyDescription>{"ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian จะแสดงที่นี่"}</EmptyDescription></EmptyHeader></Empty>}
{feed.busy && <AdminCommentRowsSkeleton count={feed.page.items.length ? 1 : 3} />}
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>{t("ดูความคิดเห็นเพิ่มเติม", "Load more comments")}</Button>}
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>{"ดูความคิดเห็นเพิ่มเติม"}</Button>}
</div>;
}
@@ -1,7 +1,6 @@
"use client";
import { useCommentLocale } from "./comment-locale";
import { commentRequest } from "./client";
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
@@ -10,32 +9,29 @@ import { useCommentEvents } from "./use-comment-events";
type Counts = { counts: Record<string, number>; total: number };
const UnreadContext = createContext<Counts & { markRead: (target: string) => Promise<void> }>({ counts: {}, total: 0, markRead: async () => {} });
export function AdminUnreadCommentsProvider({ children }: { children: ReactNode }) {
const { request: commentRequest } = useCommentLocale();
const [counts, setCounts] = useState<Counts>({ counts: {}, total: 0 });
const generation = useRef(0);
const refresh = useCallback(async () => {
try { const sequence = ++generation.current; const result = await commentRequest<Counts>("/api/admin/comments/unread"); if (sequence === generation.current) setCounts(result); } catch { /* Protected routes handle expired admin sessions. */ }
}, [commentRequest]);
}, []);
const markRead = useCallback(async (target: string) => {
try {
await commentRequest("/api/admin/comments/unread", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ target }) });
await refresh();
} catch { /* Reading stays available when the counter cannot be saved. */ }
}, [refresh, commentRequest]);
}, [refresh]);
useEffect(() => { void Promise.resolve().then(refresh); }, [refresh]);
useCommentEvents({ admin: true }, refresh);
return <UnreadContext value={{ ...counts, markRead }}>{children}</UnreadContext>;
}
export function useMarkCommentsRead() { return useContext(UnreadContext).markRead; }
export function AdminUnreadCount({ target }: { target?: string }) {
const { t } = useCommentLocale();
const state = useContext(UnreadContext);
const count = target ? state.counts[target] ?? 0 : state.total;
return count > 0 ? <Badge variant="destructive" className="min-w-5 justify-center rounded-full bg-destructive px-1.5 text-background dark:bg-destructive" aria-label={t(`ความคิดเห็นใหม่ ${count} รายการ`, `${count} unread comments`)}>{count > 99 ? "99+" : count}</Badge> : null;
return count > 0 ? <Badge variant="destructive" className="min-w-5 justify-center rounded-full bg-destructive px-1.5 text-background dark:bg-destructive" aria-label={`ความคิดเห็นใหม่ ${count} รายการ`}>{count > 99 ? "99+" : count}</Badge> : null;
}
export function AdminUnreadBadge({ target }: { target: string }) {
const { t } = useCommentLocale();
const state = useContext(UnreadContext);
if (!(state.counts[target] > 0)) return null;
return <Link href={`/admin/comments?target=${encodeURIComponent(target)}`} prefetch={false} className="relative z-20 self-end" aria-label={t("ดูความคิดเห็นใหม่", "View unread comments")}><AdminUnreadCount target={target} /></Link>;
return <Link href={`/admin/comments?target=${encodeURIComponent(target)}`} prefetch={false} className="relative z-20 self-end" aria-label={"ดูความคิดเห็นใหม่"}><AdminUnreadCount target={target} /></Link>;
}
+7 -11
View File
@@ -1,26 +1,22 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { commentRequest, getCommentErrors } from "./client";
import { commentRequest, COMMENT_ERRORS } from "./client";
afterEach(() => vi.unstubAllGlobals());
describe("comment error language", () => {
it("retains Thai by default and supplies English for every error code", () => {
const thai = getCommentErrors();
const english = getCommentErrors("en");
expect(Object.keys(english).sort()).toEqual(Object.keys(thai).sort());
for (const message of Object.values(english)) expect(message).not.toMatch(/[\u0e00-\u0e7f]/);
expect(thai["image-too-large"]).toMatch(/[\u0e00-\u0e7f]/);
it("supplies Thai messages for every error code", () => {
for (const message of Object.values(COMMENT_ERRORS)) expect(message).toMatch(/[\u0e00-\u0e7f]/);
});
it("localizes server errors and preserves uncached requests", async () => {
it("shows Thai server errors and preserves uncached requests", async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: "comment-edited-reload" }), { status: 409 }));
vi.stubGlobal("fetch", fetch);
await expect(commentRequest("/api/comments/test", undefined, "en")).rejects.toThrow("Your draft has been kept");
await expect(commentRequest("/api/comments/test")).rejects.toThrow("ข้อความร่างของคุณยังอยู่");
expect(fetch).toHaveBeenCalledWith("/api/comments/test", { cache: "no-store" });
});
it("uses the selected language when an error response is not JSON", async () => {
it("uses a Thai fallback when an error response is not JSON", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Unavailable", { status: 503 })));
await expect(commentRequest("/api/comments", undefined, "en")).rejects.toThrow("Please try again");
await expect(commentRequest("/api/comments")).rejects.toThrow("ไม่สามารถทำรายการได้ กรุณาลองใหม่");
});
});
+2 -30
View File
@@ -1,5 +1,3 @@
export type CommentLocale = "th" | "en";
export const COMMENT_ERRORS: Record<string, string> = {
"sign-in-required": "กรุณาเข้าสู่ระบบเพื่อแสดงความคิดเห็น",
"too-many-requests": "ทำรายการบ่อยเกินไป กรุณารอสักครู่แล้วลองใหม่",
@@ -21,37 +19,11 @@ export const COMMENT_ERRORS: Record<string, string> = {
"comment-author-ban-not-allowed": "ไม่สามารถแบนผู้เขียนความคิดเห็นนี้ได้",
"guide-trashed": "ไกด์นี้อยู่ในถังขยะ จึงไม่สามารถแสดงความคิดเห็นได้",
};
const ENGLISH_COMMENT_ERRORS: Record<keyof typeof COMMENT_ERRORS, string> = {
"sign-in-required": "Please sign in to comment",
"too-many-requests": "Too many requests. Please wait and try again.",
"comment-abusive-language": "Please remove abusive language before posting",
"comment-spam": "Please remove excessive links or repeated text before posting",
"comment-duplicate": "You already posted this comment. Wait 5 minutes before posting it again.",
"uploads-busy": "Uploads are busy. Please try again.",
"image-too-large": "Each image must be no larger than 10 MiB",
"body-too-large": "Attach up to 5 images, each no larger than 10 MiB",
"unsupported-image-type": "JPEG, PNG, and WebP images are supported. Videos are not supported.",
"invalid-image": "Could not read this image. Please choose a valid image file.",
"comment-image-upload-failed": "Image upload failed. Please try again. Your draft and images have been kept.",
"empty-comment": "Write a comment or attach an image",
"invalid-comment": "Comments can contain up to 4,000 characters and 5 images",
"comment-edited-reload": "This comment has changed. Reload before saving. Your draft has been kept.",
"comment-unavailable": "This comment is no longer available",
"comment-not-editable": "This comment can no longer be edited",
"comment-author-banned": "Your account has been banned from commenting",
"comment-author-ban-not-allowed": "This author cannot be banned",
"guide-trashed": "This guide is in the trash and cannot receive comments",
};
export function getCommentErrors(locale: CommentLocale = "th") {
return locale === "en" ? ENGLISH_COMMENT_ERRORS : COMMENT_ERRORS;
}
export async function commentRequest<T>(url: string, options?: RequestInit, locale: CommentLocale = "th"): Promise<T> {
export async function commentRequest<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, { cache: "no-store", ...options });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(getCommentErrors(locale)[body.error] ?? (locale === "en" ? "Could not complete this action. Please try again." : "ไม่สามารถทำรายการได้ กรุณาลองใหม่"));
throw new Error(COMMENT_ERRORS[body.error] ?? "ไม่สามารถทำรายการได้ กรุณาลองใหม่");
}
return response.status === 204 ? undefined as T : await response.json() as T;
}
+10 -13
View File
@@ -1,7 +1,5 @@
"use client";
import { useCommentLocale } from "./comment-locale";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@@ -20,7 +18,6 @@ import { useCommentEvents } from "./use-comment-events";
import { useCommentFeed } from "./use-comment-feed";
export function CommentBoard({ target, initial }: { target: string; initial: CommentPage }) {
const { t } = useCommentLocale();
const router = useRouter();
const [sort, setSort] = useState("top");
const [linkedReply, setLinkedReply] = useState<string | null>(null);
@@ -41,21 +38,21 @@ export function CommentBoard({ target, initial }: { target: string; initial: Com
return () => window.removeEventListener("hashchange", showThread);
}, []);
return <div className="flex flex-col gap-6 py-2">
<div className="flex items-start gap-3"><Avatar className="size-10 shrink-0"><AvatarImage src={feed.page.viewer?.image ?? undefined} alt={feed.page.viewer?.name ?? t("โปรไฟล์", "Profile")} /><AvatarFallback>{feed.page.viewer?.name?.slice(0, 2) || t("คุณ", "You")}</AvatarFallback></Avatar><div className="min-w-0 flex-1">{feed.page.viewer ? <CommentComposer target={target} onSaved={() => void feed.refresh()} />
: <div className="flex flex-col items-start gap-2"><p className="text-sm text-muted-foreground">{t("เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ", "Sign in to comment, reply, and like comments")}</p><Button onClick={() => router.push(commentSignInHref())}>{t("เข้าสู่ระบบ", "Sign in")}</Button></div>}</div></div>
<div className="order-first flex flex-wrap items-center gap-3"><h2 className="font-heading text-xl font-semibold">{t("ความคิดเห็น", "Comments")}</h2>
<ToggleGroup value={[sort]} onValueChange={(values) => { if (values[0]) setSort(values[0]); }} variant="outline" aria-label={t("เรียงความคิดเห็น", "Sort comments")}>
<ToggleGroupItem value="newest">{t("ล่าสุด", "Newest")}</ToggleGroupItem><ToggleGroupItem value="top">{t("ยอดนิยม", "Top")}</ToggleGroupItem>
<div className="flex items-start gap-3"><Avatar className="size-10 shrink-0"><AvatarImage src={feed.page.viewer?.image ?? undefined} alt={feed.page.viewer?.name ?? "โปรไฟล์"} /><AvatarFallback>{feed.page.viewer?.name?.slice(0, 2) || "คุณ"}</AvatarFallback></Avatar><div className="min-w-0 flex-1">{feed.page.viewer ? <CommentComposer target={target} onSaved={() => void feed.refresh()} />
: <div className="flex flex-col items-start gap-2"><p className="text-sm text-muted-foreground">{"เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ"}</p><Button onClick={() => router.push(commentSignInHref())}>{"เข้าสู่ระบบ"}</Button></div>}</div></div>
<div className="order-first flex flex-wrap items-center gap-3"><h2 className="font-heading text-xl font-semibold">{"ความคิดเห็น"}</h2>
<ToggleGroup value={[sort]} onValueChange={(values) => { if (values[0]) setSort(values[0]); }} variant="outline" aria-label={"เรียงความคิดเห็น"}>
<ToggleGroupItem value="newest">{"ล่าสุด"}</ToggleGroupItem><ToggleGroupItem value="top">{"ยอดนิยม"}</ToggleGroupItem>
</ToggleGroup>
<Button variant="ghost" className="ml-auto rounded-full" aria-label={t("รีเฟรชความคิดเห็น", "Refresh comments")} size="icon" disabled={feed.busy} onClick={() => void feed.refresh()}><RefreshCwIcon /></Button>
<Button variant="ghost" className="ml-auto rounded-full" aria-label={"รีเฟรชความคิดเห็น"} size="icon" disabled={feed.busy} onClick={() => void feed.refresh()}><RefreshCwIcon /></Button>
</div>
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
{feed.page.items.filter((item) => item.id !== linkedThread).map((item) => <CommentCard key={item.id} item={item} viewer={feed.page.viewer} onChange={() => feed.refresh({ silent: true })} revision={feed.revision} />)}
{!feed.page.items.length && !feed.busy && !feed.error && <Empty><EmptyHeader><EmptyTitle>{t("ยังไม่มีความคิดเห็น", "No comments yet")}</EmptyTitle><EmptyDescription>{t("เป็นคนแรกที่ร่วมพูดคุยเกี่ยวกับไกด์นี้", "Be the first to discuss this guide")}</EmptyDescription></EmptyHeader></Empty>}
{!feed.page.items.length && !feed.busy && !feed.error && <Empty><EmptyHeader><EmptyTitle>{"ยังไม่มีความคิดเห็น"}</EmptyTitle><EmptyDescription>{"เป็นคนแรกที่ร่วมพูดคุยเกี่ยวกับไกด์นี้"}</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()}>{t("ดูความคิดเห็นเพิ่มเติม", "Load more comments")}</Button>}
{linkedThread && <section aria-label={t("การสนทนาที่เลือก", "Selected thread")} className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2"><h3 className="font-semibold">{t("การสนทนา", "Thread")}</h3><Button variant="ghost" onClick={() => { setLinkedThread(null); setLinkedReply(null); window.history.replaceState(null, "", window.location.pathname + window.location.search); }}>{t("ปิดการสนทนา", "Close thread")}</Button></div>
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>{"ดูความคิดเห็นเพิ่มเติม"}</Button>}
{linkedThread && <section aria-label={"การสนทนาที่เลือก"} className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2"><h3 className="font-semibold">{"การสนทนา"}</h3><Button variant="ghost" onClick={() => { setLinkedThread(null); setLinkedReply(null); window.history.replaceState(null, "", window.location.pathname + window.location.search); }}>{"ปิดการสนทนา"}</Button></div>
<CommentThread rootId={linkedThread} focusReplyId={linkedReply ?? undefined} expectedTarget={target} onChange={() => feed.refresh({ silent: true })} />
</section>}
+45 -53
View File
@@ -1,7 +1,5 @@
"use client";
import { useCommentLocale } from "./comment-locale";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
@@ -22,7 +20,7 @@ import { TableCell, TableRow } from "@/components/ui/table";
import type { CommentHistoryItem, CommentImage, CommentItem, CommentViewer } from "@/lib/comments/types";
import { CommentImageView } from "./comment-image";
import { CommentComposer } from "./comment-composer";
import { commentSignInHref } from "./client";
import { commentRequest, commentSignInHref } from "./client";
import { useCommentEvents } from "./use-comment-events";
import { useCommentFeed } from "./use-comment-feed";
@@ -45,13 +43,12 @@ function subscribeClock(listener: () => void) {
const clockSnapshot = () => clockTime;
const serverClockSnapshot = () => 0;
function CommentTimestamp({ value }: { value: string }) {
const { t, locale } = useCommentLocale();
const now = useSyncExternalStore(subscribeClock, clockSnapshot, serverClockSnapshot);
function relativeTime() {
const seconds = Math.round((new Date(value).getTime() - now) / 1000);
const absolute = Math.abs(seconds);
if (absolute < 60) return t("เมื่อสักครู่", "just now");
const formatter = new Intl.RelativeTimeFormat(locale === "en" ? "en-US" : "th-TH", { numeric: "auto" });
if (absolute < 60) return "เมื่อสักครู่";
const formatter = new Intl.RelativeTimeFormat("th-TH", { numeric: "auto" });
const units: [number, Intl.RelativeTimeFormatUnit][] = [[31536000, "year"], [2592000, "month"], [86400, "day"], [3600, "hour"], [60, "minute"]];
const [size, unit] = units.find(([size]) => absolute >= size)!;
return formatter.format(Math.round(seconds / size), unit);
@@ -62,13 +59,12 @@ function CommentTimestamp({ value }: { value: string }) {
}
export function CommentImages({ images }: { images: CommentImage[] }) {
const { t } = useCommentLocale();
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const selected = selectedIndex === null ? null : images[selectedIndex];
return images.length ? <>
<div className="flex flex-wrap gap-2">{images.map((image, index) =>
<Button key={image.id} variant="ghost" className="h-auto rounded-lg p-0" aria-label={t(`เปิดรูปภาพที่ ${index + 1}`, `Open image ${index + 1}`)} onClick={() => setSelectedIndex(index)}>
<CommentImageView image={image} alt={t(`รูปภาพประกอบความคิดเห็นที่ ${index + 1}`, `Comment image ${index + 1}`)} className="size-28 rounded-lg border object-cover sm:size-36" />
<Button key={image.id} variant="ghost" className="h-auto rounded-lg p-0" aria-label={`เปิดรูปภาพที่ ${index + 1}`} onClick={() => setSelectedIndex(index)}>
<CommentImageView image={image} alt={`รูปภาพประกอบความคิดเห็นที่ ${index + 1}`} className="size-28 rounded-lg border object-cover sm:size-36" />
</Button>)}</div>
<Dialog open={Boolean(selected)} onOpenChange={(open) => { if (!open) setSelectedIndex(null); }}>
<DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-5xl" onKeyDown={(event) => {
@@ -77,12 +73,12 @@ export function CommentImages({ images }: { images: CommentImage[] }) {
setSelectedIndex((current) => Math.max(0, Math.min(images.length - 1, (current ?? 0) + (event.key === "ArrowLeft" ? -1 : 1))));
}
}}>
<DialogHeader><DialogTitle>{t("รูปภาพประกอบความคิดเห็น", "Comment images")}</DialogTitle><DialogDescription>{t("รูปที่", "Image")} {(selectedIndex ?? 0) + 1} {t("จาก", "of")} {images.length}</DialogDescription></DialogHeader>
<DialogHeader><DialogTitle>{"รูปภาพประกอบความคิดเห็น"}</DialogTitle><DialogDescription>{"รูปที่"} {(selectedIndex ?? 0) + 1} {"จาก"} {images.length}</DialogDescription></DialogHeader>
{selected && <>
<CommentImageView fullSize key={selected.id} image={selected} alt={t(`รูปภาพประกอบความคิดเห็นที่ ${(selectedIndex ?? 0) + 1}`, `Comment image ${(selectedIndex ?? 0) + 1}`)} className="object-contain" />
<CommentImageView fullSize key={selected.id} image={selected} alt={`รูปภาพประกอบความคิดเห็นที่ ${(selectedIndex ?? 0) + 1}`} className="object-contain" />
{images.length > 1 && <div className="flex justify-center gap-2">
<Button variant="outline" size="icon" aria-label={t("รูปก่อนหน้า", "Previous image")} disabled={selectedIndex === 0} onClick={() => setSelectedIndex((current) => (current ?? 0) - 1)}><ChevronLeftIcon /></Button>
<Button variant="outline" size="icon" aria-label={t("รูปถัดไป", "Next image")} disabled={selectedIndex === images.length - 1} onClick={() => setSelectedIndex((current) => (current ?? 0) + 1)}><ChevronRightIcon /></Button>
<Button variant="outline" size="icon" aria-label={"รูปก่อนหน้า"} disabled={selectedIndex === 0} onClick={() => setSelectedIndex((current) => (current ?? 0) - 1)}><ChevronLeftIcon /></Button>
<Button variant="outline" size="icon" aria-label={"รูปถัดไป"} disabled={selectedIndex === images.length - 1} onClick={() => setSelectedIndex((current) => (current ?? 0) + 1)}><ChevronRightIcon /></Button>
</div>}
</>}
</DialogContent>
@@ -96,7 +92,6 @@ function CommentText({ text }: { text: string }) {
}
function RevisionHistory({ id }: { id: string }) {
const { request: commentRequest, t, locale } = useCommentLocale();
const [items, setItems] = useState<CommentHistoryItem[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [error, setError] = useState("");
@@ -107,26 +102,25 @@ function RevisionHistory({ id }: { id: string }) {
const result = await commentRequest<{ items: CommentHistoryItem[]; nextCursor: string | null }>(`/api/comments/${id}/history${next ? `?cursor=${next}` : ""}`);
setItems((current) => next ? [...current, ...result.items] : result.items);
setCursor(result.nextCursor); setError("");
} catch (cause) { setError(cause instanceof Error ? cause.message : t("ไม่สามารถโหลดประวัติการแก้ไขได้", "Could not load revision history")); }
} catch (cause) { setError(cause instanceof Error ? cause.message : "ไม่สามารถโหลดประวัติการแก้ไขได้"); }
finally { setBusy(false); }
}, [id, commentRequest, t]);
}, [id]);
useEffect(() => { void Promise.resolve().then(() => load()); }, [load]);
return <div className="flex max-h-[65vh] flex-col gap-4 overflow-y-auto">
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
{items.map((revision) => <section key={revision.id} className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">{t("เวอร์ชัน", "Version")} {revision.version} · {new Date(revision.createdAt).toLocaleString(locale === "en" ? "en-US" : "th-TH")}</p>
<p className="text-sm text-muted-foreground">{"เวอร์ชัน"} {revision.version} · {new Date(revision.createdAt).toLocaleString("th-TH")}</p>
<CommentText text={revision.text} /><CommentImages images={revision.images} /><Separator />
</section>)}
{busy && <CommentSkeleton count={2} />}
{cursor && <Button variant="outline" disabled={busy} onClick={() => void load(cursor)}>{t("ดูเวอร์ชันก่อนหน้า", "Load earlier versions")}</Button>}
{error && <Button variant="outline" disabled={busy} onClick={() => void load()}>{t("ลองใหม่", "Try again")}</Button>}
{cursor && <Button variant="outline" disabled={busy} onClick={() => void load(cursor)}>{"ดูเวอร์ชันก่อนหน้า"}</Button>}
{error && <Button variant="outline" disabled={busy} onClick={() => void load()}>{"ลองใหม่"}</Button>}
</div>;
}
export function CommentCard({ item, viewer, onChange, revision = 0, inbox = false, inboxReply = false, moderationDetails = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false, overview }: {
item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; revision?: number; inbox?: boolean; inboxReply?: boolean; moderationDetails?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean; overview?: ReactNode;
}) {
const { request: commentRequest, t, locale } = useCommentLocale();
const router = useRouter();
const fullWidthMobile = inbox || moderationDetails;
const [mode, setMode] = useState<"reply" | "edit" | null>(null);
@@ -186,7 +180,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
desired.current = confirmedReaction.current;
setReactionOverride(confirmedReaction.current);
}
toast.error(cause instanceof Error ? cause.message : t("ไม่สามารถบันทึกได้ กรุณาลองใหม่", "Could not save. Please try again."));
toast.error(cause instanceof Error ? cause.message : "ไม่สามารถบันทึกได้ กรุณาลองใหม่");
} finally {
queuedReactions.current--;
if (!queuedReactions.current && sequence === reactionSequence.current) {
@@ -205,7 +199,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
...(action === "delete" ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ banned: value }) }),
});
setDeleting(false); setBanning(false); await onChange();
} catch (cause) { toast.error(cause instanceof Error ? cause.message : t("ไม่สามารถอัปเดตความคิดเห็นได้", "Could not update the comment")); }
} catch (cause) { toast.error(cause instanceof Error ? cause.message : "ไม่สามารถอัปเดตความคิดเห็นได้"); }
finally { setPending(false); }
}
function compose(next: "reply" | "edit") {
@@ -214,12 +208,12 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
}
const canReadHistory = (!item.deleted && !item.hidden) || Boolean(viewer?.admin);
const optionsMenu = (item.canEdit || viewer?.admin) && <DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" className="shrink-0 rounded-full" aria-label={t("ตัวเลือกความคิดเห็น", "Comment options")} disabled={pending} />}><EllipsisVerticalIcon /></DropdownMenuTrigger>
<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")}><PencilIcon />{t("แก้ไข", "Edit")}</DropdownMenuItem>}
{(item.canEdit || viewer?.admin) && <DropdownMenuItem variant="destructive" disabled={pending || (viewer?.admin && !item.canModerate)} onClick={() => setDeleting(true)}><Trash2Icon />{t("ลบ", "Delete")}</DropdownMenuItem>}
{item.canEdit && <DropdownMenuItem onClick={() => compose("edit")}><PencilIcon />{"แก้ไข"}</DropdownMenuItem>}
{(item.canEdit || viewer?.admin) && <DropdownMenuItem variant="destructive" disabled={pending || (viewer?.admin && !item.canModerate)} onClick={() => setDeleting(true)}><Trash2Icon />{"ลบ"}</DropdownMenuItem>}
{viewer?.admin && <DropdownMenuItem disabled={pending || !item.canBanAuthor} onClick={() => item.authorBanned ? void mutate("ban", false) : setBanning(true)}>
{item.authorBanned ? <ShieldCheckIcon /> : <BanIcon />}{item.authorBanned ? t("ยกเลิกแบนผู้เขียน", "Unban author") : t("แบนผู้เขียน", "Ban author")}
{item.authorBanned ? <ShieldCheckIcon /> : <BanIcon />}{item.authorBanned ? "ยกเลิกแบนผู้เขียน" : "แบนผู้เขียน"}
</DropdownMenuItem>}
</DropdownMenuGroup></DropdownMenuContent>
</DropdownMenu>;
@@ -231,38 +225,38 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
<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">{t("ผู้ดูแล", "Admin")}</Badge>}
<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">{t("ตอบกลับ", "Reply")}</Badge>}
{(inbox || moderationDetails) && item.hidden && <Badge variant="secondary">{item.ownHidden ? t("ซ่อนอยู่", "Hidden") : t("การสนทนาถูกซ่อน", "Thread hidden")}</Badge>}
{(inbox || moderationDetails) && item.deleted && <Badge variant="secondary">{t("ลบแล้ว", "Deleted")}</Badge>}
{item.version > 1 && canReadHistory && <Button variant="link" size="xs" onClick={() => setHistory(true)}>{t("แก้ไขแล้ว", "Edited")}</Button>}
{inbox && item.rootId && <Badge variant="outline">{"ตอบกลับ"}</Badge>}
{(inbox || moderationDetails) && item.hidden && <Badge variant="secondary">{item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"}</Badge>}
{(inbox || moderationDetails) && item.deleted && <Badge variant="secondary">{"ลบแล้ว"}</Badge>}
{item.version > 1 && canReadHistory && <Button variant="link" size="xs" onClick={() => setHistory(true)}>{"แก้ไขแล้ว"}</Button>}
</div>
{/* Inbox replies reach across the 280px content column and 24px cell padding. */}
{!inbox && optionsMenu && <div className={cn("shrink-0", inboxReply && "md:absolute md:top-2 md:right-[-304px]")}>{optionsMenu}</div>}
</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">{t("ตอบกลับ", "Reply")} {item.replyToName}</p>}
{item.replyToName && <p className="text-xs text-muted-foreground">{"ตอบกลับ"} {item.replyToName}</p>}
{mode === "edit" ? <CommentComposer key="edit" target={item.target} editing={item}
onCancel={() => setMode(null)} onSaved={() => { setMode(null); onChange(); }} /> : <>
{(item.deleted || item.hidden) && !viewer?.admin ? <p className="text-sm text-muted-foreground">{item.deleted ? t("ความคิดเห็นถูกลบแล้ว", "This comment was deleted") : t("ความคิดเห็นถูกซ่อนโดยผู้ดูแล", "This comment was hidden by an admin")}</p> : <CommentText text={item.text} />}
{(item.deleted || item.hidden) && !viewer?.admin ? <p className="text-sm text-muted-foreground">{item.deleted ? "ความคิดเห็นถูกลบแล้ว" : "ความคิดเห็นถูกซ่อนโดยผู้ดูแล"}</p> : <CommentText text={item.text} />}
<CommentImages images={item.images} />
<div className="flex flex-wrap items-center gap-1">
<Button variant={optimistic.reaction === 1 ? "secondary" : "ghost"} size="sm" className="rounded-full" aria-label={t(`ถูกใจความคิดเห็น มี ${optimistic.likes} ถูกใจ`, `Like comment, ${optimistic.likes} likes`)} aria-pressed={optimistic.reaction === 1} disabled={pending || !item.canInteract}
onClick={() => void mutate("reaction", desired.current.reaction === 1 ? 0 : 1)}><ThumbsUpIcon data-icon="inline-start" fill={optimistic.reaction === 1 ? "currentColor" : "none"} />{optimistic.likes > 0 && optimistic.likes.toLocaleString(locale === "en" ? "en-US" : "th-TH")}</Button>
<Button variant={optimistic.reaction === -1 ? "secondary" : "ghost"} size="icon-sm" className="rounded-full" aria-label={t("ไม่ถูกใจความคิดเห็น", "Dislike comment")} aria-pressed={optimistic.reaction === -1} disabled={pending || !item.canInteract}
<Button variant={optimistic.reaction === 1 ? "secondary" : "ghost"} size="sm" className="rounded-full" aria-label={`ถูกใจความคิดเห็น มี ${optimistic.likes} ถูกใจ`} aria-pressed={optimistic.reaction === 1} disabled={pending || !item.canInteract}
onClick={() => void mutate("reaction", desired.current.reaction === 1 ? 0 : 1)}><ThumbsUpIcon data-icon="inline-start" fill={optimistic.reaction === 1 ? "currentColor" : "none"} />{optimistic.likes > 0 && optimistic.likes.toLocaleString("th-TH")}</Button>
<Button variant={optimistic.reaction === -1 ? "secondary" : "ghost"} size="icon-sm" className="rounded-full" aria-label={"ไม่ถูกใจความคิดเห็น"} aria-pressed={optimistic.reaction === -1} disabled={pending || !item.canInteract}
onClick={() => void mutate("reaction", desired.current.reaction === -1 ? 0 : -1)}><ThumbsDownIcon fill={optimistic.reaction === -1 ? "currentColor" : "none"} /></Button>
{item.canInteract && <Button variant="ghost" size="sm" className="rounded-full" onClick={() => compose("reply")} disabled={pending}>{t("ตอบกลับ", "Reply")}</Button>}
{item.canInteract && <Button variant="ghost" size="sm" className="rounded-full" onClick={() => compose("reply")} disabled={pending}>{"ตอบกลับ"}</Button>}
{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 ? t("ผู้ดูแลถูกใจ", "Liked by admin") : null}</Button>
: optimistic.hearted && !item.deleted && !item.hidden && <Badge variant="secondary"><HeartIcon data-icon="inline-start" className="text-destructive" fill="currentColor" />{t("ผู้ดูแลถูกใจ", "Liked by admin")}</Badge>}
<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>}
</div>
</>}
{mode === "reply" && <CommentComposer key="reply" target={item.target} replyTo={item}
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"))}>
<AccordionItem value="replies"><AccordionTrigger ref={replyToggleRef} className="flex-none gap-2 rounded-full px-3" headerClassName={expanded ? "hidden" : "justify-start"}>{t(`การตอบกลับ ${item.replyCount.toLocaleString("th-TH")} รายการ`, `${item.replyCount.toLocaleString("en-US")} replies`)}</AccordionTrigger>
<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} inbox={inbox} />
<div className={cn("relative self-start before:absolute before:-left-8 before:top-0 before:h-5 before:w-8 before:rounded-bl-2xl before:border-b before:border-l before:border-border", fullWidthMobile && "max-md:before:hidden")}>
@@ -278,21 +272,20 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
{inbox && <TableCell className="px-0 py-5 align-top whitespace-normal max-md:block max-md:pt-0">
<div className="flex items-start gap-2 md:justify-end">{optionsMenu}{overview}</div>
</TableCell>}
<Dialog open={history} onOpenChange={setHistory}><DialogContent className="sm:max-w-2xl"><DialogHeader><DialogTitle>{t("ประวัติการแก้ไข", "Revision history")}</DialogTitle><DialogDescription>{t("ดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น", "View the text and images in each revision of this comment")}</DialogDescription></DialogHeader>
<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>{t("ลบความคิดเห็นนี้?", "Delete this comment?")}</DialogTitle><DialogDescription>{t("ความคิดเห็นนี้ คำตอบกลับทุกระดับ ประวัติการแก้ไข และรูปภาพจะถูกลบอย่างถาวร ไม่สามารถกู้คืนได้", "This comment, all replies, revision history, and images will be permanently deleted. This cannot be undone.")}</DialogDescription></DialogHeader>
<div className="flex justify-end gap-2"><Button variant="ghost" disabled={pending} onClick={() => setDeleting(false)}>{t("ยกเลิก", "Cancel")}</Button><Button variant="destructive" disabled={pending} onClick={() => void mutate("delete")}>{t("ลบ", "Delete")}</Button></div>
<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>
<AlertDialog open={banning} onOpenChange={(open) => { if (!pending) setBanning(open); }}><AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>{t("แบน", "Ban")} {item.authorName}?</AlertDialogTitle><AlertDialogDescription>{t("ผู้เขียนจะถูกออกจากระบบและไม่สามารถเข้าสู่ระบบ แสดงความคิดเห็น หรือใช้งานบัญชีได้ จนกว่าผู้ดูแลจะยกเลิกแบน ความคิดเห็นเดิมจะยังอยู่", "The author will be signed out and unable to sign in, comment, or use their account until an admin lifts the ban. Existing comments will remain.")}</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel className="max-md:min-h-11" disabled={pending}>{t("ยกเลิก", "Cancel")}</AlertDialogCancel><AlertDialogAction variant="destructive" className="max-md:min-h-11" disabled={pending} onClick={() => void mutate("ban", true)}><BanIcon data-icon="inline-start" />{t("แบนผู้เขียน", "Ban author")}</AlertDialogAction></AlertDialogFooter>
<AlertDialogHeader><AlertDialogTitle>{"แบน"} {item.authorName}?</AlertDialogTitle><AlertDialogDescription>{"ผู้เขียนจะถูกออกจากระบบและไม่สามารถเข้าสู่ระบบ แสดงความคิดเห็น หรือใช้งานบัญชีได้ จนกว่าผู้ดูแลจะยกเลิกแบน ความคิดเห็นเดิมจะยังอยู่"}</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel className="max-md:min-h-11" disabled={pending}>{"ยกเลิก"}</AlertDialogCancel><AlertDialogAction variant="destructive" className="max-md:min-h-11" disabled={pending} onClick={() => void mutate("ban", true)}><BanIcon data-icon="inline-start" />{"แบนผู้เขียน"}</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent></AlertDialog>
</Container>;
}
function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDetails = false, inbox = false }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise<unknown>; moderationDetails?: boolean; inbox?: boolean }) {
const { t } = useCommentLocale();
const feed = useCommentFeed(`/api/comments/${rootId}/replies?view=thread`);
const { refresh, busy, error, page, more } = feed;
const previousRevision = useRef(revision);
@@ -315,8 +308,8 @@ function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDe
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
<CommentReplyTree focusReplyId={focusReplyId} items={feed.page.items} viewer={feed.page.viewer} moderationDetails={moderationDetails} inbox={inbox} onChange={async () => { await refresh({ silent: true }); await onChange(); }} />
{feed.busy && <CommentSkeleton count={2} />}
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>{t("ดูคำตอบเพิ่มเติม", "Load more replies")}</Button>}
{feed.error && <Button variant="outline" disabled={feed.busy} onClick={() => void refresh()}>{t("ลองใหม่", "Try again")}</Button>}
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>{"ดูคำตอบเพิ่มเติม"}</Button>}
{feed.error && <Button variant="outline" disabled={feed.busy} onClick={() => void refresh()}>{"ลองใหม่"}</Button>}
</div>;
}
@@ -348,7 +341,6 @@ function CommentReplyTree({ items, viewer, onChange, focusReplyId, moderationDet
}
export function CommentThread({ rootId, expectedTarget, onChange, focusReplyId }: { focusReplyId?: string; rootId: string; expectedTarget: string; onChange: () => void | Promise<unknown> }) {
const { request: commentRequest, t } = useCommentLocale();
const [data, setData] = useState<{ item: CommentItem; viewer: CommentViewer | null } | null>(null);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
@@ -356,14 +348,14 @@ export function CommentThread({ rootId, expectedTarget, onChange, focusReplyId }
const load = useCallback(async (forceReplies = false) => {
try {
const result = await commentRequest<{ item: CommentItem; viewer: CommentViewer | null }>(`/api/comments/${rootId}`);
if (result.item.target !== expectedTarget) throw new Error(t("การสนทนานี้เป็นของไกด์อื่น", "This thread belongs to another guide"));
if (result.item.target !== expectedTarget) throw new Error("การสนทนานี้เป็นของไกด์อื่น");
const payload = JSON.stringify(result);
if (lastThread.current !== payload) {
lastThread.current = payload; setData(result); setRevision((value) => value + 1);
} else if (forceReplies) setRevision((value) => value + 1);
setError("");
} catch (cause) { lastThread.current = ""; setData(null); setError(cause instanceof Error ? cause.message : t("ไม่สามารถโหลดการสนทนาได้", "Could not load this thread")); }
}, [rootId, expectedTarget, commentRequest, t]);
} catch (cause) { lastThread.current = ""; setData(null); setError(cause instanceof Error ? cause.message : "ไม่สามารถโหลดการสนทนาได้"); }
}, [rootId, expectedTarget]);
useCommentEvents({ target: expectedTarget }, load);
useEffect(() => {
void Promise.resolve().then(() => load());
@@ -373,6 +365,6 @@ export function CommentThread({ rootId, expectedTarget, onChange, focusReplyId }
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
{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()}>{t("ลองใหม่", "Try again")}</Button>}
{error && <Button variant="outline" onClick={() => void load()}>{"ลองใหม่"}</Button>}
</div>;
}
+25 -29
View File
@@ -1,7 +1,5 @@
"use client";
import { useCommentLocale } from "./comment-locale";
import { useEffect, useId, useRef, useState, type ClipboardEvent, type ComponentProps, type FormEvent } from "react";
import dynamic from "next/dynamic";
import Image from "@/components/ui/resilient-image";
@@ -16,7 +14,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import type { CommentImage, CommentItem } from "@/lib/comments/types";
import { CommentImageView } from "./comment-image";
import { getCommentErrors } from "./client";
import { COMMENT_ERRORS } from "./client";
const EmojiPicker = dynamic(async () => {
const { default: Picker, EmojiStyle } = await import("emoji-picker-react");
@@ -38,8 +36,6 @@ function FilePreview({ file }: { file: File }) {
export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }: {
target: string; replyTo?: CommentItem; editing?: CommentItem; onSaved: () => void; onCancel?: () => void;
}) {
const { t, locale } = useCommentLocale();
const commentErrors = getCommentErrors(locale);
const id = useId();
const [text, setText] = useState(editing?.text ?? "");
const [kept, setKept] = useState<CommentImage[]>(editing?.images ?? []);
@@ -61,7 +57,7 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
const start = textRef.current?.selectionStart ?? text.length;
const end = textRef.current?.selectionEnd ?? start;
const next = text.slice(0, start) + emoji + text.slice(end);
if (next.length > 4000) return setError(t("ความคิดเห็นต้องไม่เกิน 4,000 ตัวอักษร", "Comments must not exceed 4,000 characters"));
if (next.length > 4000) return setError("ความคิดเห็นต้องไม่เกิน 4,000 ตัวอักษร");
setText(next);
setExpanded(true);
setEmojiOpen(false);
@@ -72,10 +68,10 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
}
function addImages(incoming: File[]) {
if (incoming.length + files.length + kept.length > 5) return setError(t("แนบรูปภาพได้สูงสุด 5 รูปต่อความคิดเห็น", "Attach up to 5 images per comment"));
if (incoming.length + files.length + kept.length > 5) return setError("แนบรูปภาพได้สูงสุด 5 รูปต่อความคิดเห็น");
if (incoming.some((file) => !["image/jpeg", "image/png", "image/webp"].includes(file.type)))
return setError(commentErrors["unsupported-image-type"]);
if (incoming.some((file) => !file.size || file.size > 10 * 1024 * 1024)) return setError(commentErrors["image-too-large"]);
return setError(COMMENT_ERRORS["unsupported-image-type"]);
if (incoming.some((file) => !file.size || file.size > 10 * 1024 * 1024)) return setError(COMMENT_ERRORS["image-too-large"]);
setFiles((current) => [...current, ...incoming]);
setError("");
}
@@ -99,7 +95,7 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
async function submit(event: FormEvent) {
event.preventDefault();
if (pending) return;
if (!text.trim() && !files.length && !kept.length) return setError(commentErrors["empty-comment"]);
if (!text.trim() && !files.length && !kept.length) return setError(COMMENT_ERRORS["empty-comment"]);
setPending(true);
setError("");
setProgress(0);
@@ -117,25 +113,25 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
xhr.upload.onprogress = (event) => { if (event.lengthComputable) setProgress(Math.round(event.loaded / event.total * 100)); };
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) return resolve();
let message = t("ไม่สามารถบันทึกความคิดเห็นได้ กรุณาลองใหม่", "Could not save your comment. Please try again.");
try { message = commentErrors[JSON.parse(xhr.responseText).error] ?? message; } catch { /* Keep the draft if the server returned a non-JSON error. */ }
let message = "ไม่สามารถบันทึกความคิดเห็นได้ กรุณาลองใหม่";
try { message = COMMENT_ERRORS[JSON.parse(xhr.responseText).error] ?? message; } catch { /* Keep the draft if the server returned a non-JSON error. */ }
reject(new Error(message));
};
xhr.onerror = () => reject(new Error(t("การเชื่อมต่อขัดข้อง ข้อความร่างของคุณยังอยู่", "Connection failed. Your draft has been kept.")));
xhr.onabort = () => reject(new Error(t("ยกเลิกการอัปโหลดแล้ว", "Upload cancelled")));
xhr.onerror = () => reject(new Error("การเชื่อมต่อขัดข้อง ข้อความร่างของคุณยังอยู่"));
xhr.onabort = () => reject(new Error("ยกเลิกการอัปโหลดแล้ว"));
xhr.send(form);
});
setText(""); setFiles([]); setKept([]); setExpanded(false);
toast.success(editing ? t("แก้ไขความคิดเห็นแล้ว", "Comment updated") : t("ส่งความคิดเห็นแล้ว", "Comment posted"));
toast.success(editing ? "แก้ไขความคิดเห็นแล้ว" : "ส่งความคิดเห็นแล้ว");
onSaved();
} catch (cause) { setError(cause instanceof Error ? cause.message : t("ไม่สามารถบันทึกความคิดเห็นได้", "Could not save your comment")); }
} catch (cause) { setError(cause instanceof Error ? cause.message : "ไม่สามารถบันทึกความคิดเห็นได้"); }
finally { setPending(false); uploadRef.current = null; }
}
return <form onSubmit={submit} className="flex flex-col gap-3">
<FieldGroup>
<Field data-invalid={Boolean(error)}>
<FieldLabel htmlFor={`${id}-text`} className="sr-only">{editing ? t("แก้ไขความคิดเห็น", "Edit comment") : replyTo ? t(`ตอบกลับ ${replyTo.authorName}`, `Reply to ${replyTo.authorName}`) : t("เพิ่มความคิดเห็น", "Add a comment")}</FieldLabel>
<FieldLabel htmlFor={`${id}-text`} className="sr-only">{editing ? "แก้ไขความคิดเห็น" : replyTo ? `ตอบกลับ ${replyTo.authorName}` : "เพิ่มความคิดเห็น"}</FieldLabel>
<Textarea ref={textRef} id={`${id}-text`} value={text} onChange={(event) => setText(event.target.value)} maxLength={4000} rows={expanded ? 2 : 1} onFocus={() => setExpanded(true)} onPaste={pasteImages}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing && event.nativeEvent.keyCode !== 229) {
@@ -144,39 +140,39 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
}
}}
className="min-h-10 resize-none rounded-none border-x-0 border-t-0 px-0 focus-visible:ring-0"
disabled={pending} aria-invalid={Boolean(error)} placeholder={t("เพิ่มความคิดเห็น…", "Add a comment…")} />
{expanded && <FieldDescription>{text.length.toLocaleString(locale === "en" ? "en-US" : "th-TH")} / 4,000</FieldDescription>}
disabled={pending} aria-invalid={Boolean(error)} placeholder={"เพิ่มความคิดเห็น…"} />
{expanded && <FieldDescription>{text.length.toLocaleString("th-TH")} / 4,000</FieldDescription>}
</Field>
<Field className="hidden">
<FieldLabel htmlFor={`${id}-images`} className="sr-only">{t("แนบรูปภาพ", "Attach images")}</FieldLabel>
<FieldLabel htmlFor={`${id}-images`} className="sr-only">{"แนบรูปภาพ"}</FieldLabel>
<Input ref={fileRef} id={`${id}-images`} type="file" tabIndex={-1} multiple accept="image/jpeg,image/png,image/webp" className="hidden" disabled={pending}
onChange={(event) => { addImages(Array.from(event.target.files ?? [])); event.target.value = ""; }} />
</Field>
</FieldGroup>
{(files.length > 0 || kept.length > 0) && <div className="flex flex-wrap gap-3">
{kept.map((image) => <div key={image.id} className="flex flex-col gap-1">
<CommentImageView image={image} alt={t("รูปภาพที่แนบ", "Attached image")} className="size-24 rounded-lg object-cover" />
<Button type="button" variant="ghost" size="sm" disabled={pending} onClick={() => setKept((current) => current.filter((entry) => entry.id !== image.id))}><XIcon data-icon="inline-start" />{t("นำออก", "Remove")}</Button>
<CommentImageView image={image} alt={"รูปภาพที่แนบ"} className="size-24 rounded-lg object-cover" />
<Button type="button" variant="ghost" size="sm" disabled={pending} onClick={() => setKept((current) => current.filter((entry) => entry.id !== image.id))}><XIcon data-icon="inline-start" />{"นำออก"}</Button>
</div>)}
{files.map((file, index) => <div key={`${file.name}-${index}`} className="flex flex-col gap-1"><FilePreview file={file} />
<Button type="button" variant="ghost" size="sm" disabled={pending} onClick={() => setFiles((current) => current.filter((_, position) => position !== index))}><XIcon data-icon="inline-start" />{t("นำออก", "Remove")}</Button>
<Button type="button" variant="ghost" size="sm" disabled={pending} onClick={() => setFiles((current) => current.filter((_, position) => position !== index))}><XIcon data-icon="inline-start" />{"นำออก"}</Button>
</div>)}
</div>}
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
{pending && <Progress value={progress}><ProgressLabel>{progress === 100 ? t("กำลังบันทึกความคิดเห็น…", "Saving comment…") : t("กำลังอัปโหลด…", "Uploading…")}</ProgressLabel><ProgressValue /></Progress>}
{pending && <Progress value={progress}><ProgressLabel>{progress === 100 ? "กำลังบันทึกความคิดเห็น…" : "กำลังอัปโหลด…"}</ProgressLabel><ProgressValue /></Progress>}
<div className="flex flex-wrap items-center justify-end gap-2">
<div className="mr-auto flex items-center gap-1">
<Button type="button" variant="ghost" className="rounded-full" aria-label={t("แนบรูปภาพ", "Attach images")} disabled={pending || files.length + kept.length >= 5} onClick={() => fileRef.current?.click()}><ImagePlusIcon data-icon="inline-start" /><span className="hidden sm:inline">{t("รูปภาพ", "Images")}</span></Button>
<Button type="button" variant="ghost" className="rounded-full" aria-label={"แนบรูปภาพ"} disabled={pending || files.length + kept.length >= 5} onClick={() => fileRef.current?.click()}><ImagePlusIcon data-icon="inline-start" /><span className="hidden sm:inline">{"รูปภาพ"}</span></Button>
<Popover open={emojiOpen} onOpenChange={setEmojiOpen}>
<PopoverTrigger render={<Button type="button" variant="ghost" size="icon" className="rounded-full" aria-label={t("เพิ่มอีโมจิ", "Add emoji")} disabled={pending} />}><SmilePlusIcon /></PopoverTrigger>
<PopoverTrigger render={<Button type="button" variant="ghost" size="icon" className="rounded-full" aria-label={"เพิ่มอีโมจิ"} disabled={pending} />}><SmilePlusIcon /></PopoverTrigger>
<PopoverContent align="start" className="w-80 max-w-[calc(100vw-2rem)] p-0" finalFocus={textRef}>
<PopoverTitle className="sr-only">{t("เลือกอีโมจิ", "Choose an emoji")}</PopoverTitle>
<PopoverTitle className="sr-only">{"เลือกอีโมจิ"}</PopoverTitle>
{emojiOpen && <EmojiPicker width="100%" height={350} lazyLoadEmojis onEmojiClick={(value) => insertEmoji(value.emoji)} />}
</PopoverContent>
</Popover>
</div>
{expanded && <Button type="button" variant="ghost" disabled={pending} onClick={cancel}>{t("ยกเลิก", "Cancel")}</Button>}
<Button type="submit" disabled={pending || (!text.trim() && !files.length && !kept.length)}>{pending ? t("กำลังบันทึก…", "Saving…") : editing ? t("บันทึกการแก้ไข", "Save changes") : replyTo ? t("ตอบกลับ", "Reply") : t("แสดงความคิดเห็น", "Post comment")}</Button>
{expanded && <Button type="button" variant="ghost" disabled={pending} onClick={cancel}>{"ยกเลิก"}</Button>}
<Button type="submit" disabled={pending || (!text.trim() && !files.length && !kept.length)}>{pending ? "กำลังบันทึก…" : editing ? "บันทึกการแก้ไข" : replyTo ? "ตอบกลับ" : "แสดงความคิดเห็น"}</Button>
</div>
</form>;
}
@@ -1,6 +1,5 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { CommentLocaleProvider } from "./comment-locale";
import { CommentBoard } from "./comment-board";
import { CommentComposer } from "./comment-composer";
import { AdminCommentInbox } from "./admin-comment-inbox";
@@ -20,28 +19,25 @@ const comment: CommentItem = {
target: "guide:test", targetName: "Test", targetImage: null, href: "/test/comment",
};
describe("comment UI language boundaries", () => {
describe("Thai comment UI", () => {
it("keeps the public board in Thai", () => {
const html = renderToStaticMarkup(<CommentBoard target="guide:test" initial={{ items: [], nextCursor: null, viewer }} />);
expect(html).toContain("ความคิดเห็น");
expect(html).toContain("เพิ่มความคิดเห็น…");
});
it("renders the admin inbox filters and empty state in English", () => {
const html = renderToStaticMarkup(<CommentLocaleProvider locale="en">
<AdminCommentInbox initial={{ items: [], nextCursor: null, viewer }} targets={[]} />
</CommentLocaleProvider>);
expect(html).toContain("Admin replies");
expect(html).toContain("No matching comments");
expect(html).not.toMatch(/[\u0e00-\u0e7f]/);
it("renders the admin inbox filters and empty state in Thai", () => {
const html = renderToStaticMarkup(<AdminCommentInbox initial={{ items: [], nextCursor: null, viewer }} targets={[]} />);
expect(html).toContain("การตอบกลับจากผู้ดูแล");
expect(html).toContain("ไม่พบความคิดเห็นที่ตรงกัน");
expect(html).not.toContain("Admin replies");
expect(html).not.toContain("No matching comments");
});
it("localizes nested editing controls while preserving the draft", () => {
const html = renderToStaticMarkup(<CommentLocaleProvider locale="en">
<CommentComposer target="guide:test" editing={comment} onSaved={() => {}} />
</CommentLocaleProvider>);
expect(html).toContain("Save changes");
expect(html).toContain("Attach images");
it("renders editing controls in Thai while preserving the draft", () => {
const html = renderToStaticMarkup(<CommentComposer target="guide:test" editing={comment} onSaved={() => {}} />);
expect(html).toContain("บันทึกการแก้ไข");
expect(html).toContain("แนบรูปภาพ");
expect(html).toContain(comment.text);
});
});
-17
View File
@@ -1,17 +0,0 @@
"use client";
import { createContext, useCallback, useContext, type ReactNode } from "react";
import { commentRequest, type CommentLocale } from "./client";
const LocaleContext = createContext<CommentLocale>("th");
export function CommentLocaleProvider({ locale, children }: { locale: CommentLocale; children: ReactNode }) {
return <LocaleContext value={locale}>{children}</LocaleContext>;
}
export function useCommentLocale() {
const locale = useContext(LocaleContext);
const t = useCallback((thai: string, english: string) => locale === "en" ? english : thai, [locale]);
const request = useCallback(<T,>(url: string, options?: RequestInit) => commentRequest<T>(url, options, locale), [locale]);
return { locale, t, request };
}
+4 -7
View File
@@ -1,13 +1,11 @@
"use client";
import { useCommentLocale } from "./comment-locale";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
export function AdminCommentRowsSkeleton({ count = 3 }: { count?: number }) {
const { t } = useCommentLocale();
return <div role="status" aria-label={t("กำลังโหลดความคิดเห็น", "Loading comments")} className="flex min-w-0 flex-col">
<span className="sr-only">{t("กำลังโหลดความคิดเห็น…", "Loading comments…")}</span>
return <div role="status" aria-label={"กำลังโหลดความคิดเห็น"} className="flex min-w-0 flex-col">
<span className="sr-only">{"กำลังโหลดความคิดเห็น…"}</span>
<Table className="table-fixed max-md:block" aria-hidden="true">
<TableBody className="max-md:block">{Array.from({ length: count }, (_, index) => <TableRow key={index} className="max-md:block">
<TableCell className="px-0 py-5 align-top whitespace-normal max-md:block max-md:pb-3 md:pr-6">
@@ -40,9 +38,8 @@ export function AdminCommentInboxSkeleton() {
}
export function CommentSkeleton({ count = 3 }: { count?: number }) {
const { t } = useCommentLocale();
return <div role="status" aria-label={t("กำลังโหลดความคิดเห็น", "Loading comments")} className="flex flex-col gap-6">
<span className="sr-only">{t("กำลังโหลดความคิดเห็น…", "Loading comments…")}</span>
return <div role="status" aria-label={"กำลังโหลดความคิดเห็น"} className="flex flex-col gap-6">
<span className="sr-only">{"กำลังโหลดความคิดเห็น…"}</span>
<div aria-hidden="true" className="flex flex-col gap-6">{Array.from({ length: count }, (_, index) =>
<div key={index} className="flex gap-3">
<Skeleton className="size-10 shrink-0 rounded-full" />
+5 -7
View File
@@ -1,12 +1,10 @@
"use client";
import { useCommentLocale } from "./comment-locale";
import { commentRequest } from "./client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { CommentPage } from "@/lib/comments/types";
export function useCommentFeed(endpoint: string, initial?: CommentPage) {
const { request: commentRequest, t } = useCommentLocale();
const [page, setPage] = useState<CommentPage>(initial ?? { items: [], nextCursor: null, viewer: null });
const [dataEndpoint, setDataEndpoint] = useState(endpoint);
const [busy, setBusy] = useState(!initial);
@@ -40,9 +38,9 @@ export function useCommentFeed(endpoint: string, initial?: CommentPage) {
setDataEndpoint(endpoint); setError("");
}
} catch (cause) {
if (!controller.signal.aborted && sequence === generation.current) setError(cause instanceof Error ? cause.message : t("ไม่สามารถโหลดความคิดเห็นได้", "Could not load comments"));
if (!controller.signal.aborted && sequence === generation.current) setError(cause instanceof Error ? cause.message : "ไม่สามารถโหลดความคิดเห็นได้");
} finally { if (sequence === generation.current) setBusy(false); }
}, [endpoint, commentRequest, t]);
}, [endpoint]);
const more = useCallback(async () => {
if (!page.nextCursor || busy) return;
const sequence = ++generation.current;
@@ -56,9 +54,9 @@ export function useCommentFeed(endpoint: string, initial?: CommentPage) {
loadedPages.current += 1;
setError("");
}
} catch (cause) { if (sequence === generation.current) setError(cause instanceof Error ? cause.message : t("ไม่สามารถโหลดความคิดเห็นได้", "Could not load comments")); }
} catch (cause) { if (sequence === generation.current) setError(cause instanceof Error ? cause.message : "ไม่สามารถโหลดความคิดเห็นได้"); }
finally { if (sequence === generation.current) setBusy(false); }
}, [endpoint, page, busy, commentRequest, t]);
}, [endpoint, page, busy]);
useEffect(() => {
if (mountedEndpoint.current !== endpoint) {
loadedPages.current = 1;