Files
buzz-sheet/components/admin/editor-inspector.tsx
T
2026-09-01 20:54:54 +07:00

328 lines
12 KiB
TypeScript

"use client";
import { FunctionSquareIcon, PlusIcon, SaveIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { PublicBlockRenderer } from "@/components/public/block-renderer";
import { blockLabels } from "@/components/admin/block-canvas";
import {
MediaUploader,
type UploadedMedia,
} from "@/components/admin/media-uploader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { evaluateDataSource } from "@/lib/content/data-source";
import type { DataSourceSnapshot, PageSnapshot } from "@/lib/content/types";
import type { EditorDraft } from "@/lib/editor/draft";
export function EditorInspector({
snapshot,
draft,
selectedId,
dataSources,
onDraftChange,
onDataSourceChange,
onSaveDataSource,
sourceSaving,
onMediaReady,
}: {
snapshot: PageSnapshot;
draft: EditorDraft;
selectedId: string | null;
dataSources: Record<string, DataSourceSnapshot>;
onDraftChange: (draft: EditorDraft) => void;
onDataSourceChange: (source: DataSourceSnapshot) => void;
onSaveDataSource: (source: DataSourceSnapshot) => void;
sourceSaving: boolean;
onMediaReady: (media: UploadedMedia) => void;
}) {
const selected = draft.blocks.find((block) => block.id === selectedId);
const preview = {
...snapshot,
page: {
...snapshot.page,
title: draft.title,
slug: draft.slug,
visible: draft.visible,
publicNote: draft.publicNote,
},
blocks: draft.blocks,
dataSources,
};
const patchSelected = (patch: Partial<(typeof draft.blocks)[number]>) => {
if (!selected) return;
onDraftChange({
...draft,
blocks: draft.blocks.map((block) =>
block.id === selected.id ? { ...block, ...patch } : block,
),
});
};
return (
<Tabs defaultValue="inspector" className="flex h-full min-h-0 flex-col">
<div className="border-b p-3">
<TabsList className="w-full">
<TabsTrigger value="inspector" className="flex-1">Inspector</TabsTrigger>
<TabsTrigger value="preview" className="flex-1">Live Preview</TabsTrigger>
</TabsList>
</div>
<TabsContent value="inspector" className="min-h-0 overflow-y-auto p-4">
{selected ? (
<FieldGroup>
<div>
<Badge variant="outline">{blockLabels[selected.type] ?? selected.type}</Badge>
<p className="mt-2 font-mono text-xs text-muted-foreground">{selected.id}</p>
</div>
<MediaUploader onReady={onMediaReady} />
<Field>
<FieldLabel htmlFor="block-width">ความกว้าง</FieldLabel>
<Select
value={String((selected.responsiveLayout as Record<string, unknown> | undefined)?.width ?? "content")}
onValueChange={(width) =>
patchSelected({
responsiveLayout: {
...(selected.responsiveLayout as object ?? {}),
width,
},
})
}
>
<SelectTrigger id="block-width" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="content">คอลัมน์อ่านง่าย</SelectItem>
<SelectItem value="wide">พื้นที่กว้าง</SelectItem>
<SelectItem value="full">เต็มพื้นที่</SelectItem>
</SelectContent>
</Select>
</Field>
<Field orientation="horizontal">
<div className="flex-1">
<FieldTitle>ซ่อนบนมือถือ</FieldTitle>
<FieldDescription>เนื้อหานี้จะเริ่มแสดงตั้งแต่หน้าจอแท็บเล็ต</FieldDescription>
</div>
<Switch
checked={Boolean((selected.responsiveLayout as Record<string, unknown> | undefined)?.hideOnMobile)}
onCheckedChange={(hideOnMobile) =>
patchSelected({
responsiveLayout: {
...(selected.responsiveLayout as object ?? {}),
hideOnMobile,
},
})
}
/>
</Field>
<AdvancedBlockSettings
block={selected}
sources={dataSources}
onConfigChange={(config) => patchSelected({ config })}
/>
{Object.values(dataSources).map((source) => (
<DataSourceEditor
key={source.id}
source={source}
onChange={onDataSourceChange}
onSave={() => onSaveDataSource(source)}
saving={sourceSaving}
/>
))}
</FieldGroup>
) : (
<div className="grid min-h-48 place-items-center text-center text-sm text-muted-foreground">
เลือกบล็อกบนแคนวาสเพื่อแก้ไขการจัดวางและข้อมูลขั้นสูง
</div>
)}
</TabsContent>
<TabsContent value="preview" className="min-h-0 overflow-y-auto bg-muted/30 p-4">
<div className="origin-top scale-[0.92] rounded-xl bg-background p-4 shadow-sm ring-1 ring-border">
<PublicBlockRenderer snapshot={preview} />
</div>
</TabsContent>
</Tabs>
);
}
function AdvancedBlockSettings({
block,
sources,
onConfigChange,
}: {
block: EditorDraft["blocks"][number];
sources: Record<string, DataSourceSnapshot>;
onConfigChange: (config: Record<string, unknown>) => void;
}) {
const config = block.config as Record<string, unknown>;
if (!("dataSourceId" in config)) return null;
return (
<Field>
<FieldLabel htmlFor="data-source">แหล่งข้อมูล</FieldLabel>
<Select
value={String(config.dataSourceId)}
onValueChange={(dataSourceId) => onConfigChange({ ...config, dataSourceId })}
>
<SelectTrigger id="data-source" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.values(sources).map((source) => (
<SelectItem key={source.id} value={source.id}>{source.name} · v{source.version}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
function DataSourceEditor({
source,
onChange,
onSave,
saving,
}: {
source: DataSourceSnapshot;
onChange: (source: DataSourceSnapshot) => void;
onSave: () => void;
saving: boolean;
}) {
const [rowId, setRowId] = useState<string | null>(null);
const evaluated = useMemo(() => {
try { return evaluateDataSource(source); } catch { return null; }
}, [source]);
const selectedRow = source.rows.find((row) => row.id === rowId);
const formulaFields = source.fields.filter((field) => field.formula);
const previewRow = evaluated?.rows[0];
function updateField(key: string, formula: string) {
onChange({
...source,
fields: source.fields.map((field) =>
field.key === key ? { ...field, formula: formula || null } : field,
),
});
}
return (
<div className="space-y-3 border-t pt-5">
<div className="flex items-center justify-between gap-2">
<div>
<FieldTitle>{source.name}</FieldTitle>
<FieldDescription>แหล่งข้อมูลทั่วโลก · เวอร์ชัน {source.version}</FieldDescription>
</div>
<Button size="sm" variant="outline" onClick={onSave} disabled={saving}>
<SaveIcon />{saving ? "กำลังบันทึก" : "บันทึกข้อมูล"}
</Button>
</div>
{formulaFields.map((field) => {
const cell = previewRow?.cells[field.key];
return (
<Field key={field.key} data-invalid={Boolean(cell?.error)}>
<FieldLabel htmlFor={`formula-${field.key}`}>{field.label}</FieldLabel>
<InputGroup>
<InputGroupAddon><InputGroupText className="font-mono text-primary">fx</InputGroupText></InputGroupAddon>
<InputGroupInput
id={`formula-${field.key}`}
className="font-mono"
value={field.formula ?? ""}
list={`fields-${source.id}`}
onChange={(event) => updateField(field.key, event.target.value)}
/>
<InputGroupAddon align="inline-end"><FunctionSquareIcon aria-hidden="true" /></InputGroupAddon>
</InputGroup>
<datalist id={`fields-${source.id}`}>
{source.fields.filter((candidate) => candidate.key !== field.key).map((candidate) => <option key={candidate.key} value={candidate.key}>{candidate.label}</option>)}
</datalist>
{cell?.error ? <FieldError>สูตรนี้ยังคำนวณไม่ได้: ตรวจสอบชื่อฟิลด์และตัวหาร</FieldError> : <FieldDescription>ตัวอย่างผลลัพธ์: <span className="font-mono text-foreground">{cell?.display ?? "-"}</span></FieldDescription>}
</Field>
);
})}
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader><TableRow>{source.fields.slice(0, 3).map((field) => <TableHead key={field.key}>{field.label}</TableHead>)}<TableHead className="w-12" /></TableRow></TableHeader>
<TableBody>{source.rows.slice(0, 10).map((row) => <TableRow key={row.id}>{source.fields.slice(0, 3).map((field) => <TableCell key={field.key} className="max-w-28 truncate">{row.values[field.key] ?? previewRow?.cells[field.key]?.display ?? "-"}</TableCell>)}<TableCell><Button size="xs" variant="ghost" onClick={() => setRowId(row.id)}>แก้ไข</Button></TableCell></TableRow>)}</TableBody>
</Table>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
const id = crypto.randomUUID();
onChange({ ...source, rows: [...source.rows, { id, values: Object.fromEntries(source.fields.map((field) => [field.key, null])) }] });
setRowId(id);
}}
><PlusIcon />เพิ่มแถว</Button>
<Dialog open={Boolean(selectedRow)} onOpenChange={(open) => { if (!open) setRowId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>แก้ไขแถวข้อมูล</DialogTitle>
<DialogDescription>กรอกค่าต้นทาง ระบบจะคำนวณฟิลด์สูตรให้อัตโนมัติ</DialogDescription>
</DialogHeader>
{selectedRow ? (
<FieldGroup className="max-h-[60vh] overflow-y-auto pr-1">
{source.fields.map((field) => (
<Field key={field.key}>
<FieldLabel htmlFor={`row-${selectedRow.id}-${field.key}`}>{field.label}</FieldLabel>
<Input
id={`row-${selectedRow.id}-${field.key}`}
value={selectedRow.values[field.key] ?? ""}
disabled={Boolean(field.formula)}
inputMode={field.type === "number" ? "decimal" : undefined}
onChange={(event) => onChange({
...source,
rows: source.rows.map((row) => row.id === selectedRow.id ? { ...row, values: { ...row.values, [field.key]: event.target.value || null } } : row),
})}
/>
</Field>
))}
</FieldGroup>
) : null}
<DialogFooter><Button onClick={() => setRowId(null)}>เสร็จสิ้น</Button></DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}