feat(editor): add responsive visual workspace
This commit is contained in:
@@ -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
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user