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 { useMemo, useRef } from "react"
import type { LayoutNode, ThemeConfig, Widget } from "@/api/types" import type { LayoutNode, ThemeConfig, Widget } from "@/api/types"
import { computeLayout } from "@/lib/layout-engine" import { computeLayout } from "@/lib/layout-engine"
import { useWidgetPreview } from "@/api/widgets"
interface LayoutPreviewProps { interface LayoutPreviewProps {
layout: LayoutNode layout: LayoutNode
@@ -19,6 +20,91 @@ function collectWidgetIds(node: LayoutNode): number[] {
return (node.children ?? []).flatMap((c) => collectWidgetIds(c.node)) 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({ export function LayoutPreview({
layout, layout,
screenWidth, screenWidth,
@@ -54,11 +140,6 @@ export function LayoutPreview({
const box = bounds.get(wid) const box = bounds.get(wid)
if (!box) return null if (!box) return null
const w = widgets.find((w) => w.id === wid) 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 ( return (
<div <div
key={wid} key={wid}
@@ -70,35 +151,11 @@ export function LayoutPreview({
height: box.height * scale, height: box.height * scale,
border: `1px solid ${colorToCSS(theme.secondary)}`, border: `1px solid ${colorToCSS(theme.secondary)}`,
boxSizing: "border-box", boxSizing: "border-box",
display: "flex",
flexDirection: "column",
alignItems: flexAlign,
justifyContent: flexJustify,
overflow: "hidden", overflow: "hidden",
padding: 2 * scale, padding: 2 * scale,
}} }}
> >
<span <WidgetCell wid={wid} widget={w} scale={scale} theme={theme} />
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>
)}
</div> </div>
) )
})} })}

View File

