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