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.
This commit is contained in:
2026-06-19 13:08:00 +02:00
parent 13497dd53c
commit b964801765
5 changed files with 577 additions and 611 deletions

View File

@@ -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<string, unknown> | 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<string, unknown> | undefined)
const hasData = previewText.length > 0
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: flexAlign,
justifyContent: flexJustify,
width: "100%",
height: "100%",
}}
>
{hasData ? (
<span
style={{
fontSize: 8 * scale,
color: colorToCSS(theme.text),
textAlign,
lineHeight: 1.3,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{previewText}
</span>
) : (
<>
<span
style={{
fontSize: 10 * scale,
color: colorToCSS(theme.text),
textAlign,
lineHeight: 1.2,
opacity: 0.5,
}}
>
{widget?.name ?? `#${wid}`}
</span>
{widget && (
<span style={{ fontSize: 7 * scale, color: colorToCSS(theme.accent), textAlign, opacity: 0.5 }}>
{kind}
</span>
)}
</>
)}
</div>
)
}
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 (
<div
key={wid}
@@ -70,35 +151,11 @@ export function LayoutPreview({
height: box.height * scale,
border: `1px solid ${colorToCSS(theme.secondary)}`,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
alignItems: flexAlign,
justifyContent: flexJustify,
overflow: "hidden",
padding: 2 * scale,
}}
>
<span
style={{
fontSize: 10 * scale,
color: colorToCSS(theme.text),
textAlign,
lineHeight: 1.2,
}}
>
{w?.name ?? `#${wid}`}
</span>
{w && (
<span
style={{
fontSize: 8 * scale,
color: colorToCSS(theme.accent),
textAlign,
}}
>
{w.display_hint.kind}
</span>
)}
<WidgetCell wid={wid} widget={w} scale={scale} theme={theme} />
</div>
)
})}

View File

