diff --git a/lib/templates/apply.ts b/lib/templates/apply.ts new file mode 100644 index 0000000..55226f7 --- /dev/null +++ b/lib/templates/apply.ts @@ -0,0 +1,131 @@ +import { resolveBlock } from "@/lib/blocks"; + +import type { + AppliedTemplate, + TemplateBlueprint, + TemplateDataSourceBlueprint, +} from "./types"; + +export type IdFactory = () => string; + +function cloneAndReplace( + value: unknown, + sourceIds: ReadonlyMap, + nestedIds: Map, + idFactory: IdFactory, +): unknown { + if (typeof value === "string") { + if (value.startsWith("@source:")) { + const key = value.slice("@source:".length); + const sourceId = sourceIds.get(key); + if (!sourceId) throw new Error(`Template references missing data source: ${key}`); + return sourceId; + } + if (value.startsWith("@id:")) { + const key = value.slice("@id:".length); + const existing = nestedIds.get(key); + if (existing) return existing; + const generated = idFactory(); + nestedIds.set(key, generated); + return generated; + } + return value; + } + if (Array.isArray(value)) { + return value.map((item) => + cloneAndReplace(item, sourceIds, nestedIds, idFactory), + ); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + cloneAndReplace(item, sourceIds, nestedIds, idFactory), + ]), + ); + } + return value; +} + +function cloneDataSource( + blueprint: TemplateDataSourceBlueprint, + id: string, + idFactory: IdFactory, +): AppliedTemplate["dataSources"][number] { + return { + id, + name: blueprint.name, + description: blueprint.description ?? null, + version: 1, + fields: structuredClone(blueprint.fields), + rows: (blueprint.rows ?? []).map((row) => ({ + id: row.id ?? idFactory(), + values: structuredClone(row.values), + hidden: row.hidden, + })), + }; +} + +export function applyTemplate( + template: TemplateBlueprint, + characterId: string, + idFactory: IdFactory = () => crypto.randomUUID(), +): AppliedTemplate { + const sourceIds = new Map( + template.dataSources.map((source) => [source.key, idFactory()]), + ); + const nestedIds = new Map(); + const dataSources = template.dataSources.map((source) => + cloneDataSource(source, sourceIds.get(source.key)!, idFactory), + ); + + const pages = template.pages.map((page, pageIndex) => { + const pageId = idFactory(); + const blocks = page.blocks.map((block, blockIndex) => { + const serialized = { + id: idFactory(), + type: block.type, + schemaVersion: block.schemaVersion ?? 1, + config: cloneAndReplace( + block.config, + sourceIds, + nestedIds, + idFactory, + ) as Record, + responsiveLayout: cloneAndReplace( + block.responsiveLayout ?? {}, + sourceIds, + nestedIds, + idFactory, + ) as Record, + sortOrder: blockIndex, + }; + const resolution = resolveBlock(serialized); + if (resolution.kind !== "ready") { + throw new Error( + `Built-in template ${template.slug} contains an invalid ${block.type} block.`, + ); + } + return resolution.block; + }); + + return { + id: pageId, + characterId, + slug: page.slug, + title: page.title, + publicNote: page.publicNote ?? null, + navOrder: pageIndex, + visible: false as const, + version: 1 as const, + blocks, + }; + }); + + return { + templateSlug: template.slug, + characterId, + pages, + dataSources, + }; +} diff --git a/lib/templates/built-in.ts b/lib/templates/built-in.ts new file mode 100644 index 0000000..2feae8a --- /dev/null +++ b/lib/templates/built-in.ts @@ -0,0 +1,310 @@ +import type { TemplateBlueprint } from "./types"; + +const comparisonFields = [ + { key: "name", label: "ชื่อ", type: "text" as const }, + { key: "base", label: "ค่าพื้นฐาน", type: "number" as const, displayPrecision: 0 }, + { key: "bonus", label: "โบนัส", type: "number" as const, displayPrecision: 0 }, + { key: "cost", label: "ต้นทุน", type: "number" as const, displayPrecision: 0 }, + { + key: "score", + label: "คะแนนเปรียบเทียบ", + type: "number" as const, + formula: "(base + bonus) / cost", + displayPrecision: 2, + }, +]; + +export const builtInTemplates: readonly TemplateBlueprint[] = [ + { + slug: "classic-character-guide", + name: "คู่มือตัวละครแบบคลาสสิก", + description: "โครงสร้างหลายหน้าสำหรับข้อมูล บิลด์ ทีม และคำอธิบายเชิงลึก", + category: "character-guide", + builtIn: true, + dataSources: [ + { + key: "weapons", + name: "อันดับอาวุธ", + fields: comparisonFields, + }, + { + key: "teams", + name: "ทีมแนะนำ", + fields: [ + { key: "team", label: "ทีม", type: "text" }, + { key: "member_1", label: "สมาชิก 1", type: "text" }, + { key: "member_2", label: "สมาชิก 2", type: "text" }, + { key: "member_3", label: "สมาชิก 3", type: "text" }, + { key: "member_4", label: "สมาชิก 4", type: "text" }, + { key: "notes", label: "หมายเหตุ", type: "text" }, + ], + }, + ], + pages: [ + { + slug: "info", + title: "ข้อมูล", + blocks: [ + { + type: "profile", + config: { + eyebrow: "ภาพรวมตัวละคร", + title: "ชื่อตัวละคร", + summary: "เพิ่มบทบาท จุดเด่น และแนวทางใช้งาน", + badges: [], + }, + }, + { + type: "section", + config: { title: "จุดเด่น", anchor: "highlights" }, + }, + { + type: "rich-text", + config: { content: [{ type: "paragraph", text: "เริ่มเขียนคำแนะนำที่นี่" }] }, + }, + ], + }, + { + slug: "build", + title: "บิลด์", + blocks: [ + { + type: "ranking", + config: { + title: "อันดับอาวุธ", + dataSourceId: "@source:weapons", + labelField: "name", + valueField: "score", + detailField: "notes", + higherIsBetter: true, + }, + }, + ], + }, + { + slug: "teams", + title: "ทีม", + blocks: [ + { + type: "team-composition", + config: { + title: "ทีมแนะนำ", + dataSourceId: "@source:teams", + teamField: "team", + memberFields: ["member_1", "member_2", "member_3", "member_4"], + notesField: "notes", + }, + }, + ], + }, + ], + }, + { + slug: "combo-rotation-guide", + name: "คู่มือคอมโบและโรเทชัน", + description: "ขั้นตอนคอมโบตามลำดับ พร้อมผู้ใช้สกิล เวลา และคำอธิบายเสริม", + category: "rotation", + builtIn: true, + dataSources: [], + pages: [ + { + slug: "combo", + title: "คอมโบ", + blocks: [ + { + type: "callout", + config: { + title: "ก่อนเริ่ม", + body: "ระบุเงื่อนไขและทรัพยากรที่ต้องเตรียม", + tone: "tip", + }, + }, + { + type: "rotation", + config: { + title: "ลำดับการเล่น", + steps: [ + { + id: "@id:rotation-step-1", + actor: "ตัวละคร A", + action: "เริ่มต้นด้วยสกิล", + detail: "อธิบายเหตุผลและจังหวะสลับตัว", + }, + ], + }, + }, + ], + }, + ], + }, + { + slug: "combined-one-page-build", + name: "บิลด์รวมหน้าเดียว", + description: "สรุปโปรไฟล์ ค่าสำคัญ อันดับอุปกรณ์ และทีมในหน้าเดียว", + category: "one-page", + builtIn: true, + dataSources: [ + { key: "builds", name: "ตัวเลือกบิลด์", fields: comparisonFields }, + ], + pages: [ + { + slug: "guide", + title: "คู่มือ", + blocks: [ + { + type: "profile", + config: { title: "ชื่อตัวละคร", summary: "สรุปแนวทางการเล่น", badges: [] }, + }, + { + type: "stat-cards", + config: { + title: "เป้าหมายค่าสถานะ", + items: [ + { id: "@id:stat-1", label: "ค่าสำคัญ", value: "—" }, + { id: "@id:stat-2", label: "ค่ารอง", value: "—" }, + ], + }, + }, + { + type: "comparison-chart", + responsiveLayout: { width: "wide" }, + config: { + title: "เปรียบเทียบบิลด์", + dataSourceId: "@source:builds", + categoryField: "name", + series: [{ field: "score", label: "คะแนน", color: "chart-1" }], + presentation: "horizontal-bar", + normalization: "raw", + sort: "descending", + showValues: true, + showLegend: false, + tableFallback: true, + }, + }, + ], + }, + ], + }, + { + slug: "multi-role-reaction-build", + name: "บิลด์หลายบทบาทและปฏิกิริยา", + description: "แยกแนวทางตามบทบาทหรือปฏิกิริยาด้วยแท็บที่อ่านง่าย", + category: "multi-role", + builtIn: true, + dataSources: [], + pages: [ + { + slug: "roles", + title: "บทบาท", + blocks: [ + { + type: "profile", + config: { title: "ชื่อตัวละคร", summary: "เลือกแนวทางตามทีม", badges: [] }, + }, + { + type: "tabs", + config: { + title: "แนวทางบิลด์", + tabs: [ + { id: "@id:role-1", label: "บทบาทหลัก", content: "อธิบายบิลด์สำหรับบทบาทหลัก" }, + { id: "@id:role-2", label: "บทบาทรอง", content: "อธิบายบิลด์สำหรับบทบาทรอง" }, + ], + }, + }, + { + type: "accordion", + config: { + title: "รายละเอียดปฏิกิริยา", + items: [ + { id: "@id:reaction-1", title: "ปฏิกิริยาที่ 1", content: "เพิ่มเงื่อนไขและข้อควรระวัง" }, + ], + }, + }, + ], + }, + ], + }, + { + slug: "categorized-team-database", + name: "ฐานข้อมูลทีมแบบหมวดหมู่", + description: "ตารางทีมที่กรองตามหมวด พร้อมรายละเอียดสมาชิกและผลรวม", + category: "teams", + builtIn: true, + dataSources: [ + { + key: "team-database", + name: "ฐานข้อมูลทีม", + fields: [ + { key: "category", label: "หมวด", type: "badge" }, + { key: "team", label: "ชื่อทีม", type: "text" }, + { key: "members", label: "สมาชิก", type: "text" }, + { key: "score", label: "คะแนน", type: "number", displayPrecision: 1 }, + { key: "notes", label: "หมายเหตุ", type: "text" }, + ], + }, + ], + pages: [ + { + slug: "teams", + title: "ฐานข้อมูลทีม", + blocks: [ + { type: "section", config: { title: "ค้นหาทีมที่เหมาะกับคุณ", anchor: "teams" } }, + { + type: "table", + responsiveLayout: { width: "wide" }, + config: { + dataSourceId: "@source:team-database", + columns: [ + { field: "category", label: "หมวด", align: "start" }, + { field: "team", label: "ทีม", align: "start" }, + { field: "members", label: "สมาชิก", align: "start" }, + { field: "score", label: "คะแนน", align: "end" }, + ], + }, + }, + ], + }, + ], + }, + { + slug: "calculation-comparison-guide", + name: "คู่มือเปรียบเทียบด้วยการคำนวณ", + description: "ข้อมูลดิบ สูตรเลขคณิตแบบจำกัด กราฟ และตารางสำรองที่เข้าถึงได้", + category: "calculation", + builtIn: true, + dataSources: [ + { key: "comparison", name: "ข้อมูลเปรียบเทียบ", fields: comparisonFields }, + ], + pages: [ + { + slug: "comparison", + title: "เปรียบเทียบ", + blocks: [ + { + type: "callout", + config: { body: "ผลลัพธ์คำนวณจากค่าที่ผู้เขียนกำหนด", tone: "info" }, + }, + { + type: "comparison-chart", + responsiveLayout: { width: "wide" }, + config: { + title: "ผลการเปรียบเทียบ", + dataSourceId: "@source:comparison", + categoryField: "name", + series: [ + { field: "base", label: "ค่าพื้นฐาน", color: "chart-2" }, + { field: "score", label: "คะแนน", color: "chart-1" }, + ], + presentation: "grouped-bar", + normalization: "raw", + sort: "descending", + showValues: true, + showLegend: true, + tableFallback: true, + }, + }, + ], + }, + ], + }, +] as const; diff --git a/lib/templates/index.ts b/lib/templates/index.ts new file mode 100644 index 0000000..ce3086a --- /dev/null +++ b/lib/templates/index.ts @@ -0,0 +1,10 @@ +export { applyTemplate } from "./apply"; +export type { IdFactory } from "./apply"; +export { builtInTemplates } from "./built-in"; +export type { + AppliedTemplate, + TemplateBlockBlueprint, + TemplateBlueprint, + TemplateDataSourceBlueprint, + TemplatePageBlueprint, +} from "./types"; diff --git a/lib/templates/templates.test.ts b/lib/templates/templates.test.ts new file mode 100644 index 0000000..cc78b67 --- /dev/null +++ b/lib/templates/templates.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { applyTemplate, builtInTemplates } from "."; + +const characterId = "a23ff62d-e74a-44fd-a8b1-c3aa62bedf13"; + +describe("built-in templates", () => { + it("contains the six required reusable structures", () => { + expect(builtInTemplates.map((template) => template.slug)).toEqual([ + "classic-character-guide", + "combo-rotation-guide", + "combined-one-page-build", + "multi-role-reaction-build", + "categorized-team-database", + "calculation-comparison-guide", + ]); + }); + + it("applies every template as hidden, independently validated content", () => { + for (const template of builtInTemplates) { + const applied = applyTemplate(template, characterId); + expect(applied.pages.length).toBeGreaterThan(0); + expect(applied.pages.every((page) => page.visible === false)).toBe(true); + expect(applied.pages.every((page) => page.version === 1)).toBe(true); + expect( + applied.pages.flatMap((page) => page.blocks).every((block) => block.schemaVersion === 1), + ).toBe(true); + } + }); + + it("clones pages, blocks, nested items, and data sources independently", () => { + const template = builtInTemplates[0]; + const first = applyTemplate(template, characterId); + const second = applyTemplate(template, characterId); + + expect(first.pages[0].id).not.toBe(second.pages[0].id); + expect(first.pages[0].blocks[0].id).not.toBe(second.pages[0].blocks[0].id); + expect(first.dataSources[0].id).not.toBe(second.dataSources[0].id); + expect( + (first.pages[1].blocks[0].config as { dataSourceId: string }).dataSourceId, + ).toBe(first.dataSources[0].id); + expect( + (second.pages[1].blocks[0].config as { dataSourceId: string }).dataSourceId, + ).toBe(second.dataSources[0].id); + + first.pages[0].title = "แก้เฉพาะสำเนาแรก"; + expect(second.pages[0].title).toBe("ข้อมูล"); + expect(template.pages[0].title).toBe("ข้อมูล"); + }); + + it("applies later template edits only to future applications", () => { + const before = applyTemplate(builtInTemplates[1], characterId); + const edited = structuredClone(builtInTemplates[1]); + edited.pages[0].title = "โรเทชันฉบับใหม่"; + const after = applyTemplate(edited, characterId); + + expect(before.pages[0].title).toBe("คอมโบ"); + expect(after.pages[0].title).toBe("โรเทชันฉบับใหม่"); + }); +}); diff --git a/lib/templates/types.ts b/lib/templates/types.ts new file mode 100644 index 0000000..02feee6 --- /dev/null +++ b/lib/templates/types.ts @@ -0,0 +1,64 @@ +import type { DataSourceField, DataSourceRow } from "@/lib/content/types"; + +export interface TemplateDataSourceBlueprint { + key: string; + name: string; + description?: string; + fields: DataSourceField[]; + rows?: Array & { id?: string }>; +} + +export interface TemplateBlockBlueprint { + type: string; + schemaVersion?: number; + config: Record; + responsiveLayout?: Record; +} + +export interface TemplatePageBlueprint { + slug: string; + title: string; + publicNote?: string; + blocks: TemplateBlockBlueprint[]; +} + +export interface TemplateBlueprint { + slug: string; + name: string; + description: string; + category: string; + builtIn: boolean; + dataSources: TemplateDataSourceBlueprint[]; + pages: TemplatePageBlueprint[]; +} + +export interface AppliedTemplate { + templateSlug: string; + characterId: string; + pages: Array<{ + id: string; + characterId: string; + slug: string; + title: string; + publicNote: string | null; + navOrder: number; + visible: false; + version: 1; + blocks: Array<{ + id: string; + type: string; + schemaVersion: number; + config: Record; + responsiveLayout: Record; + sortOrder: number; + }>; + }>; + dataSources: Array<{ + id: string; + name: string; + description: string | null; + version: 1; + fields: DataSourceField[]; + rows: DataSourceRow[]; + }>; +} diff --git a/package.json b/package.json index db176df..37dfdb4 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "test:coverage": "vitest run --coverage", "db:generate": "drizzle-kit generate", "db:migrate": "bun scripts/migrate.ts", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "db:seed": "bun scripts/seed-templates.ts" }, "dependencies": { "better-auth": "^1.7.2", diff --git a/scripts/seed-templates.ts b/scripts/seed-templates.ts new file mode 100644 index 0000000..464514c --- /dev/null +++ b/scripts/seed-templates.ts @@ -0,0 +1,31 @@ +import { closeDb, getDb } from "@/db"; +import { templates } from "@/db/schema"; +import { builtInTemplates } from "@/lib/templates"; + +const db = getDb(); + +for (const template of builtInTemplates) { + await db + .insert(templates) + .values({ + slug: template.slug, + name: template.name, + description: template.description, + category: template.category, + structure: template as unknown as Record, + builtIn: true, + }) + .onConflictDoUpdate({ + target: templates.slug, + set: { + name: template.name, + description: template.description, + category: template.category, + structure: template as unknown as Record, + builtIn: true, + updatedAt: new Date(), + }, + }); +} + +await closeDb();