- 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)
345 lines
9.5 KiB
TypeScript
345 lines
9.5 KiB
TypeScript
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>
|
|
)
|
|
}
|