From 2de07c4917fd98ca4b85eebf0accc8f93379d183 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sat, 5 Sep 2026 22:36:31 +0700 Subject: [PATCH] feat : update --- app/admin/[character]/[page]/page.tsx | 28 ++- app/admin/[character]/page.tsx | 54 ++++- app/admin/actions.ts | 62 ++++++ components/admin/guide-extra-controls.tsx | 240 ++++++++++++++++++++++ components/admin/guide-section-editor.tsx | 102 ++++----- components/admin/guide-section-nav.tsx | 29 ++- components/public/structured-guide.tsx | 67 +----- components/public/team-search.tsx | 2 +- lib/guides/mutations.ts | 133 +++++++++++- lib/guides/queries.ts | 2 +- lib/guides/schemas.test.ts | 36 +++- lib/guides/schemas.ts | 28 ++- 12 files changed, 637 insertions(+), 146 deletions(-) create mode 100644 components/admin/guide-extra-controls.tsx diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 49bdeac..adf3dc6 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -21,7 +21,6 @@ const sections = [ "artifact", "constellations", "team", - "extra", ] as const; export const instant = false; @@ -34,9 +33,22 @@ export default async function StructuredEditorPage({ await connection(); if (!(await getAdminSession())) redirect("/admin/login"); const { character, page } = await params; - if (!sections.includes(page as (typeof sections)[number])) notFound(); const guide = await getGuideByCharacterKey(character); if (!guide) notFound(); + const extraSections = guide.sections.filter((section) => section.kind === "extra"); + if (page === "extra") { + redirect( + extraSections[0] + ? `/admin/${character}/${extraSections[0].slug}` + : `/admin/${character}`, + ); + } + const fixedSection = sections.includes(page as (typeof sections)[number]); + const extraSection = extraSections.find((section) => section.slug === page); + if (!fixedSection && !extraSection) notFound(); + const editorSection = fixedSection + ? page as (typeof sections)[number] + : "extra" as const; const [catalog, content] = await Promise.all([ getActiveCatalog(), getAdminGuideContent(guide.slug), @@ -65,18 +77,20 @@ export default async function StructuredEditorPage({

{guide.name}

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

({ id, slug, title }))} /> ({ + extras: content.extras.filter((section) => !extraSection || section.id === extraSection.id).map((section) => ({ id: section.id, title: section.title, rows: section.rows.map((row) => ({ diff --git a/app/admin/[character]/page.tsx b/app/admin/[character]/page.tsx index 08609d2..0039196 100644 --- a/app/admin/[character]/page.tsx +++ b/app/admin/[character]/page.tsx @@ -4,13 +4,14 @@ import { ArrowLeftIcon, ArrowUpRightIcon } from "lucide-react"; import { notFound, redirect } from "next/navigation"; import { connection } from "next/server"; +import { AdminHeader } from "@/components/admin/admin-header"; +import { EditorPresence } from "@/components/admin/editor-presence"; +import { ExtraOverview } from "@/components/admin/guide-extra-controls"; +import { GuideOverviewForm } from "@/components/admin/guide-overview-form"; +import { GuideSectionNav } from "@/components/admin/guide-section-nav"; +import { GuideVisibilityControl } from "@/components/admin/guide-visibility-control"; import { Button } from "@/components/ui/button"; import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { AdminHeader } from "@/components/admin/admin-header"; -import { GuideOverviewForm } from "@/components/admin/guide-overview-form"; -import { GuideVisibilityControl } from "@/components/admin/guide-visibility-control"; -import { GuideSectionNav } from "@/components/admin/guide-section-nav"; -import { EditorPresence } from "@/components/admin/editor-presence"; import { getAdminSession } from "@/lib/auth/server"; import { getGuideByCharacterKey } from "@/lib/guides/queries"; import { navigationIconPath } from "@/lib/navigation"; @@ -28,5 +29,46 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{ const { character } = await params; const guide = await getGuideByCharacterKey(character); if (!guide) notFound(); - return
{guide.name}

เลือกส่วนที่ต้องการแก้ไข

{guide.name}

{sections.map(({ slug, title, description }) => {navigationIconPath(slug) ? : null}{title}{description})}Extra sectionsส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์
; + const extras = guide.sections + .filter((section) => section.kind === "extra") + .map(({ id, slug, title }) => ({ id, slug, title })); + + return ( +
+ +
+
+ +
+ {guide.name} +
+
+

เลือกส่วนที่ต้องการแก้ไข

+

{guide.name}

+ +
+ +
+ + +
+ {sections.map(({ slug, title, description }) => ( + + + + {navigationIconPath(slug) ? : null} + {title} + {description} + + + + + ))} +
+ +
+
+ ); } diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 7add01d..da85272 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -21,13 +21,17 @@ import { requireAdmin } from "@/lib/auth/server"; import { builtInTemplates } from "@/lib/templates"; import { persistTemplateApplication } from "@/lib/templates/persist"; import { + createExtraSection, createGuide, + deleteExtraSection, GuideVersionConflictError, permanentlyDeleteGuide, + reorderExtraSections, restoreGuide, runCatalogSync, saveArtifacts, saveConstellations, + saveExtraSection, saveExtraSections, saveTeams, setGuideVisibility, @@ -274,6 +278,64 @@ export async function saveExtraSectionList(guideId: string, input: unknown) { return result; } +export async function createGuideExtra(guideId: string, input: unknown) { + await requireAdmin(); + try { + const section = await createExtraSection(z.string().uuid().parse(guideId), input); + updateTag(pageCacheTag(guideId)); + return { + status: "saved" as const, + version: section.version, + section: { id: section.id, slug: section.slug, title: section.title }, + }; + } catch (cause) { + return { + status: "error" as const, + message: cause instanceof z.ZodError + ? cause.issues.map((issue) => issue.message).join(" • ") + : cause instanceof Error + ? cause.message + : "สร้าง Extra section ไม่สำเร็จ", + }; + } +} + +export async function saveGuideExtra( + guideId: string, + sectionId: string, + input: unknown, +) { + await requireAdmin(); + const parsedGuideId = z.string().uuid().parse(guideId); + const parsedSectionId = z.string().uuid().parse(sectionId); + const result = await runGuideAction(() => + saveExtraSection(parsedGuideId, parsedSectionId, input), + ); + if (result.status === "saved") updateTag(pageCacheTag(guideId)); + return result; +} + +export async function deleteGuideExtra(guideId: string, sectionId: string) { + await requireAdmin(); + const result = await runGuideAction(() => + deleteExtraSection( + z.string().uuid().parse(guideId), + z.string().uuid().parse(sectionId), + ), + ); + if (result.status === "saved") updateTag(pageCacheTag(guideId)); + return result; +} + +export async function reorderGuideExtras(guideId: string, input: unknown) { + await requireAdmin(); + const result = await runGuideAction(() => + reorderExtraSections(z.string().uuid().parse(guideId), input), + ); + if (result.status === "saved") updateTag(pageCacheTag(guideId)); + return result; +} + export async function moveGuideToTrash(guideId: string) { await requireAdmin(); return runGuideAction(() => trashGuide(guideId)); diff --git a/components/admin/guide-extra-controls.tsx b/components/admin/guide-extra-controls.tsx new file mode 100644 index 0000000..fc0c077 --- /dev/null +++ b/components/admin/guide-extra-controls.tsx @@ -0,0 +1,240 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + ArrowDownIcon, + ArrowUpIcon, + ArrowUpRightIcon, + PlusIcon, +} from "lucide-react"; +import { useState, type ComponentProps } from "react"; +import { toast } from "sonner"; + +import { + createGuideExtra, + reorderGuideExtras, +} from "@/app/admin/actions"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; + +type ExtraLink = { id: string; slug: string; title: string }; + +export function AddExtraButton({ + guideId, + characterKey, + variant = "ghost", + size = "sm", + label = "เพิ่ม Extra", +}: { + guideId: string; + characterKey: string; + variant?: ComponentProps["variant"]; + size?: ComponentProps["size"]; + label?: string; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(""); + const [pending, setPending] = useState(false); + + const create = async () => { + const trimmedTitle = title.trim(); + if (!trimmedTitle) return; + const browserWindow = window as Window & { __guideHasUnsavedChanges?: boolean }; + if ( + browserWindow.__guideHasUnsavedChanges && + !window.confirm("ข้อมูลยังบันทึกไม่เสร็จ ต้องการสร้าง Extra ใหม่หรือไม่?") + ) { + return; + } + + setPending(true); + const result = await createGuideExtra(guideId, { title: trimmedTitle }); + setPending(false); + if (result.status === "error") { + toast.error(result.message); + return; + } + setOpen(false); + setTitle(""); + toast.success("สร้าง Extra แล้ว"); + router.push(`/admin/${characterKey}/${result.section.slug}`); + router.refresh(); + }; + + return ( + <> + + + + + เพิ่ม Extra section + + ตั้งชื่อส่วนเสริม ระบบจะสร้างและเปิดหน้าแก้ไขให้ทันที + + +
{ + event.preventDefault(); + void create(); + }} + > + + + ชื่อ Extra + setTitle(event.target.value)} + /> + + + + + +
+
+
+ + ); +} + +export function ExtraOverview({ + guideId, + characterKey, + initialVersion, + initialExtras, +}: { + guideId: string; + characterKey: string; + initialVersion: number; + initialExtras: ExtraLink[]; +}) { + const router = useRouter(); + const [extras, setExtras] = useState(initialExtras); + const [version, setVersion] = useState(initialVersion); + const [pendingId, setPendingId] = useState(null); + + const move = async (index: number, direction: -1 | 1) => { + const target = index + direction; + if (target < 0 || target >= extras.length) return; + const previous = extras; + const next = [...extras]; + [next[index], next[target]] = [next[target], next[index]]; + setExtras(next); + setPendingId(extras[index].id); + const result = await reorderGuideExtras(guideId, { + expectedVersion: version, + sectionIds: next.map((extra) => extra.id), + }); + setPendingId(null); + if (result.status !== "saved") { + setExtras(previous); + toast.error(result.message); + if (result.status === "conflict") window.location.reload(); + return; + } + if (result.version) setVersion(result.version); + router.refresh(); + }; + + return ( +
+
+
+

Extra sections

+

แต่ละ Extra มีหน้าแก้ไขและบันทึกแยกจากกัน

+
+ +
+ {extras.length ? ( +
+ {extras.map((extra, index) => ( + + + {extra.title} + Extra section {index + 1} + + + + + + + + + + ))} +
+ ) : ( + + + ยังไม่มี Extra + กดเพิ่ม Extra เพื่อสร้างส่วนเสริมแรกของ Guide นี้ + + + )} +
+ ); +} diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 12522e9..1fd41f9 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -2,6 +2,7 @@ import dynamic from "next/dynamic"; import Image from "next/image"; +import { useRouter } from "next/navigation"; import { ChartNoAxesCombinedIcon, ClipboardCopyIcon, @@ -43,9 +44,10 @@ import { import { CSS } from "@dnd-kit/utilities"; import { + deleteGuideExtra, saveArtifactSection, saveConstellationSection, - saveExtraSectionList, + saveGuideExtra, saveTeamSection, saveWeaponSection, } from "@/app/admin/actions"; @@ -317,6 +319,7 @@ function hasIssuePrefix( export interface GuideSectionEditorProps { section: "weapon" | "artifact" | "constellations" | "team" | "extra"; + extraSectionId?: string; guide: Guide; weapons: CatalogItem[]; artifacts: CatalogItem[]; @@ -340,6 +343,7 @@ export interface GuideSectionEditorProps { body: string; percent: string | null; backgroundColor: string | null; + textColor: string | null; sheet: ExtraSheet | null; catalogItems: ExtraCatalogItem[]; highlights: Array<{ text: string; color: string }>; @@ -3523,7 +3527,9 @@ function ExtraCatalogItemsEditor({ ); } -function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorBase) { +function ExtraEditor({ guide, extraSectionId, initial, characters, weapons, artifacts }: EditorBase) { + const router = useRouter(); + const sectionId = extraSectionId ?? initial.extras[0]?.id ?? ""; const extraSensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { @@ -3557,6 +3563,7 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB ? null : Number(row.percent), backgroundColor: row.backgroundColor, + textColor: row.textColor, sheet: row.sheet, catalogItems: row.catalogItems, highlights: [], @@ -3575,8 +3582,11 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB valid: validation.success, initialVersion: guide.version, persist: (next, expectedVersion) => - saveExtraSectionList(guide.id, { ...next, expectedVersion }), - storageKey: adminDraftKey(guide.id, "extra"), + saveGuideExtra(guide.id, sectionId, { + section: next.sections[0], + expectedVersion, + }), + storageKey: adminDraftKey(guide.id, `extra:${sectionId}`), localDraft, parseLocalDraft: (candidate) => parseEditorDraft(candidate, ["sections"]), @@ -3590,6 +3600,7 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB id: row.id || createClientId("extra-row-record"), body: normalizeExtraRichText(row.body, row.highlights ?? []), backgroundColor: row.backgroundColor ?? null, + textColor: row.textColor ?? null, sheet: row.sheet ?? null, catalogItems: row.catalogItems ?? [], highlights: [], @@ -3600,14 +3611,6 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB setOpenSectionIds(restoredSections.map((section) => section.clientId)); }, }); - const handleSectionDragEnd = ({ active, over }: DragEndEvent) => { - if (!over || active.id === over.id) return; - setSections((current) => { - const from = current.findIndex((section) => section.clientId === active.id); - const to = current.findIndex((section) => section.clientId === over.id); - return from === -1 || to === -1 ? current : arrayMove(current, from, to); - }); - }; const handleRowDragEnd = (sectionId: string, { active, over }: DragEndEvent) => { if (!over || active.id === over.id) return; setSections((current) => current.map((section) => { @@ -3622,36 +3625,21 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB return ( - Extra sections + {sections[0]?.title ?? "Extra section"} เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความที่จัดรูปแบบได้ และเปอร์เซ็นต์ ระบบจะบันทึกอัตโนมัติ - - - section.clientId)} - strategy={verticalListSortingStrategy} - > {sections.map((section, sectionIndex) => ( - - {(sectionDragHandle) => ( - + - {sectionDragHandle} } title={`ลบ ${section.title || `Extra section ${sectionIndex + 1}`}?`} description="แถว ข้อความ รูปภาพ และเปอร์เซ็นต์ทั้งหมดในส่วนนี้จะถูกลบออกจาก Guide" - onConfirm={() => - setSections( - sections.filter((_, index) => index !== sectionIndex), - ) - } + showSuccessToast={false} + onConfirm={async () => { + const result = await deleteGuideExtra(guide.id, section.id); + if (result.status !== "saved") { + toast.error(result.message); + return; + } + window.localStorage.removeItem(adminDraftKey(guide.id, `extra:${section.id}`)); + toast.success("ลบ Extra แล้ว"); + router.push(`/admin/${guide.characterKey}`); + router.refresh(); + }} > ลบส่วน @@ -4168,6 +4162,7 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB body: "", percent: null, backgroundColor: null, + textColor: null, sheet: null, catalogItems: [], highlights: [], @@ -4187,33 +4182,10 @@ function ExtraEditor({ guide, initial, characters, weapons, artifacts }: EditorB - )} - ))} - - - + - - + ; }) { const confirmNavigation = (event: MouseEvent) => { const browserWindow = window as Window & { __guideHasUnsavedChanges?: boolean }; @@ -64,6 +68,27 @@ export function GuideSectionNav({ ); })} + {extras.map((extra) => ( + + + {extra.title} + + ))} + ); } diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index 2815d2b..c589e88 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -5,7 +5,6 @@ import type { ReactNode } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Kbd } from "@/components/ui/kbd"; import { Card, CardAction, @@ -26,7 +25,7 @@ import { import { SiteFooter } from "@/components/public/site-footer"; import { SiteHeader } from "@/components/public/site-header"; import { ScrollableNav } from "@/components/public/scrollable-nav"; -import { TeamSearch } from "@/components/public/team-search"; +import { TeamArtifactIcon, TeamSearch } from "@/components/public/team-search"; import { GuideImageExport } from "@/components/public/guide-image-export"; import { GuideExportBranding } from "@/components/public/guide-export-branding"; import { ConstellationImage } from "@/components/public/constellation-image"; @@ -709,62 +708,14 @@ function ArtifactSectionContent({ index % 2 === 0 ? "bg-muted/60" : "bg-muted/20", )} > - +

{artifactTierCount(row.second?.name ?? null)}{" "} diff --git a/components/public/team-search.tsx b/components/public/team-search.tsx index 90999da..47528a2 100644 --- a/components/public/team-search.tsx +++ b/components/public/team-search.tsx @@ -75,7 +75,7 @@ function ArtifactHalf({ ); } -function TeamArtifactIcon({ +export function TeamArtifactIcon({ first, second, eager = false, diff --git a/lib/guides/mutations.ts b/lib/guides/mutations.ts index ca3d2f7..716c2ac 100644 --- a/lib/guides/mutations.ts +++ b/lib/guides/mutations.ts @@ -1,6 +1,6 @@ import "server-only"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { getDb } from "@/db"; import { @@ -26,8 +26,11 @@ import { import { artifactSectionSchema, constellationSectionSchema, + createExtraSectionSchema, createGuideSchema, extraSectionSchema, + reorderExtraSectionsSchema, + saveExtraSectionSchema, slugFromCharacterName, teamSectionSchema, updateGuideSchema, @@ -222,6 +225,134 @@ export async function saveExtraSections(guideId: string, input: unknown) { }); } +export async function createExtraSection(guideId: string, input: unknown) { + const data = createExtraSectionSchema.parse(input); + return getDb().transaction(async (tx) => { + const existing = await tx + .select({ slug: guideSections.slug, sortOrder: guideSections.sortOrder }) + .from(guideSections) + .where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra"))) + .orderBy(asc(guideSections.sortOrder)); + if (existing.length >= 20) throw new Error("Guide มี Extra sections ได้สูงสุด 20 ส่วน"); + + const [updated] = await tx + .update(guides) + .set({ version: sql`${guides.version} + 1` }) + .where(and(eq(guides.id, guideId), sql`${guides.trashedAt} is null`)) + .returning(); + if (!updated) throw new Error("ไม่พบ Guide"); + + const suffix = existing.reduce((highest, section) => { + const match = /^extra-(\d+)$/u.exec(section.slug); + return match ? Math.max(highest, Number(match[1])) : highest; + }, 0) + 1; + const sortOrder = Math.max(3, ...existing.map((section) => section.sortOrder)) + 1; + const [section] = await tx + .insert(guideSections) + .values({ + guideId, + kind: "extra", + slug: `extra-${suffix}`, + title: data.title, + enabled: true, + sortOrder, + }) + .returning(); + await tx.insert(extraSections).values({ sectionId: section.id }); + await emitGuideEvents(tx, guideId, updated.version); + return { ...section, version: updated.version }; + }); +} + +export async function saveExtraSection( + guideId: string, + sectionId: string, + input: unknown, +) { + const data = saveExtraSectionSchema.parse(input); + return getDb().transaction(async (tx) => { + const [target] = await tx + .select({ id: guideSections.id }) + .from(guideSections) + .where(and( + eq(guideSections.id, sectionId), + eq(guideSections.guideId, guideId), + eq(guideSections.kind, "extra"), + )) + .limit(1); + if (!target) throw new Error("ไม่พบ Extra section"); + + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion); + await tx + .update(guideSections) + .set({ title: data.section.title }) + .where(eq(guideSections.id, sectionId)); + await tx.delete(extraSectionRows).where(eq(extraSectionRows.sectionId, sectionId)); + if (data.section.rows.length) { + await tx.insert(extraSectionRows).values(data.section.rows.map((row, sortOrder) => ({ + ...row, + body: normalizeExtraRichText(row.body, row.highlights), + highlights: [], + percent: row.percent?.toString() ?? null, + sectionId, + sortOrder, + }))); + } + return updated; + }); +} + +export async function deleteExtraSection(guideId: string, sectionId: string) { + return getDb().transaction(async (tx) => { + const [target] = await tx + .select({ id: guideSections.id }) + .from(guideSections) + .where(and( + eq(guideSections.id, sectionId), + eq(guideSections.guideId, guideId), + eq(guideSections.kind, "extra"), + )) + .limit(1); + if (!target) throw new Error("ไม่พบ Extra section"); + + const [updated] = await tx + .update(guides) + .set({ version: sql`${guides.version} + 1` }) + .where(and(eq(guides.id, guideId), sql`${guides.trashedAt} is null`)) + .returning(); + if (!updated) throw new Error("ไม่พบ Guide"); + await tx.delete(guideSections).where(eq(guideSections.id, sectionId)); + await emitGuideEvents(tx, guideId, updated.version); + return updated; + }); +} + +export async function reorderExtraSections(guideId: string, input: unknown) { + const data = reorderExtraSectionsSchema.parse(input); + return getDb().transaction(async (tx) => { + const existing = await tx + .select({ id: guideSections.id }) + .from(guideSections) + .where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra"))); + const existingIds = new Set(existing.map((section) => section.id)); + if ( + existingIds.size !== data.sectionIds.length || + data.sectionIds.some((sectionId) => !existingIds.has(sectionId)) + ) { + throw new Error("รายการ Extra sections ไม่ตรงกับข้อมูลล่าสุด"); + } + + const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion); + for (const [index, sectionId] of data.sectionIds.entries()) { + await tx + .update(guideSections) + .set({ sortOrder: 4 + index }) + .where(eq(guideSections.id, sectionId)); + } + return updated; + }); +} + export async function trashGuide(guideId: string) { return getDb().transaction(async (tx) => { const [guide] = await tx.update(guides).set({ trashedAt: new Date(), version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning(); diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts index d9f593c..b76644c 100644 --- a/lib/guides/queries.ts +++ b/lib/guides/queries.ts @@ -173,7 +173,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) { sections, weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })), weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], 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 }; }) })), + 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, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, aliases: groupedAliases.artifact.get(second.key) ?? [], imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })), artifactProfile: profile[0] ?? null, constellationGroups: constellationGroups.map((group) => ({ ...group, diff --git a/lib/guides/schemas.test.ts b/lib/guides/schemas.test.ts index 84f705c..22fc5a4 100644 --- a/lib/guides/schemas.test.ts +++ b/lib/guides/schemas.test.ts @@ -2,7 +2,18 @@ import { describe, expect, it } from "vitest"; import { calculateTeamMetrics } from "@/lib/guides/calculations"; import { createExtraSheet } from "@/lib/guides/extra-sheet"; -import { artifactSectionSchema, constellationSectionSchema, createGuideSchema, extraSectionSchema, teamSectionSchema, updateGuideSchema, weaponSectionSchema } from "@/lib/guides/schemas"; +import { + artifactSectionSchema, + constellationSectionSchema, + createExtraSectionSchema, + createGuideSchema, + extraSectionSchema, + reorderExtraSectionsSchema, + saveExtraSectionSchema, + teamSectionSchema, + updateGuideSchema, + weaponSectionSchema, +} from "@/lib/guides/schemas"; import { recommendedStatElement, recommendedStatIconName, @@ -245,6 +256,29 @@ describe("structured guide validation", () => { sections: [{ title: "Extra", rows: [{ ...row, sheet: invalidSheet }] }], }).success).toBe(false); }); + + it("validates targeted Extra create, save, and reorder inputs", () => { + const section = { + title: "Build notes", + rows: [{ + mediaId: null, + heading: "Rotation", + body: "", + percent: null, + backgroundColor: null, + highlights: [], + catalogItems: [], + sheet: null, + }], + }; + const firstId = "550e8400-e29b-41d4-a716-446655440000"; + const secondId = "d9428888-122b-11e1-b85c-61cd3cbb3210"; + + expect(createExtraSectionSchema.parse({ title: " Build notes " }).title).toBe("Build notes"); + expect(saveExtraSectionSchema.safeParse({ expectedVersion: 2, section }).success).toBe(true); + expect(reorderExtraSectionsSchema.safeParse({ expectedVersion: 2, sectionIds: [firstId, secondId] }).success).toBe(true); + expect(reorderExtraSectionsSchema.safeParse({ expectedVersion: 2, sectionIds: [firstId, firstId] }).success).toBe(false); + }); }); describe("Team Calc", () => { diff --git a/lib/guides/schemas.ts b/lib/guides/schemas.ts index c0666a2..fa1652f 100644 --- a/lib/guides/schemas.ts +++ b/lib/guides/schemas.ts @@ -146,6 +146,7 @@ const extraRowSchema = z.strictObject({ body: z.string().trim().max(10_000), percent: percent.nullable(), backgroundColor: hexColor.nullable().default(null), + textColor: hexColor.nullable().default(null), catalogItems: z.array(z.strictObject({ kind: z.enum(["character", "weapon", "artifact"]), key: z.string().trim().min(1).max(64), @@ -168,12 +169,31 @@ const extraRowSchema = z.strictObject({ highlights: [], })); +export const extraSectionItemSchema = z.strictObject({ + title: z.string().trim().min(1).max(160), + rows: z.array(extraRowSchema).max(50), +}); + export const extraSectionSchema = z.strictObject({ expectedVersion: z.number().int().positive(), - sections: z.array(z.strictObject({ - title: z.string().trim().min(1).max(160), - rows: z.array(extraRowSchema).max(50), - })).max(20), + sections: z.array(extraSectionItemSchema).max(20), +}); + +export const createExtraSectionSchema = z.strictObject({ + title: z.string().trim().min(1).max(160), +}); + +export const saveExtraSectionSchema = z.strictObject({ + expectedVersion: z.number().int().positive(), + section: extraSectionItemSchema, +}); + +export const reorderExtraSectionsSchema = z.strictObject({ + expectedVersion: z.number().int().positive(), + sectionIds: z.array(z.string().uuid()).max(20).refine( + (sectionIds) => new Set(sectionIds).size === sectionIds.length, + "Extra sections ต้องไม่ซ้ำกัน", + ), }); export type WeaponSectionInput = z.output;