From 2ddbd374aeb58ec9b1b867484c85d3f4665cbc11 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 28 Aug 2026 19:40:22 +0000 Subject: [PATCH] feat(editor): add responsive visual workspace --- app/admin/[character]/[page]/page.tsx | 22 ++ app/admin/actions.ts | 69 +++++- app/admin/loading.tsx | 14 ++ app/admin/page.tsx | 87 +++++++ components/admin/admin-command.tsx | 137 +++++++++++ components/admin/admin-sidebar.tsx | 148 ++++++++++++ components/admin/block-canvas.tsx | 288 +++++++++++++++++++++++ components/admin/editor-inspector.tsx | 320 ++++++++++++++++++++++++++ components/admin/editor-workspace.tsx | 310 +++++++++++++++++++++++++ 9 files changed, 1392 insertions(+), 3 deletions(-) create mode 100644 app/admin/[character]/[page]/page.tsx create mode 100644 app/admin/loading.tsx create mode 100644 app/admin/page.tsx create mode 100644 components/admin/admin-command.tsx create mode 100644 components/admin/admin-sidebar.tsx create mode 100644 components/admin/block-canvas.tsx create mode 100644 components/admin/editor-inspector.tsx create mode 100644 components/admin/editor-workspace.tsx diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx new file mode 100644 index 0000000..619baf3 --- /dev/null +++ b/app/admin/[character]/[page]/page.tsx @@ -0,0 +1,22 @@ +import { notFound, redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { EditorWorkspace } from "@/components/admin/editor-workspace"; +import { getAdminSession } from "@/lib/auth/server"; +import { loadAdminNavigation, loadAdminPage } from "@/lib/content/loaders"; + +export default async function AdminEditorPage({ + params, +}: { + params: Promise<{ character: string; page: string }>; +}) { + await connection(); + if (!(await getAdminSession())) redirect("/admin/login"); + const { character, page } = await params; + const [snapshot, navigation] = await Promise.all([ + loadAdminPage(character, page), + loadAdminNavigation(), + ]); + if (!snapshot) notFound(); + return ; +} diff --git a/app/admin/actions.ts b/app/admin/actions.ts index fe81b94..71fdc47 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -1,10 +1,15 @@ "use server"; -import { z } from "zod"; +import * as z from "zod"; import type { AutosaveResponse } from "@/lib/editor/autosave"; -import { savePageDraft } from "@/lib/content/mutations"; -import type { PageSnapshot } from "@/lib/content/types"; +import { + createNamedRevision, + saveDataSourceVersion, + savePageDraft, +} from "@/lib/content/mutations"; +import { dataSourceSnapshotSchema } from "@/lib/content/data-source"; +import type { DataSourceSnapshot, PageSnapshot } from "@/lib/content/types"; import { requireAdmin } from "@/lib/auth/server"; const serializedBlockSchema = z.strictObject({ @@ -69,3 +74,61 @@ export async function saveEditorPage(input: unknown): Promise }; } } + +export async function saveEditorDataSource( + input: unknown, +): Promise> { + const admin = await requireAdmin(); + const parsed = dataSourceSnapshotSchema.safeParse(input); + if (!parsed.success) { + return { + status: "validation-error", + issues: parsed.error.issues.map((issue) => issue.message), + }; + } + if (process.env.BUZZ_DEMO_MODE === "true") { + return { + status: "saved", + version: parsed.data.version + 1, + snapshot: { ...parsed.data, version: parsed.data.version + 1 }, + }; + } + try { + const result = await saveDataSourceVersion({ + ...parsed.data, + expectedVersion: parsed.data.version, + authorId: admin.user.id, + }); + if (result.status === "accepted") { + return { + status: "saved", + version: result.value.version, + snapshot: result.value, + }; + } + if (result.status === "not-found") { + return { status: "validation-error", issues: ["ไม่พบแหล่งข้อมูล"] }; + } + return result; + } catch (cause) { + return { + status: "transient-error", + message: cause instanceof Error ? cause.message : "บันทึกข้อมูลไม่สำเร็จ", + }; + } +} + +export async function createEditorRevision( + pageId: string, + label: string, +): Promise<{ status: "saved"; id: string } | { status: "error" }> { + const admin = await requireAdmin(); + const parsed = z.string().uuid().safeParse(pageId); + const safeLabel = z.string().trim().min(1).max(160).safeParse(label); + if (!parsed.success || !safeLabel.success) return { status: "error" }; + if (process.env.BUZZ_DEMO_MODE === "true") { + return { status: "saved", id: crypto.randomUUID() }; + } + const id = await createNamedRevision(parsed.data, safeLabel.data, admin.user.id); + return id ? { status: "saved", id } : { status: "error" }; +} diff --git a/app/admin/loading.tsx b/app/admin/loading.tsx new file mode 100644 index 0000000..0c1f900 --- /dev/null +++ b/app/admin/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function AdminLoading() { + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..632f083 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,87 @@ +import Link from "next/link"; +import { EyeIcon, FileTextIcon, LayoutTemplateIcon, PlusIcon } from "lucide-react"; +import { redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { AdminSidebar } from "@/components/admin/admin-sidebar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { getAdminSession } from "@/lib/auth/server"; +import { loadAdminNavigation } from "@/lib/content/loaders"; + +export default async function AdminPage() { + await connection(); + if (!(await getAdminSession())) redirect("/admin/login"); + const navigation = await loadAdminNavigation(); + + return ( + + + +
+ +
+

ภาพรวมเนื้อหา

+

ตัวละคร หน้า และสถานะการเผยแพร่

+
+ +
+
+
+
+

คลังคู่มือ

+

ตัวละครทั้งหมด

+
+ +
+ {navigation.characters.length > 0 ? ( +
+ {navigation.characters.map((character) => ( + + + {character.nameTh} + {character.nameEn ?? character.slug} + + + {character.visible ? "เผยแพร่" : "ซ่อน"} + + + + + {character.pages.map((page) => ( +
+ + + {page.title} + + v{page.version} + {page.visible && character.visible ? ( + + ) : null} +
+ ))} +
+
+ ))} +
+ ) : ( + ยังไม่มีตัวละคร เริ่มต้นด้วยเทมเพลตที่เตรียมไว้ + )} +
+
+
+ ); +} diff --git a/components/admin/admin-command.tsx b/components/admin/admin-command.tsx new file mode 100644 index 0000000..d62f216 --- /dev/null +++ b/components/admin/admin-command.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { + BarChart3Icon, + FileTextIcon, + Heading2Icon, + ImageIcon, + LayoutGridIcon, + ListOrderedIcon, + MessageSquareIcon, + Rows3Icon, + SeparatorHorizontalIcon, + Table2Icon, + TextIcon, + UsersIcon, + type LucideIcon, +} from "lucide-react"; + +import type { AdminNavigation } from "@/components/admin/admin-sidebar"; +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/components/ui/command"; +import type { SerializedBlock } from "@/lib/blocks"; +import type { DataSourceSnapshot } from "@/lib/content/types"; +import { createEditorBlock } from "@/lib/editor/draft"; + +const catalog: Array<{ + type: string; + label: string; + icon: LucideIcon; + source?: boolean; + media?: boolean; +}> = [ + { type: "profile", label: "โปรไฟล์", icon: LayoutGridIcon }, + { type: "rich-text", label: "ข้อความ", icon: TextIcon }, + { type: "heading", label: "หัวข้อ", icon: Heading2Icon }, + { type: "callout", label: "กล่องหมายเหตุ", icon: MessageSquareIcon }, + { type: "stat-cards", label: "การ์ดค่าสถานะ", icon: LayoutGridIcon }, + { type: "key-value", label: "ข้อมูลกำหนดเอง", icon: Rows3Icon }, + { type: "ranking", label: "อันดับเปรียบเทียบ", icon: ListOrderedIcon, source: true }, + { type: "team-composition", label: "องค์ประกอบทีม", icon: UsersIcon, source: true }, + { type: "rotation", label: "ลำดับคอมโบ", icon: ListOrderedIcon }, + { type: "table", label: "ตารางข้อมูล", icon: Table2Icon, source: true }, + { type: "comparison-chart", label: "กราฟเปรียบเทียบ", icon: BarChart3Icon, source: true }, + { type: "tabs", label: "แท็บ", icon: LayoutGridIcon }, + { type: "accordion", label: "แอคคอร์เดียน", icon: Rows3Icon }, + { type: "section", label: "ส่วนเนื้อหา", icon: Heading2Icon }, + { type: "columns", label: "คอลัมน์", icon: LayoutGridIcon }, + { type: "links", label: "ลิงก์", icon: FileTextIcon }, + { type: "separator", label: "เส้นคั่น", icon: SeparatorHorizontalIcon }, + { type: "image", label: "รูปภาพ", icon: ImageIcon, media: true }, + { type: "gallery", label: "แกลเลอรี", icon: ImageIcon, media: true }, +]; + +export function AdminCommandDialog({ + open, + onOpenChange, + navigation, + dataSources, + onInsert, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + navigation: AdminNavigation; + dataSources: Record; + onInsert: (block: SerializedBlock) => void; +}) { + const router = useRouter(); + const source = Object.values(dataSources)[0]; + + return ( + + + + + ไม่พบคำสั่งที่ตรงกัน + + {navigation.characters.flatMap((character) => + character.pages.map((page) => ( + { + onOpenChange(false); + router.push(`/admin/${character.slug}/${page.slug}`); + }} + > + + )), + )} + + + + {catalog.map((item) => { + const disabled = (item.source && !source) || item.media; + return ( + { + const block = createEditorBlock(item.type, source); + if (!block) return; + onInsert(block); + onOpenChange(false); + }} + > + + ); + })} + + + + + ); +} diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx new file mode 100644 index 0000000..a512795 --- /dev/null +++ b/components/admin/admin-sidebar.tsx @@ -0,0 +1,148 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + BookOpenIcon, + BoxesIcon, + ChevronRightIcon, + LayoutTemplateIcon, + LogOutIcon, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarRail, +} from "@/components/ui/sidebar"; +import type { CharacterIdentity, PageIdentity } from "@/lib/content/types"; +import { authClient } from "@/lib/auth/client"; + +export interface AdminNavigation { + characters: Array; + templates: Array<{ + id: string; + slug: string; + name: string; + description: string | null; + category: string; + builtIn: boolean; + }>; +} + +export function AdminSidebar({ + navigation, + currentCharacter, + currentPage, +}: { + navigation: AdminNavigation; + currentCharacter?: string; + currentPage?: string; +}) { + const router = useRouter(); + + return ( + + + + + } + > + + + + Buzz Sheet + ตัวแก้ไขคู่มือ + + + + + + + + ตัวละครและหน้า + + + {navigation.characters.map((character) => ( + + + + + {character.pages.map((page) => ( + + } + isActive={character.slug === currentCharacter && page.slug === currentPage} + > + {page.title} + + + ))} + + + ))} + + + + + โครงสร้าง + + + + } + > + + + + + + + + + + + + ); +} diff --git a/components/admin/block-canvas.tsx b/components/admin/block-canvas.tsx new file mode 100644 index 0000000..ff3aeee --- /dev/null +++ b/components/admin/block-canvas.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { + ArrowDownIcon, + ArrowUpIcon, + CopyIcon, + GripVerticalIcon, + MoreHorizontalIcon, + PlusIcon, + Trash2Icon, +} from "lucide-react"; +import { useState } from "react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import type { SerializedBlock } from "@/lib/blocks"; +import { + duplicateBlock, + moveBlock, + moveBlockTo, + type EditorDraft, +} from "@/lib/editor/draft"; +import { cn } from "@/lib/utils"; + +export const blockLabels: Record = { + profile: "โปรไฟล์", + "rich-text": "ข้อความ", + heading: "หัวข้อ", + callout: "กล่องหมายเหตุ", + "stat-cards": "การ์ดค่าสถานะ", + "key-value": "ข้อมูลกำหนดเอง", + ranking: "อันดับเปรียบเทียบ", + "team-composition": "องค์ประกอบทีม", + rotation: "ลำดับคอมโบ", + table: "ตารางข้อมูล", + "comparison-chart": "กราฟเปรียบเทียบ", + image: "รูปภาพ", + gallery: "แกลเลอรี", + tabs: "แท็บ", + accordion: "แอคคอร์เดียน", + section: "ส่วนเนื้อหา", + columns: "คอลัมน์", + links: "ลิงก์", + separator: "เส้นคั่น", +}; + +interface BlockCanvasProps { + draft: EditorDraft; + selectedId: string | null; + onSelect: (id: string) => void; + onChange: (draft: EditorDraft) => void; + onOpenInsert: () => void; +} + +export function BlockCanvas({ + draft, + selectedId, + onSelect, + onChange, + onOpenInsert, +}: BlockCanvasProps) { + const changeBlocks = (blocks: SerializedBlock[]) => onChange({ ...draft, blocks }); + const patchBlock = (blockId: string, config: Record) => { + changeBlocks( + draft.blocks.map((block) => + block.id === blockId ? { ...block, config: { ...block.config as object, ...config } } : block, + ), + ); + }; + + return ( +
+ + + + + ชื่อหน้า + onChange({ ...draft, title: event.target.value })} + /> + + + หมายเหตุสาธารณะ +