add SPA config UI, wire media/rss adapters, event-driven layout push

- React SPA: dashboard, data sources CRUD, widgets CRUD, layout builder,
  presets. TanStack Router + Query, shadcn/ui, Vite proxy to :3000
- wire media + rss adapters into polling loop, remove xtb source type
- media adapter: read username/password from headers, proper subsonic auth
- event handler: subscribe to LayoutChanged, push screen update to clients
- fix clippy warnings across workspace (Default impls, collapsible ifs,
  redundant closures, is_none_or, unused imports)
This commit is contained in:
2026-06-19 00:12:42 +02:00
parent 21c08911df
commit 26ebfad3a2
175 changed files with 12338 additions and 801 deletions

View File

@@ -0,0 +1,75 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { useDataSources } from "@/api/data-sources"
import { useWidgets } from "@/api/widgets"
import { useLayout } from "@/api/layout"
import { usePresets } from "@/api/presets"
import { Activity, Box, Layers, Database } from "lucide-react"
function countNodes(node: { children?: { node: unknown }[] }): number {
if (!node.children) return 1
return 1 + node.children.reduce((sum, c) => sum + countNodes(c.node as typeof node), 0)
}
export function DashboardPage() {
const sources = useDataSources()
const widgets = useWidgets()
const layout = useLayout()
const presets = usePresets()
const stats = [
{
label: "Data Sources",
value: sources.data?.length ?? "—",
icon: Database,
desc: "Configured feeds",
},
{
label: "Widgets",
value: widgets.data?.length ?? "—",
icon: Box,
desc: "Display primitives",
},
{
label: "Layout Nodes",
value: layout.data?.root ? countNodes(layout.data.root) : "—",
icon: Layers,
desc: "In active layout",
},
{
label: "Presets",
value: presets.data?.length ?? "—",
icon: Activity,
desc: "Saved layouts",
},
]
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground text-sm">K-Frame system overview</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => (
<Card key={s.label}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{s.label}</CardTitle>
<s.icon className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{s.value}</div>
<CardDescription>{s.desc}</CardDescription>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,388 @@
import { useState } from "react"
import {
useDataSources,
useCreateDataSource,
useUpdateDataSource,
useDeleteDataSource,
} from "@/api/data-sources"
import type { DataSource, SourceType } from "@/api/types"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
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 {
Select,
SelectContent,
SelectItem,
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 { toast } from "sonner"
const SOURCE_TYPES: SourceType[] = [
"weather",
"media",
"rss",
"http_json",
"webhook",
]
const EMPTY: DataSource = {
id: 0,
name: "",
source_type: "http_json",
poll_interval_secs: 300,
url: null,
api_key: null,
headers: [],
}
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)
function openNew() {
const nextId =
sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
setEditing({ ...EMPTY, id: nextId })
}
function openEdit(ds: DataSource) {
setEditing({ ...ds })
}
async function save() {
if (!editing) return
const isNew = !sources.some((s) => s.id === editing.id)
try {
if (isNew) {
await create.mutateAsync(editing)
toast.success("Data source created")
} else {
await update.mutateAsync(editing)
toast.success("Data source updated")
}
setEditing(null)
} catch (e) {
toast.error(String(e))
}
}
async function confirmDelete() {
if (deleting == null) return
try {
await del.mutateAsync(deleting)
toast.success("Data source deleted")
} catch (e) {
toast.error(String(e))
}
setDeleting(null)
}
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>
</div>
<Button onClick={openNew}>
<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) => (
<Card key={ds.id}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1">
<CardTitle className="text-base">{ds.name}</CardTitle>
<CardDescription className="flex items-center gap-2">
<Badge variant="secondary">{ds.source_type}</Badge>
<span>every {ds.poll_interval_secs}s</span>
{ds.url && (
<span className="text-muted-foreground max-w-xs truncate text-xs">
{ds.url}
</span>
)}
</CardDescription>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(ds)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(ds.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
</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}>
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>
)
}
const SENSITIVE_KEYS = ["password", "secret", "token", "api_key", "apikey"]
function isSensitiveKey(key: string) {
return SENSITIVE_KEYS.some((s) => key.toLowerCase().includes(s))
}
function HeaderRow({
headerKey,
headerValue,
onChangeKey,
onChangeValue,
onRemove,
}: {
headerKey: string
headerValue: string
onChangeKey: (v: string) => void
onChangeValue: (v: string) => void
onRemove: () => void
}) {
const sensitive = isSensitiveKey(headerKey)
const [visible, setVisible] = useState(!sensitive)
return (
<div className="flex items-center gap-2">
<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"}
value={headerValue}
onChange={(e) => onChangeValue(e.target.value)}
placeholder="value"
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>
)}
</div>
<Button variant="ghost" size="icon" onClick={onRemove}>
<X className="h-3 w-3" />
</Button>
</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 })
return (
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>Name</Label>
<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) => set("source_type", v as SourceType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOURCE_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>URL</Label>
<Input
value={value.url ?? ""}
onChange={(e) => set("url", e.target.value || null)}
placeholder="https://..."
/>
</div>
<div className="grid gap-2">
<Label>API Key</Label>
<Input
type="password"
value={value.api_key ?? ""}
onChange={(e) => set("api_key", e.target.value || null)}
placeholder="Optional"
/>
</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}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Headers</Label>
<Button
variant="outline"
size="sm"
onClick={() =>
set("headers", [...value.headers, ["", ""]])
}
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
{value.headers.map(([k, v], i) => (
<HeaderRow
key={i}
headerKey={k}
headerValue={v}
onChangeKey={(newKey) => {
const next = [...value.headers] as [string, string][]
next[i] = [newKey, v]
set("headers", next)
}}
onChangeValue={(newVal) => {
const next = [...value.headers] as [string, string][]
next[i] = [k, newVal]
set("headers", next)
}}
onRemove={() =>
set("headers", value.headers.filter((_, idx) => idx !== i))
}
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,547 @@
import { useState, useCallback } from "react"
import { useLayout, useUpdateLayout } from "@/api/layout"
import { useWidgets } from "@/api/widgets"
import type { LayoutNode, LayoutChild, Direction } from "@/api/types"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import {
Rows3,
Columns3,
Box,
Plus,
Trash2,
Save,
GripVertical,
} from "lucide-react"
import { toast } from "sonner"
function makeContainer(direction: Direction): LayoutNode {
return {
type: "container",
direction,
gap: 4,
padding: 0,
children: [],
}
}
function makeLeaf(widgetId: number): LayoutChild {
return {
sizing: { type: "flex", value: 1 },
node: { type: "leaf", widget_id: widgetId },
}
}
function makeContainerChild(direction: Direction): LayoutChild {
return {
sizing: { type: "flex", value: 1 },
node: makeContainer(direction),
}
}
type Path = number[]
function getNode(root: LayoutNode, path: Path): LayoutNode {
let node = root
for (const i of path) {
node = node.children![i].node
}
return node
}
function updateAtPath(
root: LayoutNode,
path: Path,
updater: (node: LayoutNode) => LayoutNode,
): LayoutNode {
if (path.length === 0) return updater(root)
const [head, ...rest] = path
const children = root.children!.map((child, i) =>
i === head
? { ...child, node: updateAtPath(child.node, rest, updater) }
: child,
)
return { ...root, children }
}
function updateChildAtPath(
root: LayoutNode,
path: Path,
updater: (child: LayoutChild) => LayoutChild,
): LayoutNode {
if (path.length === 0) return root
const parentPath = path.slice(0, -1)
const childIdx = path[path.length - 1]
return updateAtPath(root, parentPath, (parent) => ({
...parent,
children: parent.children!.map((c, i) => (i === childIdx ? updater(c) : c)),
}))
}
function removeAtPath(root: LayoutNode, path: Path): LayoutNode {
const parentPath = path.slice(0, -1)
const childIdx = path[path.length - 1]
return updateAtPath(root, parentPath, (parent) => ({
...parent,
children: parent.children!.filter((_, i) => i !== childIdx),
}))
}
export function LayoutBuilderPage() {
const { data: currentLayout, isLoading } = useLayout()
const { data: widgets = [] } = useWidgets()
const updateLayout = useUpdateLayout()
const [root, setRoot] = useState<LayoutNode | null>(null)
const [selected, setSelected] = useState<Path | null>(null)
const [initialized, setInitialized] = useState(false)
if (!initialized && currentLayout?.root) {
setRoot(structuredClone(currentLayout.root))
setInitialized(true)
}
const createNew = useCallback((direction: Direction) => {
setRoot(makeContainer(direction))
setInitialized(true)
}, [])
async function save() {
if (!root) return
try {
await updateLayout.mutateAsync({ root })
toast.success("Layout saved & pushed to clients")
} catch (e) {
toast.error(String(e))
}
}
function addChild(path: Path, child: LayoutChild) {
setRoot((r) =>
r
? updateAtPath(r, path, (node) => ({
...node,
children: [...(node.children ?? []), child],
}))
: r,
)
}
function removeChild(path: Path) {
if (path.length === 0) {
setRoot(null)
setSelected(null)
setInitialized(false)
return
}
setRoot((r) => (r ? removeAtPath(r, path) : r))
setSelected(null)
}
function updateSizing(path: Path, sizing: LayoutChild["sizing"]) {
setRoot((r) => (r ? updateChildAtPath(r, path, (c) => ({ ...c, sizing })) : r))
}
function updateContainerProp(
path: Path,
prop: "gap" | "padding" | "direction",
value: number | string,
) {
setRoot((r) =>
r ? updateAtPath(r, path, (n) => ({ ...n, [prop]: value })) : r,
)
}
if (isLoading) return <div className="text-muted-foreground p-4">Loading</div>
if (!root) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Layout Builder
</h1>
<p className="text-muted-foreground text-sm">
Compose the display layout tree
</p>
</div>
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<p className="text-muted-foreground">No active layout. Create one:</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => createNew("row")}>
<Rows3 className="mr-2 h-4 w-4" />
Row Root
</Button>
<Button variant="outline" onClick={() => createNew("column")}>
<Columns3 className="mr-2 h-4 w-4" />
Column Root
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
const selectedNode = selected ? getNode(root, selected) : null
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Layout Builder
</h1>
<p className="text-muted-foreground text-sm">
Compose the display layout tree
</p>
</div>
<Button onClick={save}>
<Save className="mr-2 h-4 w-4" />
Save Layout
</Button>
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_300px]">
{/* Tree view */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Layout Tree</CardTitle>
</CardHeader>
<CardContent>
<TreeNode
node={root}
path={[]}
selected={selected}
onSelect={setSelected}
widgets={widgets}
depth={0}
/>
</CardContent>
</Card>
{/* Properties panel */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Properties</CardTitle>
</CardHeader>
<CardContent>
{!selected ? (
<p className="text-muted-foreground text-sm">
Select a node to edit properties
</p>
) : selectedNode?.type === "container" ? (
<ContainerProps
node={selectedNode}
path={selected}
onUpdateProp={updateContainerProp}
onAddWidget={(path, wid) => addChild(path, makeLeaf(wid))}
onAddContainer={(path, dir) =>
addChild(path, makeContainerChild(dir))
}
onRemove={() => removeChild(selected)}
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
isRoot={selected.length === 0}
widgets={widgets}
/>
) : (
<LeafProps
path={selected}
widgetId={selectedNode?.widget_id ?? 0}
onRemove={() => removeChild(selected)}
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
widgets={widgets}
sizing={
selected.length > 0
? (() => {
const parent = getNode(
root,
selected.slice(0, -1),
)
return parent.children![selected[selected.length - 1]]
.sizing
})()
: undefined
}
/>
)}
</CardContent>
</Card>
</div>
</div>
)
}
function TreeNode({
node,
path,
selected,
onSelect,
widgets,
depth,
}: {
node: LayoutNode
path: Path
selected: Path | null
onSelect: (p: Path) => void
widgets: { id: number; name: string }[]
depth: number
}) {
const isSelected =
selected !== null && JSON.stringify(selected) === JSON.stringify(path)
if (node.type === "leaf") {
const w = widgets.find((w) => w.id === node.widget_id)
return (
<div
className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm ${
isSelected ? "bg-accent" : "hover:bg-muted"
}`}
style={{ marginLeft: depth * 16 }}
onClick={(e) => {
e.stopPropagation()
onSelect(path)
}}
>
<Box className="h-3.5 w-3.5 text-blue-500" />
<span>{w?.name ?? `widget #${node.widget_id}`}</span>
</div>
)
}
return (
<div>
<div
className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm ${
isSelected ? "bg-accent" : "hover:bg-muted"
}`}
style={{ marginLeft: depth * 16 }}
onClick={(e) => {
e.stopPropagation()
onSelect(path)
}}
>
<GripVertical className="text-muted-foreground h-3.5 w-3.5" />
{node.direction === "row" ? (
<Rows3 className="h-3.5 w-3.5 text-orange-500" />
) : (
<Columns3 className="h-3.5 w-3.5 text-green-500" />
)}
<span>{node.direction}</span>
<Badge variant="outline" className="text-[10px]">
{node.children?.length ?? 0} children
</Badge>
</div>
{node.children?.map((child, i) => (
<TreeNode
key={i}
node={child.node}
path={[...path, i]}
selected={selected}
onSelect={onSelect}
widgets={widgets}
depth={depth + 1}
/>
))}
</div>
)
}
function ContainerProps({
node,
path,
onUpdateProp,
onAddWidget,
onAddContainer,
onRemove,
onUpdateSizing,
isRoot,
widgets,
}: {
node: LayoutNode
path: Path
onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction", value: number | string) => void
onAddWidget: (path: Path, widgetId: number) => void
onAddContainer: (path: Path, direction: Direction) => void
onRemove: () => void
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
isRoot: boolean
widgets: { id: number; name: string }[]
}) {
return (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Direction</Label>
<Select
value={node.direction}
onValueChange={(v) => onUpdateProp(path, "direction", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="row">Row</SelectItem>
<SelectItem value="column">Column</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="grid gap-2">
<Label>Gap</Label>
<Input
type="number"
value={node.gap ?? 0}
onChange={(e) =>
onUpdateProp(path, "gap", Number(e.target.value))
}
min={0}
/>
</div>
<div className="grid gap-2">
<Label>Padding</Label>
<Input
type="number"
value={node.padding ?? 0}
onChange={(e) =>
onUpdateProp(path, "padding", Number(e.target.value))
}
min={0}
/>
</div>
</div>
{!isRoot && <SizingEditor path={path} onUpdate={onUpdateSizing} />}
<div className="grid gap-2">
<Label>Add Child</Label>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onAddContainer(path, "row")}
>
<Rows3 className="mr-1 h-3 w-3" />
Row
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onAddContainer(path, "column")}
>
<Columns3 className="mr-1 h-3 w-3" />
Column
</Button>
</div>
{widgets.length > 0 && (
<Select onValueChange={(v) => onAddWidget(path, Number(v))}>
<SelectTrigger>
<SelectValue placeholder="Add widget…" />
</SelectTrigger>
<SelectContent>
{widgets.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Button
variant="destructive"
size="sm"
onClick={onRemove}
>
<Trash2 className="mr-1 h-3 w-3" />
{isRoot ? "Clear Layout" : "Remove"}
</Button>
</div>
)
}
function LeafProps({
path,
widgetId,
onRemove,
onUpdateSizing,
widgets,
sizing,
}: {
path: Path
widgetId: number
onRemove: () => void
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
widgets: { id: number; name: string }[]
sizing?: LayoutChild["sizing"]
}) {
const w = widgets.find((w) => w.id === widgetId)
return (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Widget</Label>
<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>
</div>
)
}
function SizingEditor({
path: _path,
onUpdate,
sizing,
}: {
path: Path
onUpdate: (sizing: LayoutChild["sizing"]) => void
sizing?: LayoutChild["sizing"]
}) {
const s = sizing ?? { type: "flex" as const, value: 1 }
return (
<div className="grid grid-cols-2 gap-2">
<div className="grid gap-2">
<Label>Sizing</Label>
<Select
value={s.type}
onValueChange={(v) =>
onUpdate({ type: v as "fixed" | "flex", value: s.value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="flex">Flex</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>{s.type === "flex" ? "Weight" : "Pixels"}</Label>
<Input
type="number"
value={s.value}
onChange={(e) => onUpdate({ ...s, value: Number(e.target.value) })}
min={s.type === "flex" ? 1 : 0}
/>
</div>
</div>
)
}

196
spa/src/pages/presets.tsx Normal file
View File

@@ -0,0 +1,196 @@
import { useState } from "react"
import {
usePresets,
useCreatePreset,
useDeletePreset,
useLoadPreset,
} from "@/api/presets"
import { useLayout } from "@/api/layout"
import type { Preset } from "@/api/types"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
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 { toast } from "sonner"
export function PresetsPage() {
const { data: presets = [], isLoading } = usePresets()
const { data: layout } = useLayout()
const createPreset = useCreatePreset()
const deletePreset = useDeletePreset()
const loadPreset = useLoadPreset()
const [saving, setSaving] = useState(false)
const [saveName, setSaveName] = useState("")
const [deleting, setDeleting] = useState<number | null>(null)
async function saveAsPreset() {
if (!layout || !saveName) return
const nextId =
presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1
try {
await createPreset.mutateAsync({
id: nextId,
name: saveName,
layout,
})
toast.success("Preset saved")
setSaving(false)
setSaveName("")
} catch (e) {
toast.error(String(e))
}
}
async function load(id: number) {
try {
await loadPreset.mutateAsync(id)
toast.success("Preset loaded as active layout")
} catch (e) {
toast.error(String(e))
}
}
async function confirmDelete() {
if (deleting == null) return
try {
await deletePreset.mutateAsync(deleting)
toast.success("Preset deleted")
} catch (e) {
toast.error(String(e))
}
setDeleting(null)
}
function nodeCount(node: Preset["layout"]["root"]): number {
if (!node.children) return 1
return (
1 + node.children.reduce((s, c) => s + nodeCount(c.node), 0)
)
}
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">Presets</h1>
<p className="text-muted-foreground text-sm">
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>
</div>
{presets.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No presets saved yet.</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{presets.map((p) => (
<Card key={p.id}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1">
<CardTitle className="text-base">{p.name}</CardTitle>
<CardDescription>
{nodeCount(p.layout.root)} nodes
</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>
</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>
)
}

344
spa/src/pages/widgets.tsx Normal file
View File

@@ -0,0 +1,344 @@
import { useState } from "react"
import {
useWidgets,
useCreateWidget,
useUpdateWidget,
useDeleteWidget,
} from "@/api/widgets"
import { useDataSources } from "@/api/data-sources"
import type { Widget, DisplayHint, KeyMapping } from "@/api/types"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
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 {
Select,
SelectContent,
SelectItem,
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 } from "lucide-react"
import { toast } from "sonner"
const DISPLAY_HINTS: DisplayHint[] = ["icon_value", "text_block", "key_value"]
const EMPTY: Widget = {
id: 0,
name: "",
display_hint: "icon_value",
data_source_id: 0,
mappings: [],
max_data_size: 2048,
}
export function WidgetsPage() {
const { data: widgets = [], isLoading } = useWidgets()
const { data: sources = [] } = useDataSources()
const create = useCreateWidget()
const update = useUpdateWidget()
const del = useDeleteWidget()
const [editing, setEditing] = useState<Widget | null>(null)
const [deleting, setDeleting] = 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,
})
}
async function save() {
if (!editing) return
const isNew = !widgets.some((w) => w.id === editing.id)
try {
if (isNew) {
await create.mutateAsync(editing)
toast.success("Widget created")
} else {
await update.mutateAsync(editing)
toast.success("Widget updated")
}
setEditing(null)
} catch (e) {
toast.error(String(e))
}
}
async function confirmDelete() {
if (deleting == null) return
try {
await del.mutateAsync(deleting)
toast.success("Widget deleted")
} catch (e) {
toast.error(String(e))
}
setDeleting(null)
}
const sourceName = (id: number) =>
sources.find((s) => s.id === id)?.name ?? `#${id}`
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">Widgets</h1>
<p className="text-muted-foreground text-sm">
Display primitives bound to data sources
</p>
</div>
<Button onClick={openNew}>
<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) => (
<Card key={w.id}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1">
<CardTitle className="text-base">{w.name}</CardTitle>
<CardDescription className="flex items-center gap-2">
<Badge variant="secondary">{w.display_hint}</Badge>
<span>source: {sourceName(w.data_source_id)}</span>
<span>{w.mappings.length} mapping(s)</span>
</CardDescription>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setEditing({ ...w })}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(w.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
</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}>
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>
</div>
)
}
function WidgetForm({
value,
onChange,
sources,
}: {
value: Widget
onChange: (w: Widget) => void
sources: { id: number; name: string }[]
}) {
const set = <K extends keyof Widget>(k: K, v: Widget[K]) =>
onChange({ ...value, [k]: v })
function addMapping() {
set("mappings", [...value.mappings, { source_path: "", target_key: "" }])
}
function updateMapping(i: number, m: KeyMapping) {
const next = [...value.mappings]
next[i] = m
set("mappings", next)
}
function removeMapping(i: number) {
set(
"mappings",
value.mappings.filter((_, idx) => idx !== i),
)
}
return (
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>Name</Label>
<Input
value={value.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. weather"
/>
</div>
<div className="grid gap-2">
<Label>Display Hint</Label>
<Select
value={value.display_hint}
onValueChange={(v) => set("display_hint", v as DisplayHint)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DISPLAY_HINTS.map((h) => (
<SelectItem key={h} value={h}>
{h}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Data Source</Label>
<Select
value={String(value.data_source_id)}
onValueChange={(v) => set("data_source_id", Number(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{sources.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Max Data Size (bytes)</Label>
<Input
type="number"
value={value.max_data_size}
onChange={(e) => set("max_data_size", Number(e.target.value))}
min={1}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Key Mappings</Label>
<Button variant="outline" size="sm" onClick={addMapping}>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
{value.mappings.map((m, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={m.source_path}
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>
<Input
value={m.target_key}
onChange={(e) =>
updateMapping(i, { ...m, target_key: e.target.value })
}
placeholder="target_key"
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeMapping(i)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)
}