diff --git a/app/admin/[character]/comment/page.tsx b/app/admin/[character]/comment/page.tsx index 7b95143..bb58f60 100644 --- a/app/admin/[character]/comment/page.tsx +++ b/app/admin/[character]/comment/page.tsx @@ -32,10 +32,10 @@ export default async function GuideCommentPage({ return (
-
+
-
@@ -43,7 +43,7 @@ export default async function GuideCommentPage({

{guide.name}

-

Comment

+

ความคิดเห็น

diff --git a/app/admin/comments/page.tsx b/app/admin/comments/page.tsx index 83669e2..2662104 100644 --- a/app/admin/comments/page.tsx +++ b/app/admin/comments/page.tsx @@ -12,8 +12,8 @@ export default async function AdminCommentsPage({ searchParams }: { searchParams const initialTarget = query.target; const initialComment = initialTarget && query.comment && /^[0-9a-f-]{36}$/i.test(query.comment) ? query.comment : undefined; const [initial, targets] = await Promise.all([listComments({ viewer, inbox: true, grouped: true, target: initialTarget }), listCommentTargets()]); - return <>
-

Comments

Reply to and manage comments from every guide

+ return <>
+

ความคิดเห็น

ตอบกลับและจัดการความคิดเห็นจากทุกไกด์และทุกรอบ Stygian

; } diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 3fefbb1..d8304f5 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,6 +1,5 @@ import { Suspense } from "react"; -import { CommentLocaleProvider } from "@/components/comments/comment-locale"; import { AdminUnreadCommentsProvider } from "@/components/comments/admin-unread-comments"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -14,7 +13,7 @@ export default function AdminLayout({ children }: LayoutProps<"/admin">) {
} > - {children} + {children} ); diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index e79b77c..257a16c 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -34,8 +34,8 @@ export function AdminHeader() {
; } diff --git a/components/comments/admin-unread-comments.tsx b/components/comments/admin-unread-comments.tsx index f8ee596..fd2d3e2 100644 --- a/components/comments/admin-unread-comments.tsx +++ b/components/comments/admin-unread-comments.tsx @@ -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; total: number }; const UnreadContext = createContext Promise }>({ counts: {}, total: 0, markRead: async () => {} }); export function AdminUnreadCommentsProvider({ children }: { children: ReactNode }) { - const { request: commentRequest } = useCommentLocale(); const [counts, setCounts] = useState({ counts: {}, total: 0 }); const generation = useRef(0); const refresh = useCallback(async () => { try { const sequence = ++generation.current; const result = await commentRequest("/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 {children}; } 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 ? {count > 99 ? "99+" : count} : null; + return count > 0 ? {count > 99 ? "99+" : count} : null; } export function AdminUnreadBadge({ target }: { target: string }) { - const { t } = useCommentLocale(); const state = useContext(UnreadContext); if (!(state.counts[target] > 0)) return null; - return ; + return ; } diff --git a/components/comments/client.test.ts b/components/comments/client.test.ts index e9651c2..74cf76b 100644 --- a/components/comments/client.test.ts +++ b/components/comments/client.test.ts @@ -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("ไม่สามารถทำรายการได้ กรุณาลองใหม่"); }); }); diff --git a/components/comments/client.ts b/components/comments/client.ts index 2a3e9d6..9f57bad 100644 --- a/components/comments/client.ts +++ b/components/comments/client.ts @@ -1,5 +1,3 @@ -export type CommentLocale = "th" | "en"; - export const COMMENT_ERRORS: Record = { "sign-in-required": "กรุณาเข้าสู่ระบบเพื่อแสดงความคิดเห็น", "too-many-requests": "ทำรายการบ่อยเกินไป กรุณารอสักครู่แล้วลองใหม่", @@ -21,37 +19,11 @@ export const COMMENT_ERRORS: Record = { "comment-author-ban-not-allowed": "ไม่สามารถแบนผู้เขียนความคิดเห็นนี้ได้", "guide-trashed": "ไกด์นี้อยู่ในถังขยะ จึงไม่สามารถแสดงความคิดเห็นได้", }; -const ENGLISH_COMMENT_ERRORS: Record = { - "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(url: string, options?: RequestInit, locale: CommentLocale = "th"): Promise { +export async function commentRequest(url: string, options?: RequestInit): Promise { 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; } diff --git a/components/comments/comment-board.tsx b/components/comments/comment-board.tsx index 85bd59c..e9e991f 100644 --- a/components/comments/comment-board.tsx +++ b/components/comments/comment-board.tsx @@ -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(null); @@ -41,21 +38,21 @@ export function CommentBoard({ target, initial }: { target: string; initial: Com return () => window.removeEventListener("hashchange", showThread); }, []); return
-
{feed.page.viewer?.name?.slice(0, 2) || t("คุณ", "You")}
{feed.page.viewer ? void feed.refresh()} /> - :

{t("เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ", "Sign in to comment, reply, and like comments")}

}
-

{t("ความคิดเห็น", "Comments")}

- { if (values[0]) setSort(values[0]); }} variant="outline" aria-label={t("เรียงความคิดเห็น", "Sort comments")}> - {t("ล่าสุด", "Newest")}{t("ยอดนิยม", "Top")} +
{feed.page.viewer?.name?.slice(0, 2) || "คุณ"}
{feed.page.viewer ? void feed.refresh()} /> + :

{"เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ"}

}
+

{"ความคิดเห็น"}

+ { if (values[0]) setSort(values[0]); }} variant="outline" aria-label={"เรียงความคิดเห็น"}> + {"ล่าสุด"}{"ยอดนิยม"} - +
{feed.error && {feed.error}} {feed.page.items.filter((item) => item.id !== linkedThread).map((item) => feed.refresh({ silent: true })} revision={feed.revision} />)} - {!feed.page.items.length && !feed.busy && !feed.error && {t("ยังไม่มีความคิดเห็น", "No comments yet")}{t("เป็นคนแรกที่ร่วมพูดคุยเกี่ยวกับไกด์นี้", "Be the first to discuss this guide")}} + {!feed.page.items.length && !feed.busy && !feed.error && {"ยังไม่มีความคิดเห็น"}{"เป็นคนแรกที่ร่วมพูดคุยเกี่ยวกับไกด์นี้"}} {feed.busy && } - {feed.page.nextCursor && } - {linkedThread &&
-

{t("การสนทนา", "Thread")}

+ {feed.page.nextCursor && } + {linkedThread &&
+

{"การสนทนา"}

feed.refresh({ silent: true })} />
} diff --git a/components/comments/comment-card.tsx b/components/comments/comment-card.tsx index ceed22c..c3b4d6b 100644 --- a/components/comments/comment-card.tsx +++ b/components/comments/comment-card.tsx @@ -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(null); const selected = selectedIndex === null ? null : images[selectedIndex]; return images.length ? <>
{images.map((image, index) => - )}
{ if (!open) setSelectedIndex(null); }}> { @@ -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)))); } }}> - {t("รูปภาพประกอบความคิดเห็น", "Comment images")}{t("รูปที่", "Image")} {(selectedIndex ?? 0) + 1} {t("จาก", "of")} {images.length} + {"รูปภาพประกอบความคิดเห็น"}{"รูปที่"} {(selectedIndex ?? 0) + 1} {"จาก"} {images.length} {selected && <> - + {images.length > 1 &&
- - + +
} }
@@ -96,7 +92,6 @@ function CommentText({ text }: { text: string }) { } function RevisionHistory({ id }: { id: string }) { - const { request: commentRequest, t, locale } = useCommentLocale(); const [items, setItems] = useState([]); const [cursor, setCursor] = useState(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
{error && {error}} {items.map((revision) =>
-

{t("เวอร์ชัน", "Version")} {revision.version} · {new Date(revision.createdAt).toLocaleString(locale === "en" ? "en-US" : "th-TH")}

+

{"เวอร์ชัน"} {revision.version} · {new Date(revision.createdAt).toLocaleString("th-TH")}

)} {busy && } - {cursor && } - {error && } + {cursor && } + {error && }
; } 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; 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) && - }> + }> - {item.canEdit && compose("edit")}>{t("แก้ไข", "Edit")}} - {(item.canEdit || viewer?.admin) && setDeleting(true)}>{t("ลบ", "Delete")}} + {item.canEdit && compose("edit")}>{"แก้ไข"}} + {(item.canEdit || viewer?.admin) && setDeleting(true)}>{"ลบ"}} {viewer?.admin && item.authorBanned ? void mutate("ban", false) : setBanning(true)}> - {item.authorBanned ? : }{item.authorBanned ? t("ยกเลิกแบนผู้เขียน", "Unban author") : t("แบนผู้เขียน", "Ban author")} + {item.authorBanned ? : }{item.authorBanned ? "ยกเลิกแบนผู้เขียน" : "แบนผู้เขียน"} } ; @@ -231,38 +225,38 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
{item.authorName.slice(0, 2)}
-
{item.authorName}{item.authorAdmin && {t("ผู้ดูแล", "Admin")}} +
{item.authorName}{item.authorAdmin && {"ผู้ดูแล"}} - {inbox && item.rootId && {t("ตอบกลับ", "Reply")}} - {(inbox || moderationDetails) && item.hidden && {item.ownHidden ? t("ซ่อนอยู่", "Hidden") : t("การสนทนาถูกซ่อน", "Thread hidden")}} - {(inbox || moderationDetails) && item.deleted && {t("ลบแล้ว", "Deleted")}} - {item.version > 1 && canReadHistory && } + {inbox && item.rootId && {"ตอบกลับ"}} + {(inbox || moderationDetails) && item.hidden && {item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"}} + {(inbox || moderationDetails) && item.deleted && {"ลบแล้ว"}} + {item.version > 1 && canReadHistory && }
{/* Inbox replies reach across the 280px content column and 24px cell padding. */} {!inbox && optionsMenu &&
{optionsMenu}
}
- {item.replyToName &&

{t("ตอบกลับ", "Reply")} {item.replyToName}

} + {item.replyToName &&

{"ตอบกลับ"} {item.replyToName}

} {mode === "edit" ? setMode(null)} onSaved={() => { setMode(null); onChange(); }} /> : <> - {(item.deleted || item.hidden) && !viewer?.admin ?

{item.deleted ? t("ความคิดเห็นถูกลบแล้ว", "This comment was deleted") : t("ความคิดเห็นถูกซ่อนโดยผู้ดูแล", "This comment was hidden by an admin")}

: } + {(item.deleted || item.hidden) && !viewer?.admin ?

{item.deleted ? "ความคิดเห็นถูกลบแล้ว" : "ความคิดเห็นถูกซ่อนโดยผู้ดูแล"}

: }
- - + - {item.canInteract && } + {item.canInteract && } {viewer?.admin ? - : optimistic.hearted && !item.deleted && !item.hidden && {t("ผู้ดูแลถูกใจ", "Liked by admin")}} + {optimistic.hearted ? "ผู้ดูแลถูกใจ" : null} + : optimistic.hearted && !item.deleted && !item.hidden && {"ผู้ดูแลถูกใจ"}}
} {mode === "reply" && setMode(null)} onSaved={() => { setMode(null); expandReplies(true); onChange(); }} />} {replies && !item.rootId && (item.replyCount > 0 || expanded) && expandReplies(value.includes("replies"))}> - {t(`การตอบกลับ ${item.replyCount.toLocaleString("th-TH")} รายการ`, `${item.replyCount.toLocaleString("en-US")} replies`)} + {`การตอบกลับ ${item.replyCount.toLocaleString("th-TH")} รายการ`} {hasOpenedReplies &&
@@ -278,21 +272,20 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals {inbox &&
{optionsMenu}{overview}
} - {t("ประวัติการแก้ไข", "Revision history")}{t("ดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น", "View the text and images in each revision of this comment")} + {"ประวัติการแก้ไข"}{"ดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น"} {history && } - {t("ลบความคิดเห็นนี้?", "Delete this comment?")}{t("ความคิดเห็นนี้ คำตอบกลับทุกระดับ ประวัติการแก้ไข และรูปภาพจะถูกลบอย่างถาวร ไม่สามารถกู้คืนได้", "This comment, all replies, revision history, and images will be permanently deleted. This cannot be undone.")} -
+ {"ลบความคิดเห็นนี้?"}{"ความคิดเห็นนี้ คำตอบกลับทุกระดับ ประวัติการแก้ไข และรูปภาพจะถูกลบอย่างถาวร ไม่สามารถกู้คืนได้"} +
{ if (!pending) setBanning(open); }}> - {t("แบน", "Ban")} {item.authorName}?{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.")} - {t("ยกเลิก", "Cancel")} void mutate("ban", true)}>{t("แบนผู้เขียน", "Ban author")} + {"แบน"} {item.authorName}?{"ผู้เขียนจะถูกออกจากระบบและไม่สามารถเข้าสู่ระบบ แสดงความคิดเห็น หรือใช้งานบัญชีได้ จนกว่าผู้ดูแลจะยกเลิกแบน ความคิดเห็นเดิมจะยังอยู่"} + {"ยกเลิก"} void mutate("ban", true)}>{"แบนผู้เขียน"} ; } function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDetails = false, inbox = false }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise; 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 && {feed.error}} { await refresh({ silent: true }); await onChange(); }} /> {feed.busy && } - {feed.page.nextCursor && } - {feed.error && } + {feed.page.nextCursor && } + {feed.error && }
; } @@ -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 }) { - 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 && {error}} {data && { await load(true); await onChange(); }} />} {!data && !error && } - {error && } + {error && }
; } diff --git a/components/comments/comment-composer.tsx b/components/comments/comment-composer.tsx index 12d1aee..33cc5f9 100644 --- a/components/comments/comment-composer.tsx +++ b/components/comments/comment-composer.tsx @@ -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(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
- {editing ? t("แก้ไขความคิดเห็น", "Edit comment") : replyTo ? t(`ตอบกลับ ${replyTo.authorName}`, `Reply to ${replyTo.authorName}`) : t("เพิ่มความคิดเห็น", "Add a comment")} + {editing ? "แก้ไขความคิดเห็น" : replyTo ? `ตอบกลับ ${replyTo.authorName}` : "เพิ่มความคิดเห็น"}