feat(templates): persist independent guide applications

This commit is contained in:
2026-08-28 19:41:08 +00:00 Unverified
parent 2ddbd374ae
commit 35a786156a
4 changed files with 331 additions and 0 deletions
+56
View File
@@ -9,8 +9,11 @@ import {
savePageDraft,
} from "@/lib/content/mutations";
import { dataSourceSnapshotSchema } from "@/lib/content/data-source";
import { slugSchema } from "@/lib/content/lifecycle";
import type { DataSourceSnapshot, PageSnapshot } from "@/lib/content/types";
import { requireAdmin } from "@/lib/auth/server";
import { builtInTemplates } from "@/lib/templates";
import { persistTemplateApplication } from "@/lib/templates/persist";
const serializedBlockSchema = z.strictObject({
id: z.string().uuid(),
@@ -132,3 +135,56 @@ export async function createEditorRevision(
const id = await createNamedRevision(parsed.data, safeLabel.data, admin.user.id);
return id ? { status: "saved", id } : { status: "error" };
}
const templateApplicationSchema = z.strictObject({
templateSlug: z.string().min(1).max(120),
characterSlug: slugSchema,
nameTh: z.string().trim().min(1).max(160),
nameEn: z.string().trim().max(160).optional(),
description: z.string().trim().max(2_000).optional(),
});
export async function applyEditorTemplate(input: unknown): Promise<
| { status: "created"; location: string }
| { status: "validation-error"; issues: string[] }
| { status: "error"; message: string }
> {
const admin = await requireAdmin();
const parsed = templateApplicationSchema.safeParse(input);
if (!parsed.success) {
return {
status: "validation-error",
issues: parsed.error.issues.map((issue) => issue.message),
};
}
const template = builtInTemplates.find(
(candidate) => candidate.slug === parsed.data.templateSlug,
);
if (!template) {
return { status: "validation-error", issues: ["ไม่พบเทมเพลต"] };
}
if (process.env.BUZZ_DEMO_MODE === "true") {
return { status: "created", location: "/admin/astra/info" };
}
try {
const applied = await persistTemplateApplication(
template,
{
slug: parsed.data.characterSlug,
nameTh: parsed.data.nameTh,
nameEn: parsed.data.nameEn,
description: parsed.data.description,
},
admin.user.id,
);
return {
status: "created",
location: `/admin/${applied.characterSlug}/${applied.firstPageSlug}`,
};
} catch (cause) {
return {
status: "error",
message: cause instanceof Error ? cause.message : "สร้างคู่มือไม่สำเร็จ",
};
}
}
+33
View File
@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { connection } from "next/server";
import { AdminSidebar } from "@/components/admin/admin-sidebar";
import { TemplateGallery } from "@/components/admin/template-gallery";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { getAdminSession } from "@/lib/auth/server";
import { loadAdminNavigation } from "@/lib/content/loaders";
import { builtInTemplates } from "@/lib/templates";
export default async function TemplatesPage() {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const navigation = await loadAdminNavigation();
return (
<SidebarProvider>
<AdminSidebar navigation={navigation} />
<SidebarInset>
<header className="flex min-h-14 items-center gap-3 border-b px-4">
<SidebarTrigger />
<div>
<h1 className="font-heading font-semibold">เทมเพลตคู่มือ</h1>
<p className="text-xs text-muted-foreground">โครงสร้างเริ่มต้นที่คัดมาจากรูปแบบคู่มือทั้งห้า</p>
</div>
</header>
<main className="mx-auto w-full max-w-6xl p-4 sm:p-6 lg:p-8">
<TemplateGallery templates={builtInTemplates} />
</main>
</SidebarInset>
</SidebarProvider>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { LayoutTemplateIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { applyEditorTemplate } from "@/app/admin/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import type { TemplateBlueprint } from "@/lib/templates";
export function TemplateGallery({ templates }: { templates: readonly TemplateBlueprint[] }) {
const router = useRouter();
const [selected, setSelected] = useState<TemplateBlueprint | null>(null);
const [pending, startTransition] = useTransition();
function submit(formData: FormData) {
if (!selected) return;
startTransition(async () => {
const result = await applyEditorTemplate({
templateSlug: selected.slug,
characterSlug: String(formData.get("slug") ?? ""),
nameTh: String(formData.get("nameTh") ?? ""),
nameEn: String(formData.get("nameEn") ?? ""),
description: String(formData.get("description") ?? ""),
});
if (result.status === "created") {
toast.success("สร้างคู่มือจากเทมเพลตแล้ว");
router.push(result.location);
router.refresh();
} else if (result.status === "validation-error") {
toast.error(result.issues[0] ?? "ข้อมูลไม่ถูกต้อง");
} else {
toast.error("สร้างคู่มือไม่สำเร็จ โปรดตรวจสอบ slug ที่ซ้ำกัน");
}
});
}
return (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{templates.map((template) => (
<Card key={template.slug}>
<CardHeader>
<div className="mb-2 grid size-9 place-items-center rounded-lg bg-primary/15 text-primary"><LayoutTemplateIcon /></div>
<CardTitle>{template.name}</CardTitle>
<CardDescription>{template.description}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Badge variant="outline">{template.pages.length} หน้า</Badge>
<Badge variant="outline">{template.dataSources.length} แหล่งข้อมูล</Badge>
<Badge variant="secondary">{template.category}</Badge>
</CardContent>
<CardFooter>
<Button className="w-full" onClick={() => setSelected(template)}>ใช้เทมเพลต</Button>
</CardFooter>
</Card>
))}
</div>
<Dialog open={Boolean(selected)} onOpenChange={(open) => { if (!open) setSelected(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>สร้างจาก {selected?.name}</DialogTitle>
<DialogDescription>ระบบจะสร้างตัวละคร หน้า บล็อก และแหล่งข้อมูลแยกอิสระ โดยเริ่มต้นในสถานะซ่อน</DialogDescription>
</DialogHeader>
<form action={submit} className="space-y-5">
<FieldGroup>
<Field><FieldLabel htmlFor="template-name-th">ชื่อภาษาไทย</FieldLabel><Input id="template-name-th" name="nameTh" required /></Field>
<Field><FieldLabel htmlFor="template-name-en">ชื่อภาษาอังกฤษ</FieldLabel><Input id="template-name-en" name="nameEn" /></Field>
<Field><FieldLabel htmlFor="template-slug">Slug</FieldLabel><Input id="template-slug" name="slug" required pattern="[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*" placeholder="character-name" /><FieldDescription>ใช้ใน URL และเปลี่ยนภายหลังได้โดยลิงก์เดิมยังเปลี่ยนเส้นทางให้</FieldDescription></Field>
<Field><FieldLabel htmlFor="template-description">คำอธิบาย</FieldLabel><Textarea id="template-description" name="description" /></Field>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setSelected(null)}>ยกเลิก</Button>
<Button type="submit" disabled={pending}>{pending ? <Spinner /> : null}{pending ? "กำลังสร้าง…" : "สร้างคู่มือ"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
import "server-only";
import { getDb } from "@/db";
import {
blocks,
characters,
dataSources,
dataSourceVersions,
outboxEvents,
pageDataSources,
pages,
} from "@/db/schema";
import { evaluateDataSource } from "@/lib/content/data-source";
import { applyTemplate, type TemplateBlueprint } from "@/lib/templates";
export interface NewTemplateCharacter {
slug: string;
nameTh: string;
nameEn?: string | null;
description?: string | null;
element?: string | null;
role?: string | null;
rarity?: number | null;
}
function collectSourceIds(value: unknown, result = new Set<string>()): Set<string> {
if (Array.isArray(value)) {
for (const item of value) collectSourceIds(item, result);
} else if (value && typeof value === "object") {
for (const [key, item] of Object.entries(value)) {
if (key === "dataSourceId" && typeof item === "string") result.add(item);
collectSourceIds(item, result);
}
}
return result;
}
export async function persistTemplateApplication(
template: TemplateBlueprint,
identity: NewTemplateCharacter,
authorId: string,
): Promise<{ characterId: string; characterSlug: string; firstPageSlug: string }> {
const characterId = crypto.randomUUID();
const applied = applyTemplate(template, characterId);
const db = getDb();
await db.transaction(async (tx) => {
await tx.insert(characters).values({
id: characterId,
slug: identity.slug,
nameTh: identity.nameTh,
nameEn: identity.nameEn?.trim() || null,
description: identity.description?.trim() || null,
element: identity.element?.trim() || null,
role: identity.role?.trim() || null,
rarity: identity.rarity ?? null,
visible: false,
});
for (const source of applied.dataSources) {
const snapshot = {
id: source.id,
name: source.name,
version: 1,
fields: source.fields,
rows: source.rows,
};
await tx.insert(dataSources).values({
id: source.id,
name: source.name,
description: source.description,
fields: source.fields,
currentVersion: 1,
});
await tx.insert(dataSourceVersions).values({
dataSourceId: source.id,
version: 1,
rows: source.rows,
evaluation: evaluateDataSource(snapshot) as unknown as Record<string, unknown>,
createdById: authorId,
});
}
for (const page of applied.pages) {
await tx.insert(pages).values({
id: page.id,
characterId,
slug: page.slug,
title: page.title,
publicNote: page.publicNote,
navOrder: page.navOrder,
visible: false,
version: 1,
});
if (page.blocks.length > 0) {
await tx.insert(blocks).values(
page.blocks.map((block) => ({
...block,
pageId: page.id,
})),
);
}
const dependencies = [
...collectSourceIds(page.blocks.map((block) => block.config)),
];
if (dependencies.length > 0) {
await tx.insert(pageDataSources).values(
dependencies.map((dataSourceId) => ({
pageId: page.id,
dataSourceId,
pinnedVersion: null,
})),
);
}
}
await tx.insert(outboxEvents).values([
{
topic: "directory",
aggregateId: characterId,
eventType: "directory.updated",
payload: { id: characterId, version: 1 },
},
{
topic: "admin",
aggregateId: characterId,
eventType: "admin.updated",
payload: { id: characterId, version: 1 },
},
]);
});
return {
characterId,
characterSlug: identity.slug,
firstPageSlug: applied.pages[0].slug,
};
}