1da67f38c7
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.
451 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|