diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 71fdc47..e85c033 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -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 : "สร้างคู่มือไม่สำเร็จ", + }; + } +} diff --git a/app/admin/templates/page.tsx b/app/admin/templates/page.tsx new file mode 100644 index 0000000..ea5f381 --- /dev/null +++ b/app/admin/templates/page.tsx @@ -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 ( + + + + + + + เทมเพลตคู่มือ + โครงสร้างเริ่มต้นที่คัดมาจากรูปแบบคู่มือทั้งห้า + + + + + + + + ); +} diff --git a/components/admin/template-gallery.tsx b/components/admin/template-gallery.tsx new file mode 100644 index 0000000..15879b5 --- /dev/null +++ b/components/admin/template-gallery.tsx @@ -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(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 ( + <> + + {templates.map((template) => ( + + + + {template.name} + {template.description} + + + {template.pages.length} หน้า + {template.dataSources.length} แหล่งข้อมูล + {template.category} + + + setSelected(template)}>ใช้เทมเพลต + + + ))} + + + { if (!open) setSelected(null); }}> + + + สร้างจาก {selected?.name} + ระบบจะสร้างตัวละคร หน้า บล็อก และแหล่งข้อมูลแยกอิสระ โดยเริ่มต้นในสถานะซ่อน + + + + ชื่อภาษาไทย + ชื่อภาษาอังกฤษ + Slugใช้ใน URL และเปลี่ยนภายหลังได้โดยลิงก์เดิมยังเปลี่ยนเส้นทางให้ + คำอธิบาย + + + setSelected(null)}>ยกเลิก + {pending ? : null}{pending ? "กำลังสร้าง…" : "สร้างคู่มือ"} + + + + + > + ); +} diff --git a/lib/templates/persist.ts b/lib/templates/persist.ts new file mode 100644 index 0000000..77c9104 --- /dev/null +++ b/lib/templates/persist.ts @@ -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()): Set { + 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, + 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, + }; +}
โครงสร้างเริ่มต้นที่คัดมาจากรูปแบบคู่มือทั้งห้า