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