@@ -1,4 +1,4 @@
import { useState } from "react" import { useState, useEffect } from "react"
import { import {
useDataSources, useDataSources,
useCreateDataSource, useCreateDataSource,
@@ -14,13 +14,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import {
@@ -30,28 +23,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } 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 { 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" import { toast } from "sonner"
const SOURCE_TYPES: SourceType[] = [ const SOURCE_TYPES: SourceType[] = [
"weather", "weather", "media", "rss", "http_json", "webhook", "clock", "static_text",
"media",
"rss",
"http_json",
"webhook",
"clock",
"static_text",
] ]
const EXTERNAL_TYPES: SourceType[] = ["weather", "media", "rss", "http_json", "webhook"] 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: [] }, 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() { export function DataSourcesPage() {
const { data: sources = [], isLoading } = useDataSources() const { data: sources = [], isLoading } = useDataSources()
const create = useCreateDataSource() const create = useCreateDataSource()
const update = useUpdateDataSource() const update = useUpdateDataSource()
const del = useDeleteDataSource() const del = useDeleteDataSource()
const [editing, setEditing] = useState<DataSource | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
const [deleting, setDeleting] = 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() { function openNew() {
const nextId = const nextId = sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1 setNewSource({ ...EMPTY, id: nextId })
setEditing({ ...EMPTY, id: nextId }) setEditingId(null)
} }
function openEdit(ds: DataSource) { function startEdit(ds: DataSource) {
setEditing({ ...ds }) setEditingId(ds.id)
setEditingData({ ...ds })
setNewSource(null)
} }
async function save() { function cancelEdit() {
if (!editing) return setEditingId(null)
const isNew = !sources.some((s) => s.id === editing.id) setEditingData(null)
setNewSource(null)
}
async function saveExisting() {
if (!editingData) return
try { try {
if (isNew) { await update.mutateAsync(editingData)
await create.mutateAsync(editing)
toast.success("Data source created")
} else {
await update.mutateAsync(editing)
toast.success("Data source updated") toast.success("Data source updated")
} setEditingId(null)
setEditing(null) setEditingData(null)
} catch (e) { } catch (e) {
toast.error(String(e)) toast.error(String(e))
} }
} }
async function confirmDelete() { async function saveNew() {
if (deleting == null) return if (!newSource) return
try { 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") toast.success("Data source deleted")
} catch (e) { } catch (e) {
toast.error(String(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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">Data Sources</h1>
Data Sources <p className="text-muted-foreground text-sm">Configure data feeds</p>
</h1>
<p className="text-muted-foreground text-sm">
Configure external data feeds
</p>
</div> </div>
<Button onClick={openNew}> <Button onClick={openNew} disabled={!!newSource}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Source Add Source
</Button> </Button>
</div> </div>
{sources.length === 0 ? ( <div className="grid gap-3">
<Card> {newSource && (
<CardContent className="py-12 text-center"> <Card className="border-primary">
<p className="text-muted-foreground"> <CardHeader className="py-3">
No data sources configured yet. <CardTitle className="text-base">New Data Source</CardTitle>
</p> </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> </CardContent>
</Card> </Card>
) : ( )}
<div className="grid gap-3">
{sources.map((ds) => ( {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}> <Card key={ds.id}>
<CardHeader className="flex flex-row items-center justify-between py-3"> <CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1"> <div className="space-y-1">
@@ -155,91 +207,43 @@ export function DataSourcesPage() {
<Badge variant="secondary">{ds.source_type}</Badge> <Badge variant="secondary">{ds.source_type}</Badge>
{ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>} {ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>}
{ds.config.type === "external" && ds.config.url && ( {ds.config.type === "external" && ds.config.url && (
<span className="text-muted-foreground max-w-xs truncate text-xs"> <span className="text-muted-foreground max-w-xs truncate text-xs">{ds.config.url}</span>
{ds.config.url}
</span>
)} )}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(ds)}>
variant="ghost" {isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
size="icon"
onClick={() => openEdit(ds)}
>
<Pencil className="h-4 w-4" />
</Button> </Button>
<Button <Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : ds.id)}>
variant="ghost"
size="icon"
onClick={() => setDeleting(ds.id)}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
</Card>
))} {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> </div>
</CardContent>
)} )}
{/* Edit / Create Dialog */} {isEditing && editingData && (
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}> <CardContent className="space-y-4 pt-0">
<DialogContent> <DataSourceForm value={editingData} onChange={setEditingData} />
<DialogHeader> <div className="flex gap-2 justify-end">
<DialogTitle> <Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
{editing && sources.some((s) => s.id === editing.id) <Button size="sm" onClick={saveExisting} disabled={!isValidSave(editingData)}>Save</Button>
? "Edit Data Source" </div>
: "New Data Source"} </CardContent>
</DialogTitle>
</DialogHeader>
{editing && (
<DataSourceForm value={editing} onChange={setEditing} />
)} )}
<DialogFooter> </Card>
<Button variant="outline" onClick={() => setEditing(null)}> )
Cancel })}
</Button> </div>
<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({ function HeaderRow({
headerKey, headerKey, headerValue, onChangeKey, onChangeValue, onRemove,
headerValue,
onChangeKey,
onChangeValue,
onRemove,
}: { }: {
headerKey: string headerKey: string
headerValue: string headerValue: string
@@ -268,12 +268,7 @@ function HeaderRow({
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input value={headerKey} onChange={(e) => onChangeKey(e.target.value)} placeholder="key" className="flex-1" />
value={headerKey}
onChange={(e) => onChangeKey(e.target.value)}
placeholder="key"
className="flex-1"
/>
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
type={sensitive && !visible ? "password" : "text"} type={sensitive && !visible ? "password" : "text"}
@@ -283,17 +278,8 @@ function HeaderRow({
className={sensitive ? "pr-9" : ""} className={sensitive ? "pr-9" : ""}
/> />
{sensitive && ( {sensitive && (
<Button <Button variant="ghost" size="icon" className="absolute top-0 right-0 h-full w-9" onClick={() => setVisible((v) => !v)}>
variant="ghost" {visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
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> </Button>
)} )}
</div> </div>
@@ -304,13 +290,28 @@ function HeaderRow({
) )
} }
function DataSourceForm({ function ClockPreview({ format, timezone }: { format: string; timezone: string }) {
value, const [preview, setPreview] = useState("")
onChange,
}: { useEffect(() => {
value: DataSource setPreview(formatClockPreview(format, timezone))
onChange: (ds: DataSource) => void 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]) => const set = <K extends keyof DataSource>(k: K, v: DataSource[K]) =>
onChange({ ...value, [k]: v }) onChange({ ...value, [k]: v })
@@ -326,29 +327,18 @@ function DataSourceForm({
const isStaticText = value.config.type === "static_text" const isStaticText = value.config.type === "static_text"
return ( return (
<div className="grid gap-4 py-2"> <div className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Name</Label> <Label>Name</Label>
<Input <Input value={value.name} onChange={(e) => set("name", e.target.value)} placeholder="e.g. weather" />
value={value.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. weather"
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Source Type</Label> <Label>Source Type</Label>
<Select <Select value={value.source_type} onValueChange={(v) => onSourceTypeChange(v as SourceType)}>
value={value.source_type} <SelectTrigger><SelectValue /></SelectTrigger>
onValueChange={(v) => onSourceTypeChange(v as SourceType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{SOURCE_TYPES.map((t) => ( {SOURCE_TYPES.map((t) => (
<SelectItem key={t} value={t}> <SelectItem key={t} value={t}>{t}</SelectItem>
{t}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -358,20 +348,11 @@ function DataSourceForm({
<> <>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>URL</Label> <Label>URL</Label>
<Input <Input value={value.config.url ?? ""} onChange={(e) => setConfig({ url: e.target.value || null })} placeholder="https://..." />
value={value.config.url ?? ""}
onChange={(e) => setConfig({ url: e.target.value || null })}
placeholder="https://..."
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>API Key</Label> <Label>API Key</Label>
<Input <Input type="password" value={value.config.api_key ?? ""} onChange={(e) => setConfig({ api_key: e.target.value || null })} placeholder="Optional" />
type="password"
value={value.config.api_key ?? ""}
onChange={(e) => setConfig({ api_key: e.target.value || null })}
placeholder="Optional"
/>
</div> </div>
</> </>
)} )}
@@ -380,55 +361,33 @@ function DataSourceForm({
<> <>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Format</Label> <Label>Format</Label>
<Input <Input value={value.config.format} onChange={(e) => setConfig({ format: e.target.value })} placeholder="%H:%M:%S" />
value={value.config.format}
onChange={(e) => setConfig({ format: e.target.value })}
placeholder="%H:%M:%S"
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Timezone</Label> <Label>Timezone</Label>
<Input <Input value={value.config.timezone} onChange={(e) => setConfig({ timezone: e.target.value })} placeholder="Europe/Warsaw" />
value={value.config.timezone}
onChange={(e) => setConfig({ timezone: e.target.value })}
placeholder="Europe/Warsaw"
/>
</div> </div>
<ClockPreview format={value.config.format} timezone={value.config.timezone} />
</> </>
)} )}
{isStaticText && ( {isStaticText && (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Text</Label> <Label>Text</Label>
<Input <Input value={value.config.text} onChange={(e) => setConfig({ text: e.target.value })} placeholder="Hello world" />
value={value.config.text}
onChange={(e) => setConfig({ text: e.target.value })}
placeholder="Hello world"
/>
</div> </div>
)} )}
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Poll Interval (seconds)</Label> <Label>Poll Interval (seconds)</Label>
<Input <Input type="number" value={value.poll_interval_secs} onChange={(e) => set("poll_interval_secs", Number(e.target.value))} min={1} />
type="number"
value={value.poll_interval_secs}
onChange={(e) => set("poll_interval_secs", Number(e.target.value))}
min={1}
/>
</div> </div>
{isExternal && ( {isExternal && (
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Headers</Label> <Label>Headers</Label>
<Button <Button variant="outline" size="sm" onClick={() => setConfig({ headers: [...value.config.headers, ["", ""]] })}>
variant="outline"
size="sm"
onClick={() =>
setConfig({ headers: [...value.config.headers, ["", ""]] })
}
>
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
Add Add
</Button> </Button>
@@ -448,9 +407,7 @@ function DataSourceForm({
next[i] = [k, newVal] next[i] = [k, newVal]
setConfig({ headers: next }) setConfig({ headers: next })
}} }}
onRemove={() => onRemove={() => setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })}
setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })
}
/> />
))} ))}
</div> </div>

View File

@@ -18,16 +18,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } 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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -287,7 +277,15 @@ export function LayoutBuilderPage() {
onAddContainer={(path, dir) => onAddContainer={(path, dir) =>
addChild(path, makeContainerChild(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)} onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
isRoot={selected.length === 0} isRoot={selected.length === 0}
widgets={widgets} widgets={widgets}
@@ -296,7 +294,15 @@ export function LayoutBuilderPage() {
<LeafProps <LeafProps
path={selected} path={selected}
widgetId={selectedNode?.widget_id ?? 0} 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)} onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
widgets={widgets} widgets={widgets}
sizing={ sizing={
@@ -317,39 +323,6 @@ export function LayoutBuilderPage() {
</Card> </Card>
</div> </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 && ( {showPreview && root && theme && (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -493,7 +466,10 @@ function ContainerProps({
onUpdateProp, onUpdateProp,
onAddWidget, onAddWidget,
onAddContainer, onAddContainer,
onRemove, pendingDelete,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onUpdateSizing, onUpdateSizing,
isRoot, isRoot,
widgets, widgets,
@@ -503,7 +479,10 @@ function ContainerProps({
onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string) => void onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string) => void
onAddWidget: (path: Path, widgetId: number) => void onAddWidget: (path: Path, widgetId: number) => void
onAddContainer: (path: Path, direction: Direction) => void onAddContainer: (path: Path, direction: Direction) => void
onRemove: () => void pendingDelete: Path | null
onRequestDelete: () => void
onConfirmDelete: () => void
onCancelDelete: () => void
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
isRoot: boolean isRoot: boolean
widgets: { id: number; name: string }[] widgets: { id: number; name: string }[]
@@ -622,14 +601,37 @@ function ContainerProps({
</Select> </Select>
)} )}
</div> </div>
{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 <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={onRemove} 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" /> <Trash2 className="mr-1 h-3 w-3" />
{isRoot ? "Clear Layout" : "Remove"} {isRoot ? "Clear Layout" : "Remove"}
</Button> </Button>
)}
</div> </div>
) )
} }
@@ -637,14 +639,20 @@ function ContainerProps({
function LeafProps({ function LeafProps({
path, path,
widgetId, widgetId,
onRemove, pendingDelete,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onUpdateSizing, onUpdateSizing,
widgets, widgets,
sizing, sizing,
}: { }: {
path: Path path: Path
widgetId: number widgetId: number
onRemove: () => void pendingDelete: Path | null
onRequestDelete: () => void
onConfirmDelete: () => void
onCancelDelete: () => void
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
widgets: { id: number; name: string }[] widgets: { id: number; name: string }[]
sizing?: LayoutChild["sizing"] sizing?: LayoutChild["sizing"]
@@ -657,10 +665,31 @@ function LeafProps({
<p className="text-sm">{w?.name ?? `#${widgetId}`}</p> <p className="text-sm">{w?.name ?? `#${widgetId}`}</p>
</div> </div>
{sizing && <SizingEditor path={path} onUpdate={onUpdateSizing} sizing={sizing} />} {sizing && <SizingEditor path={path} onUpdate={onUpdateSizing} sizing={sizing} />}
<Button variant="destructive" size="sm" onClick={onRemove}> {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" /> <Trash2 className="mr-1 h-3 w-3" />
Remove Remove
</Button> </Button>
)}
</div> </div>
) )
} }

View File

@@ -15,26 +15,8 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } 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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Save, Upload, Trash2, ChevronUp } from "lucide-react"
import { Save, Upload, Trash2 } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
export function PresetsPage() { export function PresetsPage() {
@@ -45,22 +27,22 @@ export function PresetsPage() {
const loadPreset = useLoadPreset() const loadPreset = useLoadPreset()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveName, setSaveName] = useState("") const [presetName, setPresetName] = useState("")
const [deleting, setDeleting] = useState<number | null>(null) const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
async function saveAsPreset() { async function saveAsPreset() {
if (!layout || !saveName) return if (!layout || !presetName) return
const nextId = const nextId =
presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1 presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1
try { try {
await createPreset.mutateAsync({ await createPreset.mutateAsync({
id: nextId, id: nextId,
name: saveName, name: presetName,
layout, layout,
}) })
toast.success("Preset saved") toast.success("Preset saved")
setSaving(false) setSaving(false)
setSaveName("") setPresetName("")
} catch (e) { } catch (e) {
toast.error(String(e)) toast.error(String(e))
} }
@@ -75,15 +57,14 @@ export function PresetsPage() {
} }
} }
async function confirmDelete() { async function confirmDelete(id: number) {
if (deleting == null) return
try { try {
await deletePreset.mutateAsync(deleting) await deletePreset.mutateAsync(id)
toast.success("Preset deleted") toast.success("Preset deleted")
} catch (e) { } catch (e) {
toast.error(String(e)) toast.error(String(e))
} }
setDeleting(null) setConfirmingDelete(null)
} }
function nodeCount(node: Preset["layout"]["root"]): number { function nodeCount(node: Preset["layout"]["root"]): number {
@@ -104,12 +85,50 @@ export function PresetsPage() {
Save and restore layout configurations Save and restore layout configurations
</p> </p>
</div> </div>
<Button onClick={() => setSaving(true)} disabled={!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" /> <Save className="mr-2 h-4 w-4" />
Save Current Layout )}
{saving ? "Close" : "Save Current Layout"}
</Button> </Button>
</div> </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 ? ( {presets.length === 0 ? (
<Card> <Card>
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
@@ -128,6 +147,28 @@ export function PresetsPage() {
</CardDescription> </CardDescription>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
{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)}> <Button variant="outline" size="sm" onClick={() => load(p.id)}>
<Upload className="mr-1 h-3 w-3" /> <Upload className="mr-1 h-3 w-3" />
Load Load
@@ -135,62 +176,18 @@ export function PresetsPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setDeleting(p.id)} onClick={() => setConfirmingDelete(p.id)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</>
)}
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
))} ))}
</div> </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> </div>
) )
} }

View File

@@ -16,13 +16,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import {
@@ -32,18 +25,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } 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 { 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" import { toast } from "sonner"
const DISPLAY_HINT_KINDS: DisplayHintKind[] = ["icon_value", "text_block", "key_value"] const DISPLAY_HINT_KINDS: DisplayHintKind[] = ["icon_value", "text_block", "key_value"]
@@ -64,52 +47,72 @@ export function WidgetsPage() {
const update = useUpdateWidget() const update = useUpdateWidget()
const del = useDeleteWidget() const del = useDeleteWidget()
const [editing, setEditing] = useState<Widget | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
const [deleting, setDeleting] = useState<number | null>(null) const [editingData, setEditingData] = useState<Widget | null>(null)
const [previewing, setPreviewing] = useState<number | 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() { function openNew() {
const nextId = const nextId =
widgets.length > 0 ? Math.max(...widgets.map((w) => w.id)) + 1 : 1 widgets.length > 0 ? Math.max(...widgets.map((w) => w.id)) + 1 : 1
setEditing({ setNewWidget({ ...EMPTY, id: nextId, data_source_id: sources[0]?.id ?? 0 })
...EMPTY, setEditingId(null)
id: nextId,
data_source_id: sources[0]?.id ?? 0,
})
} }
async function save() { function startEdit(w: Widget) {
if (!editing) return setEditingId(w.id)
const isNew = !widgets.some((w) => w.id === editing.id) setEditingData({ ...w })
try { setNewWidget(null)
if (isNew) {
await create.mutateAsync(editing)
toast.success("Widget created")
} else {
await update.mutateAsync(editing)
toast.success("Widget updated")
} }
setEditing(null)
function cancelEdit() {
setEditingId(null)
setEditingData(null)
setNewWidget(null)
}
async function saveExisting() {
if (!editingData) return
try {
await update.mutateAsync(editingData)
toast.success("Widget updated")
setEditingId(null)
setEditingData(null)
} catch (e) { } catch (e) {
toast.error(String(e)) toast.error(String(e))
} }
} }
async function confirmDelete() { async function saveNew() {
if (deleting == null) return if (!newWidget) return
try { 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") toast.success("Widget deleted")
} catch (e) { } catch (e) {
toast.error(String(e)) toast.error(String(e))
} }
setDeleting(null) setConfirmingDelete(null)
}
function togglePreview(id: number) {
setPreviewingId(previewingId === id ? null : id)
} }
const sourceName = (id: number) => const sourceName = (id: number) =>
sources.find((s) => s.id === id)?.name ?? `#${id}` 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -120,21 +123,42 @@ export function WidgetsPage() {
Display primitives bound to data sources Display primitives bound to data sources
</p> </p>
</div> </div>
<Button onClick={openNew}> <Button onClick={openNew} disabled={!!newWidget}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Widget Add Widget
</Button> </Button>
</div> </div>
{widgets.length === 0 ? ( <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> <Card>
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<p className="text-muted-foreground">No widgets configured yet.</p> <p className="text-muted-foreground">No widgets configured yet.</p>
</CardContent> </CardContent>
</Card> </Card>
) : ( )}
<div className="grid gap-3">
{widgets.map((w) => ( {widgets.map((w) => {
const isEditing = editingId === w.id
const isDeleting = confirmingDelete === w.id
const isPreviewing = previewingId === w.id
return (
<Card key={w.id}> <Card key={w.id}>
<CardHeader className="flex flex-row items-center justify-between py-3"> <CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1"> <div className="space-y-1">
@@ -142,143 +166,65 @@ export function WidgetsPage() {
<CardDescription className="flex items-center gap-2"> <CardDescription className="flex items-center gap-2">
<Badge variant="secondary">{w.display_hint.kind}</Badge> <Badge variant="secondary">{w.display_hint.kind}</Badge>
<span>source: {sourceName(w.data_source_id)}</span> <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> </CardDescription>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button variant="ghost" size="icon" onClick={() => togglePreview(w.id)} title="Preview data">
variant="ghost" {isPreviewing ? <ChevronUp className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
size="icon"
onClick={() => setPreviewing(w.id)}
title="Preview current data"
>
<Eye className="h-4 w-4" />
</Button> </Button>
<Button <Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(w)}>
variant="ghost" {isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
size="icon"
onClick={() => setEditing({ ...w })}
>
<Pencil className="h-4 w-4" />
</Button> </Button>
<Button <Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : w.id)}>
variant="ghost"
size="icon"
onClick={() => setDeleting(w.id)}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
</Card>
))} {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> </div>
</CardContent>
)} )}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}> {isPreviewing && !isEditing && (
<DialogContent className="max-w-lg"> <CardContent className="pt-0">
<DialogHeader> <WidgetPreviewInline widgetId={w.id} />
<DialogTitle> </CardContent>
{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 {isEditing && editingData && (
open={deleting != null} <CardContent className="space-y-4 pt-0">
onOpenChange={(o) => !o && setDeleting(null)} <WidgetForm value={editingData} onChange={setEditingData} sources={sources} />
> <div className="flex gap-2 justify-end">
<AlertDialogContent> <Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<AlertDialogHeader> <Button size="sm" onClick={saveExisting} disabled={!editingData.name || !editingData.data_source_id}>Save</Button>
<AlertDialogTitle>Delete widget?</AlertDialogTitle> </div>
<AlertDialogDescription> </CardContent>
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)}
/>
)} )}
</Card>
)
})}
</div>
</div> </div>
) )
} }
function WidgetPreviewDialog({ function WidgetPreviewInline({ widgetId }: { widgetId: number }) {
widgetId,
widgetName,
onClose,
}: {
widgetId: number
widgetName: string
onClose: () => void
}) {
const { data, isLoading, isError } = useWidgetPreview(widgetId, true) 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 ( 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"> <pre className="bg-muted rounded-md p-3 text-xs">
{JSON.stringify(data, null, 2)} {JSON.stringify(data, null, 2)}
</pre> </pre>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) )
} }
@@ -312,7 +258,7 @@ function WidgetForm({
} }
return ( return (
<div className="grid gap-4 py-2"> <div className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Name</Label> <Label>Name</Label>
<Input <Input
@@ -327,14 +273,10 @@ function WidgetForm({
value={value.display_hint.kind} value={value.display_hint.kind}
onValueChange={(v) => set("display_hint", { ...value.display_hint, kind: v as DisplayHintKind })} onValueChange={(v) => set("display_hint", { ...value.display_hint, kind: v as DisplayHintKind })}
> >
<SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{DISPLAY_HINT_KINDS.map((h) => ( {DISPLAY_HINT_KINDS.map((h) => (
<SelectItem key={h} value={h}> <SelectItem key={h} value={h}>{h}</SelectItem>
{h}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -346,9 +288,7 @@ function WidgetForm({
value={value.display_hint.h_align} value={value.display_hint.h_align}
onValueChange={(v) => set("display_hint", { ...value.display_hint, h_align: v as HAlign })} onValueChange={(v) => set("display_hint", { ...value.display_hint, h_align: v as HAlign })}
> >
<SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="left">Left</SelectItem> <SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem> <SelectItem value="center">Center</SelectItem>
@@ -362,9 +302,7 @@ function WidgetForm({
value={value.display_hint.v_align} value={value.display_hint.v_align}
onValueChange={(v) => set("display_hint", { ...value.display_hint, v_align: v as VAlign })} onValueChange={(v) => set("display_hint", { ...value.display_hint, v_align: v as VAlign })}
> >
<SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="top">Top</SelectItem> <SelectItem value="top">Top</SelectItem>
<SelectItem value="middle">Middle</SelectItem> <SelectItem value="middle">Middle</SelectItem>
@@ -379,14 +317,10 @@ function WidgetForm({
value={String(value.data_source_id)} value={String(value.data_source_id)}
onValueChange={(v) => set("data_source_id", Number(v))} onValueChange={(v) => set("data_source_id", Number(v))}
> >
<SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{sources.map((s) => ( {sources.map((s) => (
<SelectItem key={s.id} value={String(s.id)}> <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
{s.name}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -412,26 +346,18 @@ function WidgetForm({
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<Input <Input
value={m.source_path} value={m.source_path}
onChange={(e) => onChange={(e) => updateMapping(i, { ...m, source_path: e.target.value })}
updateMapping(i, { ...m, source_path: e.target.value })
}
placeholder="$.path.to.value" placeholder="$.path.to.value"
className="flex-1" className="flex-1"
/> />
<span className="text-muted-foreground text-sm"></span> <span className="text-muted-foreground text-sm">&rarr;</span>
<Input <Input
value={m.target_key} value={m.target_key}
onChange={(e) => onChange={(e) => updateMapping(i, { ...m, target_key: e.target.value })}
updateMapping(i, { ...m, target_key: e.target.value })
}
placeholder="target_key" placeholder="target_key"
className="flex-1" className="flex-1"
/> />
<Button <Button variant="ghost" size="icon" onClick={() => removeMapping(i)}>
variant="ghost"
size="icon"
onClick={() => removeMapping(i)}
>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
</div> </div>