440 lines
14 KiB
TypeScript
440 lines
14 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>;
|
|
}
|