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 } nativeButton={false} aria-label="กลับหน้า Guide">{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 } nativeButton={false} aria-label="กลับหน้า Guide">{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 ?
- {issues.length ? {issues.map((issue, index) => - {issue}
)}
: "ข้อมูลที่จำเป็นยังกรอกไม่ครบ"}
+
+
Local draft ถูกเก็บไว้แล้ว และจะบันทึกอัตโนมัติเมื่อข้อมูลครบ
+ {issues.length ?
{issues.map((issue, index) => - {issue}
)}
: null}
+
: 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
setCover(asset)} />Overview;
+ return Overviewภาพและข้อความนี้แสดงบนหน้าแรกและส่วนแนะนำของ GuideGuide Image
setCover(asset)} />Overview;
}
diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx
index 7837204..33a2e16 100644
--- a/components/admin/guide-section-editor.tsx
+++ b/components/admin/guide-section-editor.tsx
@@ -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(value: unknown, arrayKeys: string[], stringKeys: string[] = []): Draft | null {
+ if (!value || typeof value !== "object") return null;
+ const record = value as Record;
+ 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(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) {
- issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} />
+ issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} />
);
}
@@ -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(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
- issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} />
+ issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} />
);
@@ -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(candidate, ["groups"], ["note"]),
+ restoreLocalDraft: (draft) => { setNote(draft.note); setGroups(draft.groups); },
+ });
return (
Constellationsชื่อ รายละเอียด และไอคอนมาจาก Lunaris อัตโนมัติ กรอกเฉพาะเปอร์เซ็นต์เทียบกับ C0 = 100%
Header note (optional)
-
- {rows.map((row, index) => {row.iconUrl ? : null}C{row.level} · {row.name}{row.description}Cumulative % setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, cumulativePercent: event.target.value } : item))} /> 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")}>Great %)}
-
+ {groups.map((group, groupIndex) =>
+ Condition {groupIndex + 1}ใช้ note เพื่ออธิบายอาวุธ ทีม หรือเงื่อนไขที่ใช้เปรียบเทียบ{groups.length > 1 ? }>ลบ Condition {groupIndex + 1}ลบ Condition {groupIndex + 1}?เปอร์เซ็นต์กลุ่มดาวทั้งหมดในเงื่อนไขนี้จะถูกลบออกยกเลิก setGroups(groups.filter((item) => item.clientId !== group.clientId))}>ยืนยันลบ : null}
+
+ Condition note (optional)
+
+ {group.rows.map((row, rowIndex) => {row.iconUrl ? : null}C{row.level} · {row.name}{row.description}Cumulative % setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: event.target.value } : candidate) } : item))} /> setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, isGreat } : candidate) } : item))} aria-label={`Mark Condition ${groupIndex + 1} C${row.level} percentage as great`} className={cn(SELECTED_TOGGLE_CLASS, "data-[state=on]:text-warning")}>Great %)}
+
+
+ )}
- issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} />
+ issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} />
);
}
@@ -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(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
- issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} />
+ issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} />
);
}
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(candidate, ["sections"]),
+ restoreLocalDraft: (draft) => setSections(draft.sections),
+ });
return (
Extra sectionsเพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์ ระบบจะบันทึกอัตโนมัติ
{sections.map((section, sectionIndex) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, title: event.target.value } : item))} />{section.rows.map((row, rowIndex) => {row.heading || `แถว ${rowIndex + 1}`}{row.imageUrl ?
: null} 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))} />อัตราส่วนภาพ { 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">1:116:9หัวข้อ setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, heading: event.target.value } : candidate) } : item))} />ข้อความPercent (optional) setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value || null } : candidate) } : item))} />)})}
- issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} />
+ issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} />
);
}
diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx
index 841dcaa..a14ef8d 100644
--- a/components/public/structured-guide.tsx
+++ b/components/public/structured-guide.tsx
@@ -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 }) {
) : null}
-
- {guide.constellations.map((row) => (
-
-
- {row.iconUrl ? (
-
- ) : null}
-
-
- {metric(row.cumulativePercent)}
-
-
- C{row.level} · {row.name}
-
-
-
-
- {row.detail}
-
-
- ))}
-
+ {guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => (
+
+ {groups.length > 1 ?
Condition {groupIndex + 1}
: null}
+ {group.note ?
{group.note}
: null}
+
+ {group.rows.map((row) => (
+
+
+ {row.iconUrl ? (
+
+ ) : null}
+
+
+ {metric(row.cumulativePercent)}
+
+
+ C{row.level} · {row.name}
+
+
+
+
+ {row.detail}
+
+
+ ))}
+
+
+ ))}
);
}
diff --git a/db/schema.ts b/db/schema.ts
index 516aa34..3ecf1ef 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -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(
diff --git a/drizzle/0019_equal_lord_hawal.sql b/drizzle/0019_equal_lord_hawal.sql
new file mode 100644
index 0000000..65d1fbd
--- /dev/null
+++ b/drizzle/0019_equal_lord_hawal.sql
@@ -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";
diff --git a/drizzle/meta/0019_snapshot.json b/drizzle/meta/0019_snapshot.json
new file mode 100644
index 0000000..3908945
--- /dev/null
+++ b/drizzle/meta/0019_snapshot.json
@@ -0,0 +1,3310 @@
+{
+ "id": "dc8a3a77-70cb-474d-87a7-92ed68be3855",
+ "prevId": "244c7e7c-69b2-4b0f-b013-9be313e918ac",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "auth.account": {
+ "name": "account",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_issuer_unique": {
+ "name": "account_issuer_unique",
+ "columns": [
+ {
+ "expression": "issuer",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.artifact_group": {
+ "name": "artifact_group",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "guide_id": {
+ "name": "guide_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "artifact_group_order_idx": {
+ "name": "artifact_group_order_idx",
+ "columns": [
+ {
+ "expression": "guide_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "artifact_group_guide_id_guide_id_fk": {
+ "name": "artifact_group_guide_id_guide_id_fk",
+ "tableFrom": "artifact_group",
+ "tableTo": "guide",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "guide_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.artifact_profile": {
+ "name": "artifact_profile",
+ "schema": "guides",
+ "columns": {
+ "guide_id": {
+ "name": "guide_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "sands": {
+ "name": "sands",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "goblet": {
+ "name": "goblet",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "circlet": {
+ "name": "circlet",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "substats": {
+ "name": "substats",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "targets": {
+ "name": "targets",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "talents": {
+ "name": "talents",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "artifact_profile_guide_id_guide_id_fk": {
+ "name": "artifact_profile_guide_id_guide_id_fk",
+ "tableFrom": "artifact_profile",
+ "tableTo": "guide",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "guide_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.artifact_recommendation": {
+ "name": "artifact_recommendation",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "first_artifact_key": {
+ "name": "first_artifact_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "second_artifact_key": {
+ "name": "second_artifact_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "percent": {
+ "name": "percent",
+ "type": "numeric(8, 2)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "artifact_recommendation_order_idx": {
+ "name": "artifact_recommendation_order_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "artifact_recommendation_group_id_artifact_group_id_fk": {
+ "name": "artifact_recommendation_group_id_artifact_group_id_fk",
+ "tableFrom": "artifact_recommendation",
+ "tableTo": "artifact_group",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.block": {
+ "name": "block",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "schema_version": {
+ "name": "schema_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "responsive_layout": {
+ "name": "responsive_layout",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "block_page_order_idx": {
+ "name": "block_page_order_idx",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "block_page_id_page_id_fk": {
+ "name": "block_page_id_page_id_fk",
+ "tableFrom": "block",
+ "tableTo": "page",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "catalog.artifact": {
+ "name": "artifact",
+ "schema": "catalog",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rarity": {
+ "name": "rarity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_key": {
+ "name": "image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "catalog_artifact_name_idx": {
+ "name": "catalog_artifact_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "artifact_key_pk": {
+ "name": "artifact_key_pk",
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "catalog.character": {
+ "name": "character",
+ "schema": "catalog",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "element": {
+ "name": "element",
+ "type": "varchar(48)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rarity": {
+ "name": "rarity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weapon_type": {
+ "name": "weapon_type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_key": {
+ "name": "image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "element_icon_key": {
+ "name": "element_icon_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "coop_image_key": {
+ "name": "coop_image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name_card_image_key": {
+ "name": "name_card_image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "constellations": {
+ "name": "constellations",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "talents": {
+ "name": "talents",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ }
+ },
+ "indexes": {
+ "catalog_character_name_idx": {
+ "name": "catalog_character_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "character_key_pk": {
+ "name": "character_key_pk",
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "catalog.weapon": {
+ "name": "weapon",
+ "schema": "catalog",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rarity": {
+ "name": "rarity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weapon_type": {
+ "name": "weapon_type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_key": {
+ "name": "image_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "catalog_weapon_name_idx": {
+ "name": "catalog_weapon_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "weapon_key_pk": {
+ "name": "weapon_key_pk",
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.character": {
+ "name": "character",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name_th": {
+ "name": "name_th",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name_en": {
+ "name": "name_en",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "portrait_media_id": {
+ "name": "portrait_media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "element": {
+ "name": "element",
+ "type": "varchar(48)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(80)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rarity": {
+ "name": "rarity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "visible": {
+ "name": "visible",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "character_slug_unique": {
+ "name": "character_slug_unique",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "character_directory_idx": {
+ "name": "character_directory_idx",
+ "columns": [
+ {
+ "expression": "visible",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "character_portrait_media_id_media_id_fk": {
+ "name": "character_portrait_media_id_media_id_fk",
+ "tableFrom": "character",
+ "tableTo": "media",
+ "schemaTo": "media",
+ "columnsFrom": [
+ "portrait_media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.constellation_condition": {
+ "name": "constellation_condition",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "guide_id": {
+ "name": "guide_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "constellation_condition_order_idx": {
+ "name": "constellation_condition_order_idx",
+ "columns": [
+ {
+ "expression": "guide_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "constellation_condition_guide_id_guide_id_fk": {
+ "name": "constellation_condition_guide_id_guide_id_fk",
+ "tableFrom": "constellation_condition",
+ "tableTo": "guide",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "guide_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.constellation_recommendation": {
+ "name": "constellation_recommendation",
+ "schema": "guides",
+ "columns": {
+ "condition_id": {
+ "name": "condition_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level": {
+ "name": "level",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cumulative_percent": {
+ "name": "cumulative_percent",
+ "type": "numeric(8, 2)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_great": {
+ "name": "is_great",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "constellation_recommendation_condition_id_constellation_condition_id_fk": {
+ "name": "constellation_recommendation_condition_id_constellation_condition_id_fk",
+ "tableFrom": "constellation_recommendation",
+ "tableTo": "constellation_condition",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "condition_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "constellation_recommendation_condition_id_level_pk": {
+ "name": "constellation_recommendation_condition_id_level_pk",
+ "columns": [
+ "condition_id",
+ "level"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.data_source_version": {
+ "name": "data_source_version",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "data_source_id": {
+ "name": "data_source_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rows": {
+ "name": "rows",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "evaluation": {
+ "name": "evaluation",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "data_source_version_unique": {
+ "name": "data_source_version_unique",
+ "columns": [
+ {
+ "expression": "data_source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "data_source_version_data_source_id_data_source_id_fk": {
+ "name": "data_source_version_data_source_id_data_source_id_fk",
+ "tableFrom": "data_source_version",
+ "tableTo": "data_source",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "data_source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "data_source_version_created_by_id_user_id_fk": {
+ "name": "data_source_version_created_by_id_user_id_fk",
+ "tableFrom": "data_source_version",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.data_source": {
+ "name": "data_source",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fields": {
+ "name": "fields",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "current_version": {
+ "name": "current_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "data_source_name_idx": {
+ "name": "data_source_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.extra_section_row": {
+ "name": "extra_section_row",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ratio": {
+ "name": "ratio",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'16:9'"
+ },
+ "heading": {
+ "name": "heading",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "percent": {
+ "name": "percent",
+ "type": "numeric(8, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "extra_section_row_order_idx": {
+ "name": "extra_section_row_order_idx",
+ "columns": [
+ {
+ "expression": "section_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "extra_section_row_section_id_extra_section_section_id_fk": {
+ "name": "extra_section_row_section_id_extra_section_section_id_fk",
+ "tableFrom": "extra_section_row",
+ "tableTo": "extra_section",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "section_id"
+ ],
+ "columnsTo": [
+ "section_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "extra_section_row_media_id_media_id_fk": {
+ "name": "extra_section_row_media_id_media_id_fk",
+ "tableFrom": "extra_section_row",
+ "tableTo": "media",
+ "schemaTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.extra_section": {
+ "name": "extra_section",
+ "schema": "guides",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "extra_section_section_id_guide_section_id_fk": {
+ "name": "extra_section_section_id_guide_section_id_fk",
+ "tableFrom": "extra_section",
+ "tableTo": "guide_section",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "section_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.guide_section": {
+ "name": "guide_section",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "guide_id": {
+ "name": "guide_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "guide_section_slug_unique": {
+ "name": "guide_section_slug_unique",
+ "columns": [
+ {
+ "expression": "guide_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "guide_section_order_idx": {
+ "name": "guide_section_order_idx",
+ "columns": [
+ {
+ "expression": "guide_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "guide_section_guide_id_guide_id_fk": {
+ "name": "guide_section_guide_id_guide_id_fk",
+ "tableFrom": "guide_section",
+ "tableTo": "guide",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "guide_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.guide": {
+ "name": "guide",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "character_key": {
+ "name": "character_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "overview": {
+ "name": "overview",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cover_media_id": {
+ "name": "cover_media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "trashed_at": {
+ "name": "trashed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "guide_character_key_unique": {
+ "name": "guide_character_key_unique",
+ "columns": [
+ {
+ "expression": "character_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "guide_slug_unique": {
+ "name": "guide_slug_unique",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "guide_directory_idx": {
+ "name": "guide_directory_idx",
+ "columns": [
+ {
+ "expression": "trashed_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "guide_cover_media_id_media_id_fk": {
+ "name": "guide_cover_media_id_media_id_fk",
+ "tableFrom": "guide",
+ "tableTo": "media",
+ "schemaTo": "media",
+ "columnsFrom": [
+ "cover_media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "media.media": {
+ "name": "media",
+ "schema": "media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "object_key": {
+ "name": "object_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "byte_size": {
+ "name": "byte_size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(24)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "current_reference_count": {
+ "name": "current_reference_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "revision_reference_count": {
+ "name": "revision_reference_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "media_object_key_unique": {
+ "name": "media_object_key_unique",
+ "columns": [
+ {
+ "expression": "object_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "media_visibility_idx": {
+ "name": "media_visibility_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "current_reference_count",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_created_by_id_user_id_fk": {
+ "name": "media_created_by_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "media.media_reference": {
+ "name": "media_reference",
+ "schema": "media",
+ "columns": {
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_type": {
+ "name": "owner_type",
+ "type": "varchar(24)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reference_kind": {
+ "name": "reference_kind",
+ "type": "varchar(24)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "media_reference_owner_idx": {
+ "name": "media_reference_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_reference_media_id_media_id_fk": {
+ "name": "media_reference_media_id_media_id_fk",
+ "tableFrom": "media_reference",
+ "tableTo": "media",
+ "schemaTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_reference_media_id_owner_type_owner_id_reference_kind_pk": {
+ "name": "media_reference_media_id_owner_type_owner_id_reference_kind_pk",
+ "columns": [
+ "media_id",
+ "owner_type",
+ "owner_id",
+ "reference_kind"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.outbox_event": {
+ "name": "outbox_event",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigint",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "outbox_event_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "9223372036854775807",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "topic": {
+ "name": "topic",
+ "type": "varchar(160)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "aggregate_id": {
+ "name": "aggregate_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(24)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "available_at": {
+ "name": "available_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "outbox_pending_idx": {
+ "name": "outbox_pending_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "available_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.page_data_source": {
+ "name": "page_data_source",
+ "schema": "editor",
+ "columns": {
+ "page_id": {
+ "name": "page_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data_source_id": {
+ "name": "data_source_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pinned_version": {
+ "name": "pinned_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "page_data_source_dependency_idx": {
+ "name": "page_data_source_dependency_idx",
+ "columns": [
+ {
+ "expression": "data_source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "page_data_source_page_id_page_id_fk": {
+ "name": "page_data_source_page_id_page_id_fk",
+ "tableFrom": "page_data_source",
+ "tableTo": "page",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "page_data_source_data_source_id_data_source_id_fk": {
+ "name": "page_data_source_data_source_id_data_source_id_fk",
+ "tableFrom": "page_data_source",
+ "tableTo": "data_source",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "data_source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "page_data_source_page_id_data_source_id_pk": {
+ "name": "page_data_source_page_id_data_source_id_pk",
+ "columns": [
+ "page_id",
+ "data_source_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.page": {
+ "name": "page",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "nav_order": {
+ "name": "nav_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "visible": {
+ "name": "visible",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "public_note": {
+ "name": "public_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "page_character_slug_unique": {
+ "name": "page_character_slug_unique",
+ "columns": [
+ {
+ "expression": "character_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "page_navigation_idx": {
+ "name": "page_navigation_idx",
+ "columns": [
+ {
+ "expression": "character_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "visible",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "nav_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "page_character_id_character_id_fk": {
+ "name": "page_character_id_character_id_fk",
+ "tableFrom": "page",
+ "tableTo": "character",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.public_snapshot": {
+ "name": "public_snapshot",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "page_version": {
+ "name": "page_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version_vector": {
+ "name": "version_vector",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "vector_hash": {
+ "name": "vector_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dto": {
+ "name": "dto",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "visible": {
+ "name": "visible",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "public_snapshot_version_unique": {
+ "name": "public_snapshot_version_unique",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "page_version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "vector_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "public_snapshot_lookup_idx": {
+ "name": "public_snapshot_lookup_idx",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "visible",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "public_snapshot_page_id_page_id_fk": {
+ "name": "public_snapshot_page_id_page_id_fk",
+ "tableFrom": "public_snapshot",
+ "tableTo": "page",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.revision": {
+ "name": "revision",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "page_version": {
+ "name": "page_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(24)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_revision_id": {
+ "name": "source_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "checkpoint_hour": {
+ "name": "checkpoint_hour",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "snapshot": {
+ "name": "snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data_source_versions": {
+ "name": "data_source_versions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "media_ids": {
+ "name": "media_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "revision_page_created_idx": {
+ "name": "revision_page_created_idx",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "revision_checkpoint_hour_unique": {
+ "name": "revision_checkpoint_hour_unique",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "checkpoint_hour",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"editor\".\"revision\".\"kind\" = 'checkpoint'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "revision_page_id_page_id_fk": {
+ "name": "revision_page_id_page_id_fk",
+ "tableFrom": "revision",
+ "tableTo": "page",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "revision_author_id_user_id_fk": {
+ "name": "revision_author_id_user_id_fk",
+ "tableFrom": "revision",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.session": {
+ "name": "session",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "session_user_id_idx": {
+ "name": "session_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.slug_alias": {
+ "name": "slug_alias",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(24)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "old_character_slug": {
+ "name": "old_character_slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "old_page_slug": {
+ "name": "old_page_slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_character_id": {
+ "name": "target_character_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_page_id": {
+ "name": "target_page_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "slug_alias_path_unique": {
+ "name": "slug_alias_path_unique",
+ "columns": [
+ {
+ "expression": "old_character_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "old_page_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "slug_alias_target_character_id_character_id_fk": {
+ "name": "slug_alias_target_character_id_character_id_fk",
+ "tableFrom": "slug_alias",
+ "tableTo": "character",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "target_character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "slug_alias_target_page_id_page_id_fk": {
+ "name": "slug_alias_target_page_id_page_id_fk",
+ "tableFrom": "slug_alias",
+ "tableTo": "page",
+ "schemaTo": "editor",
+ "columnsFrom": [
+ "target_page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.team_composition": {
+ "name": "team_composition",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "guide_id": {
+ "name": "guide_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rotation_seconds": {
+ "name": "rotation_seconds",
+ "type": "numeric(8, 2)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "combo": {
+ "name": "combo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "team_composition_order_idx": {
+ "name": "team_composition_order_idx",
+ "columns": [
+ {
+ "expression": "guide_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "team_composition_guide_id_guide_id_fk": {
+ "name": "team_composition_guide_id_guide_id_fk",
+ "tableFrom": "team_composition",
+ "tableTo": "guide",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "guide_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.team_member": {
+ "name": "team_member",
+ "schema": "guides",
+ "columns": {
+ "team_id": {
+ "name": "team_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "character_key": {
+ "name": "character_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weapon_key": {
+ "name": "weapon_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "artifact_key": {
+ "name": "artifact_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "second_artifact_key": {
+ "name": "second_artifact_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "constellation": {
+ "name": "constellation",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "refinement": {
+ "name": "refinement",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "damage": {
+ "name": "damage",
+ "type": "numeric(18, 2)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "team_member_team_id_team_composition_id_fk": {
+ "name": "team_member_team_id_team_composition_id_fk",
+ "tableFrom": "team_member",
+ "tableTo": "team_composition",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "team_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "team_member_team_id_position_pk": {
+ "name": "team_member_team_id_position_pk",
+ "columns": [
+ "team_id",
+ "position"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "editor.template": {
+ "name": "template",
+ "schema": "editor",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "structure": {
+ "name": "structure",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "built_in": {
+ "name": "built_in",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "template_slug_unique": {
+ "name": "template_slug_unique",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.user": {
+ "name": "user",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.verification": {
+ "name": "verification",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "guides.weapon_recommendation": {
+ "name": "weapon_recommendation",
+ "schema": "guides",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "guide_id": {
+ "name": "guide_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weapon_key": {
+ "name": "weapon_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refinement": {
+ "name": "refinement",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "overall_percent": {
+ "name": "overall_percent",
+ "type": "numeric(8, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "personal_percent": {
+ "name": "personal_percent",
+ "type": "numeric(8, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "weapon_recommendation_order_idx": {
+ "name": "weapon_recommendation_order_idx",
+ "columns": [
+ {
+ "expression": "guide_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "weapon_recommendation_guide_id_guide_id_fk": {
+ "name": "weapon_recommendation_guide_id_guide_id_fk",
+ "tableFrom": "weapon_recommendation",
+ "tableTo": "guide",
+ "schemaTo": "guides",
+ "columnsFrom": [
+ "guide_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 10a262c..f056c05 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -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
}
]
}
\ No newline at end of file
diff --git a/hooks/use-autosave.ts b/hooks/use-autosave.ts
index 00687af..1308826 100644
--- a/hooks/use-autosave.ts
+++ b/hooks/use-autosave.ts
@@ -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({
+export function useAutosave({
value,
valid,
initialVersion,
persist,
delay = 900,
+ storageKey,
+ localDraft,
+ parseLocalDraft,
+ restoreLocalDraft,
}: {
- value: T;
+ value: Value;
valid: boolean;
initialVersion: number;
- persist: (value: T, expectedVersion: number) => Promise;
+ persist: (value: Value, expectedVersion: number) => Promise;
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(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({
});
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);
+ },
+ };
}
diff --git a/lib/editor/local-draft.test.ts b/lib/editor/local-draft.test.ts
new file mode 100644
index 0000000..d3e3137
--- /dev/null
+++ b/lib/editor/local-draft.test.ts
@@ -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();
+ });
+});
diff --git a/lib/editor/local-draft.ts b/lib/editor/local-draft.ts
new file mode 100644
index 0000000..93f88d5
--- /dev/null
+++ b/lib/editor/local-draft.ts
@@ -0,0 +1,39 @@
+export const LOCAL_DRAFT_SCHEMA_VERSION = 1;
+
+export interface LocalDraftEnvelope {
+ 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, serverVersion: number): string {
+ return JSON.stringify({
+ schemaVersion: LOCAL_DRAFT_SCHEMA_VERSION,
+ serverVersion,
+ updatedAt: new Date().toISOString(),
+ draft,
+ } satisfies LocalDraftEnvelope);
+}
+
+export function parseLocalDraft(serialized: string, parseDraft: (value: unknown) => Draft | null): LocalDraftEnvelope | null {
+ try {
+ const value: unknown = JSON.parse(serialized);
+ if (!value || typeof value !== "object") return null;
+ const envelope = value as Record;
+ 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;
+ }
+}
diff --git a/lib/guides/mutations.ts b/lib/guides/mutations.ts
index 934aa1c..86ba470 100644
--- a/lib/guides/mutations.ts
+++ b/lib/guides/mutations.ts
@@ -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["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["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;
});
}
diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts
index 14a731e..b48e142 100644
--- a/lib/guides/queries.ts
+++ b/lib/guides/queries.ts
@@ -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 })) })),
};
diff --git a/lib/guides/schemas.test.ts b/lib/guides/schemas.test.ts
index 512e24c..0fe45e6 100644
--- a/lib/guides/schemas.test.ts
+++ b/lib/guides/schemas.test.ts
@@ -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", () => {
diff --git a/lib/guides/schemas.ts b/lib/guides/schemas.ts
index 2bdcbce..8725aae 100644
--- a/lib/guides/schemas.ts
+++ b/lib/guides/schemas.ts
@@ -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({