d05de0aacd
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.
597 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|