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:
75
spa/src/pages/dashboard.tsx
Normal file
75
spa/src/pages/dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
388
spa/src/pages/data-sources.tsx
Normal file
388
spa/src/pages/data-sources.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
547
spa/src/pages/layout-builder.tsx
Normal file
547
spa/src/pages/layout-builder.tsx
Normal 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
196
spa/src/pages/presets.tsx
Normal 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
344
spa/src/pages/widgets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user