diff --git a/components/admin/autosave-status.tsx b/components/admin/autosave-status.tsx
index 2bbfbae..bbd2944 100644
--- a/components/admin/autosave-status.tsx
+++ b/components/admin/autosave-status.tsx
@@ -1,7 +1,7 @@
import { AlertCircleIcon, CheckIcon, ClockIcon } from "lucide-react";
import { useState } from "react";
-import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
@@ -14,13 +14,14 @@ export function AutosaveStatus({ state, issues = [], onSave }: { state: Autosave
if (state === "unsaved") return Waiting to save…;
if (state === "incomplete") return
{showIssues ?
+ กรุณากรอกข้อมูลให้ครบ
-
Local draft ถูกเก็บไว้แล้ว และจะบันทึกอัตโนมัติเมื่อข้อมูลครบ
+
แก้ไขรายการด้านล่าง แล้วระบบจะบันทึกให้อัตโนมัติ ข้อมูลที่กรอกไว้ยังไม่หาย
{issues.length ?
{issues.map((issue, index) => - {issue}
)}
: null}
diff --git a/components/admin/guide-overview-form.tsx b/components/admin/guide-overview-form.tsx
index 489a6be..b43fba8 100644
--- a/components/admin/guide-overview-form.tsx
+++ b/components/admin/guide-overview-form.tsx
@@ -17,8 +17,8 @@ export function GuideOverviewForm({ guide }: { guide: { id: string; version: num
const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl });
const value = { coverMediaId: cover.id, overview };
const incompleteIssues = [
- !cover.id ? "Guide Image is required" : null,
- !overview.trim() ? "Overview is required" : null,
+ !cover.id ? "กรุณาอัปโหลด Guide Image" : null,
+ !overview.trim() ? "กรุณากรอก Overview" : null,
].filter((issue): issue is string => Boolean(issue));
const localDraft = { overview, cover };
const autosave = useAutosave({
diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx
index 87e3e17..103ce6a 100644
--- a/components/admin/guide-section-editor.tsx
+++ b/components/admin/guide-section-editor.tsx
@@ -93,6 +93,8 @@ import {
type TalentPriority,
type TalentRelation,
} from "@/lib/guides/talents";
+import { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input";
+import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
import { cn } from "@/lib/utils";
type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null; weaponType?: string | null };
@@ -357,12 +359,12 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
row.clientId)} strategy={verticalListSortingStrategy}>
- {rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return
{(dragHandle) => {selectedWeapon ?
: null}อันดับ {rowIndex + 1}{dragHandle}} title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`} description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide" onConfirm={() => setRows(rows.filter((item) => item.clientId !== row.clientId))}>ลบอาวุธ
อาวุธ setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, weaponKey } : item))} />Refinement { const next = selected[0]; if (next) setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => R{refinement})}
Overall % setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: event.target.value } : item))} />Personal % setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: event.target.value } : item))} />
Note (optional)}; })}
+ {rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return
{(dragHandle) => {selectedWeapon ?
: null}อันดับ {rowIndex + 1}{dragHandle}} title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`} description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide" onConfirm={() => setRows(rows.filter((item) => item.clientId !== row.clientId))}>ลบอาวุธ
อาวุธ setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, weaponKey } : item))} />Refinement { const next = selected[0]; if (next) setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => R{refinement})}
Overall % setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: normalizePercentInput(event.target.value, item.overallPercent) } : item))} onBlur={() => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: finalizePercentInput(item.overallPercent) } : item))} />Personal % setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: normalizePercentInput(event.target.value, item.personalPercent) } : item))} onBlur={() => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: finalizePercentInput(item.personalPercent) } : item))} />
Note (optional)}; })}
- issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} />
+
);
}
@@ -461,7 +463,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate) } : item))} />
setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate) } : item))} />
- setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value } : candidate) } : item))} />
+ setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: normalizePercentInput(event.target.value, candidate.percent) } : candidate) } : item))} onBlur={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: finalizePercentInput(candidate.percent) } : candidate) } : item))} />
} title={`ลบ Artifact อันดับ ${rowIndex + 1}?`} description={`รายการ Artifact ในกลุ่ม ${group.name} และเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide`} onConfirm={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}>ลบ Artifact
Note (optional)
@@ -552,7 +554,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
- issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} />
+
);
@@ -594,12 +596,12 @@ function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBa
Condition note (optional)
- {group.rows.map((row, rowIndex) => {row.iconUrl ? : null}C{row.level} · {row.name}{row.description}Cumulative % setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: event.target.value } : candidate) } : item))} /> setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, isGreat } : candidate) } : item))} aria-label={`Mark Condition ${groupIndex + 1} C${row.level} percentage as great`} className={cn(SELECTED_TOGGLE_CLASS, "data-[state=on]:text-warning")}>Great %)}
+ {group.rows.map((row, rowIndex) => {row.iconUrl ? : null}C{row.level} · {row.name}{row.description}Cumulative % setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: normalizePercentInput(event.target.value, candidate.cumulativePercent) } : candidate) } : item))} onBlur={() => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: finalizePercentInput(candidate.cumulativePercent) } : candidate) } : item))} /> setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, isGreat } : candidate) } : item))} aria-label={`Mark Condition ${groupIndex + 1} C${row.level} percentage as great`} className={cn(SELECTED_TOGGLE_CLASS, "data-[state=on]:text-warning")}>Great %)}
)}
- issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} />
+
);
}
@@ -728,7 +730,7 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
- issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} />
+
);
}
@@ -809,7 +811,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
หัวข้อ setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, heading: event.target.value } : candidate) } : item))} />
ข้อความ
- Percent (optional) setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value || null } : candidate) } : item))} />
+ Percent (optional) setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: normalizePercentInput(event.target.value, candidate.percent ?? "") || null } : candidate) } : item))} onBlur={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: finalizePercentInput(candidate.percent ?? "") || null } : candidate) } : item))} />
@@ -819,7 +821,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
))}
- issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} />
+
);
}
diff --git a/lib/guides/percent-input.test.ts b/lib/guides/percent-input.test.ts
new file mode 100644
index 0000000..4e226b0
--- /dev/null
+++ b/lib/guides/percent-input.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+
+import { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input";
+
+describe("percentage input", () => {
+ it("turns only a lone dash into zero when editing finishes", () => {
+ expect(normalizePercentInput("-")).toBe("-");
+ expect(finalizePercentInput("-")).toBe("0");
+ expect(finalizePercentInput("-20")).toBe("-20");
+ });
+
+ it("keeps positive and negative decimal input and ignores other text", () => {
+ expect(normalizePercentInput("12.5")).toBe("12.5");
+ expect(normalizePercentInput("-12.5")).toBe("-12.5");
+ expect(normalizePercentInput("invalid", "12")).toBe("12");
+ });
+});
diff --git a/lib/guides/percent-input.ts b/lib/guides/percent-input.ts
new file mode 100644
index 0000000..65866cf
--- /dev/null
+++ b/lib/guides/percent-input.ts
@@ -0,0 +1,10 @@
+const DECIMAL = /^-?(?:\d+\.?\d*|\.\d*)?$/u;
+
+export function normalizePercentInput(nextValue: string, currentValue = ""): string {
+ const value = nextValue.trim();
+ return DECIMAL.test(value) ? value : currentValue;
+}
+
+export function finalizePercentInput(value: string): string {
+ return value === "-" ? "0" : value;
+}
diff --git a/lib/guides/validation-messages.test.ts b/lib/guides/validation-messages.test.ts
new file mode 100644
index 0000000..ea78135
--- /dev/null
+++ b/lib/guides/validation-messages.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+
+import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
+
+describe("guide validation messages", () => {
+ it("turns a raw weapon path into an actionable message", () => {
+ expect(formatGuideValidationIssues("weapon", [{ path: ["rows", 5, "weaponKey"], message: "Invalid input" }])).toEqual([
+ "อาวุธอันดับ 6: กรุณาเลือกอาวุธ",
+ ]);
+ });
+
+ it("keeps specific Thai schema messages with a useful item label", () => {
+ expect(formatGuideValidationIssues("team", [{ path: ["teams", 1, "members"], message: "Team ต้องมีตัวละครไม่ซ้ำกัน 4 ตัว" }])).toEqual([
+ "Team 2: Team ต้องมีตัวละครไม่ซ้ำกัน 4 ตัว",
+ ]);
+ });
+});
diff --git a/lib/guides/validation-messages.ts b/lib/guides/validation-messages.ts
new file mode 100644
index 0000000..370fd8c
--- /dev/null
+++ b/lib/guides/validation-messages.ts
@@ -0,0 +1,72 @@
+export type GuideValidationSection = "weapon" | "artifact" | "constellations" | "team" | "extra";
+
+interface ValidationIssue {
+ path: readonly PropertyKey[];
+ message: string;
+}
+
+const fieldMessages: Record = {
+ weaponKey: "กรุณาเลือกอาวุธ",
+ firstArtifactKey: "กรุณาเลือก Artifact ชุดแรก",
+ characterKey: "กรุณาเลือกตัวละคร",
+ overallPercent: "กรุณากรอก Overall % ระหว่าง -9,999 ถึง 9,999",
+ personalPercent: "กรุณากรอก Personal % ระหว่าง -9,999 ถึง 9,999",
+ percent: "กรุณากรอกเปอร์เซ็นต์ระหว่าง -9,999 ถึง 9,999",
+ cumulativePercent: "กรุณากรอก Cumulative % ระหว่าง -9,999 ถึง 9,999",
+ title: "กรุณากรอกชื่อไม่เกิน 160 ตัวอักษร",
+ heading: "กรุณากรอกหัวข้อไม่เกิน 200 ตัวอักษร",
+ combo: "กรุณากรอก Combo",
+ rotationSeconds: "กรุณากรอก Rotation เป็นตัวเลขมากกว่า 0",
+ damage: "กรุณากรอก Damage เป็นตัวเลข 0 ขึ้นไป",
+};
+
+const sectionLabels: Record = {
+ weapon: "Weapon tier list",
+ artifact: "Artifact",
+ constellations: "Constellations",
+ team: "Team Calc",
+ extra: "Extra sections",
+};
+
+function indexAfter(path: readonly PropertyKey[], key: string): number | null {
+ const position = path.indexOf(key);
+ const value = path[position + 1];
+ return position >= 0 && typeof value === "number" ? value : null;
+}
+
+function issueContext(section: GuideValidationSection, path: readonly PropertyKey[]): string {
+ const rowIndex = indexAfter(path, "rows");
+ const groupIndex = indexAfter(path, "groups");
+ const teamIndex = indexAfter(path, "teams");
+ const memberIndex = indexAfter(path, "members");
+ const sectionIndex = indexAfter(path, "sections");
+
+ if (section === "weapon" && rowIndex !== null) return `อาวุธอันดับ ${rowIndex + 1}`;
+ if (section === "artifact" && groupIndex !== null) {
+ const group = groupIndex === 0 ? "Late Game" : "Early Game";
+ return rowIndex === null ? `Artifact ${group}` : `Artifact ${group} อันดับ ${rowIndex + 1}`;
+ }
+ if (section === "constellations" && groupIndex !== null) {
+ return rowIndex === null ? `Condition ${groupIndex + 1}` : `Condition ${groupIndex + 1} · C${rowIndex + 1}`;
+ }
+ if (section === "team" && teamIndex !== null) {
+ return memberIndex === null ? `Team ${teamIndex + 1}` : `Team ${teamIndex + 1} · ตัวละคร ${memberIndex + 1}`;
+ }
+ if (section === "extra" && sectionIndex !== null) {
+ return rowIndex === null ? `Extra section ${sectionIndex + 1}` : `Extra section ${sectionIndex + 1} · แถว ${rowIndex + 1}`;
+ }
+ return sectionLabels[section];
+}
+
+function issueMessage(issue: ValidationIssue): string {
+ const field = issue.path.at(-1);
+ if (typeof field === "string" && fieldMessages[field]) return fieldMessages[field];
+ if (/[-]/u.test(issue.message)) return issue.message;
+ if (issue.message.startsWith("Too small")) return "กรุณากรอกข้อมูลให้ครบ";
+ if (issue.message.startsWith("Too big")) return "ข้อมูลเกินจำนวนหรือความยาวที่กำหนด";
+ return "กรุณาตรวจสอบข้อมูลให้ถูกต้อง";
+}
+
+export function formatGuideValidationIssues(section: GuideValidationSection, issues: readonly ValidationIssue[]): string[] {
+ return issues.map((issue) => `${issueContext(section, issue.path)}: ${issueMessage(issue)}`);
+}