458 lines
15 KiB
TypeScript
458 lines
15 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>
|
|
);
|
|
}
|