feat(templates): add six reusable guide structures
This commit is contained in:
@@ -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<string, string>,
|
||||
nestedIds: Map<string, string>,
|
||||
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<string, string>();
|
||||
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<string, unknown>,
|
||||
responsiveLayout: cloneAndReplace(
|
||||
block.responsiveLayout ?? {},
|
||||
sourceIds,
|
||||
nestedIds,
|
||||
idFactory,
|
||||
) as Record<string, unknown>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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("โรเทชันฉบับใหม่");
|
||||
});
|
||||
});
|
||||
@@ -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<Omit<DataSourceRow, "id"> & { id?: string }>;
|
||||
}
|
||||
|
||||
export interface TemplateBlockBlueprint {
|
||||
type: string;
|
||||
schemaVersion?: number;
|
||||
config: Record<string, unknown>;
|
||||
responsiveLayout?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
responsiveLayout: Record<string, unknown>;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}>;
|
||||
dataSources: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
version: 1;
|
||||
fields: DataSourceField[];
|
||||
rows: DataSourceRow[];
|
||||
}>;
|
||||
}
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
builtIn: true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: templates.slug,
|
||||
set: {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
structure: template as unknown as Record<string, unknown>,
|
||||
builtIn: true,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await closeDb();
|
||||
Reference in New Issue
Block a user