feat(editor): add responsive visual workspace

This commit is contained in:
2026-08-28 19:40:22 +00:00 Unverified
parent 33d2812d2e
commit 2ddbd374ae
9 changed files with 1392 additions and 3 deletions
+22
View File
@@ -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 <EditorWorkspace snapshot={snapshot} navigation={navigation} />;
}
+66 -3
View File
@@ -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<EditorSaveResult>
};
}
}
export async function saveEditorDataSource(
input: unknown,
): Promise<AutosaveResponse<DataSourceSnapshot>> {
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" };
}
+14
View File
@@ -0,0 +1,14 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function AdminLoading() {
return (
<div className="flex min-h-svh">
<Skeleton className="hidden w-64 rounded-none md:block" />
<div className="flex-1 space-y-5 p-6">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-72 w-full" />
</div>
</div>
);
}
+87
View File
@@ -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 (
<SidebarProvider>
<AdminSidebar navigation={navigation} />
<SidebarInset>
<header className="flex min-h-14 items-center gap-3 border-b px-4">
<SidebarTrigger />
<div className="min-w-0 flex-1">
<h1 className="font-heading font-semibold">ภาพรวมเนื้อหา</h1>
<p className="text-xs text-muted-foreground">ตัวละคร หน้า และสถานะการเผยแพร่</p>
</div>
<Button variant="outline" render={<Link href="/admin/templates" />}>
<LayoutTemplateIcon />เทมเพลต
</Button>
</header>
<main className="mx-auto w-full max-w-6xl space-y-8 p-4 sm:p-6 lg:p-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-sm font-medium text-primary">คลังคู่มือ</p>
<h2 className="mt-1 font-heading text-2xl font-semibold">ตัวละครทั้งหมด</h2>
</div>
<Button render={<Link href="/admin/templates" />}><PlusIcon />สร้างจากเทมเพลต</Button>
</div>
{navigation.characters.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{navigation.characters.map((character) => (
<Card key={character.id}>
<CardHeader>
<CardTitle>{character.nameTh}</CardTitle>
<CardDescription>{character.nameEn ?? character.slug}</CardDescription>
<CardAction>
<Badge variant={character.visible ? "default" : "outline"}>
{character.visible ? "เผยแพร่" : "ซ่อน"}
</Badge>
</CardAction>
</CardHeader>
<CardContent className="space-y-2">
{character.pages.map((page) => (
<div key={page.id} className="flex min-h-11 items-center gap-2 rounded-lg border px-3">
<FileTextIcon className="size-4 text-muted-foreground" />
<Link href={`/admin/${character.slug}/${page.slug}`} className="min-w-0 flex-1 truncate text-sm font-medium hover:text-primary">
{page.title}
</Link>
<Badge variant="outline">v{page.version}</Badge>
{page.visible && character.visible ? (
<Button variant="ghost" size="icon-xs" render={<Link href={`/${character.slug}/${page.slug}`} target="_blank" />} aria-label={`เปิด ${page.title}`}>
<EyeIcon />
</Button>
) : null}
</div>
))}
</CardContent>
</Card>
))}
</div>
) : (
<Card className="border-dashed"><CardContent className="grid min-h-48 place-items-center text-center text-muted-foreground">ยังไม่มีตัวละคร เริ่มต้นด้วยเทมเพลตที่เตรียมไว้</CardContent></Card>
)}
</main>
</SidebarInset>
</SidebarProvider>
);
}
+137
View File
@@ -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<string, DataSourceSnapshot>;
onInsert: (block: SerializedBlock) => void;
}) {
const router = useRouter();
const source = Object.values(dataSources)[0];
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="คำสั่งตัวแก้ไข"
description="ค้นหาหน้าหรือเพิ่มบล็อก"
className="max-w-xl"
>
<Command>
<CommandInput placeholder="ค้นหาหน้าหรือชนิดบล็อก…" />
<CommandList>
<CommandEmpty>ไม่พบคำสั่งที่ตรงกัน</CommandEmpty>
<CommandGroup heading="ไปยังหน้า">
{navigation.characters.flatMap((character) =>
character.pages.map((page) => (
<CommandItem
key={page.id}
value={`${character.nameTh} ${page.title}`}
onSelect={() => {
onOpenChange(false);
router.push(`/admin/${character.slug}/${page.slug}`);
}}
>
<FileTextIcon aria-hidden="true" />
<span>{character.nameTh} / {page.title}</span>
</CommandItem>
)),
)}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="เพิ่มบล็อก">
{catalog.map((item) => {
const disabled = (item.source && !source) || item.media;
return (
<CommandItem
key={item.type}
value={`เพิ่ม ${item.label} ${item.type}`}
disabled={disabled}
onSelect={() => {
const block = createEditorBlock(item.type, source);
if (!block) return;
onInsert(block);
onOpenChange(false);
}}
>
<item.icon aria-hidden="true" />
<span>{item.label}</span>
{item.media ? <CommandShortcut>เลือกจากคลังสื่อ</CommandShortcut> : null}
{item.source && !source ? <CommandShortcut>ต้องมีแหล่งข้อมูล</CommandShortcut> : null}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
);
}
+148
View File
@@ -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<CharacterIdentity & { pages: PageIdentity[] }>;
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 (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
tooltip="Buzz Sheet"
render={<Link href="/admin" />}
>
<span className="grid size-8 place-items-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<BookOpenIcon aria-hidden="true" />
</span>
<span className="grid flex-1 text-left leading-tight">
<span className="font-semibold">Buzz Sheet</span>
<span className="text-xs text-sidebar-foreground/60">ตัวแก้ไขคู่มือ</span>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>ตัวละครและหน้า</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navigation.characters.map((character) => (
<SidebarMenuItem key={character.id}>
<SidebarMenuButton
tooltip={character.nameTh}
isActive={character.slug === currentCharacter && !currentPage}
>
<BoxesIcon aria-hidden="true" />
<span>{character.nameTh}</span>
<ChevronRightIcon aria-hidden="true" className="ml-auto" />
</SidebarMenuButton>
<SidebarMenuSub>
{character.pages.map((page) => (
<SidebarMenuSubItem key={page.id}>
<SidebarMenuSubButton
render={<Link href={`/admin/${character.slug}/${page.slug}`} />}
isActive={character.slug === currentCharacter && page.slug === currentPage}
>
<span>{page.title}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>โครงสร้าง</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="เทมเพลต"
render={<Link href="/admin/templates" />}
>
<LayoutTemplateIcon aria-hidden="true" />
<span>เทมเพลต</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<Button
variant="ghost"
className="w-full justify-start"
onClick={() =>
authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/admin/login");
router.refresh();
},
},
})
}
>
<LogOutIcon aria-hidden="true" />
<span className="group-data-[collapsible=icon]:hidden">ออกจากระบบ</span>
</Button>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
+288
View File
@@ -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<string, string> = {
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<string, unknown>) => {
changeBlocks(
draft.blocks.map((block) =>
block.id === blockId ? { ...block, config: { ...block.config as object, ...config } } : block,
),
);
};
return (
<div className="mx-auto w-full max-w-4xl space-y-5 p-4 sm:p-6 lg:p-8">
<Card className="border-dashed bg-card/70">
<CardContent className="space-y-4 pt-1">
<FieldGroup>
<Field>
<FieldLabel htmlFor="page-title">ชื่อหน้า</FieldLabel>
<Input
id="page-title"
value={draft.title}
className="h-auto border-0 bg-transparent px-0 font-heading text-2xl font-semibold shadow-none focus-visible:ring-0"
onChange={(event) => onChange({ ...draft, title: event.target.value })}
/>
</Field>
<Field>
<FieldLabel htmlFor="public-note">หมายเหตุสาธารณะ</FieldLabel>
<Textarea
id="public-note"
value={draft.publicNote ?? ""}
placeholder="เพิ่มหมายเหตุที่จะแสดงบนหน้าสาธารณะ"
onChange={(event) => onChange({ ...draft, publicNote: event.target.value || null })}
/>
</Field>
</FieldGroup>
</CardContent>
</Card>
<div className="space-y-3" aria-label="บล็อกในหน้า">
{draft.blocks.map((block, index) => (
<EditableBlock
key={block.id}
block={block}
index={index}
total={draft.blocks.length}
selected={selectedId === block.id}
onSelect={() => onSelect(block.id)}
onPatch={(config) => patchBlock(block.id, config)}
onMove={(direction) => changeBlocks(moveBlock(draft.blocks, block.id, direction))}
onDrop={(draggedId) => changeBlocks(moveBlockTo(draft.blocks, draggedId, block.id))}
onDuplicate={() => changeBlocks(duplicateBlock(draft.blocks, block.id))}
onDelete={() => {
changeBlocks(draft.blocks.filter((candidate) => candidate.id !== block.id));
if (selectedId === block.id) onSelect("");
}}
/>
))}
</div>
<Button type="button" variant="outline" className="w-full border-dashed" onClick={onOpenInsert}>
<PlusIcon aria-hidden="true" />
เพิ่มบล็อก
</Button>
</div>
);
}
function EditableBlock({
block,
index,
total,
selected,
onSelect,
onPatch,
onMove,
onDrop,
onDuplicate,
onDelete,
}: {
block: SerializedBlock;
index: number;
total: number;
selected: boolean;
onSelect: () => void;
onPatch: (config: Record<string, unknown>) => void;
onMove: (direction: -1 | 1) => void;
onDrop: (draggedId: string) => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
const [deleteOpen, setDeleteOpen] = useState(false);
const config = block.config as Record<string, unknown>;
return (
<>
<Card
tabIndex={0}
draggable
onClick={onSelect}
onFocus={onSelect}
onKeyDown={(event) => {
if (!event.altKey) return;
if (event.key === "ArrowUp") { event.preventDefault(); onMove(-1); }
if (event.key === "ArrowDown") { event.preventDefault(); onMove(1); }
}}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/buzz-block-id", block.id);
}}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
const draggedId = event.dataTransfer.getData("text/buzz-block-id");
if (draggedId) onDrop(draggedId);
}}
className={cn(
"group/editor-block scroll-mt-24 outline-none transition-shadow",
selected && "ring-2 ring-primary",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={`${blockLabels[block.type] ?? block.type} ลำดับ ${index + 1}`}
>
<CardHeader className="border-b pb-3">
<div className="flex min-w-0 items-center gap-2">
<GripVerticalIcon aria-hidden="true" className="size-4 shrink-0 cursor-grab text-muted-foreground" />
<CardTitle className="truncate">{blockLabels[block.type] ?? block.type}</CardTitle>
<Badge variant="outline" className="ml-auto">{index + 1}</Badge>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={index === 0}
onClick={(event) => { event.stopPropagation(); onMove(-1); }}
aria-label="ย้ายบล็อกขึ้น"
><ArrowUpIcon /></Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={index === total - 1}
onClick={(event) => { event.stopPropagation(); onMove(1); }}
aria-label="ย้ายบล็อกลง"
><ArrowDownIcon /></Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="ghost" size="icon-sm" aria-label="การทำงานของบล็อก" />}
onClick={(event) => event.stopPropagation()}
><MoreHorizontalIcon /></DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onDuplicate}><CopyIcon />ทำสำเนา</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => setDeleteOpen(true)}><Trash2Icon />ลบบล็อก</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent onClick={(event) => event.stopPropagation()}>
<InlineBlockFields type={block.type} config={config} onPatch={onPatch} />
</CardContent>
</Card>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>ลบบล็อกนี้หรือไม่</AlertDialogTitle>
<AlertDialogDescription>
บล็อก “{blockLabels[block.type] ?? block.type}” จะถูกนำออกจากหน้า คุณยังย้อนกลับได้ก่อนประวัติการแก้ไขถูกรีเซ็ต
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onDelete}>ลบบล็อก</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
function InlineBlockFields({
type,
config,
onPatch,
}: {
type: string;
config: Record<string, unknown>;
onPatch: (config: Record<string, unknown>) => void;
}) {
if (type === "profile") {
return <div className="space-y-3"><Input value={String(config.title ?? "")} aria-label="หัวข้อโปรไฟล์" onChange={(event) => onPatch({ title: event.target.value })} /><Textarea value={String(config.summary ?? "")} aria-label="คำอธิบายโปรไฟล์" placeholder="คำอธิบายสั้น ๆ" onChange={(event) => onPatch({ summary: event.target.value })} /></div>;
}
if (type === "heading" || type === "section") {
const key = type === "heading" ? "text" : "title";
return <Input value={String(config[key] ?? "")} aria-label="ข้อความหัวข้อ" className="border-0 px-0 font-heading text-xl shadow-none focus-visible:ring-0" onChange={(event) => onPatch({ [key]: event.target.value })} />;
}
if (type === "callout") {
return <div className="space-y-3"><Input value={String(config.title ?? "")} aria-label="ชื่อกล่องหมายเหตุ" onChange={(event) => onPatch({ title: event.target.value })} /><Textarea value={String(config.body ?? "")} aria-label="เนื้อหากล่องหมายเหตุ" onChange={(event) => onPatch({ body: event.target.value })} /></div>;
}
if (type === "rich-text") {
const content = Array.isArray(config.content) ? config.content : [];
const first = content[0] as { type?: string; text?: string } | undefined;
return <Textarea value={first?.text ?? ""} aria-label="เนื้อหาข้อความ" className="min-h-28 leading-7" onChange={(event) => onPatch({ content: [{ type: "paragraph", text: event.target.value }, ...content.slice(1)] })} />;
}
if (typeof config.title === "string") {
return <div className="space-y-2"><Input value={config.title} aria-label="ชื่อบล็อก" onChange={(event) => onPatch({ title: event.target.value })} /><p className="text-sm text-muted-foreground">เลือกบล็อกเพื่อแก้ไขโครงสร้างและแหล่งข้อมูลใน Inspector</p></div>;
}
return <p className="text-sm text-muted-foreground">เลือกบล็อกเพื่อแก้ไขรายละเอียดใน Inspector</p>;
}
+320
View File
@@ -0,0 +1,320 @@
"use client";
import { FunctionSquareIcon, PlusIcon, SaveIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { PublicBlockRenderer } from "@/components/public/block-renderer";
import { blockLabels } from "@/components/admin/block-canvas";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { evaluateDataSource } from "@/lib/content/data-source";
import type { DataSourceSnapshot, PageSnapshot } from "@/lib/content/types";
import type { EditorDraft } from "@/lib/editor/draft";
export function EditorInspector({
snapshot,
draft,
selectedId,
dataSources,
onDraftChange,
onDataSourceChange,
onSaveDataSource,
sourceSaving,
}: {
snapshot: PageSnapshot;
draft: EditorDraft;
selectedId: string | null;
dataSources: Record<string, DataSourceSnapshot>;
onDraftChange: (draft: EditorDraft) => void;
onDataSourceChange: (source: DataSourceSnapshot) => void;
onSaveDataSource: (source: DataSourceSnapshot) => void;
sourceSaving: boolean;
}) {
const selected = draft.blocks.find((block) => block.id === selectedId);
const preview = {
...snapshot,
page: {
...snapshot.page,
title: draft.title,
slug: draft.slug,
visible: draft.visible,
publicNote: draft.publicNote,
},
blocks: draft.blocks,
dataSources,
};
const patchSelected = (patch: Partial<(typeof draft.blocks)[number]>) => {
if (!selected) return;
onDraftChange({
...draft,
blocks: draft.blocks.map((block) =>
block.id === selected.id ? { ...block, ...patch } : block,
),
});
};
return (
<Tabs defaultValue="inspector" className="flex h-full min-h-0 flex-col">
<div className="border-b p-3">
<TabsList className="w-full">
<TabsTrigger value="inspector" className="flex-1">Inspector</TabsTrigger>
<TabsTrigger value="preview" className="flex-1">Live Preview</TabsTrigger>
</TabsList>
</div>
<TabsContent value="inspector" className="min-h-0 overflow-y-auto p-4">
{selected ? (
<FieldGroup>
<div>
<Badge variant="outline">{blockLabels[selected.type] ?? selected.type}</Badge>
<p className="mt-2 font-mono text-xs text-muted-foreground">{selected.id}</p>
</div>
<Field>
<FieldLabel htmlFor="block-width">ความกว้าง</FieldLabel>
<Select
value={String((selected.responsiveLayout as Record<string, unknown> | undefined)?.width ?? "content")}
onValueChange={(width) =>
patchSelected({
responsiveLayout: {
...(selected.responsiveLayout as object ?? {}),
width,
},
})
}
>
<SelectTrigger id="block-width" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="content">คอลัมน์อ่านง่าย</SelectItem>
<SelectItem value="wide">พื้นที่กว้าง</SelectItem>
<SelectItem value="full">เต็มพื้นที่</SelectItem>
</SelectContent>
</Select>
</Field>
<Field orientation="horizontal">
<div className="flex-1">
<FieldTitle>ซ่อนบนมือถือ</FieldTitle>
<FieldDescription>เนื้อหานี้จะเริ่มแสดงตั้งแต่หน้าจอแท็บเล็ต</FieldDescription>
</div>
<Switch
checked={Boolean((selected.responsiveLayout as Record<string, unknown> | undefined)?.hideOnMobile)}
onCheckedChange={(hideOnMobile) =>
patchSelected({
responsiveLayout: {
...(selected.responsiveLayout as object ?? {}),
hideOnMobile,
},
})
}
/>
</Field>
<AdvancedBlockSettings
block={selected}
sources={dataSources}
onConfigChange={(config) => patchSelected({ config })}
/>
{Object.values(dataSources).map((source) => (
<DataSourceEditor
key={source.id}
source={source}
onChange={onDataSourceChange}
onSave={() => onSaveDataSource(source)}
saving={sourceSaving}
/>
))}
</FieldGroup>
) : (
<div className="grid min-h-48 place-items-center text-center text-sm text-muted-foreground">
เลือกบล็อกบนแคนวาสเพื่อแก้ไขการจัดวางและข้อมูลขั้นสูง
</div>
)}
</TabsContent>
<TabsContent value="preview" className="min-h-0 overflow-y-auto bg-muted/30 p-4">
<div className="origin-top scale-[0.92] rounded-xl bg-background p-4 shadow-sm ring-1 ring-border">
<PublicBlockRenderer snapshot={preview} />
</div>
</TabsContent>
</Tabs>
);
}
function AdvancedBlockSettings({
block,
sources,
onConfigChange,
}: {
block: EditorDraft["blocks"][number];
sources: Record<string, DataSourceSnapshot>;
onConfigChange: (config: Record<string, unknown>) => void;
}) {
const config = block.config as Record<string, unknown>;
if (!("dataSourceId" in config)) return null;
return (
<Field>
<FieldLabel htmlFor="data-source">แหล่งข้อมูล</FieldLabel>
<Select
value={String(config.dataSourceId)}
onValueChange={(dataSourceId) => onConfigChange({ ...config, dataSourceId })}
>
<SelectTrigger id="data-source" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.values(sources).map((source) => (
<SelectItem key={source.id} value={source.id}>{source.name} · v{source.version}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
function DataSourceEditor({
source,
onChange,
onSave,
saving,
}: {
source: DataSourceSnapshot;
onChange: (source: DataSourceSnapshot) => void;
onSave: () => void;
saving: boolean;
}) {
const [rowId, setRowId] = useState<string | null>(null);
const evaluated = useMemo(() => {
try { return evaluateDataSource(source); } catch { return null; }
}, [source]);
const selectedRow = source.rows.find((row) => row.id === rowId);
const formulaFields = source.fields.filter((field) => field.formula);
const previewRow = evaluated?.rows[0];
function updateField(key: string, formula: string) {
onChange({
...source,
fields: source.fields.map((field) =>
field.key === key ? { ...field, formula: formula || null } : field,
),
});
}
return (
<div className="space-y-3 border-t pt-5">
<div className="flex items-center justify-between gap-2">
<div>
<FieldTitle>{source.name}</FieldTitle>
<FieldDescription>แหล่งข้อมูลทั่วโลก · เวอร์ชัน {source.version}</FieldDescription>
</div>
<Button size="sm" variant="outline" onClick={onSave} disabled={saving}>
<SaveIcon />{saving ? "กำลังบันทึก" : "บันทึกข้อมูล"}
</Button>
</div>
{formulaFields.map((field) => {
const cell = previewRow?.cells[field.key];
return (
<Field key={field.key} data-invalid={Boolean(cell?.error)}>
<FieldLabel htmlFor={`formula-${field.key}`}>{field.label}</FieldLabel>
<InputGroup>
<InputGroupAddon><InputGroupText className="font-mono text-primary">fx</InputGroupText></InputGroupAddon>
<InputGroupInput
id={`formula-${field.key}`}
className="font-mono"
value={field.formula ?? ""}
list={`fields-${source.id}`}
onChange={(event) => updateField(field.key, event.target.value)}
/>
<InputGroupAddon align="inline-end"><FunctionSquareIcon aria-hidden="true" /></InputGroupAddon>
</InputGroup>
<datalist id={`fields-${source.id}`}>
{source.fields.filter((candidate) => candidate.key !== field.key).map((candidate) => <option key={candidate.key} value={candidate.key}>{candidate.label}</option>)}
</datalist>
{cell?.error ? <FieldError>สูตรนี้ยังคำนวณไม่ได้: ตรวจสอบชื่อฟิลด์และตัวหาร</FieldError> : <FieldDescription>ตัวอย่างผลลัพธ์: <span className="font-mono text-foreground">{cell?.display ?? "—"}</span></FieldDescription>}
</Field>
);
})}
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader><TableRow>{source.fields.slice(0, 3).map((field) => <TableHead key={field.key}>{field.label}</TableHead>)}<TableHead className="w-12" /></TableRow></TableHeader>
<TableBody>{source.rows.slice(0, 10).map((row) => <TableRow key={row.id}>{source.fields.slice(0, 3).map((field) => <TableCell key={field.key} className="max-w-28 truncate">{row.values[field.key] ?? previewRow?.cells[field.key]?.display ?? "—"}</TableCell>)}<TableCell><Button size="xs" variant="ghost" onClick={() => setRowId(row.id)}>แก้ไข</Button></TableCell></TableRow>)}</TableBody>
</Table>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
const id = crypto.randomUUID();
onChange({ ...source, rows: [...source.rows, { id, values: Object.fromEntries(source.fields.map((field) => [field.key, null])) }] });
setRowId(id);
}}
><PlusIcon />เพิ่มแถว</Button>
<Dialog open={Boolean(selectedRow)} onOpenChange={(open) => { if (!open) setRowId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>แก้ไขแถวข้อมูล</DialogTitle>
<DialogDescription>กรอกค่าต้นทาง ระบบจะคำนวณฟิลด์สูตรให้อัตโนมัติ</DialogDescription>
</DialogHeader>
{selectedRow ? (
<FieldGroup className="max-h-[60vh] overflow-y-auto pr-1">
{source.fields.map((field) => (
<Field key={field.key}>
<FieldLabel htmlFor={`row-${selectedRow.id}-${field.key}`}>{field.label}</FieldLabel>
<Input
id={`row-${selectedRow.id}-${field.key}`}
value={selectedRow.values[field.key] ?? ""}
disabled={Boolean(field.formula)}
inputMode={field.type === "number" ? "decimal" : undefined}
onChange={(event) => onChange({
...source,
rows: source.rows.map((row) => row.id === selectedRow.id ? { ...row, values: { ...row.values, [field.key]: event.target.value || null } } : row),
})}
/>
</Field>
))}
</FieldGroup>
) : null}
<DialogFooter><Button onClick={() => setRowId(null)}>เสร็จสิ้น</Button></DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
CommandIcon,
EyeIcon,
MonitorIcon,
PanelRightIcon,
Redo2Icon,
SmartphoneIcon,
TabletIcon,
Undo2Icon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
saveEditorDataSource,
saveEditorPage,
type EditorSaveInput,
} from "@/app/admin/actions";
import { AdminCommandDialog } from "@/components/admin/admin-command";
import {
AdminSidebar,
type AdminNavigation,
} from "@/components/admin/admin-sidebar";
import { BlockCanvas } from "@/components/admin/block-canvas";
import { EditorInspector } from "@/components/admin/editor-inspector";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import type { SerializedBlock } from "@/lib/blocks";
import type { DataSourceSnapshot, PageSnapshot } from "@/lib/content/types";
import {
AutosaveCoordinator,
type AutosaveStatus,
} from "@/lib/editor/autosave";
import {
draftFromSnapshot,
normalizeBlockOrder,
type EditorDraft,
} from "@/lib/editor/draft";
import { cn } from "@/lib/utils";
type Viewport = "mobile" | "tablet" | "desktop";
interface HistoryState {
items: EditorDraft[];
index: number;
}
export function EditorWorkspace({
snapshot,
navigation,
}: {
snapshot: PageSnapshot;
navigation: AdminNavigation;
}) {
const router = useRouter();
const initialDraft = draftFromSnapshot(snapshot);
const [history, setHistory] = useState<HistoryState>({ items: [initialDraft], index: 0 });
const draft = history.items[history.index];
const [selectedId, setSelectedId] = useState<string | null>(draft.blocks[0]?.id ?? null);
const [commandOpen, setCommandOpen] = useState(false);
const [viewport, setViewport] = useState<Viewport>("desktop");
const [autosaveStatus, setAutosaveStatus] = useState<AutosaveStatus>({ state: "idle" });
const [dataSources, setDataSources] = useState(snapshot.dataSources);
const [sourceSaving, setSourceSaving] = useState(false);
const coordinatorRef = useRef<AutosaveCoordinator<EditorDraft, EditorSaveInput, PageSnapshot> | null>(null);
useEffect(() => {
const coordinator = new AutosaveCoordinator<EditorDraft, EditorSaveInput, PageSnapshot>({
initialVersion: snapshot.page.version,
createPayload: (next, expectedVersion) => ({ ...next, expectedVersion }),
save: saveEditorPage,
onStatus: setAutosaveStatus,
onSaved: (savedSnapshot) => {
if (savedSnapshot) setDataSources(savedSnapshot.dataSources);
},
});
coordinatorRef.current = coordinator;
return () => {
coordinator.dispose();
coordinatorRef.current = null;
};
}, [snapshot.page.id, snapshot.page.version]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setCommandOpen(true);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
function commit(next: EditorDraft) {
setHistory((current) => ({
items: [...current.items.slice(0, current.index + 1), structuredClone(next)].slice(-80),
index: Math.min(current.index + 1, 79),
}));
coordinatorRef.current?.schedule(next);
}
function travel(direction: -1 | 1) {
setHistory((current) => {
const index = current.index + direction;
if (index < 0 || index >= current.items.length) return current;
coordinatorRef.current?.schedule(current.items[index]);
return { ...current, index };
});
}
function insert(block: SerializedBlock) {
const blocks = normalizeBlockOrder([...draft.blocks, block]);
commit({ ...draft, blocks });
setSelectedId(block.id);
}
async function saveSource(source: DataSourceSnapshot) {
setSourceSaving(true);
const result = await saveEditorDataSource(source);
setSourceSaving(false);
if (result.status === "saved") {
if (result.snapshot) {
setDataSources((current) => ({ ...current, [source.id]: result.snapshot! }));
}
toast.success("บันทึกแหล่งข้อมูลแล้ว");
} else if (result.status === "conflict") {
toast.error("แหล่งข้อมูลมีการแก้ไขจากที่อื่น กรุณาโหลดหน้าใหม่");
} else {
toast.error("บันทึกแหล่งข้อมูลไม่สำเร็จ");
}
}
const inspector = (
<EditorInspector
snapshot={snapshot}
draft={draft}
selectedId={selectedId}
dataSources={dataSources}
onDraftChange={commit}
onDataSourceChange={(source) => setDataSources((current) => ({ ...current, [source.id]: source }))}
onSaveDataSource={saveSource}
sourceSaving={sourceSaving}
/>
);
return (
<SidebarProvider>
<AdminSidebar
navigation={navigation}
currentCharacter={snapshot.character.slug}
currentPage={snapshot.page.slug}
/>
<SidebarInset className="h-svh min-h-0 overflow-hidden">
<header className="z-20 flex min-h-14 shrink-0 flex-wrap items-center gap-2 border-b bg-background/95 px-3 py-2 supports-backdrop-filter:backdrop-blur">
<SidebarTrigger />
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="truncate">{snapshot.character.nameTh}</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem className="min-w-0"><BreadcrumbPage className="truncate">{draft.title}</BreadcrumbPage></BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<Button variant="ghost" size="icon-sm" onClick={() => travel(-1)} disabled={history.index === 0} aria-label="ย้อนกลับ"><Undo2Icon /></Button>
<Button variant="ghost" size="icon-sm" onClick={() => travel(1)} disabled={history.index === history.items.length - 1} aria-label="ทำซ้ำ"><Redo2Icon /></Button>
<SaveStatus status={autosaveStatus} />
<label className="hidden min-h-8 items-center gap-2 text-sm sm:flex">
<Switch
size="sm"
checked={draft.visible}
onCheckedChange={(visible) => commit({ ...draft, visible })}
/>
{draft.visible ? "เผยแพร่" : "ซ่อน"}
</label>
<ToggleGroup
value={[viewport]}
onValueChange={(values) => {
const next = values.at(-1) as Viewport | undefined;
if (next) setViewport(next);
}}
variant="outline"
size="sm"
spacing={0}
className="hidden lg:flex"
>
<ToggleGroupItem value="mobile" aria-label="ตัวอย่างมือถือ"><SmartphoneIcon /></ToggleGroupItem>
<ToggleGroupItem value="tablet" aria-label="ตัวอย่างแท็บเล็ต"><TabletIcon /></ToggleGroupItem>
<ToggleGroupItem value="desktop" aria-label="ตัวอย่างเดสก์ท็อป"><MonitorIcon /></ToggleGroupItem>
</ToggleGroup>
<Button variant="outline" size="sm" onClick={() => setCommandOpen(true)}>
<CommandIcon />
<span className="hidden xl:inline">คำสั่ง</span>
</Button>
<Button variant="ghost" size="icon-sm" render={<Link href={`/${snapshot.character.slug}/${draft.slug}`} target="_blank" />} aria-label="เปิดหน้าสาธารณะ"><EyeIcon /></Button>
<Drawer swipeDirection="right">
<DrawerTrigger render={<Button variant="outline" size="icon-sm" className="lg:hidden" aria-label="เปิด Inspector" />}><PanelRightIcon /></DrawerTrigger>
<DrawerContent className="[--drawer-content-width:min(92vw,28rem)]">
<DrawerHeader className="sr-only">
<DrawerTitle>Inspector และ Live Preview</DrawerTitle>
<DrawerDescription>แก้ไขคุณสมบัติบล็อกและดูตัวอย่าง</DrawerDescription>
</DrawerHeader>
{inspector}
</DrawerContent>
</Drawer>
</header>
<div className="min-h-0 flex-1">
<ResizablePanelGroup orientation="horizontal" className="h-full">
<ResizablePanel defaultSize="70%" minSize="45%">
<div className="h-full overflow-y-auto bg-muted/25">
<div className={cn(
"mx-auto min-h-full transition-[max-width]",
viewport === "mobile" && "max-w-[390px] border-x",
viewport === "tablet" && "max-w-[768px] border-x",
viewport === "desktop" && "max-w-none",
)}>
<BlockCanvas
draft={draft}
selectedId={selectedId}
onSelect={(id) => setSelectedId(id || null)}
onChange={commit}
onOpenInsert={() => setCommandOpen(true)}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle className="hidden lg:flex" />
<ResizablePanel defaultSize="30%" minSize="22%" maxSize="48%" className="hidden lg:block">
{inspector}
</ResizablePanel>
</ResizablePanelGroup>
</div>
</SidebarInset>
<AdminCommandDialog
open={commandOpen}
onOpenChange={setCommandOpen}
navigation={navigation}
dataSources={dataSources}
onInsert={insert}
/>
<AlertDialog open={autosaveStatus.state === "conflict"}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>พบการแก้ไขที่ใหม่กว่า</AlertDialogTitle>
<AlertDialogDescription>
เวอร์ชันบนเซิร์ฟเวอร์เปลี่ยนไประหว่างที่คุณกำลังทำงาน ฉบับในเครื่องยังไม่ถูกเขียนทับ คุณสามารถกลับไปตรวจเทียบหรือโหลดฉบับล่าสุด
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>กลับไปตรวจเทียบ</AlertDialogCancel>
<AlertDialogAction onClick={() => router.refresh()}>โหลดเวอร์ชันล่าสุด</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SidebarProvider>
);
}
function SaveStatus({ status }: { status: AutosaveStatus }) {
if (status.state === "saving") return <Badge variant="secondary"><Spinner />กำลังบันทึก</Badge>;
if (status.state === "dirty") return <Badge variant="outline">รอบันทึก</Badge>;
if (status.state === "saved") return <Badge variant="outline" className="text-success">บันทึกแล้ว · v{status.version}</Badge>;
if (status.state === "conflict") return <Badge variant="destructive">ข้อมูลชนกัน</Badge>;
if (status.state === "error") return <Badge variant="destructive">บันทึกไม่สำเร็จ</Badge>;
return <Badge variant="outline">พร้อมแก้ไข</Badge>;
}