From b964801765bdf2d91d182fa705a01e5711dce161 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 19 Jun 2026 13:08:00 +0200 Subject: [PATCH] remove all modals, inline editing, live layout preview, clock preview all Dialog/AlertDialog removed from widgets, data-sources, presets, layout-builder pages. replaced with inline card expansion for edit/create and inline confirm bars for delete. data source form: live clock format preview with 1s tick, timezone validation against Intl.supportedValuesOf. layout preview: fetches live widget data via useWidgetPreview, renders formatted content based on display_hint kind instead of widget names. --- spa/src/components/layout-preview.tsx | 117 ++++++-- spa/src/pages/data-sources.tsx | 415 ++++++++++++-------------- spa/src/pages/layout-builder.tsx | 151 ++++++---- spa/src/pages/presets.tsx | 173 ++++++----- spa/src/pages/widgets.tsx | 332 ++++++++------------- 5 files changed, 577 insertions(+), 611 deletions(-) diff --git a/spa/src/components/layout-preview.tsx b/spa/src/components/layout-preview.tsx index c594b6c..9b49127 100644 --- a/spa/src/components/layout-preview.tsx +++ b/spa/src/components/layout-preview.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef } from "react" import type { LayoutNode, ThemeConfig, Widget } from "@/api/types" import { computeLayout } from "@/lib/layout-engine" +import { useWidgetPreview } from "@/api/widgets" interface LayoutPreviewProps { layout: LayoutNode @@ -19,6 +20,91 @@ function collectWidgetIds(node: LayoutNode): number[] { return (node.children ?? []).flatMap((c) => collectWidgetIds(c.node)) } +function formatPreviewData( + kind: string, + data: Record | undefined, +): string { + if (!data) return "" + const entries = Object.entries(data) + if (entries.length === 0) return "" + + switch (kind) { + case "key_value": + return entries.map(([k, v]) => `${k}: ${v}`).join("\n") + case "text_block": + return entries.map(([, v]) => String(v ?? "")).join("\n") + case "icon_value": + return entries.map(([, v]) => String(v ?? "")).join(" ") + default: + return entries.map(([, v]) => String(v ?? "")).join("\n") + } +} + +function WidgetCell({ wid, widget, scale, theme }: { + wid: number + widget: Widget | undefined + scale: number + theme: ThemeConfig +}) { + const { data } = useWidgetPreview(wid, true) + const hAlign = widget?.display_hint?.h_align ?? "left" + const vAlign = widget?.display_hint?.v_align ?? "top" + const flexAlign = hAlign === "center" ? "center" : hAlign === "right" ? "flex-end" : "flex-start" + const flexJustify = vAlign === "middle" ? "center" : vAlign === "bottom" ? "flex-end" : "flex-start" + const textAlign = hAlign === "center" ? "center" as const : hAlign === "right" ? "right" as const : "left" as const + + const kind = widget?.display_hint?.kind ?? "text_block" + const previewText = formatPreviewData(kind, data as Record | undefined) + const hasData = previewText.length > 0 + + return ( +
+ {hasData ? ( + + {previewText} + + ) : ( + <> + + {widget?.name ?? `#${wid}`} + + {widget && ( + + {kind} + + )} + + )} +
+ ) +} + export function LayoutPreview({ layout, screenWidth, @@ -54,11 +140,6 @@ export function LayoutPreview({ const box = bounds.get(wid) if (!box) return null const w = widgets.find((w) => w.id === wid) - const hAlign = w?.display_hint?.h_align ?? "left" - const vAlign = w?.display_hint?.v_align ?? "top" - const flexAlign = hAlign === "center" ? "center" : hAlign === "right" ? "flex-end" : "flex-start" - const flexJustify = vAlign === "middle" ? "center" : vAlign === "bottom" ? "flex-end" : "flex-start" - const textAlign = hAlign === "center" ? "center" as const : hAlign === "right" ? "right" as const : "left" as const return (
- - {w?.name ?? `#${wid}`} - - {w && ( - - {w.display_hint.kind} - - )} +
) })} diff --git a/spa/src/pages/data-sources.tsx b/spa/src/pages/data-sources.tsx index 8df8b30..6c4705a 100644 --- a/spa/src/pages/data-sources.tsx +++ b/spa/src/pages/data-sources.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { useState, useEffect } from "react" import { useDataSources, useCreateDataSource, @@ -14,13 +14,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { @@ -30,28 +23,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" -import { Plus, Pencil, Trash2, X, Eye, EyeOff } from "lucide-react" +import { Plus, Pencil, Trash2, X, Eye, EyeOff, ChevronUp } from "lucide-react" import { toast } from "sonner" const SOURCE_TYPES: SourceType[] = [ - "weather", - "media", - "rss", - "http_json", - "webhook", - "clock", - "static_text", + "weather", "media", "rss", "http_json", "webhook", "clock", "static_text", ] const EXTERNAL_TYPES: SourceType[] = ["weather", "media", "rss", "http_json", "webhook"] @@ -70,83 +47,158 @@ const EMPTY: DataSource = { config: { type: "external", url: null, api_key: null, headers: [] }, } +const VALID_TIMEZONES = new Set(Intl.supportedValuesOf("timeZone")) + +const STRFTIME_MAP: Record string> = { + "%H": (d) => String(d.getHours()).padStart(2, "0"), + "%M": (d) => String(d.getMinutes()).padStart(2, "0"), + "%S": (d) => String(d.getSeconds()).padStart(2, "0"), + "%I": (d) => String(d.getHours() % 12 || 12).padStart(2, "0"), + "%p": (d) => (d.getHours() >= 12 ? "PM" : "AM"), + "%Y": (d) => String(d.getFullYear()), + "%m": (d) => String(d.getMonth() + 1).padStart(2, "0"), + "%d": (d) => String(d.getDate()).padStart(2, "0"), +} + +function formatClockPreview(fmt: string, tz: string): string { + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", + hour12: false, + }).formatToParts(new Date()) + + const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "" + const h24 = Number(get("hour")) + const fakeDate = new Date(2000, 0, 1, h24, Number(get("minute")), Number(get("second"))) + + let result = fmt + for (const [token, fn] of Object.entries(STRFTIME_MAP)) { + if (token === "%Y") result = result.replaceAll(token, get("year")) + else if (token === "%m") result = result.replaceAll(token, get("month")) + else if (token === "%d") result = result.replaceAll(token, get("day")) + else result = result.replaceAll(token, fn(fakeDate)) + } + return result + } catch { + return "invalid timezone" + } +} + +function isValidSave(ds: DataSource): boolean { + if (!ds.name) return false + if (EXTERNAL_TYPES.includes(ds.source_type) && ds.source_type !== "webhook" && ds.poll_interval_secs <= 0) return false + if (EXTERNAL_TYPES.includes(ds.source_type) && ds.source_type !== "webhook" && ds.config.type === "external" && !ds.config.url) return false + return true +} + export function DataSourcesPage() { const { data: sources = [], isLoading } = useDataSources() const create = useCreateDataSource() const update = useUpdateDataSource() const del = useDeleteDataSource() - const [editing, setEditing] = useState(null) - const [deleting, setDeleting] = useState(null) + const [editingId, setEditingId] = useState(null) + const [editingData, setEditingData] = useState(null) + const [newSource, setNewSource] = useState(null) + const [confirmingDelete, setConfirmingDelete] = useState(null) function openNew() { - const nextId = - sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1 - setEditing({ ...EMPTY, id: nextId }) + const nextId = sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1 + setNewSource({ ...EMPTY, id: nextId }) + setEditingId(null) } - function openEdit(ds: DataSource) { - setEditing({ ...ds }) + function startEdit(ds: DataSource) { + setEditingId(ds.id) + setEditingData({ ...ds }) + setNewSource(null) } - async function save() { - if (!editing) return - const isNew = !sources.some((s) => s.id === editing.id) + function cancelEdit() { + setEditingId(null) + setEditingData(null) + setNewSource(null) + } + + async function saveExisting() { + if (!editingData) return try { - if (isNew) { - await create.mutateAsync(editing) - toast.success("Data source created") - } else { - await update.mutateAsync(editing) - toast.success("Data source updated") - } - setEditing(null) + await update.mutateAsync(editingData) + toast.success("Data source updated") + setEditingId(null) + setEditingData(null) } catch (e) { toast.error(String(e)) } } - async function confirmDelete() { - if (deleting == null) return + async function saveNew() { + if (!newSource) return try { - await del.mutateAsync(deleting) + await create.mutateAsync(newSource) + toast.success("Data source created") + setNewSource(null) + } catch (e) { + toast.error(String(e)) + } + } + + async function confirmDelete(id: number) { + try { + await del.mutateAsync(id) toast.success("Data source deleted") } catch (e) { toast.error(String(e)) } - setDeleting(null) + setConfirmingDelete(null) } - if (isLoading) return
Loading…
+ if (isLoading) return
Loading...
return (
-

- Data Sources -

-

- Configure external data feeds -

+

Data Sources

+

Configure data feeds

-
- {sources.length === 0 ? ( - - -

- No data sources configured yet. -

-
-
- ) : ( -
- {sources.map((ds) => ( +
+ {newSource && ( + + + New Data Source + + + +
+ + +
+
+
+ )} + + {sources.length === 0 && !newSource && ( + + +

No data sources configured yet.

+
+
+ )} + + {sources.map((ds) => { + const isEditing = editingId === ds.id + const isDeleting = confirmingDelete === ds.id + + return (
@@ -155,91 +207,43 @@ export function DataSourcesPage() { {ds.source_type} {ds.poll_interval_secs > 0 && every {ds.poll_interval_secs}s} {ds.config.type === "external" && ds.config.url && ( - - {ds.config.url} - + {ds.config.url} )}
- -
+ + {isDeleting && ( + +
+ Delete? Widgets referencing it will lose their feed. + + +
+
+ )} + + {isEditing && editingData && ( + + +
+ + +
+
+ )}
- ))} -
- )} - - {/* Edit / Create Dialog */} - !o && setEditing(null)}> - - - - {editing && sources.some((s) => s.id === editing.id) - ? "Edit Data Source" - : "New Data Source"} - - - {editing && ( - - )} - - - - - - - - {/* Delete Confirmation */} - !o && setDeleting(null)} - > - - - Delete data source? - - This will permanently remove this data source. Widgets referencing - it will lose their feed. - - - - Cancel - - Delete - - - - + ) + })} +
) } @@ -251,11 +255,7 @@ function isSensitiveKey(key: string) { } function HeaderRow({ - headerKey, - headerValue, - onChangeKey, - onChangeValue, - onRemove, + headerKey, headerValue, onChangeKey, onChangeValue, onRemove, }: { headerKey: string headerValue: string @@ -268,12 +268,7 @@ function HeaderRow({ return (
- onChangeKey(e.target.value)} - placeholder="key" - className="flex-1" - /> + onChangeKey(e.target.value)} placeholder="key" className="flex-1" />
{sensitive && ( - )}
@@ -304,13 +290,28 @@ function HeaderRow({ ) } -function DataSourceForm({ - value, - onChange, -}: { - value: DataSource - onChange: (ds: DataSource) => void -}) { +function ClockPreview({ format, timezone }: { format: string; timezone: string }) { + const [preview, setPreview] = useState("") + + useEffect(() => { + setPreview(formatClockPreview(format, timezone)) + const id = setInterval(() => setPreview(formatClockPreview(format, timezone)), 1000) + return () => clearInterval(id) + }, [format, timezone]) + + const validTz = VALID_TIMEZONES.has(timezone) + + return ( +
+

{preview}

+ {timezone && !validTz && ( +

Unknown timezone

+ )} +
+ ) +} + +function DataSourceForm({ value, onChange }: { value: DataSource; onChange: (ds: DataSource) => void }) { const set = (k: K, v: DataSource[K]) => onChange({ ...value, [k]: v }) @@ -326,29 +327,18 @@ function DataSourceForm({ const isStaticText = value.config.type === "static_text" return ( -
+
- set("name", e.target.value)} - placeholder="e.g. weather" - /> + set("name", e.target.value)} placeholder="e.g. weather" />
- onSourceTypeChange(v as SourceType)}> + {SOURCE_TYPES.map((t) => ( - - {t} - + {t} ))} @@ -358,20 +348,11 @@ function DataSourceForm({ <>
- setConfig({ url: e.target.value || null })} - placeholder="https://..." - /> + setConfig({ url: e.target.value || null })} placeholder="https://..." />
- setConfig({ api_key: e.target.value || null })} - placeholder="Optional" - /> + setConfig({ api_key: e.target.value || null })} placeholder="Optional" />
)} @@ -380,55 +361,33 @@ function DataSourceForm({ <>
- setConfig({ format: e.target.value })} - placeholder="%H:%M:%S" - /> + setConfig({ format: e.target.value })} placeholder="%H:%M:%S" />
- setConfig({ timezone: e.target.value })} - placeholder="Europe/Warsaw" - /> + setConfig({ timezone: e.target.value })} placeholder="Europe/Warsaw" />
+ )} {isStaticText && (
- setConfig({ text: e.target.value })} - placeholder="Hello world" - /> + setConfig({ text: e.target.value })} placeholder="Hello world" />
)}
- set("poll_interval_secs", Number(e.target.value))} - min={1} - /> + set("poll_interval_secs", Number(e.target.value))} min={1} />
{isExternal && (
- @@ -448,9 +407,7 @@ function DataSourceForm({ next[i] = [k, newVal] setConfig({ headers: next }) }} - onRemove={() => - setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) }) - } + onRemove={() => setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })} /> ))}
diff --git a/spa/src/pages/layout-builder.tsx b/spa/src/pages/layout-builder.tsx index b1ec104..e8568cc 100644 --- a/spa/src/pages/layout-builder.tsx +++ b/spa/src/pages/layout-builder.tsx @@ -18,16 +18,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Badge } from "@/components/ui/badge" @@ -287,7 +277,15 @@ export function LayoutBuilderPage() { onAddContainer={(path, dir) => addChild(path, makeContainerChild(dir)) } - onRemove={() => setPendingDelete(selected)} + pendingDelete={pendingDelete} + onRequestDelete={() => setPendingDelete(selected)} + onConfirmDelete={() => { + if (pendingDelete !== null) { + removeChild(pendingDelete) + setPendingDelete(null) + } + }} + onCancelDelete={() => setPendingDelete(null)} onUpdateSizing={(sizing) => updateSizing(selected, sizing)} isRoot={selected.length === 0} widgets={widgets} @@ -296,7 +294,15 @@ export function LayoutBuilderPage() { setPendingDelete(selected)} + pendingDelete={pendingDelete} + onRequestDelete={() => setPendingDelete(selected)} + onConfirmDelete={() => { + if (pendingDelete !== null) { + removeChild(pendingDelete) + setPendingDelete(null) + } + }} + onCancelDelete={() => setPendingDelete(null)} onUpdateSizing={(sizing) => updateSizing(selected, sizing)} widgets={widgets} sizing={ @@ -317,39 +323,6 @@ export function LayoutBuilderPage() {
- !o && setPendingDelete(null)} - > - - - - {pendingDelete?.length === 0 - ? "Clear entire layout?" - : "Remove this node?"} - - - {pendingDelete?.length === 0 - ? "This will remove the entire layout tree. You can rebuild it afterward." - : "This will remove the selected node and all its children."} - - - - Cancel - { - if (pendingDelete !== null) { - removeChild(pendingDelete) - setPendingDelete(null) - } - }} - > - {pendingDelete?.length === 0 ? "Clear" : "Remove"} - - - - - {showPreview && root && theme && ( @@ -493,7 +466,10 @@ function ContainerProps({ onUpdateProp, onAddWidget, onAddContainer, - onRemove, + pendingDelete, + onRequestDelete, + onConfirmDelete, + onCancelDelete, onUpdateSizing, isRoot, widgets, @@ -503,7 +479,10 @@ function ContainerProps({ onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string) => void onAddWidget: (path: Path, widgetId: number) => void onAddContainer: (path: Path, direction: Direction) => void - onRemove: () => void + pendingDelete: Path | null + onRequestDelete: () => void + onConfirmDelete: () => void + onCancelDelete: () => void onUpdateSizing: (sizing: LayoutChild["sizing"]) => void isRoot: boolean widgets: { id: number; name: string }[] @@ -622,14 +601,37 @@ function ContainerProps({ )}
- + {pendingDelete !== null && + JSON.stringify(pendingDelete) === JSON.stringify(path) ? ( +
+

+ {isRoot + ? "Clear entire layout? You can rebuild afterward." + : "Remove this node and all its children?"} +

+
+ + +
+
+ ) : ( + + )}
) } @@ -637,14 +639,20 @@ function ContainerProps({ function LeafProps({ path, widgetId, - onRemove, + pendingDelete, + onRequestDelete, + onConfirmDelete, + onCancelDelete, onUpdateSizing, widgets, sizing, }: { path: Path widgetId: number - onRemove: () => void + pendingDelete: Path | null + onRequestDelete: () => void + onConfirmDelete: () => void + onCancelDelete: () => void onUpdateSizing: (sizing: LayoutChild["sizing"]) => void widgets: { id: number; name: string }[] sizing?: LayoutChild["sizing"] @@ -657,10 +665,31 @@ function LeafProps({

{w?.name ?? `#${widgetId}`}

{sizing && } - + {pendingDelete !== null && + JSON.stringify(pendingDelete) === JSON.stringify(path) ? ( +
+

+ Remove this widget? +

+
+ + +
+
+ ) : ( + + )}
) } diff --git a/spa/src/pages/presets.tsx b/spa/src/pages/presets.tsx index bb70883..b3d6d33 100644 --- a/spa/src/pages/presets.tsx +++ b/spa/src/pages/presets.tsx @@ -15,26 +15,8 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Save, Upload, Trash2 } from "lucide-react" +import { Save, Upload, Trash2, ChevronUp } from "lucide-react" import { toast } from "sonner" export function PresetsPage() { @@ -45,22 +27,22 @@ export function PresetsPage() { const loadPreset = useLoadPreset() const [saving, setSaving] = useState(false) - const [saveName, setSaveName] = useState("") - const [deleting, setDeleting] = useState(null) + const [presetName, setPresetName] = useState("") + const [confirmingDelete, setConfirmingDelete] = useState(null) async function saveAsPreset() { - if (!layout || !saveName) return + if (!layout || !presetName) return const nextId = presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1 try { await createPreset.mutateAsync({ id: nextId, - name: saveName, + name: presetName, layout, }) toast.success("Preset saved") setSaving(false) - setSaveName("") + setPresetName("") } catch (e) { toast.error(String(e)) } @@ -75,15 +57,14 @@ export function PresetsPage() { } } - async function confirmDelete() { - if (deleting == null) return + async function confirmDelete(id: number) { try { - await deletePreset.mutateAsync(deleting) + await deletePreset.mutateAsync(id) toast.success("Preset deleted") } catch (e) { toast.error(String(e)) } - setDeleting(null) + setConfirmingDelete(null) } function nodeCount(node: Preset["layout"]["root"]): number { @@ -104,12 +85,50 @@ export function PresetsPage() { Save and restore layout configurations

- + {saving && ( + + + Save Current Layout as Preset + + + setPresetName(e.target.value)} + placeholder="e.g. dashboard" + onKeyDown={(e) => e.key === "Enter" && saveAsPreset()} + autoFocus + /> + + + + + )} + {presets.length === 0 ? ( @@ -128,69 +147,47 @@ export function PresetsPage() {
- - + {confirmingDelete === p.id ? ( + <> + + Delete? + + + + + ) : ( + <> + + + + )}
))} )} - - !o && setSaving(false)}> - - - Save Current Layout as Preset - -
-
- - setSaveName(e.target.value)} - placeholder="e.g. dashboard" - /> -
-
- - - - -
-
- - !o && setDeleting(null)} - > - - - Delete preset? - - This will permanently remove this preset. - - - - Cancel - - Delete - - - - ) } diff --git a/spa/src/pages/widgets.tsx b/spa/src/pages/widgets.tsx index e9690b6..7ca7d14 100644 --- a/spa/src/pages/widgets.tsx +++ b/spa/src/pages/widgets.tsx @@ -16,13 +16,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { @@ -32,18 +25,8 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" -import { Plus, Pencil, Trash2, X, Eye } from "lucide-react" +import { Plus, Pencil, Trash2, X, Eye, ChevronUp } from "lucide-react" import { toast } from "sonner" const DISPLAY_HINT_KINDS: DisplayHintKind[] = ["icon_value", "text_block", "key_value"] @@ -64,52 +47,72 @@ export function WidgetsPage() { const update = useUpdateWidget() const del = useDeleteWidget() - const [editing, setEditing] = useState(null) - const [deleting, setDeleting] = useState(null) - const [previewing, setPreviewing] = useState(null) + const [editingId, setEditingId] = useState(null) + const [editingData, setEditingData] = useState(null) + const [newWidget, setNewWidget] = useState(null) + const [confirmingDelete, setConfirmingDelete] = useState(null) + const [previewingId, setPreviewingId] = useState(null) function openNew() { const nextId = widgets.length > 0 ? Math.max(...widgets.map((w) => w.id)) + 1 : 1 - setEditing({ - ...EMPTY, - id: nextId, - data_source_id: sources[0]?.id ?? 0, - }) + setNewWidget({ ...EMPTY, id: nextId, data_source_id: sources[0]?.id ?? 0 }) + setEditingId(null) } - async function save() { - if (!editing) return - const isNew = !widgets.some((w) => w.id === editing.id) + function startEdit(w: Widget) { + setEditingId(w.id) + setEditingData({ ...w }) + setNewWidget(null) + } + + function cancelEdit() { + setEditingId(null) + setEditingData(null) + setNewWidget(null) + } + + async function saveExisting() { + if (!editingData) return try { - if (isNew) { - await create.mutateAsync(editing) - toast.success("Widget created") - } else { - await update.mutateAsync(editing) - toast.success("Widget updated") - } - setEditing(null) + await update.mutateAsync(editingData) + toast.success("Widget updated") + setEditingId(null) + setEditingData(null) } catch (e) { toast.error(String(e)) } } - async function confirmDelete() { - if (deleting == null) return + async function saveNew() { + if (!newWidget) return try { - await del.mutateAsync(deleting) + await create.mutateAsync(newWidget) + toast.success("Widget created") + setNewWidget(null) + } catch (e) { + toast.error(String(e)) + } + } + + async function confirmDelete(id: number) { + try { + await del.mutateAsync(id) toast.success("Widget deleted") } catch (e) { toast.error(String(e)) } - setDeleting(null) + setConfirmingDelete(null) + } + + function togglePreview(id: number) { + setPreviewingId(previewingId === id ? null : id) } const sourceName = (id: number) => sources.find((s) => s.id === id)?.name ?? `#${id}` - if (isLoading) return
Loading…
+ if (isLoading) return
Loading...
return (
@@ -120,21 +123,42 @@ export function WidgetsPage() { Display primitives bound to data sources

- - {widgets.length === 0 ? ( - - -

No widgets configured yet.

-
-
- ) : ( -
- {widgets.map((w) => ( +
+ {newWidget && ( + + + New Widget + + + +
+ + +
+
+
+ )} + + {widgets.length === 0 && !newWidget && ( + + +

No widgets configured yet.

+
+
+ )} + + {widgets.map((w) => { + const isEditing = editingId === w.id + const isDeleting = confirmingDelete === w.id + const isPreviewing = previewingId === w.id + + return (
@@ -142,143 +166,65 @@ export function WidgetsPage() { {w.display_hint.kind} source: {sourceName(w.data_source_id)} - {w.mappings.length} mapping(s) + {w.mappings.length > 0 && {w.mappings.length} mapping(s)}
- - -
+ + {isDeleting && ( + +
+ Delete this widget? Layout references will become dangling. + + +
+
+ )} + + {isPreviewing && !isEditing && ( + + + + )} + + {isEditing && editingData && ( + + +
+ + +
+
+ )}
- ))} -
- )} - - !o && setEditing(null)}> - - - - {editing && widgets.some((w) => w.id === editing.id) - ? "Edit Widget" - : "New Widget"} - - - {editing && ( - - )} - - - - - - - - !o && setDeleting(null)} - > - - - Delete widget? - - This will permanently remove this widget. Layout references will - become dangling. - - - - Cancel - - Delete - - - - - - {previewing != null && ( - w.id === previewing)?.name ?? ""} - onClose={() => setPreviewing(null)} - /> - )} + ) + })} +
) } -function WidgetPreviewDialog({ - widgetId, - widgetName, - onClose, -}: { - widgetId: number - widgetName: string - onClose: () => void -}) { +function WidgetPreviewInline({ widgetId }: { widgetId: number }) { const { data, isLoading, isError } = useWidgetPreview(widgetId, true) + if (isLoading) return

Loading...

+ if (isError) return

No data yet

+ return ( - !o && onClose()}> - - - Preview: {widgetName} - -
- {isLoading && ( -

Loading…

- )} - {isError && ( -

- No data yet — widget hasn't been polled -

- )} - {data && ( -
-              {JSON.stringify(data, null, 2)}
-            
- )} -
- - - -
-
+
+      {JSON.stringify(data, null, 2)}
+    
) } @@ -312,7 +258,7 @@ function WidgetForm({ } return ( -
+
set("display_hint", { ...value.display_hint, kind: v as DisplayHintKind })} > - - - + {DISPLAY_HINT_KINDS.map((h) => ( - - {h} - + {h} ))} @@ -346,9 +288,7 @@ function WidgetForm({ value={value.display_hint.h_align} onValueChange={(v) => set("display_hint", { ...value.display_hint, h_align: v as HAlign })} > - - - + Left Center @@ -362,9 +302,7 @@ function WidgetForm({ value={value.display_hint.v_align} onValueChange={(v) => set("display_hint", { ...value.display_hint, v_align: v as VAlign })} > - - - + Top Middle @@ -379,14 +317,10 @@ function WidgetForm({ value={String(value.data_source_id)} onValueChange={(v) => set("data_source_id", Number(v))} > - - - + {sources.map((s) => ( - - {s.name} - + {s.name} ))} @@ -412,26 +346,18 @@ function WidgetForm({
- updateMapping(i, { ...m, source_path: e.target.value }) - } + onChange={(e) => updateMapping(i, { ...m, source_path: e.target.value })} placeholder="$.path.to.value" className="flex-1" /> - + - updateMapping(i, { ...m, target_key: e.target.value }) - } + onChange={(e) => updateMapping(i, { ...m, target_key: e.target.value })} placeholder="target_key" className="flex-1" /> -