328 lines
13 KiB
TypeScript
328 lines
13 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>
|
|
);
|
|
}
|