Files
manage/frontend/src/pages/Dashboard.tsx
T
Developer d05de0aacd Touch-target pass: 44px min on default-size buttons (WCAG 2.5.5)
Applies .mobile-touch-target to 32 default-size <Button> elements (32px
tall, below the mobile minimum) across 9 files for strict WCAG 2.5.5
compliance: Save, Cancel, Delete, Validate SSH, Run job, Build index,
Update connection, Add service, etc. Plus the shared DialogFooter Cancel
+ Confirm buttons (used by every ConfirmDialog).

The class applies min-height/min-width: 44px only below md
(max-width: 767px); no-op at md+, so desktop sizing is unchanged.

Completes the touch-target audit started in Slice 9 (which covered icon
buttons, size=sm buttons, checkboxes, switches). 122 tests pass; lint/
build green. No new tests (@media queries aren't honored by jsdom).

Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #2.
2026-06-26 15:45:34 +00:00

597 lines
16 KiB
TypeScript

import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Activity,
DatabaseBackup,
LayoutDashboard,
Monitor,
} from "lucide-react";
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 { useServiceInstances } from "../hooks/useServices";
import { useIsMobile } from "../hooks/useIsMobile";
import type {
DashboardShortcut,
DashboardShortcutInput,
ServiceInstance,
WidgetInstance,
} 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";
// --- Mobile section grouping (spec R7.2) ---
const SECTION_ORDER = ["observability", "media", "backups", "custom"] as const;
type SectionId = (typeof SECTION_ORDER)[number];
const SECTION_META: Record<
SectionId,
{ label: string; icon: typeof Activity }
> = {
observability: { label: "Observability", icon: Activity },
media: { label: "Media", icon: Monitor },
backups: { label: "Backups", icon: DatabaseBackup },
custom: { label: "Custom", icon: LayoutDashboard },
};
const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus", "grafana"]);
function widgetSection(
widget: WidgetInstance,
services: ServiceInstance[],
): SectionId {
if (!widget.service_id) {
return widget.widget_kind === "backups" ? "backups" : "custom";
}
const service = services.find((s) => s.id === widget.service_id);
const serviceType = service?.service_type ?? "";
if (OBSERVABILITY_TYPES.has(serviceType)) return "observability";
if (serviceType === "jellyfin") return "media";
return "custom";
}
function groupWidgetsBySection(
widgets: WidgetInstance[],
services: ServiceInstance[],
): { id: SectionId; widgets: WidgetInstance[] }[] {
const groups: Record<SectionId, WidgetInstance[]> = {
observability: [],
media: [],
backups: [],
custom: [],
};
for (const w of widgets) {
groups[widgetSection(w, services)].push(w);
}
return SECTION_ORDER.map((id) => ({ id, widgets: groups[id] })).filter(
(s) => s.widgets.length > 0,
);
}
function MobileWidgetSections({
sections,
}: {
sections: { id: SectionId; widgets: WidgetInstance[] }[];
}) {
return (
<>
{/* Anchor bar — horizontally scrollable pills (spec R7.2, md:hidden) */}
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
{sections.map((section) => {
const meta = SECTION_META[section.id];
const Icon = meta.icon;
return (
<button
key={section.id}
type="button"
className="mobile-touch-target inline-flex shrink-0 items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() =>
document
.getElementById(`dashboard-section-${section.id}`)
?.scrollIntoView({
behavior: "smooth",
block: "start",
})
}
>
<Icon className="size-3.5" />
{meta.label}
</button>
);
})}
</div>
{/* Sectioned widgets — single column (spec R7.1) */}
<div className="grid grid-cols-1 gap-4">
{sections.map((section) => (
<section
key={section.id}
id={`dashboard-section-${section.id}`}
className="scroll-mt-16 flex flex-col gap-2"
>
<h3 className="text-sm font-semibold text-muted-foreground">
{SECTION_META[section.id].label}
</h3>
{section.widgets.map((widget) => (
<WidgetInstanceCard key={widget.id} widget={widget} />
))}
</section>
))}
</div>
</>
);
}
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"
className="mobile-touch-target"
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}
className="mobile-touch-target"
>
Open
</Button>
<Button
size="sm"
variant="outline"
onClick={onEdit}
className="mobile-touch-target"
>
Edit
</Button>
<Button
size="sm"
variant="destructive"
onClick={onDelete}
className="mobile-touch-target"
>
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 { data: services = [] } = useServiceInstances();
const isMobile = useIsMobile();
const visibleWidgets = useMemo(
() =>
widgetInstances
.filter((w) => w.enabled)
.sort((a, b) => a.sort_order - b.sort_order),
[widgetInstances],
);
const mobileSections = useMemo(
() => groupWidgetsBySection(visibleWidgets, services),
[visibleWidgets, services],
);
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"
className="mobile-touch-target"
onClick={() => setWidgetDialogOpen(true)}
>
Edit dashboard
</Button>
<Button
variant="outline"
className="mobile-touch-target"
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>
{isMobile && mobileSections.length > 0 ? (
<MobileWidgetSections sections={mobileSections} />
) : (
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>
);
}