feat(admin) : persist drafts and add constellation conditions
CI / Verify (push) Successful in 59s
CI / Build immutable images and deploy (push) Successful in 1m58s

This commit is contained in:
2026-09-02 12:40:13 +07:00 Unverified
parent 5a2e2e4829
commit 7708a2df31
17 changed files with 3738 additions and 100 deletions
+1 -1
View File
@@ -22,5 +22,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise
const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]);
if (!catalog || !content) notFound();
const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey);
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, ratio: row.ratio === "1:1" ? "1:1" : "16:9", heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellationGroups: content.constellationGroups.map((group) => ({ id: group.id, note: group.note, rows: group.rows })), teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, ratio: row.ratio === "1:1" ? "1:1" : "16:9", heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
}
+8 -1
View File
@@ -16,6 +16,7 @@ import { builtInTemplates } from "@/lib/templates";
import { persistTemplateApplication } from "@/lib/templates/persist";
import {
createGuide,
GuideVersionConflictError,
permanentlyDeleteGuide,
restoreGuide,
runCatalogSync,
@@ -204,13 +205,19 @@ export async function applyEditorTemplate(input: unknown): Promise<
type GuideActionResult =
| { status: "saved"; version?: number; location?: string }
| { status: "conflict"; currentVersion: number; message: string }
| { status: "error"; message: string };
type CreateGuideActionResult = Exclude<GuideActionResult, { status: "conflict" }>;
async function runGuideAction(action: () => Promise<{ version?: number }>): Promise<GuideActionResult> {
try {
const result = await action();
return { status: "saved", version: result.version };
} catch (cause) {
if (cause instanceof GuideVersionConflictError) {
return { status: "conflict", currentVersion: cause.currentVersion, message: cause.message };
}
if (cause instanceof z.ZodError) {
return { status: "error", message: cause.issues.map((issue) => issue.message).join(" • ") };
}
@@ -218,7 +225,7 @@ async function runGuideAction(action: () => Promise<{ version?: number }>): Prom
}
}
export async function createStructuredGuide(input: unknown): Promise<GuideActionResult> {
export async function createStructuredGuide(input: unknown): Promise<CreateGuideActionResult> {
await requireAdmin();
try {
const guide = await createGuide(input);
+6 -3
View File
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { AutosaveState } from "@/hooks/use-autosave";
export function AutosaveStatus({ state, issues = [] }: { state: AutosaveState; issues?: readonly string[] }) {
export function AutosaveStatus({ state, issues = [], onSave }: { state: AutosaveState; issues?: readonly string[]; onSave?: () => void }) {
const [showIssues, setShowIssues] = useState(false);
if (state === "saving") return <Badge variant="secondary"><Spinner data-icon="inline-start" />Saving…</Badge>;
@@ -19,10 +19,13 @@ export function AutosaveStatus({ state, issues = [] }: { state: AutosaveState; i
{showIssues ? <Alert variant="destructive" className="max-w-md">
<AlertCircleIcon aria-hidden="true" />
<AlertDescription>
{issues.length ? <ul className="flex flex-col gap-1">{issues.map((issue, index) => <li key={`${issue}-${index}`}>{issue}</li>)}</ul> : "ข้อมูลที่จำเป็นยังกรอกไม่ครบ"}
<div className="flex flex-col gap-2">
<p>Local draft ถูกเก็บไว้แล้ว และจะบันทึกอัตโนมัติเมื่อข้อมูลครบ</p>
{issues.length ? <ul className="flex flex-col gap-1">{issues.map((issue, index) => <li key={`${issue}-${index}`}>{issue}</li>)}</ul> : null}
</div>
</AlertDescription>
</Alert> : null}
</div>;
if (state === "error") return <Badge variant="destructive"><AlertCircleIcon data-icon="inline-start" />Autosave failed</Badge>;
if (state === "error") return <div className="flex items-center gap-2"><Badge variant="destructive"><AlertCircleIcon data-icon="inline-start" />Autosave failed</Badge>{onSave ? <Button type="button" variant="outline" size="sm" onClick={onSave}>Save now</Button> : null}</div>;
return <Badge variant="secondary"><CheckIcon data-icon="inline-start" />Saved</Badge>;
}
+16 -1
View File
@@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
import { useAutosave } from "@/hooks/use-autosave";
import { adminDraftKey } from "@/lib/editor/local-draft";
export function GuideOverviewForm({ guide }: { guide: { id: string; version: number; overview: string; coverMediaId: string; coverUrl: string; name: string } }) {
const [overview, setOverview] = useState(guide.overview);
@@ -19,11 +20,25 @@ export function GuideOverviewForm({ guide }: { guide: { id: string; version: num
!cover.id ? "Guide Image is required" : null,
!overview.trim() ? "Overview is required" : null,
].filter((issue): issue is string => Boolean(issue));
const localDraft = { overview, cover };
const autosave = useAutosave({
value,
valid: Boolean(cover.id && overview.trim()),
initialVersion: guide.version,
persist: (next, expectedVersion) => saveGuideOverview(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "overview"),
localDraft,
parseLocalDraft: (candidate) => {
if (!candidate || typeof candidate !== "object") return null;
const draft = candidate as Record<string, unknown>;
if (typeof draft.overview !== "string" || !draft.cover || typeof draft.cover !== "object") return null;
const draftCover = draft.cover as Record<string, unknown>;
return typeof draftCover.id === "string" && typeof draftCover.url === "string" ? candidate as typeof localDraft : null;
},
restoreLocalDraft: (draft) => {
setOverview(draft.overview);
setCover(draft.cover);
},
});
return <Card><CardHeader><CardTitle>Overview</CardTitle><CardDescription>ภาพและข้อความนี้แสดงบนหน้าแรกและส่วนแนะนำของ Guide</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>Guide Image</FieldLabel><div className="relative mx-auto aspect-square w-full max-w-md overflow-hidden rounded-lg border"><Image src={cover.url} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><MediaUploader onReady={(asset) => setCover(asset)} /></Field><Field><FieldLabel htmlFor="guide-overview">Overview</FieldLabel><Textarea id="guide-overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} /><FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription></Field></FieldGroup></CardContent><CardFooter className="justify-end"><AutosaveStatus state={autosave} issues={incompleteIssues} /></CardFooter></Card>;
return <Card><CardHeader><CardTitle>Overview</CardTitle><CardDescription>ภาพและข้อความนี้แสดงบนหน้าแรกและส่วนแนะนำของ Guide</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>Guide Image</FieldLabel><div className="relative mx-auto aspect-square w-full max-w-md overflow-hidden rounded-lg border"><Image src={cover.url} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><MediaUploader onReady={(asset) => setCover(asset)} /></Field><Field><FieldLabel htmlFor="guide-overview">Overview</FieldLabel><Textarea id="guide-overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} /><FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription></Field></FieldGroup></CardContent><CardFooter className="justify-end"><AutosaveStatus state={autosave.state} issues={incompleteIssues} onSave={autosave.saveNow} /></CardFooter></Card>;
}
+89 -19
View File
@@ -76,6 +76,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Toggle } from "@/components/ui/toggle";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useAutosave } from "@/hooks/use-autosave";
import { adminDraftKey } from "@/lib/editor/local-draft";
import {
artifactSectionSchema,
constellationSectionSchema,
@@ -109,6 +110,7 @@ type WeaponValue = { id: string; weaponKey: string; refinement: number; overallP
type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string; note: string | null }> };
type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: TalentPriority[]; note: string | null } | null;
type ConstellationValue = { level: number; detail: string; cumulativePercent: string; isGreat: boolean };
type ConstellationGroupValue = { id: string; note: string | null; rows: ConstellationValue[] };
type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null };
type CatalogTalent = { type: "NA" | "E" | "Q"; name: string; iconUrl: string | null };
type TeamValue = { id: string; title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; secondArtifactKey: string | null; constellation: number; refinement: number; damage: string }> };
@@ -122,6 +124,14 @@ function createClientId(prefix: string): string {
return `${prefix}-${Date.now()}-${clientIdCounter}`;
}
function parseEditorDraft<Draft>(value: unknown, arrayKeys: string[], stringKeys: string[] = []): Draft | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (arrayKeys.some((key) => !Array.isArray(record[key]))) return null;
if (stringKeys.some((key) => typeof record[key] !== "string")) return null;
return value as Draft;
}
export interface GuideSectionEditorProps {
section: "weapon" | "artifact" | "constellations" | "team" | "extra";
guide: Guide;
@@ -135,7 +145,7 @@ export interface GuideSectionEditorProps {
weapons: WeaponValue[];
artifactGroups: ArtifactValue[];
artifactProfile: ArtifactProfile;
constellations: ConstellationValue[];
constellationGroups: ConstellationGroupValue[];
teams: TeamValue[];
extras: Array<{ title: string; rows: Array<{ mediaId: string | null; ratio: "1:1" | "16:9"; heading: string; body: string; percent: string | null; imageUrl: string | null }> }>;
};
@@ -329,7 +339,17 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
const [rows, setRows] = useState(initial.weapons.map((row) => ({ clientId: row.id, weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent ?? "", personalPercent: row.personalPercent ?? "", note: row.note ?? "" })));
const value = { note: note.trim() || null, rows: rows.map((row) => ({ weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent), personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent), note: row.note.trim() || null })) };
const validation = weaponSectionSchema.safeParse({ ...value, expectedVersion: guide.version });
const autosave = useAutosave({ value, valid: validation.success, initialVersion: guide.version, persist: (next, expectedVersion) => saveWeaponSection(guide.id, { ...next, expectedVersion }) });
const localDraft = { note, rows };
const autosave = useAutosave({
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion) => saveWeaponSection(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "weapon"),
localDraft,
parseLocalDraft: (candidate) => parseEditorDraft<typeof localDraft>(candidate, ["rows"], ["note"]),
restoreLocalDraft: (draft) => { setNote(draft.note); setRows(draft.rows); },
});
const handleWeaponDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setRows((current) => {
@@ -351,7 +371,7 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
</SortableContext>
</DndContext>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { clientId: createClientId("weapon"), weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "", note: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} /></CardFooter>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { clientId: createClientId("weapon"), weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "", note: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
</Card>
);
}
@@ -398,7 +418,17 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
const [note, setNote] = useState(profile?.note ?? "");
const value = { groups: groups.map((group) => ({ name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: Number(row.percent), note: row.note?.trim() || null })) })), sands, goblet, circlet, substats, targets: targets.filter((target) => target.value.trim()).map((target) => ({ stat: target.stat, value: target.value.trim() })), talents, note: note || null };
const validation = artifactSectionSchema.safeParse({ ...value, expectedVersion: guide.version });
const autosave = useAutosave({ value, valid: validation.success, initialVersion: guide.version, persist: (next, expectedVersion) => saveArtifactSection(guide.id, { ...next, expectedVersion }) });
const localDraft = { groups, sands, goblet, circlet, substats, targets, talents, note };
const autosave = useAutosave({
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion) => saveArtifactSection(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "artifact"),
localDraft,
parseLocalDraft: (candidate) => parseEditorDraft<typeof localDraft>(candidate, ["groups", "sands", "goblet", "circlet", "substats", "targets", "talents"], ["note"]),
restoreLocalDraft: (draft) => { setGroups(draft.groups); setSands(draft.sands); setGoblet(draft.goblet); setCirclet(draft.circlet); setSubstats(draft.substats); setTargets(draft.targets); setTalents(draft.talents); setNote(draft.note); },
});
const orderedTalents = normalizeTalentPriorities(talents);
const handleArtifactDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
@@ -531,7 +561,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
</Field>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end"><AutosaveStatus state={autosave} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} /></CardFooter>
<CardFooter className="justify-end"><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
</Card>
</div>
);
@@ -539,26 +569,46 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBase) {
const [note, setNote] = useState(initial.sectionNote ?? "");
const [rows, setRows] = useState(() => {
const source = catalogConstellations.length ? catalogConstellations : Array.from({ length: 6 }, (_, index) => ({ level: index + 1, name: `C${index + 1}`, description: initial.constellations.find((row) => row.level === index + 1)?.detail ?? "", iconUrl: null }));
const makeRows = (savedRows: ConstellationValue[] = []) => {
const source = catalogConstellations.length ? catalogConstellations : Array.from({ length: 6 }, (_, index) => ({ level: index + 1, name: `C${index + 1}`, description: savedRows.find((row) => row.level === index + 1)?.detail ?? "", iconUrl: null }));
return source.map((constellation) => {
const saved = initial.constellations.find((row) => row.level === constellation.level);
const saved = savedRows.find((row) => row.level === constellation.level);
return { ...constellation, cumulativePercent: saved?.cumulativePercent ?? "", isGreat: saved?.isGreat ?? false };
});
};
const [groups, setGroups] = useState(() => {
const source = initial.constellationGroups.length ? initial.constellationGroups : [{ id: "constellation-condition-default", note: null, rows: [] }];
return source.map((group) => ({ clientId: group.id, note: group.note ?? "", rows: makeRows(group.rows) }));
});
const value = { note: note.trim() || null, rows: rows.filter((row) => row.description.trim() && row.cumulativePercent !== "").map((row) => ({ level: row.level, detail: row.description, cumulativePercent: Number(row.cumulativePercent), isGreat: row.isGreat })) };
const value = { note: note.trim() || null, groups: groups.map((group) => ({ note: group.note.trim() || null, rows: group.rows.filter((row) => row.description.trim() && row.cumulativePercent !== "").map((row) => ({ level: row.level, detail: row.description, cumulativePercent: Number(row.cumulativePercent), isGreat: row.isGreat })) })) };
const validation = constellationSectionSchema.safeParse({ ...value, expectedVersion: guide.version });
const autosave = useAutosave({ value, valid: validation.success, initialVersion: guide.version, persist: (next, expectedVersion) => saveConstellationSection(guide.id, { ...next, expectedVersion }) });
const localDraft = { note, groups };
const autosave = useAutosave({
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion) => saveConstellationSection(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "constellations"),
localDraft,
parseLocalDraft: (candidate) => parseEditorDraft<typeof localDraft>(candidate, ["groups"], ["note"]),
restoreLocalDraft: (draft) => { setNote(draft.note); setGroups(draft.groups); },
});
return (
<Card>
<CardHeader><CardTitle>Constellations</CardTitle><CardDescription>ชื่อ รายละเอียด และไอคอนมาจาก Lunaris อัตโนมัติ กรอกเฉพาะเปอร์เซ็นต์เทียบกับ C0 = 100%</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-4">
<Field><FieldLabel htmlFor="constellation-section-note">Header note (optional)</FieldLabel><Textarea id="constellation-section-note" value={note} onChange={(event) => setNote(event.target.value)} /></Field>
<div className="grid gap-4 lg:grid-cols-2">
{rows.map((row, index) => <Card key={row.level} size="sm"><CardHeader>{row.iconUrl ? <Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>C{row.level} · {row.name}</CardTitle><CardDescription>{row.description}</CardDescription></CardHeader><CardContent className="flex items-end gap-3"><Field className="min-w-0 flex-1"><FieldLabel>Cumulative %</FieldLabel><Input type="number" min="0" step="0.01" value={row.cumulativePercent} onChange={(event) => setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, cumulativePercent: event.target.value } : item))} /></Field><Toggle variant="outline" pressed={row.isGreat} onPressedChange={(isGreat) => setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, isGreat } : item))} aria-label={`Mark C${row.level} percentage as great`} className={cn(SELECTED_TOGGLE_CLASS, "data-[state=on]:text-warning")}><SparklesIcon data-icon="inline-start" />Great %</Toggle></CardContent></Card>)}
</div>
{groups.map((group, groupIndex) => <Card key={group.clientId} size="sm">
<CardHeader><CardTitle>Condition {groupIndex + 1}</CardTitle><CardDescription>ใช้ note เพื่ออธิบายอาวุธ ทีม หรือเงื่อนไขที่ใช้เปรียบเทียบ</CardDescription>{groups.length > 1 ? <CardAction><AlertDialog><AlertDialogTrigger render={<Button type="button" variant="ghost" size="icon-sm" />}><Trash2Icon /><span className="sr-only">ลบ Condition {groupIndex + 1}</span></AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>ลบ Condition {groupIndex + 1}?</AlertDialogTitle><AlertDialogDescription>เปอร์เซ็นต์กลุ่มดาวทั้งหมดในเงื่อนไขนี้จะถูกลบออก</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>ยกเลิก</AlertDialogCancel><AlertDialogAction variant="destructive" onClick={() => setGroups(groups.filter((item) => item.clientId !== group.clientId))}>ยืนยันลบ</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></CardAction> : null}</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field><FieldLabel htmlFor={`constellation-condition-note-${group.clientId}`}>Condition note (optional)</FieldLabel><Textarea id={`constellation-condition-note-${group.clientId}`} value={group.note} onChange={(event) => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, note: event.target.value } : item))} /></Field>
<div className="grid gap-4 lg:grid-cols-2">
{group.rows.map((row, rowIndex) => <Card key={row.level} size="sm"><CardHeader>{row.iconUrl ? <Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>C{row.level} · {row.name}</CardTitle><CardDescription>{row.description}</CardDescription></CardHeader><CardContent className="flex items-end gap-3"><Field className="min-w-0 flex-1"><FieldLabel>Cumulative %</FieldLabel><Input type="number" min="0" step="0.01" value={row.cumulativePercent} onChange={(event) => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: event.target.value } : candidate) } : item))} /></Field><Toggle variant="outline" pressed={row.isGreat} onPressedChange={(isGreat) => 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")}><SparklesIcon data-icon="inline-start" />Great %</Toggle></CardContent></Card>)}
</div>
</CardContent>
</Card>)}
</CardContent>
<CardFooter className="justify-end"><AutosaveStatus state={autosave} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} /></CardFooter>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" disabled={groups.length >= 20} onClick={() => setGroups([...groups, { clientId: createClientId("constellation-condition"), note: "", rows: makeRows() }])}><PlusIcon data-icon="inline-start" />เพิ่ม Condition</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
</Card>
);
}
@@ -572,7 +622,17 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
const [teams, setTeams] = useState(initial.teams.map(({ id, ...team }) => ({ clientId: id, ...team })));
const value = { teams: teams.map((team) => ({ title: team.title, rotationSeconds: Number(team.rotationSeconds), combo: team.combo, members: team.members.map((member) => ({ ...member, damage: Number(member.damage) })) })) };
const validation = teamSectionSchema.safeParse({ ...value, expectedVersion: guide.version });
const autosave = useAutosave({ value, valid: validation.success, initialVersion: guide.version, persist: (next, expectedVersion) => saveTeamSection(guide.id, { ...next, expectedVersion }) });
const localDraft = { teams };
const autosave = useAutosave({
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion) => saveTeamSection(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "team"),
localDraft,
parseLocalDraft: (candidate) => parseEditorDraft<typeof localDraft>(candidate, ["teams"]),
restoreLocalDraft: (draft) => setTeams(draft.teams),
});
const handleTeamDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setTeams((current) => {
@@ -660,23 +720,33 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
</SortableContext>
</DndContext>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" disabled={characters.length < 4} onClick={() => setTeams([...teams, { clientId: createClientId("team"), title: "", rotationSeconds: "20", combo: "", members: fallbackMembers }])}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button><AutosaveStatus state={autosave} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} /></CardFooter>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" disabled={characters.length < 4} onClick={() => setTeams([...teams, { clientId: createClientId("team"), title: "", rotationSeconds: "20", combo: "", members: fallbackMembers }])}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
</Card>
);
}
function ExtraEditor({ guide, initial }: EditorBase) {
const [sections, setSections] = useState(initial.extras);
const value = { sections: sections.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, ratio: row.ratio, heading: row.heading, body: row.body, percent: row.percent === null ? null : Number(row.percent) })) })) };
const value = { sections: sections.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, ratio: row.ratio, heading: row.heading, body: row.body, percent: row.percent === null || row.percent === "" ? null : Number(row.percent) })) })) };
const validation = extraSectionSchema.safeParse({ ...value, expectedVersion: guide.version });
const autosave = useAutosave({ value, valid: validation.success, initialVersion: guide.version, persist: (next, expectedVersion) => saveExtraSectionList(guide.id, { ...next, expectedVersion }) });
const localDraft = { sections };
const autosave = useAutosave({
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion) => saveExtraSectionList(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "extra"),
localDraft,
parseLocalDraft: (candidate) => parseEditorDraft<typeof localDraft>(candidate, ["sections"]),
restoreLocalDraft: (draft) => setSections(draft.sections),
});
return (
<Card>
<CardHeader><CardTitle>Extra sections</CardTitle><CardDescription>เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์ ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-5">
{sections.map((section, sectionIndex) => <Card key={sectionIndex} size="sm"><CardHeader><CardTitle><Input value={section.title} aria-label="ชื่อ Extra section" onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, title: event.target.value } : item))} /></CardTitle><CardAction><Button variant="ghost" size="icon-sm" onClick={() => setSections(sections.filter((_, index) => index !== sectionIndex))}><Trash2Icon /><span className="sr-only">ลบส่วน</span></Button></CardAction></CardHeader><CardContent className="flex flex-col gap-4">{section.rows.map((row, rowIndex) => <Card key={rowIndex} size="sm"><CardHeader><CardTitle>{row.heading || `แถว ${rowIndex + 1}`}</CardTitle><CardAction><Button variant="ghost" size="icon-sm" onClick={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}><Trash2Icon /><span className="sr-only">ลบแถว</span></Button></CardAction></CardHeader><CardContent><FieldGroup>{row.imageUrl ? <div className={cn("relative overflow-hidden rounded-lg border", row.ratio === "1:1" ? "aspect-square" : "aspect-video")}><Image src={row.imageUrl} alt={row.heading} fill className="object-cover" /><Button type="button" variant="destructive" size="icon-sm" className="absolute top-2 right-2" onClick={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, mediaId: null, imageUrl: null } : candidate) } : item))} aria-label="ลบภาพ"><XIcon /></Button></div> : null}<MediaUploader onReady={(asset) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, mediaId: asset.id, imageUrl: asset.url } : candidate) } : item))} /><Field><FieldLabel>อัตราส่วนภาพ</FieldLabel><ToggleGroup variant="outline" value={[row.ratio]} onValueChange={(selected) => { const ratio = selected[0]; if (ratio === "1:1" || ratio === "16:9") setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, ratio } : candidate) } : item)); }} className="flex-wrap justify-start"><ToggleGroupItem className={SELECTED_TOGGLE_CLASS} value="1:1">1:1</ToggleGroupItem><ToggleGroupItem className={SELECTED_TOGGLE_CLASS} value="16:9">16:9</ToggleGroupItem></ToggleGroup></Field><Field><FieldLabel>หัวข้อ</FieldLabel><Input value={row.heading} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, heading: event.target.value } : candidate) } : item))} /></Field><Field><FieldLabel>ข้อความ</FieldLabel><Textarea value={row.body} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, body: event.target.value } : candidate) } : item))} /></Field><Field><FieldLabel>Percent (optional)</FieldLabel><Input type="number" min="0" step="0.01" value={row.percent ?? ""} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value || null } : candidate) } : item))} /></Field></FieldGroup></CardContent></Card>)}<Button variant="outline" onClick={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: [...item.rows, { mediaId: null, imageUrl: null, ratio: "16:9", heading: "", body: "", percent: null }] } : item))}><PlusIcon data-icon="inline-start" />เพิ่มแถว</Button></CardContent></Card>)}
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" onClick={() => setSections([...sections, { title: "Extra", rows: [] }])}><PlusIcon data-icon="inline-start" />เพิ่ม Extra section</Button><AutosaveStatus state={autosave} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} /></CardFooter>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" onClick={() => setSections([...sections, { title: "Extra", rows: [] }])}><PlusIcon data-icon="inline-start" />เพิ่ม Extra section</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
</Card>
);
}
+40 -34
View File
@@ -47,7 +47,7 @@ function hasSection(guide: PublicGuide, slug: string): boolean {
guide.artifactProfile.circlet.length),
)
);
if (slug === "constellations") return guide.constellations.length > 0;
if (slug === "constellations") return guide.constellationGroups.some((group) => group.rows.length > 0);
if (slug === "team") return guide.teams.length > 0;
return guide.extras.some(
(extra) => extra.slug === slug && extra.rows.length > 0,
@@ -718,39 +718,45 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
</p>
) : null}
</header>
<div className="grid gap-4 md:grid-cols-2">
{guide.constellations.map((row) => (
<Card key={row.level}>
<CardHeader className="flex flex-row items-center gap-4">
{row.iconUrl ? (
<Image
src={row.iconUrl}
alt=""
width={64}
height={64}
className="size-16 shrink-0 rounded-lg bg-muted object-contain"
/>
) : null}
<div className="min-w-0">
<CardTitle
className={cn(
"text-3xl text-white",
row.isGreat && "text-warning",
)}
>
{metric(row.cumulativePercent)}
</CardTitle>
<CardDescription className="text-base font-medium text-white">
C{row.level} · {row.name}
</CardDescription>
</div>
</CardHeader>
<CardContent>
<p className="whitespace-pre-line leading-7">{row.detail}</p>
</CardContent>
</Card>
))}
</div>
{guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => (
<div key={group.id} className="flex flex-col gap-4">
{groups.length > 1 ? <h2 className="font-heading text-2xl font-semibold">Condition {groupIndex + 1}</h2> : null}
{group.note ? <p className="whitespace-pre-wrap text-muted-foreground">{group.note}</p> : null}
<div className="grid gap-4 md:grid-cols-2">
{group.rows.map((row) => (
<Card key={row.level}>
<CardHeader className="flex flex-row items-center gap-4">
{row.iconUrl ? (
<Image
src={row.iconUrl}
alt=""
width={64}
height={64}
className="size-16 shrink-0 rounded-lg bg-muted object-contain"
/>
) : null}
<div className="min-w-0">
<CardTitle
className={cn(
"text-3xl text-white",
row.isGreat && "text-warning",
)}
>
{metric(row.cumulativePercent)}
</CardTitle>
<CardDescription className="text-base font-medium text-white">
C{row.level} · {row.name}
</CardDescription>
</div>
</CardHeader>
<CardContent>
<p className="whitespace-pre-line leading-7">{row.detail}</p>
</CardContent>
</Card>
))}
</div>
</div>
))}
</section>
);
}
+13 -2
View File
@@ -574,16 +574,27 @@ export const artifactProfiles = guidesSchema.table("artifact_profile", {
...timestamps,
});
export const constellationConditions = guidesSchema.table(
"constellation_condition",
{
id: uuid("id").defaultRandom().primaryKey(),
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
note: text("note"),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("constellation_condition_order_idx").on(table.guideId, table.sortOrder)],
);
export const constellationRecommendations = guidesSchema.table(
"constellation_recommendation",
{
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
conditionId: uuid("condition_id").notNull().references(() => constellationConditions.id, { onDelete: "cascade" }),
level: integer("level").notNull(),
detail: text("detail").notNull(),
cumulativePercent: numeric("cumulative_percent", { precision: 8, scale: 2 }).notNull(),
isGreat: boolean("is_great").default(false).notNull(),
},
(table) => [primaryKey({ columns: [table.guideId, table.level] })],
(table) => [primaryKey({ columns: [table.conditionId, table.level] })],
);
export const teamCompositions = guidesSchema.table(
+34
View File
@@ -0,0 +1,34 @@
CREATE TABLE "guides"."constellation_condition" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"guide_id" uuid NOT NULL,
"note" text,
"sort_order" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
ALTER TABLE "guides"."constellation_condition" ADD CONSTRAINT "constellation_condition_guide_id_guide_id_fk" FOREIGN KEY ("guide_id") REFERENCES "guides"."guide"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "constellation_condition_order_idx" ON "guides"."constellation_condition" USING btree ("guide_id","sort_order");
--> statement-breakpoint
INSERT INTO "guides"."constellation_condition" ("id", "guide_id", "note", "sort_order")
SELECT gen_random_uuid(), "guide_id", NULL, 0
FROM "guides"."constellation_recommendation"
GROUP BY "guide_id";
--> statement-breakpoint
ALTER TABLE "guides"."constellation_recommendation" ADD COLUMN "condition_id" uuid;
--> statement-breakpoint
UPDATE "guides"."constellation_recommendation" AS recommendation
SET "condition_id" = condition_group."id"
FROM "guides"."constellation_condition" AS condition_group
WHERE condition_group."guide_id" = recommendation."guide_id" AND condition_group."sort_order" = 0;
--> statement-breakpoint
ALTER TABLE "guides"."constellation_recommendation" ALTER COLUMN "condition_id" SET NOT NULL;
--> statement-breakpoint
ALTER TABLE "guides"."constellation_recommendation" DROP CONSTRAINT "constellation_recommendation_guide_id_guide_id_fk";
--> statement-breakpoint
ALTER TABLE "guides"."constellation_recommendation" DROP CONSTRAINT "constellation_recommendation_guide_id_level_pk";
--> statement-breakpoint
ALTER TABLE "guides"."constellation_recommendation" ADD CONSTRAINT "constellation_recommendation_condition_id_level_pk" PRIMARY KEY("condition_id","level");
--> statement-breakpoint
ALTER TABLE "guides"."constellation_recommendation" ADD CONSTRAINT "constellation_recommendation_condition_id_constellation_condition_id_fk" FOREIGN KEY ("condition_id") REFERENCES "guides"."constellation_condition"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "guides"."constellation_recommendation" DROP COLUMN "guide_id";
File diff suppressed because it is too large Load Diff
+7
View File
@@ -134,6 +134,13 @@
"when": 1788309185497,
"tag": "0018_blushing_blacklash",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1788326787914,
"tag": "0019_equal_lord_hawal",
"breakpoints": true
}
]
}
+91 -15
View File
@@ -1,47 +1,110 @@
"use client";
import { useEffect, useEffectEvent, useState } from "react";
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { toast } from "sonner";
import { parseLocalDraft as parseStoredDraft, serializeLocalDraft } from "@/lib/editor/local-draft";
export type AutosaveState = "saved" | "unsaved" | "saving" | "incomplete" | "error";
interface SaveResult {
status: string;
message?: string;
version?: number;
currentVersion?: number;
}
export function useAutosave<T>({
export function useAutosave<Value, Draft = Value>({
value,
valid,
initialVersion,
persist,
delay = 900,
storageKey,
localDraft,
parseLocalDraft,
restoreLocalDraft,
}: {
value: T;
value: Value;
valid: boolean;
initialVersion: number;
persist: (value: T, expectedVersion: number) => Promise<SaveResult>;
persist: (value: Value, expectedVersion: number) => Promise<SaveResult>;
delay?: number;
storageKey?: string;
localDraft?: Draft;
parseLocalDraft?: (value: unknown) => Draft | null;
restoreLocalDraft?: (draft: Draft) => void;
}) {
const fingerprint = JSON.stringify(value);
const draftFingerprint = JSON.stringify(localDraft ?? value);
const [savedFingerprint, setSavedFingerprint] = useState(fingerprint);
const [savedDraftFingerprint, setSavedDraftFingerprint] = useState(draftFingerprint);
const [errorFingerprint, setErrorFingerprint] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [version, setVersion] = useState(initialVersion);
const [hydrated, setHydrated] = useState(!storageKey);
const [manualRequest, setManualRequest] = useState(0);
const handledManualRequest = useRef(0);
const storageWarningShown = useRef(false);
const save = useEffectEvent(async (snapshot: T, snapshotFingerprint: string) => {
const restore = useEffectEvent((serialized: string) => {
if (!parseLocalDraft || !restoreLocalDraft) return false;
const stored = parseStoredDraft(serialized, parseLocalDraft);
if (!stored) return false;
restoreLocalDraft(stored.draft);
return true;
});
useEffect(() => {
if (!storageKey) return;
const timer = window.setTimeout(() => {
let restored = false;
try {
const serialized = window.localStorage.getItem(storageKey);
if (serialized) restored = restore(serialized);
if (serialized && !restored) window.localStorage.removeItem(storageKey);
} catch {
if (!storageWarningShown.current) {
storageWarningShown.current = true;
toast.error("ไม่สามารถเปิด Local draft ได้");
}
}
setHydrated(true);
if (restored) toast.info("กู้คืน Local draft แล้ว ระบบจะบันทึกอัตโนมัติเมื่อข้อมูลครบ");
}, 0);
return () => window.clearTimeout(timer);
}, [storageKey]);
useEffect(() => {
if (!hydrated || !storageKey || localDraft === undefined) return;
try {
if (draftFingerprint === savedDraftFingerprint) {
window.localStorage.removeItem(storageKey);
return;
}
window.localStorage.setItem(storageKey, serializeLocalDraft(localDraft, version));
} catch {
if (!storageWarningShown.current) {
storageWarningShown.current = true;
toast.error("บันทึก Local draft ไม่สำเร็จ กรุณาตรวจสอบพื้นที่ของเบราว์เซอร์");
}
}
}, [draftFingerprint, hydrated, localDraft, savedDraftFingerprint, storageKey, version]);
const save = useEffectEvent(async (snapshot: Value, snapshotFingerprint: string, snapshotDraftFingerprint: string) => {
setSaving(true);
setErrorFingerprint(null);
try {
const result = await persist(snapshot, version);
if (result.status === "error") {
if (result.status === "error" || result.status === "conflict") {
if (result.currentVersion) setVersion(result.currentVersion);
setErrorFingerprint(snapshotFingerprint);
toast.error(result.message ?? "บันทึกอัตโนมัติไม่สำเร็จ");
return;
}
if (result.version) setVersion(result.version);
setSavedFingerprint(snapshotFingerprint);
setSavedDraftFingerprint(snapshotDraftFingerprint);
} catch (cause) {
setErrorFingerprint(snapshotFingerprint);
toast.error(cause instanceof Error ? cause.message : "บันทึกอัตโนมัติไม่สำเร็จ");
@@ -51,16 +114,29 @@ export function useAutosave<T>({
});
useEffect(() => {
if (!valid || saving || fingerprint === savedFingerprint || fingerprint === errorFingerprint) return;
if (!hydrated || !valid || saving) return;
const manual = manualRequest !== handledManualRequest.current;
if (!manual && (fingerprint === savedFingerprint || fingerprint === errorFingerprint)) return;
if (manual) handledManualRequest.current = manualRequest;
const timer = window.setTimeout(() => {
void save(JSON.parse(fingerprint) as T, fingerprint);
}, delay);
void save(JSON.parse(fingerprint) as Value, fingerprint, draftFingerprint);
}, manual ? 0 : delay);
return () => window.clearTimeout(timer);
}, [delay, errorFingerprint, fingerprint, savedFingerprint, saving, valid]);
}, [delay, draftFingerprint, errorFingerprint, fingerprint, hydrated, manualRequest, savedFingerprint, saving, valid]);
if (!valid) return "incomplete";
if (saving) return "saving";
if (fingerprint === errorFingerprint) return "error";
if (fingerprint !== savedFingerprint) return "unsaved";
return "saved";
let state: AutosaveState;
if (!valid) state = "incomplete";
else if (saving) state = "saving";
else if (fingerprint === errorFingerprint) state = "error";
else if (fingerprint !== savedFingerprint) state = "unsaved";
else state = "saved";
return {
state,
saveNow: () => {
if (!valid || saving) return;
setErrorFingerprint(null);
setManualRequest((current) => current + 1);
},
};
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { adminDraftKey, parseLocalDraft, serializeLocalDraft } from "./local-draft";
describe("local admin drafts", () => {
it("isolates drafts by guide and section", () => {
expect(adminDraftKey("guide-1", "team")).toBe("buzz-sheet:admin-draft:guide-1:team");
expect(adminDraftKey("guide-1", "weapon")).not.toBe(adminDraftKey("guide-1", "team"));
expect(adminDraftKey("guide-2", "team")).not.toBe(adminDraftKey("guide-1", "team"));
});
it("round trips a versioned draft", () => {
const serialized = serializeLocalDraft({ title: "Recovered team" }, 7);
const parsed = parseLocalDraft(serialized, (value) => {
if (!value || typeof value !== "object" || typeof (value as { title?: unknown }).title !== "string") return null;
return value as { title: string };
});
expect(parsed).toMatchObject({ schemaVersion: 1, serverVersion: 7, draft: { title: "Recovered team" } });
});
it("rejects corrupt, incompatible, and invalid drafts", () => {
const parse = (value: unknown) => typeof value === "string" ? value : null;
expect(parseLocalDraft("not-json", parse)).toBeNull();
expect(parseLocalDraft(JSON.stringify({ schemaVersion: 2, serverVersion: 1, updatedAt: "now", draft: "x" }), parse)).toBeNull();
expect(parseLocalDraft(JSON.stringify({ schemaVersion: 1, serverVersion: 1, updatedAt: "now", draft: 5 }), parse)).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
export const LOCAL_DRAFT_SCHEMA_VERSION = 1;
export interface LocalDraftEnvelope<Draft> {
schemaVersion: typeof LOCAL_DRAFT_SCHEMA_VERSION;
serverVersion: number;
updatedAt: string;
draft: Draft;
}
export function adminDraftKey(guideId: string, section: string): string {
return `buzz-sheet:admin-draft:${guideId}:${section}`;
}
export function serializeLocalDraft<Draft>(draft: Draft, serverVersion: number): string {
return JSON.stringify({
schemaVersion: LOCAL_DRAFT_SCHEMA_VERSION,
serverVersion,
updatedAt: new Date().toISOString(),
draft,
} satisfies LocalDraftEnvelope<Draft>);
}
export function parseLocalDraft<Draft>(serialized: string, parseDraft: (value: unknown) => Draft | null): LocalDraftEnvelope<Draft> | null {
try {
const value: unknown = JSON.parse(serialized);
if (!value || typeof value !== "object") return null;
const envelope = value as Record<string, unknown>;
if (envelope.schemaVersion !== LOCAL_DRAFT_SCHEMA_VERSION || typeof envelope.serverVersion !== "number" || typeof envelope.updatedAt !== "string") return null;
const draft = parseDraft(envelope.draft);
return draft === null ? null : {
schemaVersion: LOCAL_DRAFT_SCHEMA_VERSION,
serverVersion: envelope.serverVersion,
updatedAt: envelope.updatedAt,
draft,
};
} catch {
return null;
}
}
+18 -3
View File
@@ -10,6 +10,7 @@ import {
catalogArtifacts,
catalogCharacters,
catalogWeapons,
constellationConditions,
constellationRecommendations,
extraSectionRows,
extraSections,
@@ -40,6 +41,13 @@ const DEFAULT_SECTIONS = [
{ kind: "team", slug: "team", title: "Team Calc", sortOrder: 3 },
] as const;
export class GuideVersionConflictError extends Error {
constructor(readonly currentVersion: number) {
super("ข้อมูลถูกแก้ไขจากแท็บอื่น กด Save now เพื่อบันทึก Local draft ทับเวอร์ชันล่าสุด");
this.name = "GuideVersionConflictError";
}
}
async function emitGuideEvents(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, version: number) {
await tx.insert(outboxEvents).values([
{ topic: "directory", aggregateId: guideId, eventType: "directory.updated", payload: { id: guideId, version } },
@@ -81,7 +89,11 @@ export async function createGuide(input: unknown) {
async function advanceGuideVersion(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, expectedVersion: number) {
const [guide] = await tx.update(guides).set({ version: expectedVersion + 1 }).where(and(eq(guides.id, guideId), eq(guides.version, expectedVersion), sql`${guides.trashedAt} is null`)).returning();
if (!guide) throw new Error("ข้อมูลถูกแก้ไขจากแท็บอื่น กรุณาโหลดหน้าใหม่");
if (!guide) {
const [current] = await tx.select({ version: guides.version }).from(guides).where(eq(guides.id, guideId)).limit(1);
if (current) throw new GuideVersionConflictError(current.version);
throw new Error("ไม่พบ Guide");
}
await emitGuideEvents(tx, guide.id, guide.version);
return guide;
}
@@ -137,8 +149,11 @@ export async function saveConstellations(guideId: string, input: unknown) {
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "constellations")));
await tx.delete(constellationRecommendations).where(eq(constellationRecommendations.guideId, guideId));
if (data.rows.length) await tx.insert(constellationRecommendations).values(data.rows.map((row) => ({ ...row, cumulativePercent: row.cumulativePercent.toString(), guideId })));
await tx.delete(constellationConditions).where(eq(constellationConditions.guideId, guideId));
for (const [sortOrder, group] of data.groups.entries()) {
const [created] = await tx.insert(constellationConditions).values({ guideId, note: group.note, sortOrder }).returning();
if (group.rows.length) await tx.insert(constellationRecommendations).values(group.rows.map((row) => ({ ...row, cumulativePercent: row.cumulativePercent.toString(), conditionId: created.id })));
}
return updated;
});
}
+16 -11
View File
@@ -10,6 +10,7 @@ import {
catalogArtifacts,
catalogCharacters,
catalogWeapons,
constellationConditions,
constellationRecommendations,
extraSectionRows,
guides,
@@ -85,15 +86,16 @@ export async function getPublicGuide(slug: string) {
.where(and(eq(guides.slug, slug), isNull(guides.trashedAt)))
.limit(1);
if (!row) return null;
const [sections, weaponRows, groups, profile, constellations, teams] = await Promise.all([
const [sections, weaponRows, groups, profile, constellationGroups, teams] = await Promise.all([
getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
getDb().select({ recommendation: weaponRecommendations, weapon: catalogWeapons }).from(weaponRecommendations).innerJoin(catalogWeapons, eq(catalogWeapons.key, weaponRecommendations.weaponKey)).where(eq(weaponRecommendations.guideId, row.guide.id)).orderBy(asc(weaponRecommendations.sortOrder)),
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1),
getDb().select().from(constellationRecommendations).where(eq(constellationRecommendations.guideId, row.guide.id)).orderBy(asc(constellationRecommendations.level)),
getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)),
getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
]);
const constellations = constellationGroups.length ? await getDb().select().from(constellationRecommendations).where(inArray(constellationRecommendations.conditionId, constellationGroups.map((group) => group.id))).orderBy(asc(constellationRecommendations.level)) : [];
const artifactRows = groups.length ? await getDb().select({ recommendation: artifactRecommendations, artifact: catalogArtifacts }).from(artifactRecommendations).innerJoin(catalogArtifacts, eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey)).where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id))).orderBy(asc(artifactRecommendations.sortOrder)) : [];
const secondKeys = artifactRows.map(({ recommendation }) => recommendation.secondArtifactKey).filter((key): key is string => Boolean(key));
const secondArtifacts = secondKeys.length ? await getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, secondKeys)) : [];
@@ -115,15 +117,18 @@ export async function getPublicGuide(slug: string) {
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })),
artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
artifactProfile: profile[0] ?? null,
constellations: constellations.map((recommendation) => {
const catalog = row.character.constellations.find((item) => item.level === recommendation.level);
return {
...recommendation,
name: catalog?.name ?? `C${recommendation.level}`,
detail: catalog?.description ?? recommendation.detail,
iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null,
};
}),
constellationGroups: constellationGroups.map((group) => ({
...group,
rows: constellations.filter((recommendation) => recommendation.conditionId === group.id).map((recommendation) => {
const catalog = row.character.constellations.find((item) => item.level === recommendation.level);
return {
...recommendation,
name: catalog?.name ?? `C${recommendation.level}`,
detail: catalog?.description ?? recommendation.detail,
iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null,
};
}),
})),
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: catalogCharacterWithUrls(character), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({ ...extraRow, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })) })),
};
+10 -4
View File
@@ -27,8 +27,8 @@ describe("structured guide validation", () => {
it("limits weapon and constellation header notes", () => {
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", rows: [] }).success).toBe(true);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), rows: [] }).success).toBe(false);
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", rows: [] }).success).toBe(true);
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), rows: [] }).success).toBe(false);
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", groups: [] }).success).toBe(true);
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), groups: [] }).success).toBe(false);
});
it("limits artifact main stats to three per slot", () => {
@@ -88,8 +88,14 @@ describe("structured guide validation", () => {
it("persists whether a constellation percentage is marked as great", () => {
const row = { level: 1, detail: "Test constellation", cumulativePercent: 120, isGreat: true };
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [row] }).success).toBe(true);
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ ...row, isGreat: "yes" }] }).success).toBe(false);
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: "Signature weapon", rows: [row] }] }).success).toBe(true);
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ ...row, isGreat: "yes" }] }] }).success).toBe(false);
});
it("allows the same constellation level across conditions but not twice within one condition", () => {
const row = { level: 2, detail: "Test constellation", cumulativePercent: 130, isGreat: false };
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: "A", rows: [row] }, { note: "B", rows: [row] }] }).success).toBe(true);
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [row, row] }] }).success).toBe(false);
});
it("requires four unique team members and non-zero damage", () => {
+13 -6
View File
@@ -77,15 +77,22 @@ export const artifactSectionSchema = z.strictObject({
note: z.string().trim().max(2_000).nullable(),
});
const constellationRowsSchema = z.array(z.strictObject({
level: z.number().int().min(1).max(6),
detail: z.string().trim().min(1).max(2_000),
cumulativePercent: percent,
isGreat: z.boolean(),
})).max(6).refine((rows) => new Set(rows.map((row) => row.level)).size === rows.length, {
message: "แต่ละเงื่อนไขต้องมีระดับกลุ่มดาวไม่ซ้ำกัน",
});
export const constellationSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
note: z.string().trim().max(2_000).nullable(),
rows: z.array(z.strictObject({
level: z.number().int().min(1).max(6),
detail: z.string().trim().min(1).max(2_000),
cumulativePercent: percent,
isGreat: z.boolean(),
})).max(6),
groups: z.array(z.strictObject({
note: z.string().trim().max(2_000).nullable(),
rows: constellationRowsSchema,
})).max(20),
});
export const teamSectionSchema = z.strictObject({