Files
buzz-sheet/components/admin/block-canvas.tsx
T
gunshiz c63b879ddb
CI / Verify (push) Successful in 1m1s
CI / Build immutable images and deploy (push) Successful in 1m58s
feat(admin) : streamline team editing and delete safety
2026-09-02 20:59:41 +07:00

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>;
}