@@ -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, (d: Date) => 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<DataSource | null>(null)
const [deleting, setDeleting] = useState<number | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [editingData, setEditingData] = useState<DataSource | null>(null)
const [newSource, setNewSource] = useState<DataSource | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(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 <div className="text-muted-foreground p-4">Loading</div>
if (isLoading) return <div className="text-muted-foreground p-4">Loading...</div>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Data Sources
</h1>
<p className="text-muted-foreground text-sm">
Configure external data feeds
</p>
<h1 className="text-2xl font-semibold tracking-tight">Data Sources</h1>
<p className="text-muted-foreground text-sm">Configure data feeds</p>
</div>
<Button onClick={openNew}>
<Button onClick={openNew} disabled={!!newSource}>
<Plus className="mr-2 h-4 w-4" />
Add Source
</Button>
</div>
{sources.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">
No data sources configured yet.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{sources.map((ds) => (
<div className="grid gap-3">
{newSource && (
<Card className="border-primary">
<CardHeader className="py-3">
<CardTitle className="text-base">New Data Source</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DataSourceForm value={newSource} onChange={setNewSource} />
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" onClick={saveNew} disabled={!isValidSave(newSource)}>Save</Button>
</div>
</CardContent>
</Card>
)}
{sources.length === 0 && !newSource && (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No data sources configured yet.</p>
</CardContent>
</Card>
)}
{sources.map((ds) => {
const isEditing = editingId === ds.id
const isDeleting = confirmingDelete === ds.id
return (
<Card key={ds.id}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1">
@@ -155,91 +207,43 @@ export function DataSourcesPage() {
<Badge variant="secondary">{ds.source_type}</Badge>
{ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>}
{ds.config.type === "external" && ds.config.url && (
<span className="text-muted-foreground max-w-xs truncate text-xs">
{ds.config.url}
</span>
<span className="text-muted-foreground max-w-xs truncate text-xs">{ds.config.url}</span>
)}
</CardDescription>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(ds)}
>
<Pencil className="h-4 w-4" />
<Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(ds)}>
{isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(ds.id)}
>
<Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : ds.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
{isDeleting && (
<CardContent className="pt-0">
<div className="flex gap-2 items-center p-2 bg-destructive/10 rounded">
<span className="text-sm flex-1">Delete? Widgets referencing it will lose their feed.</span>
<Button size="sm" variant="destructive" onClick={() => confirmDelete(ds.id)}>Delete</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmingDelete(null)}>Cancel</Button>
</div>
</CardContent>
)}
{isEditing && editingData && (
<CardContent className="space-y-4 pt-0">
<DataSourceForm value={editingData} onChange={setEditingData} />
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" onClick={saveExisting} disabled={!isValidSave(editingData)}>Save</Button>
</div>
</CardContent>
)}
</Card>
))}
</div>
)}
{/* Edit / Create Dialog */}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editing && sources.some((s) => s.id === editing.id)
? "Edit Data Source"
: "New Data Source"}
</DialogTitle>
</DialogHeader>
{editing && (
<DataSourceForm value={editing} onChange={setEditing} />
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button
onClick={save}
disabled={
!editing?.name ||
(EXTERNAL_TYPES.includes(editing.source_type) &&
editing.source_type !== "webhook" &&
editing.poll_interval_secs <= 0) ||
(EXTERNAL_TYPES.includes(editing.source_type) &&
editing.source_type !== "webhook" &&
editing.config.type === "external" &&
!editing.config.url)
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog
open={deleting != null}
onOpenChange={(o) => !o && setDeleting(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete data source?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this data source. Widgets referencing
it will lose their feed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
})}
</div>
</div>
)
}
@@ -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 (
<div className="flex items-center gap-2">
<Input
value={headerKey}
onChange={(e) => onChangeKey(e.target.value)}
placeholder="key"
className="flex-1"
/>
<Input value={headerKey} onChange={(e) => onChangeKey(e.target.value)} placeholder="key" className="flex-1" />
<div className="relative flex-1">
<Input
type={sensitive && !visible ? "password" : "text"}
@@ -283,17 +278,8 @@ function HeaderRow({
className={sensitive ? "pr-9" : ""}
/>
{sensitive && (
<Button
variant="ghost"
size="icon"
className="absolute top-0 right-0 h-full w-9"
onClick={() => setVisible((v) => !v)}
>
{visible ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
<Button variant="ghost" size="icon" className="absolute top-0 right-0 h-full w-9" onClick={() => setVisible((v) => !v)}>
{visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
)}
</div>
@@ -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 (
<div className="space-y-1">
<p className="text-muted-foreground text-sm font-mono">{preview}</p>
{timezone && !validTz && (
<p className="text-destructive text-xs">Unknown timezone</p>
)}
</div>
)
}
function DataSourceForm({ value, onChange }: { value: DataSource; onChange: (ds: DataSource) => void }) {
const set = <K extends keyof DataSource>(k: K, v: DataSource[K]) =>
onChange({ ...value, [k]: v })
@@ -326,29 +327,18 @@ function DataSourceForm({
const isStaticText = value.config.type === "static_text"
return (
<div className="grid gap-4 py-2">
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Name</Label>
<Input
value={value.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. weather"
/>
<Input value={value.name} onChange={(e) => set("name", e.target.value)} placeholder="e.g. weather" />
</div>
<div className="grid gap-2">
<Label>Source Type</Label>
<Select
value={value.source_type}
onValueChange={(v) => onSourceTypeChange(v as SourceType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<Select value={value.source_type} onValueChange={(v) => onSourceTypeChange(v as SourceType)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SOURCE_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
@@ -358,20 +348,11 @@ function DataSourceForm({
<>
<div className="grid gap-2">
<Label>URL</Label>
<Input
value={value.config.url ?? ""}
onChange={(e) => setConfig({ url: e.target.value || null })}
placeholder="https://..."
/>
<Input value={value.config.url ?? ""} onChange={(e) => setConfig({ url: e.target.value || null })} placeholder="https://..." />
</div>
<div className="grid gap-2">
<Label>API Key</Label>
<Input
type="password"
value={value.config.api_key ?? ""}
onChange={(e) => setConfig({ api_key: e.target.value || null })}
placeholder="Optional"
/>
<Input type="password" value={value.config.api_key ?? ""} onChange={(e) => setConfig({ api_key: e.target.value || null })} placeholder="Optional" />
</div>
</>
)}
@@ -380,55 +361,33 @@ function DataSourceForm({
<>
<div className="grid gap-2">
<Label>Format</Label>
<Input
value={value.config.format}
onChange={(e) => setConfig({ format: e.target.value })}
placeholder="%H:%M:%S"
/>
<Input value={value.config.format} onChange={(e) => setConfig({ format: e.target.value })} placeholder="%H:%M:%S" />
</div>
<div className="grid gap-2">
<Label>Timezone</Label>
<Input
value={value.config.timezone}
onChange={(e) => setConfig({ timezone: e.target.value })}
placeholder="Europe/Warsaw"
/>
<Input value={value.config.timezone} onChange={(e) => setConfig({ timezone: e.target.value })} placeholder="Europe/Warsaw" />
</div>
<ClockPreview format={value.config.format} timezone={value.config.timezone} />
</>
)}
{isStaticText && (
<div className="grid gap-2">
<Label>Text</Label>
<Input
value={value.config.text}
onChange={(e) => setConfig({ text: e.target.value })}
placeholder="Hello world"
/>
<Input value={value.config.text} onChange={(e) => setConfig({ text: e.target.value })} placeholder="Hello world" />
</div>
)}
<div className="grid gap-2">
<Label>Poll Interval (seconds)</Label>
<Input
type="number"
value={value.poll_interval_secs}
onChange={(e) => set("poll_interval_secs", Number(e.target.value))}
min={1}
/>
<Input type="number" value={value.poll_interval_secs} onChange={(e) => set("poll_interval_secs", Number(e.target.value))} min={1} />
</div>
{isExternal && (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Headers</Label>
<Button
variant="outline"
size="sm"
onClick={() =>
setConfig({ headers: [...value.config.headers, ["", ""]] })
}
>
<Button variant="outline" size="sm" onClick={() => setConfig({ headers: [...value.config.headers, ["", ""]] })}>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
@@ -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) })}
/>
))}
</div>

View File

@@ -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() {
<LeafProps
path={selected}
widgetId={selectedNode?.widget_id ?? 0}
onRemove={() => 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() {
</Card>
</div>
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(o) => !o && setPendingDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingDelete?.length === 0
? "Clear entire layout?"
: "Remove this node?"}
</AlertDialogTitle>
<AlertDialogDescription>
{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."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (pendingDelete !== null) {
removeChild(pendingDelete)
setPendingDelete(null)
}
}}
>
{pendingDelete?.length === 0 ? "Clear" : "Remove"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{showPreview && root && theme && (
<Card>
<CardHeader className="pb-3">
@@ -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({
</Select>
)}
</div>
<Button
variant="destructive"
size="sm"
onClick={onRemove}
>
<Trash2 className="mr-1 h-3 w-3" />
{isRoot ? "Clear Layout" : "Remove"}
</Button>
{pendingDelete !== null &&
JSON.stringify(pendingDelete) === JSON.stringify(path) ? (
<div className="grid gap-2">
<p className="text-sm text-destructive">
{isRoot
? "Clear entire layout? You can rebuild afterward."
: "Remove this node and all its children?"}
</p>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={onConfirmDelete}
>
{isRoot ? "Clear" : "Remove"}
</Button>
<Button variant="outline" size="sm" onClick={onCancelDelete}>
Cancel
</Button>
</div>
</div>
) : (
<Button
variant="destructive"
size="sm"
onClick={onRequestDelete}
>
<Trash2 className="mr-1 h-3 w-3" />
{isRoot ? "Clear Layout" : "Remove"}
</Button>
)}
</div>
)
}
@@ -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({
<p className="text-sm">{w?.name ?? `#${widgetId}`}</p>
</div>
{sizing && <SizingEditor path={path} onUpdate={onUpdateSizing} sizing={sizing} />}
<Button variant="destructive" size="sm" onClick={onRemove}>
<Trash2 className="mr-1 h-3 w-3" />
Remove
</Button>
{pendingDelete !== null &&
JSON.stringify(pendingDelete) === JSON.stringify(path) ? (
<div className="grid gap-2">
<p className="text-sm text-destructive">
Remove this widget?
</p>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={onConfirmDelete}
>
Remove
</Button>
<Button variant="outline" size="sm" onClick={onCancelDelete}>
Cancel
</Button>
</div>
</div>
) : (
<Button variant="destructive" size="sm" onClick={onRequestDelete}>
<Trash2 className="mr-1 h-3 w-3" />
Remove
</Button>
)}
</div>
)
}

View File

@@ -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<number | null>(null)
const [presetName, setPresetName] = useState("")
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(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
</p>
</div>
<Button onClick={() => setSaving(true)} disabled={!layout}>
<Save className="mr-2 h-4 w-4" />
Save Current Layout
<Button
onClick={() => setSaving((v) => !v)}
disabled={!layout}
variant={saving ? "secondary" : "default"}
>
{saving ? (
<ChevronUp className="mr-2 h-4 w-4" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{saving ? "Close" : "Save Current Layout"}
</Button>
</div>
{saving && (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-base">Save Current Layout as Preset</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2 pb-3">
<Input
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="e.g. dashboard"
onKeyDown={(e) => e.key === "Enter" && saveAsPreset()}
autoFocus
/>
<Button onClick={saveAsPreset} disabled={!presetName} size="sm">
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setSaving(false)
setPresetName("")
}}
>
Cancel
</Button>
</CardContent>
</Card>
)}
{presets.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
@@ -128,69 +147,47 @@ export function PresetsPage() {
</CardDescription>
</div>
<div className="flex gap-1">
<Button variant="outline" size="sm" onClick={() => load(p.id)}>
<Upload className="mr-1 h-3 w-3" />
Load
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(p.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
{confirmingDelete === p.id ? (
<>
<span className="text-sm text-muted-foreground self-center mr-1">
Delete?
</span>
<Button
variant="destructive"
size="sm"
onClick={() => confirmDelete(p.id)}
>
Delete
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmingDelete(null)}
>
Cancel
</Button>
</>
) : (
<>
<Button variant="outline" size="sm" onClick={() => load(p.id)}>
<Upload className="mr-1 h-3 w-3" />
Load
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setConfirmingDelete(p.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</CardHeader>
</Card>
))}
</div>
)}
<Dialog open={saving} onOpenChange={(o) => !o && setSaving(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save Current Layout as Preset</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>Preset Name</Label>
<Input
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="e.g. dashboard"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaving(false)}>
Cancel
</Button>
<Button onClick={saveAsPreset} disabled={!saveName}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={deleting != null}
onOpenChange={(o) => !o && setDeleting(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete preset?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this preset.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -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<Widget | null>(null)
const [deleting, setDeleting] = useState<number | null>(null)
const [previewing, setPreviewing] = useState<number | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [editingData, setEditingData] = useState<Widget | null>(null)
const [newWidget, setNewWidget] = useState<Widget | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
const [previewingId, setPreviewingId] = useState<number | null>(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 <div className="text-muted-foreground p-4">Loading</div>
if (isLoading) return <div className="text-muted-foreground p-4">Loading...</div>
return (
<div className="space-y-6">
@@ -120,21 +123,42 @@ export function WidgetsPage() {
Display primitives bound to data sources
</p>
</div>
<Button onClick={openNew}>
<Button onClick={openNew} disabled={!!newWidget}>
<Plus className="mr-2 h-4 w-4" />
Add Widget
</Button>
</div>
{widgets.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No widgets configured yet.</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{widgets.map((w) => (
<div className="grid gap-3">
{newWidget && (
<Card className="border-primary">
<CardHeader className="py-3">
<CardTitle className="text-base">New Widget</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<WidgetForm value={newWidget} onChange={setNewWidget} sources={sources} />
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" onClick={saveNew} disabled={!newWidget.name || !newWidget.data_source_id}>Save</Button>
</div>
</CardContent>
</Card>
)}
{widgets.length === 0 && !newWidget && (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No widgets configured yet.</p>
</CardContent>
</Card>
)}
{widgets.map((w) => {
const isEditing = editingId === w.id
const isDeleting = confirmingDelete === w.id
const isPreviewing = previewingId === w.id
return (
<Card key={w.id}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1">
@@ -142,143 +166,65 @@ export function WidgetsPage() {
<CardDescription className="flex items-center gap-2">
<Badge variant="secondary">{w.display_hint.kind}</Badge>
<span>source: {sourceName(w.data_source_id)}</span>
<span>{w.mappings.length} mapping(s)</span>
{w.mappings.length > 0 && <span>{w.mappings.length} mapping(s)</span>}
</CardDescription>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setPreviewing(w.id)}
title="Preview current data"
>
<Eye className="h-4 w-4" />
<Button variant="ghost" size="icon" onClick={() => togglePreview(w.id)} title="Preview data">
{isPreviewing ? <ChevronUp className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditing({ ...w })}
>
<Pencil className="h-4 w-4" />
<Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(w)}>
{isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(w.id)}
>
<Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : w.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
{isDeleting && (
<CardContent className="pt-0">
<div className="flex gap-2 items-center p-2 bg-destructive/10 rounded">
<span className="text-sm flex-1">Delete this widget? Layout references will become dangling.</span>
<Button size="sm" variant="destructive" onClick={() => confirmDelete(w.id)}>Delete</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmingDelete(null)}>Cancel</Button>
</div>
</CardContent>
)}
{isPreviewing && !isEditing && (
<CardContent className="pt-0">
<WidgetPreviewInline widgetId={w.id} />
</CardContent>
)}
{isEditing && editingData && (
<CardContent className="space-y-4 pt-0">
<WidgetForm value={editingData} onChange={setEditingData} sources={sources} />
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" onClick={saveExisting} disabled={!editingData.name || !editingData.data_source_id}>Save</Button>
</div>
</CardContent>
)}
</Card>
))}
</div>
)}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{editing && widgets.some((w) => w.id === editing.id)
? "Edit Widget"
: "New Widget"}
</DialogTitle>
</DialogHeader>
{editing && (
<WidgetForm
value={editing}
onChange={setEditing}
sources={sources}
/>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button
onClick={save}
disabled={
!editing?.name ||
!editing.data_source_id
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={deleting != null}
onOpenChange={(o) => !o && setDeleting(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete widget?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this widget. Layout references will
become dangling.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{previewing != null && (
<WidgetPreviewDialog
widgetId={previewing}
widgetName={widgets.find((w) => w.id === previewing)?.name ?? ""}
onClose={() => setPreviewing(null)}
/>
)}
)
})}
</div>
</div>
)
}
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 <p className="text-muted-foreground text-sm">Loading...</p>
if (isError) return <p className="text-muted-foreground text-sm">No data yet</p>
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Preview: {widgetName}</DialogTitle>
</DialogHeader>
<div className="py-2">
{isLoading && (
<p className="text-muted-foreground text-sm">Loading</p>
)}
{isError && (
<p className="text-muted-foreground text-sm">
No data yet widget hasn't been polled
</p>
)}
{data && (
<pre className="bg-muted rounded-md p-3 text-xs">
{JSON.stringify(data, null, 2)}
</pre>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<pre className="bg-muted rounded-md p-3 text-xs">
{JSON.stringify(data, null, 2)}
</pre>
)
}
@@ -312,7 +258,7 @@ function WidgetForm({
}
return (
<div className="grid gap-4 py-2">
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Name</Label>
<Input
@@ -327,14 +273,10 @@ function WidgetForm({
value={value.display_hint.kind}
onValueChange={(v) => set("display_hint", { ...value.display_hint, kind: v as DisplayHintKind })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{DISPLAY_HINT_KINDS.map((h) => (
<SelectItem key={h} value={h}>
{h}
</SelectItem>
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
@@ -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 })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
@@ -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 })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="top">Top</SelectItem>
<SelectItem value="middle">Middle</SelectItem>
@@ -379,14 +317,10 @@ function WidgetForm({
value={String(value.data_source_id)}
onValueChange={(v) => set("data_source_id", Number(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{sources.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
@@ -412,26 +346,18 @@ function WidgetForm({
<div key={i} className="flex items-center gap-2">
<Input
value={m.source_path}
onChange={(e) =>
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"
/>
<span className="text-muted-foreground text-sm"></span>
<span className="text-muted-foreground text-sm">&rarr;</span>
<Input
value={m.target_key}
onChange={(e) =>
updateMapping(i, { ...m, target_key: e.target.value })
}
onChange={(e) => updateMapping(i, { ...m, target_key: e.target.value })}
placeholder="target_key"
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeMapping(i)}
>
<Button variant="ghost" size="icon" onClick={() => removeMapping(i)}>
<X className="h-3 w-3" />
</Button>
</div>