feat(templates): persist independent guide applications
This commit is contained in:
@@ -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 : "สร้างคู่มือไม่สำเร็จ",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user