From 7708a2df31c1acedf327e692d30ebdaec466100f Mon Sep 17 00:00:00 2001 From: gunshiz Date: Wed, 2 Sep 2026 12:40:13 +0700 Subject: [PATCH] feat(admin) : persist drafts and add constellation conditions --- app/admin/[character]/[page]/page.tsx | 2 +- app/admin/actions.ts | 9 +- components/admin/autosave-status.tsx | 9 +- components/admin/guide-overview-form.tsx | 17 +- components/admin/guide-section-editor.tsx | 108 +- components/public/structured-guide.tsx | 74 +- db/schema.ts | 15 +- drizzle/0019_equal_lord_hawal.sql | 34 + drizzle/meta/0019_snapshot.json | 3310 +++++++++++++++++++++ drizzle/meta/_journal.json | 7 + hooks/use-autosave.ts | 106 +- lib/editor/local-draft.test.ts | 27 + lib/editor/local-draft.ts | 39 + lib/guides/mutations.ts | 21 +- lib/guides/queries.ts | 27 +- lib/guides/schemas.test.ts | 14 +- lib/guides/schemas.ts | 19 +- 17 files changed, 3738 insertions(+), 100 deletions(-) create mode 100644 drizzle/0019_equal_lord_hawal.sql create mode 100644 drizzle/meta/0019_snapshot.json create mode 100644 lib/editor/local-draft.test.ts create mode 100644 lib/editor/local-draft.ts diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 561f94d..cb1bacf 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -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

{guide.name}

{guide.sections.find((section) => section.slug === page)?.title ?? page}

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

{guide.name}

{guide.sections.find((section) => section.slug === page)?.title ?? page}

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 })) })) }} />
; } diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 438aee5..2f985e6 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -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; + async function runGuideAction(action: () => Promise<{ version?: number }>): Promise { 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 { +export async function createStructuredGuide(input: unknown): Promise { await requireAdmin(); try { const guide = await createGuide(input); diff --git a/components/admin/autosave-status.tsx b/components/admin/autosave-status.tsx index 8c5aabf..2bbfbae 100644 --- a/components/admin/autosave-status.tsx +++ b/components/admin/autosave-status.tsx @@ -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 Saving…; @@ -19,10 +19,13 @@ export function AutosaveStatus({ state, issues = [] }: { state: AutosaveState; i {showIssues ? : null} ; - if (state === "error") return Autosave failed; + if (state === "error") return
Autosave failed{onSave ? : null}
; return Saved; } diff --git a/components/admin/guide-overview-form.tsx b/components/admin/guide-overview-form.tsx index 93069e2..489a6be 100644 --- a/components/admin/guide-overview-form.tsx +++ b/components/admin/guide-overview-form.tsx @@ -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; + if (typeof draft.overview !== "string" || !draft.cover || typeof draft.cover !== "object") return null; + const draftCover = draft.cover as Record; + return typeof draftCover.id === "string" && typeof draftCover.url === "string" ? candidate as typeof localDraft : null; + }, + restoreLocalDraft: (draft) => { + setOverview(draft.overview); + setCover(draft.cover); + }, }); - return Overviewภาพและข้อความนี้แสดงบนหน้าแรกและส่วนแนะนำของ GuideGuide Image
{`Guide
setCover(asset)} />
Overview