Files
buzz-sheet/components/admin/editor-workspace.tsx
T

335 lines
13 KiB
TypeScript

"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 type { UploadedMedia } from "@/components/admin/media-uploader";
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("บันทึกแหล่งข้อมูลไม่สำเร็จ");
}
}
function addUploadedMedia(media: UploadedMedia) {
const block: SerializedBlock = {
id: crypto.randomUUID(),
type: "image",
schemaVersion: 1,
config: {
mediaId: media.id,
alt: media.fileName.replace(/\.[^.]+$/u, ""),
fit: "cover",
},
responsiveLayout: {
width: "wide",
mobileSpan: 4,
tabletSpan: 8,
desktopSpan: 12,
hideOnMobile: false,
},
sortOrder: draft.blocks.length,
};
insert(block);
}
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}
onMediaReady={addUploadedMedia}
/>
);
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>;
}