From 35a786156ade9e37335de980b5f51adc092586ef Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 28 Aug 2026 19:41:08 +0000 Subject: [PATCH] feat(templates): persist independent guide applications --- app/admin/actions.ts | 56 +++++++++++ app/admin/templates/page.tsx | 33 ++++++ components/admin/template-gallery.tsx | 104 +++++++++++++++++++ lib/templates/persist.ts | 138 ++++++++++++++++++++++++++ 4 files changed, 331 insertions(+) create mode 100644 app/admin/templates/page.tsx create mode 100644 components/admin/template-gallery.tsx create mode 100644 lib/templates/persist.ts 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} + + + + +
+ ))} +
+ + { if (!open) setSelected(null); }}> + + + สร้างจาก {selected?.name} + ระบบจะสร้างตัวละคร หน้า บล็อก และแหล่งข้อมูลแยกอิสระ โดยเริ่มต้นในสถานะซ่อน + +
+ + ชื่อภาษาไทย + ชื่อภาษาอังกฤษ + Slugใช้ใน URL และเปลี่ยนภายหลังได้โดยลิงก์เดิมยังเปลี่ยนเส้นทางให้ + คำอธิบาย