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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user