Files
manage/frontend/src/pages/Dashboard.tsx
T
Developer 1da67f38c7 feat(services): frontend services runtime and widget rebind
PR 3 of 4 for the runtime service registry change.

- Add service + new-shape widget TypeScript types; widgets carry service_id
  + widget_kind (service-bound) or null (built-in).
- Add services API client + TanStack Query hooks; reconcile the widget API
  client/hooks to the new endpoints (remove sources/types; add builtin kinds).
- Add closed frontend service registry (integrations/registry.ts) mirroring the
  backend, with resolveWidget(widget, services) mapping a widget to its
  component + refresh interval.
- Add ServicePage at /services/:serviceType/:serviceId with config view,
  empty-on-edit secret inputs + 'set' badges, enable toggle, delete, and the
  service's widget-kind list.
- Register /services/:serviceType/:serviceId in App.tsx.
- Reconcile the six widget components to refreshIntervalMs + description props;
  rewrite WidgetConfigDialog around a service -> widget-kind picker.
- Update Dashboard test; add integrations/registry.test.ts.

Verification: frontend lint 0 errors, build success, 70 tests passed; backend
ruff clean, 222 tests passed.
2026-06-22 18:59:41 +00:00

451 lines
12 KiB
TypeScript

import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} 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 { Switch } from "@/components/ui/switch";
import {
useDashboardShortcuts,
useDeleteDashboardShortcut,
useSaveDashboardShortcut,
} from "../hooks/useDashboard";
import { useWidgetInstances } from "../hooks/useWidgets";
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter";
import { WidgetInstanceCard } from "../components/WidgetInstance";
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
function emptyShortcut(): DashboardShortcutInput {
return {
id: null,
label: "",
shortcut_type: "website",
enabled: true,
icon: "",
url: "",
task_id: "",
machine_id: "",
user_id: "",
notes: "",
};
}
function normalizeWebsiteUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
function shortcutHref(shortcut: DashboardShortcut): string {
if (shortcut.shortcut_type === "website") {
return normalizeWebsiteUrl(shortcut.url);
}
if (shortcut.shortcut_type === "action") {
if (!shortcut.task_id) return "";
const params = new URLSearchParams({ task: shortcut.task_id });
if (shortcut.machine_id) params.set("machine_id", shortcut.machine_id);
return `/actions?${params.toString()}`;
}
if (!shortcut.user_id) return "";
return `/users?user=${encodeURIComponent(shortcut.user_id)}`;
}
function Field({
label,
htmlFor,
helper,
children,
}: {
label: string;
htmlFor: string;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
{helper ? (
<p className="text-xs text-muted-foreground">{helper}</p>
) : null}
</div>
);
}
function ShortcutDialog({
open,
draft,
onChange,
onClose,
onSave,
}: {
open: boolean;
draft: DashboardShortcutInput;
onChange: (shortcut: DashboardShortcutInput) => void;
onClose: () => void;
onSave: () => void;
}) {
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{draft.id ? "Edit shortcut" : "New shortcut"}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="flex flex-col gap-1.5 sm:col-span-5">
<Field label="Label" htmlFor="shortcut-label">
<Input
id="shortcut-label"
value={draft.label}
onChange={(e) =>
onChange({ ...draft, label: e.target.value })
}
/>
</Field>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Field
label="Icon"
htmlFor="shortcut-icon"
helper="Emoji or glyph"
>
<Input
id="shortcut-icon"
value={draft.icon}
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
/>
</Field>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-5">
<Field
label="Type"
htmlFor="shortcut-type"
helper="Website opens a URL. Saved actions jump to a task. Users deep-link."
>
<Select
value={draft.shortcut_type}
onValueChange={(value) =>
onChange({
...draft,
shortcut_type:
value as DashboardShortcutInput["shortcut_type"],
})
}
>
<SelectTrigger id="shortcut-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="website">Website</SelectItem>
<SelectItem value="action">Saved action</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
</div>
{draft.shortcut_type === "website" ? (
<Field
label="Website URL"
htmlFor="shortcut-url"
helper="https:// is added if missing."
>
<Input
id="shortcut-url"
value={draft.url}
onChange={(e) => onChange({ ...draft, url: e.target.value })}
/>
</Field>
) : draft.shortcut_type === "action" ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<Field
label="Task ID"
htmlFor="shortcut-task"
helper="Saved action ID."
>
<Input
id="shortcut-task"
value={draft.task_id}
onChange={(e) =>
onChange({ ...draft, task_id: e.target.value })
}
/>
</Field>
<Field
label="Machine ID"
htmlFor="shortcut-machine"
helper="Optional machine target."
>
<Input
id="shortcut-machine"
value={draft.machine_id}
onChange={(e) =>
onChange({ ...draft, machine_id: e.target.value })
}
/>
</Field>
</div>
) : (
<Field
label="User ID"
htmlFor="shortcut-user"
helper="Jellyfin user ID."
>
<Input
id="shortcut-user"
value={draft.user_id}
onChange={(e) =>
onChange({ ...draft, user_id: e.target.value })
}
/>
</Field>
)}
<Field label="Notes" htmlFor="shortcut-notes">
<Input
id="shortcut-notes"
value={draft.notes}
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
/>
</Field>
<div className="flex items-center gap-2">
<Switch
id="shortcut-enabled"
checked={draft.enabled}
onCheckedChange={(checked) =>
onChange({ ...draft, enabled: checked })
}
/>
<Label htmlFor="shortcut-enabled">Enabled</Label>
</div>
</div>
<DialogFooter
onCancel={onClose}
onConfirm={onSave}
confirmLabel="Save shortcut"
confirmBusyLabel="Save shortcut"
/>
</DialogContent>
</Dialog>
);
}
function ShortcutCard({
shortcut,
onOpen,
onEdit,
onDelete,
}: {
shortcut: DashboardShortcut;
onOpen: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const href = shortcutHref(shortcut);
const subtitle =
shortcut.shortcut_type === "website"
? shortcut.url || "No URL configured"
: shortcut.shortcut_type === "action"
? [
shortcut.task_id || "task pending",
shortcut.machine_id
? `machine ${shortcut.machine_id}`
: "any machine",
].join(" · ")
: shortcut.user_id || "No user configured";
return (
<Card className="h-full">
<CardContent className="flex flex-col gap-3 p-3">
<div className="flex flex-row items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate font-semibold">{shortcut.label}</div>
<div className="truncate text-sm text-muted-foreground">
{subtitle}
</div>
</div>
<div className="flex flex-row items-center gap-2">
{shortcut.icon ? (
<div className="grid size-8 place-items-center rounded-md bg-muted text-lg">
{shortcut.icon}
</div>
) : null}
<Badge variant="outline">{shortcut.shortcut_type}</Badge>
</div>
</div>
{shortcut.notes ? (
<p className="text-xs text-muted-foreground">{shortcut.notes}</p>
) : null}
<div className="flex flex-row flex-wrap gap-2">
<Button
size="sm"
disabled={!shortcut.enabled || !href}
onClick={onOpen}
>
Open
</Button>
<Button size="sm" variant="outline" onClick={onEdit}>
Edit
</Button>
<Button size="sm" variant="destructive" onClick={onDelete}>
Delete
</Button>
</div>
</CardContent>
</Card>
);
}
export function Dashboard() {
const navigate = useNavigate();
const { data: shortcuts = [] } = useDashboardShortcuts();
const saveShortcut = useSaveDashboardShortcut();
const deleteShortcut = useDeleteDashboardShortcut();
const [shortcutDialogOpen, setShortcutDialogOpen] = useState(false);
const [shortcutDraft, setShortcutDraft] = useState<DashboardShortcutInput>(
emptyShortcut(),
);
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
const { data: widgetInstances = [] } = useWidgetInstances();
const visibleWidgets = useMemo(
() =>
widgetInstances
.filter((w) => w.enabled)
.sort((a, b) => a.sort_order - b.sort_order),
[widgetInstances],
);
const openCreateShortcut = () => {
setShortcutDraft(emptyShortcut());
setShortcutDialogOpen(true);
};
const openEditShortcut = (shortcut: DashboardShortcut) => {
setShortcutDraft({
id: shortcut.id,
label: shortcut.label,
shortcut_type: shortcut.shortcut_type,
enabled: shortcut.enabled,
icon: shortcut.icon,
url: shortcut.url,
task_id: shortcut.task_id,
machine_id: shortcut.machine_id,
user_id: shortcut.user_id,
notes: shortcut.notes,
});
setShortcutDialogOpen(true);
};
const saveShortcutDraft = async () => {
await saveShortcut.mutateAsync(shortcutDraft);
setShortcutDialogOpen(false);
setShortcutDraft(emptyShortcut());
};
return (
<div className="flex flex-col gap-4">
<SectionCard
title="Shortcuts"
description="Quick links to websites today, with room for action and user shortcuts later."
action={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setWidgetDialogOpen(true)}>
Edit dashboard
</Button>
<Button variant="outline" onClick={openCreateShortcut}>
Add shortcut
</Button>
</div>
}
>
{shortcuts.length ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{shortcuts.map((shortcut) => (
<ShortcutCard
key={shortcut.id}
shortcut={shortcut}
onOpen={() => {
const href = shortcutHref(shortcut);
if (shortcut.shortcut_type === "website") {
window.open(href, "_blank", "noopener,noreferrer");
} else if (href) {
navigate(href);
}
}}
onEdit={() => openEditShortcut(shortcut)}
onDelete={() => setDeleteShortcutId(shortcut.id)}
/>
))}
</div>
) : (
<Alert>
<AlertDescription>
No shortcuts yet. Add a website now, then add action or user
shortcuts later.
</AlertDescription>
</Alert>
)}
</SectionCard>
{visibleWidgets.map((widget) => (
<WidgetInstanceCard key={widget.id} widget={widget} />
))}
<ShortcutDialog
open={shortcutDialogOpen}
draft={shortcutDraft}
onChange={setShortcutDraft}
onClose={() => setShortcutDialogOpen(false)}
onSave={saveShortcutDraft}
/>
<ConfirmDialog
open={Boolean(deleteShortcutId)}
title="Delete shortcut?"
message="This cannot be undone. The shortcut will be removed from the dashboard."
confirmLabel="Delete"
onCancel={() => setDeleteShortcutId(null)}
onConfirm={() => {
if (deleteShortcutId) {
deleteShortcut.mutate(deleteShortcutId);
}
setDeleteShortcutId(null);
}}
/>
<WidgetConfigDialog
open={widgetDialogOpen}
onClose={() => setWidgetDialogOpen(false)}
/>
</div>
);
}