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
+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>
);
}