274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ArrowDownIcon,
|
|
ArrowUpIcon,
|
|
CopyIcon,
|
|
GripVerticalIcon,
|
|
MoreHorizontalIcon,
|
|
PlusIcon,
|
|
Trash2Icon,
|
|
} from "lucide-react";
|
|
import { useState } from "react";
|
|
|
|
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-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>
|
|
|
|
<DeleteConfirmationDialog
|
|
open={deleteOpen}
|
|
onOpenChange={setDeleteOpen}
|
|
title="ลบบล็อกนี้หรือไม่"
|
|
description={`บล็อก “${blockLabels[block.type] ?? block.type}” จะถูกนำออกจากหน้า คุณยังย้อนกลับได้ก่อนประวัติการแก้ไขถูกรีเซ็ต`}
|
|
confirmLabel="ลบบล็อก"
|
|
onConfirm={onDelete}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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>;
|
|
}
|