Files
buzz-sheet/components/admin/extra-sheet-editor.tsx
T
gunshiz 612a8494ac
CI / Verify (push) Successful in 1m19s
CI / Build immutable images and deploy (push) Successful in 1m51s
fix : bg color
2026-09-05 18:51:43 +07:00

439 lines
17 KiB
TypeScript

"use client";
import {
ArrowDownIcon,
ArrowLeftIcon,
ArrowRightIcon,
ArrowUpIcon,
Columns3Icon,
PlusIcon,
PencilIcon,
PaletteIcon,
Rows3Icon,
Trash2Icon,
} from "lucide-react";
import Spreadsheet, { type ColumnIndicatorProps, type DataEditorProps, type Point, type Selection } from "react-spreadsheet";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
import { RichTextContent } from "@/components/rich-text-content";
import { RichTextEditor } from "@/components/rich-text-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
addExtraSheetColumn,
addExtraSheetRow,
deleteExtraSheetColumn,
deleteExtraSheetRow,
extraSheetToMatrix,
matrixToExtraSheet,
moveExtraSheetColumn,
moveExtraSheetRow,
nextExtraSheetColumnLabel,
updateExtraSheetCell,
updateExtraSheetCells,
type ExtraSheet,
type ExtraSheetMatrix,
} from "@/lib/guides/extra-sheet";
import { normalizeExtraRichText } from "@/lib/rich-text";
type DeleteTarget = "column" | "row" | null;
const DEFAULT_CELL_BACKGROUND = "#16A34A";
function LiteralDataViewer({ cell }: { cell?: { value?: unknown; backgroundColor?: string } }) {
return (
<div
className="Spreadsheet__data-viewer Spreadsheet__data-viewer--preserve-breaks size-full"
style={cell?.backgroundColor ? { backgroundColor: cell.backgroundColor } : undefined}
>
<RichTextContent html={normalizeExtraRichText(String(cell?.value ?? ""))} />
</div>
);
}
function DialogDataEditor({
cell,
row,
column,
exitEditMode,
onEdit,
}: DataEditorProps<{ value: string }> & {
onEdit: (point: Point, value: string) => void;
}) {
useEffect(() => {
onEdit({ row, column }, String(cell?.value ?? ""));
exitEditMode();
}, [cell?.value, column, exitEditMode, onEdit, row]);
return <LiteralDataViewer cell={cell} />;
}
function EditableColumnIndicator({
column,
label,
selected,
onRename,
}: ColumnIndicatorProps & { onRename: (column: number) => void }) {
return (
<th className={selected ? "Spreadsheet__header Spreadsheet__header--selected" : "Spreadsheet__header"}>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
onClick={() => onRename(column)}
>
{label}
</Button>
</th>
);
}
export function ExtraSheetEditor({
value,
createId,
onChange,
onRemove,
}: {
value: ExtraSheet;
createId: (prefix: string) => string;
onChange: (value: ExtraSheet) => void;
onRemove: () => void;
}) {
const [active, setActive] = useState<Point>({ row: 0, column: 0 });
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [columnLabel, setColumnLabel] = useState("");
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget>(null);
const [removeOpen, setRemoveOpen] = useState(false);
const [cellDialogOpen, setCellDialogOpen] = useState(false);
const [cellDraft, setCellDraft] = useState("");
const [selection, setSelection] = useState<Selection | null>(null);
const colorSelectionRef = useRef<Point[] | null>(null);
const matrix = useMemo(() => extraSheetToMatrix(value), [value]);
const activeColumn = value.columns[active.column];
const activeRow = value.rows[active.row];
const normalizedLabel = columnLabel.trim().toLocaleLowerCase();
const labelInvalid = !normalizedLabel || value.columns.some((column, index) =>
index !== (renaming ? active.column : -1) &&
column.label.toLocaleLowerCase() === normalizedLabel,
);
const selectedPoints = useCallback(() => {
const range = selection?.toRange(matrix);
return range ? Array.from(range) : [active];
}, [active, matrix, selection]);
const selectedCount = selectedPoints().length;
const openAddColumn = () => {
setRenaming(false);
setColumnLabel(nextExtraSheetColumnLabel(value.columns));
setLabelDialogOpen(true);
};
const openRenameColumn = useCallback((columnIndex = active.column) => {
const column = value.columns[columnIndex];
if (!column) return;
setActive((current) => ({ ...current, column: columnIndex }));
setRenaming(true);
setColumnLabel(column.label);
setLabelDialogOpen(true);
}, [active.column, value.columns]);
const saveColumnLabel = () => {
if (labelInvalid) return;
if (renaming) {
onChange({
...value,
columns: value.columns.map((column, index) =>
index === active.column ? { ...column, label: columnLabel.trim() } : column,
),
});
} else {
const column = value.columns.length;
onChange(addExtraSheetColumn(
value,
columnLabel.trim(),
createId("extra-sheet-column"),
));
setActive((current) => ({ ...current, column }));
}
setLabelDialogOpen(false);
};
const openCellEditorAt = useCallback((point: Point, content?: string) => {
setActive(point);
const column = value.columns[point.column];
const row = value.rows[point.row];
setCellDraft(normalizeExtraRichText(
content ?? (column && row ? row.cells[column.id] ?? "" : ""),
));
setCellDialogOpen(true);
}, [value.columns, value.rows]);
const openActiveCellEditor = () => openCellEditorAt(active);
const columnIndicator = useCallback((props: ColumnIndicatorProps) => (
<EditableColumnIndicator {...props} onRename={openRenameColumn} />
), [openRenameColumn]);
const dataEditor = useCallback((props: DataEditorProps<{ value: string }>) => (
<DialogDataEditor {...props} onEdit={openCellEditorAt} />
), [openCellEditorAt]);
return (
<div className="flex min-w-0 flex-col gap-3" aria-label="ตารางข้อมูลเสริม">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const row = value.rows.length;
onChange(addExtraSheetRow(value, createId("extra-sheet-row")));
setActive((current) => ({ ...current, row }));
}}
>
<PlusIcon data-icon="inline-start" />
เพิ่มแถว
</Button>
<Button type="button" variant="outline" size="sm" onClick={openAddColumn}>
<PlusIcon data-icon="inline-start" />
เพิ่มคอลัมน์
</Button>
<Button type="button" variant="outline" size="sm" onClick={openActiveCellEditor}>
<PencilIcon data-icon="inline-start" />
แก้ไข {selectedCount > 1 ? `${selectedCount} เซลล์` : "เซลล์"}
</Button>
<label className="relative">
<Button type="button" variant="outline" size="sm" render={<span />} nativeButton={false}>
<PaletteIcon data-icon="inline-start" />
สีพื้นหลัง {selectedCount > 1 ? `${selectedCount} เซลล์` : "เซลล์"}
</Button>
<input
type="color"
aria-label="เลือกสีพื้นหลังเซลล์"
className="absolute inset-0 cursor-pointer opacity-0"
value={activeRow?.backgrounds?.[activeColumn?.id ?? ""] ?? DEFAULT_CELL_BACKGROUND}
onPointerDown={() => {
colorSelectionRef.current = selectedPoints();
}}
onChange={(event) => {
onChange(updateExtraSheetCells(
value,
colorSelectionRef.current ?? selectedPoints(),
{ backgroundColor: event.target.value },
));
colorSelectionRef.current = null;
}}
/>
</label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onChange(updateExtraSheetCells(value, selectedPoints(), { backgroundColor: null }))}
>
ล้างสีพื้นหลัง
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="sm" />}
>
<Rows3Icon data-icon="inline-start" />
แถว {active.row + 1}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<DropdownMenuLabel>จัดการแถว {active.row + 1}</DropdownMenuLabel>
<DropdownMenuItem
disabled={!activeRow || active.row === 0}
onClick={() => {
onChange(moveExtraSheetRow(value, active.row, active.row - 1));
setActive((current) => ({ ...current, row: current.row - 1 }));
}}
>
<ArrowUpIcon />ย้ายขึ้น
</DropdownMenuItem>
<DropdownMenuItem
disabled={!activeRow || active.row === value.rows.length - 1}
onClick={() => {
onChange(moveExtraSheetRow(value, active.row, active.row + 1));
setActive((current) => ({ ...current, row: current.row + 1 }));
}}
>
<ArrowDownIcon />ย้ายลง
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
variant="destructive"
disabled={value.rows.length === 1}
onClick={() => setDeleteTarget("row")}
>
<Trash2Icon />ลบแถว
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="sm" />}
>
<Columns3Icon data-icon="inline-start" />
{activeColumn?.label ?? "คอลัมน์"}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<DropdownMenuLabel>{activeColumn?.label}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => openRenameColumn()}>แก้ไขชื่อ</DropdownMenuItem>
<DropdownMenuItem
disabled={!activeColumn || active.column === 0}
onClick={() => {
onChange(moveExtraSheetColumn(value, active.column, active.column - 1));
setActive((current) => ({ ...current, column: current.column - 1 }));
}}
>
<ArrowLeftIcon />ย้ายไปทางซ้าย
</DropdownMenuItem>
<DropdownMenuItem
disabled={!activeColumn || active.column === value.columns.length - 1}
onClick={() => {
onChange(moveExtraSheetColumn(value, active.column, active.column + 1));
setActive((current) => ({ ...current, column: current.column + 1 }));
}}
>
<ArrowRightIcon />ย้ายไปทางขวา
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
variant="destructive"
disabled={value.columns.length === 1}
onClick={() => setDeleteTarget("column")}
>
<Trash2Icon />ลบคอลัมน์
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button type="button" variant="ghost" size="sm" onClick={() => setRemoveOpen(true)}>
<Trash2Icon data-icon="inline-start" />
ลบตาราง
</Button>
</div>
<div className="overflow-x-auto rounded-lg border">
<Spreadsheet
className="extra-sheet-grid"
data={matrix}
columnLabels={value.columns.map((column) => column.label)}
ColumnIndicator={columnIndicator}
DataViewer={LiteralDataViewer}
DataEditor={dataEditor}
onActivate={setActive}
onSelect={setSelection}
onChange={(next) => onChange(matrixToExtraSheet(
value,
next as ExtraSheetMatrix,
createId,
))}
/>
</div>
<Dialog open={cellDialogOpen} onOpenChange={setCellDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>แก้ไขเซลล์</DialogTitle>
<DialogDescription>
{selectedCount > 1
? `นำข้อความเดียวกันไปใช้กับ ${selectedCount} เซลล์ที่เลือก`
: `แถว ${active.row + 1} · ${activeColumn?.label ?? "คอลัมน์"}`}
</DialogDescription>
</DialogHeader>
<RichTextEditor value={cellDraft} onChange={setCellDraft} showBubbleMenu={false} />
<DialogFooter showCloseButton>
<Button type="button" onClick={() => {
onChange(selectedCount > 1
? updateExtraSheetCells(value, selectedPoints(), { value: cellDraft })
: updateExtraSheetCell(value, active.row, active.column, cellDraft));
setCellDialogOpen(false);
}}>บันทึกเซลล์</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{renaming ? "แก้ไขชื่อคอลัมน์" : "เพิ่มคอลัมน์"}</DialogTitle>
<DialogDescription>ตั้งชื่อที่ใช้อธิบายข้อมูลในคอลัมน์นี้</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={labelInvalid || undefined}>
<FieldLabel htmlFor="extra-sheet-column-label">ชื่อคอลัมน์</FieldLabel>
<Input
id="extra-sheet-column-label"
value={columnLabel}
aria-invalid={labelInvalid || undefined}
autoFocus
onChange={(event) => setColumnLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") saveColumnLabel();
}}
/>
</Field>
</FieldGroup>
<DialogFooter showCloseButton>
<Button type="button" disabled={labelInvalid} onClick={saveColumnLabel}>
{renaming ? "บันทึกชื่อ" : "เพิ่มคอลัมน์"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteConfirmationDialog
open={deleteTarget !== null}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
title={deleteTarget === "column" ? `ลบ ${activeColumn?.label}?` : `ลบแถว ${active.row + 1}?`}
description="ข้อมูลในรายการนี้จะถูกลบออกจากตาราง"
showSuccessToast={false}
onConfirm={() => {
if (deleteTarget === "column") {
onChange(deleteExtraSheetColumn(value, active.column));
setActive((current) => ({ ...current, column: Math.max(0, current.column - 1) }));
} else if (deleteTarget === "row") {
onChange(deleteExtraSheetRow(value, active.row));
setActive((current) => ({ ...current, row: Math.max(0, current.row - 1) }));
}
setDeleteTarget(null);
}}
/>
<DeleteConfirmationDialog
open={removeOpen}
onOpenChange={setRemoveOpen}
title="ลบตารางนี้?"
description="คอลัมน์ แถว และข้อมูลในเซลล์ทั้งหมดจะถูกลบออกจากการ์ด"
confirmLabel="ยืนยันลบตาราง"
showSuccessToast={false}
onConfirm={() => {
onRemove();
setRemoveOpen(false);
}}
/>
</div>
);
}