Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b583d5a365 | |||
| 32fa01cc12 | |||
| ac703eecd2 | |||
| d05de0aacd | |||
| 09b9c45665 | |||
| 32516f6e3b | |||
| 30f1b6e6db | |||
| 7808822a55 | |||
| e805c624b2 | |||
| f7b63fead5 | |||
| 2eb649eceb | |||
| 2076ab76fa | |||
| 2e3e7b3850 | |||
| c447dfe68d | |||
| 688a18af22 | |||
| 18ee77a4e4 |
@@ -468,3 +468,57 @@ The system receives backup execution reports from an external backup tool via HT
|
|||||||
|
|
||||||
- Backup tool uses auto-generated Bearer API key
|
- Backup tool uses auto-generated Bearer API key
|
||||||
- Frontend uses existing OIDC/JWT auth
|
- Frontend uses existing OIDC/JWT auth
|
||||||
|
|
||||||
|
## Mobile Responsive Design
|
||||||
|
|
||||||
|
The frontend is fully operable in phone portrait (≥360px) at a single `md:`
|
||||||
|
(768px) breakpoint. Tablets and wider viewports use the desktop layout
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
### Breakpoint policy
|
||||||
|
|
||||||
|
- Single responsive cut: `md:` (768px). Below is "mobile"; at-or-above is
|
||||||
|
"desktop" (existing layout, unchanged).
|
||||||
|
- `useIsMobile()` hook (`frontend/src/hooks/useIsMobile.ts`) is the single
|
||||||
|
source of truth; it wraps `matchMedia("(max-width: 768px)")` and is SSR-safe.
|
||||||
|
- No `sm:` intermediate cut. No PWA, manifest, or service worker.
|
||||||
|
|
||||||
|
### Data tables (hybrid)
|
||||||
|
|
||||||
|
- The four wide tables (Media, FileBrowser, Users, Backups) render stacked
|
||||||
|
**cards per row** below `md` via `MobileCardRow`, each showing a primary
|
||||||
|
title plus 3–5 key fields. Narrow tables (SessionActivity) keep horizontal
|
||||||
|
scroll. The TanStack column-visibility toggle is hidden below `md`.
|
||||||
|
- At `md:` and above, all tables render as the existing `<DataTable>` unchanged.
|
||||||
|
|
||||||
|
### Edit forms (Sheet)
|
||||||
|
|
||||||
|
- Below `md`, ServicePage, Settings (machine editor), message compose, and
|
||||||
|
WidgetConfigDialog open inside a full-height `SheetForm` (side=bottom,
|
||||||
|
`h-[100dvh]`) with sticky header + sticky save bar instead of a centered
|
||||||
|
Dialog.
|
||||||
|
- At `md:` and above, the existing Dialog-based forms are unchanged.
|
||||||
|
|
||||||
|
### Touch targets
|
||||||
|
|
||||||
|
- All interactive elements below `md` have a minimum 44×44px hit area via the
|
||||||
|
`.mobile-touch-target` CSS utility (applied only below 768px). This covers
|
||||||
|
icon buttons, checkboxes, switches, and small text buttons. The class is a
|
||||||
|
no-op at `md:` and above.
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
- Below `md`, the widget grid collapses to a single column with a section
|
||||||
|
anchor bar (Observability / Media / Backups / Custom) for quick navigation.
|
||||||
|
- At `md:` and above, the existing multi-widget grid is unchanged.
|
||||||
|
|
||||||
|
### Polling
|
||||||
|
|
||||||
|
- Widget refresh intervals and the message-queue poll interval are identical
|
||||||
|
on mobile and desktop. A follow-up to pause refetch when the tab is hidden
|
||||||
|
(`document.visibilityState`) is tracked as a future battery optimization.
|
||||||
|
|
||||||
|
### `HoverEditButton`
|
||||||
|
|
||||||
|
- Below `md`, edit affordances are always visible (not hover-gated). At `md:`
|
||||||
|
and above, the desktop hover-reveal aesthetic is preserved.
|
||||||
|
|||||||
+15
-14
@@ -28,6 +28,7 @@ import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
|||||||
import { fetchAppVersion } from "./api/client";
|
import { fetchAppVersion } from "./api/client";
|
||||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||||
import { usePersistentState } from "./hooks/usePersistentState";
|
import { usePersistentState } from "./hooks/usePersistentState";
|
||||||
|
import { useIsMobile } from "./hooks/useIsMobile";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -61,7 +62,16 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
// Pause interval-based refetches (widgets ~30s, queue status 5s,
|
||||||
|
// media build progress 1s) when the tab is hidden. Saves battery on
|
||||||
|
// mobile (D8 follow-up). Build progress polls resume on return.
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function useDarkMode() {
|
function useDarkMode() {
|
||||||
@@ -193,7 +203,7 @@ function MobileDrawer() {
|
|||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="md:hidden">
|
<Button variant="ghost" size="icon" className="mobile-touch-target md:hidden">
|
||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
@@ -280,7 +290,7 @@ function TopBar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onToggleDarkMode}
|
onClick={onToggleDarkMode}
|
||||||
className="h-8 w-8"
|
className="mobile-touch-target h-8 w-8"
|
||||||
>
|
>
|
||||||
{darkMode ? (
|
{darkMode ? (
|
||||||
<Sun className="h-4 w-4" />
|
<Sun className="h-4 w-4" />
|
||||||
@@ -293,7 +303,7 @@ function TopBar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onSignOut}
|
onClick={onSignOut}
|
||||||
className="gap-2"
|
className="mobile-touch-target gap-2"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Logout</span>
|
<span className="hidden sm:inline">Logout</span>
|
||||||
@@ -316,16 +326,7 @@ function ShellLayout({
|
|||||||
onToggleDarkMode: () => void;
|
onToggleDarkMode: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(
|
const isMobile = useIsMobile();
|
||||||
() => window.matchMedia("(max-width: 768px)").matches,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mql = window.matchMedia("(max-width: 768px)");
|
|
||||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
|
||||||
mql.addEventListener("change", handler);
|
|
||||||
return () => mql.removeEventListener("change", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type { BackupAlert } from "../types/backups";
|
import type { BackupAlert } from "../types/backups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,7 +34,51 @@ function severityVariant(severity: string): SeverityVariant {
|
|||||||
return severity === "critical" ? "destructive" : "warning";
|
return severity === "critical" ? "destructive" : "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): message is the primary identifier;
|
||||||
|
// severity/type/created give the at-a-glance info. See OpenSpec change
|
||||||
|
// `mobile-responsive-parity`, tasks slice 5.2.
|
||||||
|
const alertCardFields: MobileCardField<BackupAlert>[] = [
|
||||||
|
{ key: "message", label: "Message", render: (a) => a.message, primary: true },
|
||||||
|
{
|
||||||
|
key: "severity",
|
||||||
|
label: "Severity",
|
||||||
|
render: (a) => (
|
||||||
|
<Badge variant={severityVariant(a.severity)}>{a.severity}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "type", label: "Type", render: (a) => a.alert_type },
|
||||||
|
{
|
||||||
|
key: "created",
|
||||||
|
label: "Created",
|
||||||
|
render: (a) => formatTimestamp(a.created_at),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
|
export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<MobileCardRow
|
||||||
|
rows={alerts}
|
||||||
|
fields={alertCardFields}
|
||||||
|
getRowId={(a) => a.id}
|
||||||
|
actions={(a) =>
|
||||||
|
!a.acknowledged ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => onAcknowledge(a.id)}
|
||||||
|
>
|
||||||
|
Ack
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-border">
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
<Table aria-label="Backup alerts">
|
<Table aria-label="Backup alerts">
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type { BackupJob, BackupRun } from "../types/backups";
|
import type { BackupJob, BackupRun } from "../types/backups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -41,7 +46,54 @@ function statusVariant(status: string): StatusVariant {
|
|||||||
return "secondary";
|
return "secondary";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): job name is primary; source/schedule/status
|
||||||
|
// give at-a-glance context. See OpenSpec change `mobile-responsive-parity`.
|
||||||
|
interface JobCardRow {
|
||||||
|
job: BackupJob;
|
||||||
|
status: string;
|
||||||
|
run_started: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobCardFields: MobileCardField<JobCardRow>[] = [
|
||||||
|
{ key: "name", label: "Name", render: (r) => r.job.name, primary: true },
|
||||||
|
{
|
||||||
|
key: "source",
|
||||||
|
label: "Source",
|
||||||
|
render: (r) => r.job.source ?? "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "schedule",
|
||||||
|
label: "Schedule",
|
||||||
|
render: (r) => formatInterval(r.job.schedule_interval_seconds),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "Last status",
|
||||||
|
render: (r) => <Badge variant={statusVariant(r.status)}>{r.status}</Badge>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function BackupJobsTable({ jobs, latestRuns }: Props) {
|
export default function BackupJobsTable({ jobs, latestRuns }: Props) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
const cardRows: JobCardRow[] = jobs.map((job) => {
|
||||||
|
const run = latestRuns.get(job.id);
|
||||||
|
return {
|
||||||
|
job,
|
||||||
|
status: run?.status ?? "unknown",
|
||||||
|
run_started: run?.started_at ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MobileCardRow
|
||||||
|
rows={cardRows}
|
||||||
|
fields={jobCardFields}
|
||||||
|
getRowId={(r) => r.job.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-border">
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
<Table aria-label="Backup jobs">
|
<Table aria-label="Backup jobs">
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type { BackupRun } from "../types/backups";
|
import type { BackupRun } from "../types/backups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -55,8 +60,35 @@ function statusVariant(status: string): StatusVariant {
|
|||||||
return "warning";
|
return "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): job_id is primary; status/duration/size/
|
||||||
|
// started give the at-a-glance info. See OpenSpec change `mobile-responsive-parity`.
|
||||||
|
const runCardFields: MobileCardField<BackupRun>[] = [
|
||||||
|
{ key: "job", label: "Job", render: (r) => r.job_id, primary: true },
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "Status",
|
||||||
|
render: (r) => <Badge variant={statusVariant(r.status)}>{r.status}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "duration",
|
||||||
|
label: "Duration",
|
||||||
|
render: (r) => formatDuration(r.duration_ms),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "size",
|
||||||
|
label: "Size",
|
||||||
|
render: (r) => formatBytes(r.bytes_transferred),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "started",
|
||||||
|
label: "Started",
|
||||||
|
render: (r) => formatTimestamp(r.started_at),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function BackupRunsTable({ runs }: Props) {
|
export default function BackupRunsTable({ runs }: Props) {
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const filteredRuns =
|
const filteredRuns =
|
||||||
statusFilter === "all"
|
statusFilter === "all"
|
||||||
@@ -77,34 +109,42 @@ export default function BackupRunsTable({ runs }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-border">
|
{isMobile ? (
|
||||||
<Table aria-label="Backup runs">
|
<MobileCardRow
|
||||||
<TableHeader>
|
rows={filteredRuns}
|
||||||
<TableRow className="bg-card hover:bg-card">
|
fields={runCardFields}
|
||||||
<TableHead>Job</TableHead>
|
getRowId={(r) => r.id}
|
||||||
<TableHead>Status</TableHead>
|
/>
|
||||||
<TableHead>Duration</TableHead>
|
) : (
|
||||||
<TableHead>Size</TableHead>
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
<TableHead>Started</TableHead>
|
<Table aria-label="Backup runs">
|
||||||
</TableRow>
|
<TableHeader>
|
||||||
</TableHeader>
|
<TableRow className="bg-card hover:bg-card">
|
||||||
<TableBody>
|
<TableHead>Job</TableHead>
|
||||||
{filteredRuns.map((run) => (
|
<TableHead>Status</TableHead>
|
||||||
<TableRow key={run.id}>
|
<TableHead>Duration</TableHead>
|
||||||
<TableCell>{run.job_id}</TableCell>
|
<TableHead>Size</TableHead>
|
||||||
<TableCell>
|
<TableHead>Started</TableHead>
|
||||||
<Badge variant={statusVariant(run.status)}>
|
|
||||||
{run.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
|
||||||
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
|
||||||
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{filteredRuns.map((run) => (
|
||||||
</div>
|
<TableRow key={run.id}>
|
||||||
|
<TableCell>{run.job_id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant(run.status)}>
|
||||||
|
{run.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
||||||
|
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
||||||
|
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ export function DialogFooter({
|
|||||||
}: DialogFooterProps) {
|
}: DialogFooterProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={onCancel}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</Button>
|
</Button>
|
||||||
{secondaryAction ? (
|
{secondaryAction ? (
|
||||||
@@ -59,6 +63,7 @@ export function DialogFooter({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
|
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
|
||||||
disabled={confirmDisabled}
|
disabled={confirmDisabled}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
|
|||||||
@@ -4,26 +4,48 @@ import { Button } from "@/components/ui/button";
|
|||||||
interface HoverEditButtonProps {
|
interface HoverEditButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Controls visibility below the `md:` (768px) breakpoint.
|
||||||
|
*
|
||||||
|
* - `always` (default): the button is always visible on mobile/touch.
|
||||||
|
* - `hover`: keep the legacy opacity-0-everywhere behavior.
|
||||||
|
*
|
||||||
|
* At `md:` and above the hover-reveal aesthetic is always preserved
|
||||||
|
* (`md:opacity-0 md:group-hover:opacity-100`), so desktop is not regressed.
|
||||||
|
* See OpenSpec change `mobile-responsive-parity`, spec R5. */
|
||||||
|
mobile?: "always" | "hover";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hover-to-reveal edit affordance.
|
* Hover-to-reveal edit affordance (desktop) / always-visible (mobile).
|
||||||
*
|
*
|
||||||
* Keeps the `rail-edit` class plus the opacity-0 base + transition so the
|
* Keeps the `rail-edit` class plus the opacity base + transition so the
|
||||||
* existing hover-reveal rules in consuming pages (Actions, Settings) still
|
* existing hover-reveal rules in consuming pages (Actions, Settings) still
|
||||||
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
|
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
|
||||||
* MUI IconButton + EditOutlined → shadcn `Button variant="ghost" size="icon-sm"`
|
*
|
||||||
* + lucide `Pencil`. Same exported props/display name.
|
* Mobile behavior (`mobile="always"`, the default): the button is visible by
|
||||||
|
* default below `md` because hover does not fire on touch. The hover-reveal
|
||||||
|
* aesthetic is layered back on at `md:` and above via `md:opacity-0
|
||||||
|
* md:group-hover:opacity-100`. MUI IconButton + EditOutlined → shadcn `Button
|
||||||
|
* variant="ghost" size="icon-sm"` + lucide `Pencil`. Same exported props/display
|
||||||
|
* name. See OpenSpec change `mobile-responsive-parity`, spec R5.
|
||||||
*/
|
*/
|
||||||
export function HoverEditButton({
|
export function HoverEditButton({
|
||||||
onClick,
|
onClick,
|
||||||
label = "Edit",
|
label = "Edit",
|
||||||
|
mobile = "always",
|
||||||
}: HoverEditButtonProps) {
|
}: HoverEditButtonProps) {
|
||||||
|
// Legacy mode: opacity-0 everywhere, revealed by group hover (the consuming
|
||||||
|
// row supplies `group`).
|
||||||
|
const hoverClasses =
|
||||||
|
mobile === "hover"
|
||||||
|
? "opacity-0 transition-opacity duration-100 ease-out group-hover:opacity-100"
|
||||||
|
: "md:opacity-0 md:transition-opacity md:duration-100 md:ease-out md:group-hover:opacity-100";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
|
className={`rail-edit text-muted-foreground mobile-touch-target ${hoverClasses}`}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ function QueryError({
|
|||||||
<AlertTitle>{label} failed</AlertTitle>
|
<AlertTitle>{label} failed</AlertTitle>
|
||||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span className="break-words">{error.message}</span>
|
<span className="break-words">{error.message}</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
<Button variant="outline" size="sm" className="mobile-touch-target" onClick={() => refetch()}>
|
||||||
<RefreshCw className="mr-1 h-3 w-3" />
|
<RefreshCw className="mr-1 h-3 w-3" />
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
@@ -255,7 +255,7 @@ function GrafanaLinkCard({
|
|||||||
<div className="font-medium">{title}</div>
|
<div className="font-medium">{title}</div>
|
||||||
<div className="text-sm text-muted-foreground">{description}</div>
|
<div className="text-sm text-muted-foreground">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -585,7 +585,7 @@ export function ObservabilityPage() {
|
|||||||
title="No Node Exporter targets"
|
title="No Node Exporter targets"
|
||||||
description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets."
|
description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets."
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
|
||||||
<Link to="/settings">Open Settings</Link>
|
<Link to="/settings">Open Settings</Link>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -641,7 +641,7 @@ export function ObservabilityPage() {
|
|||||||
title="No Grafana service configured"
|
title="No Grafana service configured"
|
||||||
description="Add a Grafana service instance to enable deep-links to dashboards and logs."
|
description="Add a Grafana service instance to enable deep-links to dashboards and logs."
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
|
||||||
<Link to="/services">Open Services</Link>
|
<Link to="/services">Open Services</Link>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -653,7 +653,7 @@ export function ObservabilityPage() {
|
|||||||
title="No machine selected"
|
title="No machine selected"
|
||||||
description="Add monitoring machines in Settings to see Grafana drill-down links."
|
description="Add monitoring machines in Settings to see Grafana drill-down links."
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
|
||||||
<Link to="/settings">Open Settings</Link>
|
<Link to="/settings">Open Settings</Link>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export function SessionActivityPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onSelectSession(session);
|
onSelectSession(session);
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
} from "../hooks/useWidgets";
|
} from "../hooks/useWidgets";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useTasks } from "../hooks/useSettings";
|
import { useTasks } from "../hooks/useSettings";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
|
import { SheetForm } from "@/components/ui/sheet-form";
|
||||||
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
||||||
import {
|
import {
|
||||||
BUILTIN_WIDGETS,
|
BUILTIN_WIDGETS,
|
||||||
@@ -277,201 +279,220 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
]?.widgets.find((w) => w.kind === draft.widgetKind)
|
]?.widgets.find((w) => w.kind === draft.widgetKind)
|
||||||
: BUILTIN_WIDGETS[draft.widgetKind]
|
: BUILTIN_WIDGETS[draft.widgetKind]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const isTaskOutput =
|
const isTaskOutput =
|
||||||
draft?.serviceId !== null &&
|
draft?.serviceId !== null &&
|
||||||
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
||||||
"ssh_tasks";
|
"ssh_tasks";
|
||||||
|
|
||||||
|
// The draft body (Title/SortOrder/Enabled/config editor) is shared between
|
||||||
|
// the Dialog (desktop) and SheetForm (mobile). On mobile the inline
|
||||||
|
// Back/Save buttons are omitted because the SheetForm footer provides them.
|
||||||
|
const draftBody = draft ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Title" htmlFor="widget-title">
|
||||||
|
<Input
|
||||||
|
id="widget-title"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Sort order" htmlFor="widget-sort-order">
|
||||||
|
<Input
|
||||||
|
id="widget-sort-order"
|
||||||
|
type="number"
|
||||||
|
value={String(draft.sortOrder)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
sortOrder: e.target.value === "" ? 0 : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="widget-enabled"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDraft({ ...draft, enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="widget-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
<WidgetConfigEditor
|
||||||
|
binding={draftBinding}
|
||||||
|
isTaskOutput={!!isTaskOutput}
|
||||||
|
config={draft.config}
|
||||||
|
onChange={(config) => setDraft({ ...draft, config })}
|
||||||
|
tasks={tasks}
|
||||||
|
/>
|
||||||
|
{!isMobile ? (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={reset} className="mobile-touch-target">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveDraft} disabled={saveWidget.isPending} className="mobile-touch-target">
|
||||||
|
Save widget
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{sortedInstances.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{sortedInstances.map((instance, index) => {
|
||||||
|
const serviceName = instance.service_id
|
||||||
|
? services.find((s) => s.id === instance.service_id)?.name
|
||||||
|
: "Built-in";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={instance.id}
|
||||||
|
className="flex items-center gap-2 rounded border p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{instance.title}</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{bindingLabel(instance.service_id, instance.widget_kind)}
|
||||||
|
</Badge>
|
||||||
|
{serviceName ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{serviceName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!instance.enabled ? (
|
||||||
|
<Badge variant="secondary">disabled</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target h-8 w-8"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => moveInstance(index, -1)}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target h-8 w-8"
|
||||||
|
disabled={index === sortedInstances.length - 1}
|
||||||
|
onClick={() => moveInstance(index, 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
className="mobile-touch-target"
|
||||||
|
checked={instance.enabled}
|
||||||
|
onCheckedChange={() => toggleEnabled(instance)}
|
||||||
|
aria-label={`Toggle ${instance.title}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target h-8 w-8"
|
||||||
|
onClick={() => startEdit(instance)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target h-8 w-8 text-destructive"
|
||||||
|
onClick={() => removeInstance(instance)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Add widget</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.values(BUILTIN_WIDGETS).map((b) => (
|
||||||
|
<Button
|
||||||
|
key={b.kind}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => startAddBuiltIn(b.kind)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{b.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{services
|
||||||
|
.filter((s) => s.enabled)
|
||||||
|
.flatMap((s) =>
|
||||||
|
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => (
|
||||||
|
<Button
|
||||||
|
key={`${s.id}:${w.kind}`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => startAddService(s.id, w.kind)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{w.name} · {s.name}
|
||||||
|
</Button>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Configure services on their service pages to unlock more widgets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialogTitle = draft
|
||||||
|
? draft.id
|
||||||
|
? "Edit widget"
|
||||||
|
: "Add widget"
|
||||||
|
: "Dashboard widgets";
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<SheetForm
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) handleClose(next);
|
||||||
|
}}
|
||||||
|
title={dialogTitle}
|
||||||
|
onSave={draft ? saveDraft : () => handleClose(false)}
|
||||||
|
onCancel={draft ? reset : () => handleClose(false)}
|
||||||
|
saveLabel={draft ? "Save widget" : "Done"}
|
||||||
|
isPending={draft ? saveWidget.isPending : false}
|
||||||
|
isDirty={draft !== null}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">{draftBody}</div>
|
||||||
|
</SheetForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
{draft
|
|
||||||
? draft.id
|
|
||||||
? "Edit widget"
|
|
||||||
: "Add widget"
|
|
||||||
: "Dashboard widgets"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{draftBody}
|
||||||
{draft ? (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<Field label="Title" htmlFor="widget-title">
|
|
||||||
<Input
|
|
||||||
id="widget-title"
|
|
||||||
value={draft.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft({ ...draft, title: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Sort order" htmlFor="widget-sort-order">
|
|
||||||
<Input
|
|
||||||
id="widget-sort-order"
|
|
||||||
type="number"
|
|
||||||
value={String(draft.sortOrder)}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft({
|
|
||||||
...draft,
|
|
||||||
sortOrder:
|
|
||||||
e.target.value === "" ? 0 : Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="widget-enabled"
|
|
||||||
checked={draft.enabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setDraft({ ...draft, enabled: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="widget-enabled">Enabled</Label>
|
|
||||||
</div>
|
|
||||||
<WidgetConfigEditor
|
|
||||||
binding={draftBinding}
|
|
||||||
isTaskOutput={!!isTaskOutput}
|
|
||||||
config={draft.config}
|
|
||||||
onChange={(config) => setDraft({ ...draft, config })}
|
|
||||||
tasks={tasks}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={reset}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button onClick={saveDraft} disabled={saveWidget.isPending}>
|
|
||||||
Save widget
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{sortedInstances.length === 0 ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
No widgets yet. Add one below.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{sortedInstances.map((instance, index) => {
|
|
||||||
const serviceName = instance.service_id
|
|
||||||
? services.find((s) => s.id === instance.service_id)?.name
|
|
||||||
: "Built-in";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={instance.id}
|
|
||||||
className="flex items-center gap-2 rounded border p-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">{instance.title}</span>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{bindingLabel(
|
|
||||||
instance.service_id,
|
|
||||||
instance.widget_kind,
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
{serviceName ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{serviceName}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{!instance.enabled ? (
|
|
||||||
<Badge variant="secondary">disabled</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={index === 0}
|
|
||||||
onClick={() => moveInstance(index, -1)}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={index === sortedInstances.length - 1}
|
|
||||||
onClick={() => moveInstance(index, 1)}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Switch
|
|
||||||
checked={instance.enabled}
|
|
||||||
onCheckedChange={() => toggleEnabled(instance)}
|
|
||||||
aria-label={`Toggle ${instance.title}`}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => startEdit(instance)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-destructive"
|
|
||||||
onClick={() => removeInstance(instance)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="text-sm font-medium">Add widget</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Object.values(BUILTIN_WIDGETS).map((b) => (
|
|
||||||
<Button
|
|
||||||
key={b.kind}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => startAddBuiltIn(b.kind)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
{b.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
{services
|
|
||||||
.filter((s) => s.enabled)
|
|
||||||
.flatMap((s) =>
|
|
||||||
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map(
|
|
||||||
(w) => (
|
|
||||||
<Button
|
|
||||||
key={`${s.id}:${w.kind}`}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => startAddService(s.id, w.kind)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
{w.name} · {s.name}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Configure services on their service pages to unlock more
|
|
||||||
widgets.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import BackupAlertsTable from "../BackupAlertsTable";
|
import BackupAlertsTable from "../BackupAlertsTable";
|
||||||
@@ -61,3 +61,46 @@ describe("BackupAlertsTable", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// jsdom lacks matchMedia; default to desktop so existing tests are unaffected.
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("BackupAlertsTable (mobile card layout — slice 5)", () => {
|
||||||
|
it("renders cards with message as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(
|
||||||
|
<BackupAlertsTable
|
||||||
|
alerts={[alert({ id: "m1", message: "Disk full" })]}
|
||||||
|
onAcknowledge={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Disk full")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Severity")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders acknowledge action on card below md", async () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
const onAck = vi.fn();
|
||||||
|
render(
|
||||||
|
<BackupAlertsTable
|
||||||
|
alerts={[alert({ id: "a1", acknowledged: false })]}
|
||||||
|
onAcknowledge={onAck}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Ack" }));
|
||||||
|
expect(onAck).toHaveBeenCalledWith("a1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import BackupJobsTable from "../BackupJobsTable";
|
||||||
|
import type { BackupJob, BackupRun } from "../../types/backups";
|
||||||
|
|
||||||
|
function job(overrides: Partial<BackupJob> = {}): BackupJob {
|
||||||
|
return {
|
||||||
|
id: "j1",
|
||||||
|
name: "nightly",
|
||||||
|
source: "/data",
|
||||||
|
target: "s3://bucket",
|
||||||
|
schedule_interval_seconds: 86400,
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(overrides: Partial<BackupRun> = {}): BackupRun {
|
||||||
|
return {
|
||||||
|
id: "r1",
|
||||||
|
job_id: "j1",
|
||||||
|
started_at: 1_700_000_000,
|
||||||
|
ended_at: null,
|
||||||
|
status: "success",
|
||||||
|
bytes_transferred: 2048,
|
||||||
|
duration_ms: 1500,
|
||||||
|
error_message: null,
|
||||||
|
details_json: null,
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsdom lacks matchMedia; default to desktop so the table renders.
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("BackupJobsTable (desktop)", () => {
|
||||||
|
it("renders job name and schedule interval", () => {
|
||||||
|
render(
|
||||||
|
<BackupJobsTable
|
||||||
|
jobs={[job({ name: "nightly", schedule_interval_seconds: 86400 })]}
|
||||||
|
latestRuns={new Map()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1d")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BackupJobsTable (mobile card layout — slice 5)", () => {
|
||||||
|
it("renders cards with job name as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(
|
||||||
|
<BackupJobsTable
|
||||||
|
jobs={[job({ id: "j1", name: "nightly", source: "/data" })]}
|
||||||
|
latestRuns={
|
||||||
|
new Map([["j1", run({ status: "success" })]]) as Map<string, BackupRun>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Source")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Schedule")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Last status")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import BackupRunsTable from "../BackupRunsTable";
|
import BackupRunsTable from "../BackupRunsTable";
|
||||||
import type { BackupRun } from "../../types/backups";
|
import type { BackupRun } from "../../types/backups";
|
||||||
@@ -57,3 +57,29 @@ describe("BackupRunsTable", () => {
|
|||||||
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
|
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// jsdom lacks matchMedia; default to desktop so existing tests are unaffected.
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("BackupRunsTable (mobile card layout — slice 5)", () => {
|
||||||
|
it("renders cards with job_id as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<BackupRunsTable runs={[run({ id: "r1", job_id: "nightly" })]} />);
|
||||||
|
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Status")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Duration")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,4 +18,23 @@ describe("HoverEditButton", () => {
|
|||||||
screen.getByRole("button", { name: "Rename machine" }),
|
screen.getByRole("button", { name: "Rename machine" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults to always-visible below md (mobile="always")', () => {
|
||||||
|
render(<HoverEditButton onClick={() => {}} />);
|
||||||
|
const button = screen.getByRole("button", { name: "Edit" });
|
||||||
|
const tokens = button.className.split(/\s+/);
|
||||||
|
// The default mobile mode layers hover-reveal only at md+ via
|
||||||
|
// md:opacity-0/md:group-hover:opacity-100, so the button is visible by
|
||||||
|
// default below md (no base opacity-0 token).
|
||||||
|
expect(tokens).toContain("md:opacity-0");
|
||||||
|
expect(tokens).toContain("md:group-hover:opacity-100");
|
||||||
|
expect(tokens).not.toContain("opacity-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the legacy opacity-0 behavior when mobile="hover"', () => {
|
||||||
|
render(<HoverEditButton onClick={() => {}} mobile="hover" />);
|
||||||
|
const button = screen.getByRole("button", { name: "Edit" });
|
||||||
|
expect(button.className).toContain("opacity-0");
|
||||||
|
expect(button.className).toContain("group-hover:opacity-100");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { WidgetConfigDialog } from "../WidgetConfigDialog";
|
||||||
|
|
||||||
|
// jsdom has no window.matchMedia; default to desktop (matches: false).
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
|
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
|
useTasks: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("WidgetConfigDialog (desktop)", () => {
|
||||||
|
it("renders a Dialog with the dashboard widgets title at md+", () => {
|
||||||
|
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Dashboard widgets" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WidgetConfigDialog (mobile SheetForm — slice 8)", () => {
|
||||||
|
beforeEach(() => setMatchMedia(true));
|
||||||
|
|
||||||
|
it("renders a SheetForm with the dashboard widgets title below md", () => {
|
||||||
|
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||||
|
expect(screen.getByText("Dashboard widgets")).toBeInTheDocument();
|
||||||
|
// List mode footer: "Done" button closes.
|
||||||
|
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts before discarding a widget draft (R4.5)", async () => {
|
||||||
|
const { userEvent } = await import("@testing-library/user-event");
|
||||||
|
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||||
|
|
||||||
|
// Enter draft mode by clicking an "Add widget" button.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Backups/i }));
|
||||||
|
|
||||||
|
// Now in draft mode — Cancel should prompt before resetting.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MobileCardRow, type MobileCardField } from "../mobile-card";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
size: string;
|
||||||
|
year: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: "a", title: "Movie A", size: "4.2GB", year: 2026 },
|
||||||
|
{ id: "b", title: "Movie B", size: "2.1GB", year: 2025 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fields: MobileCardField<Row>[] = [
|
||||||
|
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||||
|
{ key: "size", label: "Size", render: (r) => r.size },
|
||||||
|
{ key: "year", label: "Year", render: (r) => r.year },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("MobileCardRow", () => {
|
||||||
|
it("renders the primary field as a title and the rest as key/value pairs", () => {
|
||||||
|
render(<MobileCardRow rows={rows} fields={fields} />);
|
||||||
|
|
||||||
|
// Primary title
|
||||||
|
expect(screen.getByText("Movie A")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Movie B")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Field labels and values (appear once per row)
|
||||||
|
expect(screen.getAllByText("Size")).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText("4.2GB")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Year")).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText("2026")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onRowClick when the card is tapped", async () => {
|
||||||
|
const onRowClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<MobileCardRow rows={rows} fields={fields} onRowClick={onRowClick} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Movie A"));
|
||||||
|
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRowClick).toHaveBeenCalledWith(rows[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the actions slot per row", () => {
|
||||||
|
render(
|
||||||
|
<MobileCardRow
|
||||||
|
rows={rows}
|
||||||
|
fields={fields}
|
||||||
|
actions={(r) => (
|
||||||
|
<button type="button" onClick={() => undefined}>
|
||||||
|
edit-{r.id}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("edit-a")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("edit-b")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a non-interactive card when onRowClick is absent", () => {
|
||||||
|
render(<MobileCardRow rows={rows} fields={fields} />);
|
||||||
|
// No buttons wrapping the cards.
|
||||||
|
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
||||||
|
expect(screen.getByText("Movie A")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when rows is empty", () => {
|
||||||
|
const { container } = render(<MobileCardRow rows={[]} fields={fields} />);
|
||||||
|
const cards = container.querySelector(".flex.flex-col.gap-2");
|
||||||
|
expect(cards?.children).toHaveLength(0);
|
||||||
|
expect(screen.queryByText("Size")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a card without a title when no primary field is set", () => {
|
||||||
|
const noPrimary: MobileCardField<Row>[] = fields.filter(
|
||||||
|
(f) => f.key !== "title",
|
||||||
|
);
|
||||||
|
render(<MobileCardRow rows={rows} fields={noPrimary} />);
|
||||||
|
// No title text rendered, but the key/value stack still is.
|
||||||
|
expect(screen.queryByText("Movie A")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Size")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses getRowId for stable keys and emits no duplicate-key warning", () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
render(<MobileCardRow rows={rows} fields={fields} getRowId={(r) => r.id} />);
|
||||||
|
// No React duplicate-key warning should fire.
|
||||||
|
const duplicateKeyCalls = errorSpy.mock.calls.filter((args) =>
|
||||||
|
String(args[0] ?? "").includes("same key"),
|
||||||
|
);
|
||||||
|
expect(duplicateKeyCalls).toHaveLength(0);
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { SheetForm } from "../sheet-form";
|
||||||
|
|
||||||
|
describe("SheetForm", () => {
|
||||||
|
it("renders the title and children", () => {
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit service"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
>
|
||||||
|
<input aria-label="Name" />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Edit service")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSave when Save is clicked", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={onSave}
|
||||||
|
onCancel={() => {}}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onCancel when Cancel is clicked", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Save and shows a pending label when isPending", () => {
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
isPending
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole("button", { name: /Saving/i });
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
expect(screen.getByText("Saving…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onCancel when the close (X) button is clicked", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dirty-state confirm (R4.5)", () => {
|
||||||
|
it("prompts before discarding via Cancel when isDirty", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
isDirty
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel does not immediately close; a confirm opens.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Confirm discard -> actually closes.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Discard" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closing the confirm without discarding keeps the form open", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
isDirty
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
// Two Cancel buttons now exist: the SheetForm footer and the confirm dialog.
|
||||||
|
const cancelButtons = screen.getAllByRole("button", { name: "Cancel" });
|
||||||
|
await userEvent.click(cancelButtons[cancelButtons.length - 1]);
|
||||||
|
expect(onCancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes immediately when not dirty", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
type OnChangeFn,
|
type OnChangeFn,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type Table as TableInstance,
|
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -18,6 +17,7 @@ import { Columns3 } from "lucide-react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { TablePagination } from "@/components/ui/table-pagination";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -34,13 +34,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
export interface DataTableProps<TData, TValue = unknown> {
|
export interface DataTableProps<TData, TValue = unknown> {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
@@ -253,90 +246,19 @@ export function DataTable<TData, TValue = unknown>({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{enablePagination && (
|
{enablePagination && (
|
||||||
<DataTablePagination
|
<TablePagination
|
||||||
table={table}
|
pageIndex={table.getState().pagination.pageIndex}
|
||||||
|
pageSize={table.getState().pagination.pageSize}
|
||||||
pageSizeOptions={pageSizeOptions}
|
pageSizeOptions={pageSizeOptions}
|
||||||
|
totalRows={manualPagination ? (rowCount ?? 0) : table.getRowModel().rows.length}
|
||||||
pageCount={pageCount}
|
pageCount={pageCount}
|
||||||
manual={manualPagination}
|
onPaginationChange={table.setPagination}
|
||||||
rowCount={rowCount}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginationProps<TData> {
|
// DataTablePagination was extracted into the shared TablePagination component
|
||||||
table: TableInstance<TData>;
|
// (frontend/src/components/ui/table-pagination.tsx). Both the desktop DataTable
|
||||||
pageSizeOptions: number[];
|
// and the Media mobile card list consume it.
|
||||||
pageCount: number;
|
|
||||||
manual: boolean;
|
|
||||||
rowCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataTablePagination<TData>({
|
|
||||||
table,
|
|
||||||
pageSizeOptions,
|
|
||||||
pageCount,
|
|
||||||
manual,
|
|
||||||
rowCount,
|
|
||||||
}: PaginationProps<TData>) {
|
|
||||||
const pageIndex = table.getState().pagination.pageIndex;
|
|
||||||
const pageSize = table.getState().pagination.pageSize;
|
|
||||||
const visibleRows = table.getRowModel().rows.length;
|
|
||||||
const totalRows = manual ? (rowCount ?? 0) : visibleRows;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-muted-foreground">Rows per page</span>
|
|
||||||
<Select
|
|
||||||
value={String(pageSize)}
|
|
||||||
onValueChange={(value) => table.setPageSize(Number(value))}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
size="sm"
|
|
||||||
className="w-[70px]"
|
|
||||||
aria-label="Rows per page"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{pageSizeOptions.map((option) => (
|
|
||||||
<SelectItem key={option} value={String(option)}>
|
|
||||||
{option}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Page {pageIndex + 1} of {pageCount}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
aria-label="Previous page"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
aria-label="Next page"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field descriptor for a {@link MobileCardRow}.
|
||||||
|
*
|
||||||
|
* The consuming page decides which fields to show and in what order; this
|
||||||
|
* primitive does not pick them. Exactly one field should set `primary: true` —
|
||||||
|
* it renders as the card title (bold, larger). The rest render as a key/value
|
||||||
|
* stack below the title.
|
||||||
|
*/
|
||||||
|
export interface MobileCardField<T> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
render: (row: T) => React.ReactNode;
|
||||||
|
/** When true, render as the card title (bold, larger). One per card. */
|
||||||
|
primary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileCardRowProps<T> {
|
||||||
|
rows: T[];
|
||||||
|
fields: MobileCardField<T>[];
|
||||||
|
/** Stable per-row identity; falls back to the row index when omitted. */
|
||||||
|
getRowId?: (row: T) => string;
|
||||||
|
/** When set, the whole card becomes a button (44px min height). */
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
/** Optional right-aligned action slot (edit/delete icon buttons). */
|
||||||
|
actions?: (row: T) => React.ReactNode;
|
||||||
|
/** Optional className for the outer list container. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stacked card list for wide tables below the `md:` breakpoint.
|
||||||
|
*
|
||||||
|
* Each row renders as a card: the `primary` field as the title and the
|
||||||
|
* remaining fields as a key/value stack. When `onRowClick` is provided the
|
||||||
|
* whole card is a button with a 44px minimum touch target (spec R6.1). An
|
||||||
|
* optional `actions` slot renders right-aligned controls.
|
||||||
|
*
|
||||||
|
* This is the mobile counterpart to {@link DataTable}; pages branch on
|
||||||
|
* `useIsMobile()`. See OpenSpec change `mobile-responsive-parity`, design
|
||||||
|
* §`MobileCardRow`.
|
||||||
|
*/
|
||||||
|
export function MobileCardRow<T>({
|
||||||
|
rows,
|
||||||
|
fields,
|
||||||
|
getRowId,
|
||||||
|
onRowClick,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: MobileCardRowProps<T>) {
|
||||||
|
const primary = fields.find((f) => f.primary);
|
||||||
|
const rest = fields.filter((f) => !f.primary);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-2", className)}>
|
||||||
|
{rows.map((row, index) => {
|
||||||
|
const rowKey = getRowId?.(row) ?? String(index);
|
||||||
|
const body = (
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
{primary ? (
|
||||||
|
<div className="truncate text-sm font-medium text-foreground">
|
||||||
|
{primary.render(row)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{rest.length > 0 ? (
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{rest.map((field) => (
|
||||||
|
<React.Fragment key={field.key}>
|
||||||
|
<dt className="font-medium text-muted-foreground">
|
||||||
|
{field.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="truncate text-foreground">
|
||||||
|
{field.render(row)}
|
||||||
|
</dd>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{actions(row)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onRowClick) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rowKey}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onRowClick(row)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onRowClick(row);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mobile-touch-target min-h-11 w-full cursor-pointer rounded-lg border border-border bg-card p-3 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rowKey}
|
||||||
|
className="min-h-11 rounded-lg border border-border bg-card p-3"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Loader2, XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
|
|
||||||
|
export interface SheetFormProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
/** Disable Save and show a pending spinner. */
|
||||||
|
isPending?: boolean;
|
||||||
|
/** Override the Save button label (default "Save"). */
|
||||||
|
saveLabel?: string;
|
||||||
|
/** Disable the Save button (e.g. when required fields are empty). */
|
||||||
|
saveDisabled?: boolean;
|
||||||
|
/**
|
||||||
|
* When true, any close attempt (Cancel button, header X, overlay click,
|
||||||
|
* Escape) prompts a discard-confirmation instead of immediately closing.
|
||||||
|
* Spec R4.5.
|
||||||
|
*/
|
||||||
|
isDirty?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Optional className applied to the scrolling body. */
|
||||||
|
bodyClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-height form host for the mobile (`< md`) breakpoint.
|
||||||
|
*
|
||||||
|
* Wraps the shadcn `Sheet` primitive with a fixed header (title + close) and a
|
||||||
|
* fixed footer (Cancel + Save). The body scrolls between them. Laid out as a
|
||||||
|
* flex column (NOT `position: sticky`) because Radix `Sheet` uses transforms,
|
||||||
|
* which break sticky positioning — see OpenSpec change
|
||||||
|
* `mobile-responsive-parity`, design §`SheetForm` / risks.
|
||||||
|
*
|
||||||
|
* Uses `h-[100dvh]` (not `h-screen`) to avoid the iOS Safari URL-bar resize
|
||||||
|
* jump. Consumers choose this host vs the desktop `Dialog` via `useIsMobile()`.
|
||||||
|
*/
|
||||||
|
export function SheetForm({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
isPending = false,
|
||||||
|
saveDisabled = false,
|
||||||
|
saveLabel = "Save",
|
||||||
|
isDirty = false,
|
||||||
|
children,
|
||||||
|
bodyClassName,
|
||||||
|
}: SheetFormProps) {
|
||||||
|
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
|
||||||
|
|
||||||
|
// Route every close path (Cancel, header X, Radix overlay/Escape) through one
|
||||||
|
// guard so the dirty-confirm is applied uniformly (spec R4.5).
|
||||||
|
const attemptClose = React.useCallback(() => {
|
||||||
|
if (isDirty) {
|
||||||
|
setConfirmDiscardOpen(true);
|
||||||
|
} else {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, [isDirty, onCancel]);
|
||||||
|
|
||||||
|
const handleOpenChange = React.useCallback(
|
||||||
|
(next: boolean) => {
|
||||||
|
if (!next) {
|
||||||
|
attemptClose();
|
||||||
|
} else {
|
||||||
|
onOpenChange(next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attemptClose, onOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-full"
|
||||||
|
onEscapeKeyDown={(e) => {
|
||||||
|
// Prevent Radix's default Escape close so our guard runs instead.
|
||||||
|
if (isDirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
attemptClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDownOutside={(e) => {
|
||||||
|
// Prevent overlay-click close so our guard runs instead.
|
||||||
|
if (isDirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
attemptClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header — fixed at top */}
|
||||||
|
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4">
|
||||||
|
<SheetTitle className="font-heading text-base font-medium">
|
||||||
|
{title}
|
||||||
|
</SheetTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={attemptClose}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body — scrolls */}
|
||||||
|
<div className={cn("flex-1 overflow-y-auto p-4", bodyClassName)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer — fixed at bottom */}
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-muted/50 p-4">
|
||||||
|
<Button variant="outline" onClick={attemptClose} disabled={isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onSave} disabled={isPending || saveDisabled}>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
saveLabel
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDiscardOpen}
|
||||||
|
title="Discard changes?"
|
||||||
|
message="You have unsaved changes. Discard them and close?"
|
||||||
|
confirmLabel="Discard"
|
||||||
|
onCancel={() => setConfirmDiscardOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmDiscardOpen(false);
|
||||||
|
onCancel();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { OnChangeFn } from "@tanstack/react-table";
|
||||||
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared pagination footer for table-style views.
|
||||||
|
*
|
||||||
|
* Renders the rows count, page-size select, page indicator, and prev/next
|
||||||
|
* buttons. Works off the raw {@link PaginationState} primitives so it can back
|
||||||
|
* both a TanStack `Table` instance (via a thin adapter) and standalone card
|
||||||
|
* layouts that drive pagination directly (e.g. MediaMobilePagination).
|
||||||
|
*
|
||||||
|
* The Desktop DataTable and the Media mobile card list both consume this to
|
||||||
|
* avoid the duplication flagged in
|
||||||
|
* `openspec/changes/mobile-responsive-parity/verify-report.md` residual risk #5.
|
||||||
|
*/
|
||||||
|
export interface TablePaginationProps {
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageSizeOptions: number[];
|
||||||
|
totalRows: number;
|
||||||
|
pageCount: number;
|
||||||
|
onPaginationChange: OnChangeFn<PaginationState>;
|
||||||
|
/** Optional extra className on the outer container (e.g. "p-4"). */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TablePagination({
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
pageSizeOptions,
|
||||||
|
totalRows,
|
||||||
|
pageCount,
|
||||||
|
onPaginationChange,
|
||||||
|
className,
|
||||||
|
}: TablePaginationProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center justify-between gap-3 text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-muted-foreground">Rows per page</span>
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onPaginationChange(() => ({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: Number(value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
size="sm"
|
||||||
|
className="w-[70px]"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{pageSizeOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={String(option)}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Page {pageIndex + 1} of {pageCount}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() =>
|
||||||
|
onPaginationChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageIndex: Math.max(0, prev.pageIndex - 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={pageIndex <= 0}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() =>
|
||||||
|
onPaginationChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageIndex: prev.pageIndex + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={pageIndex >= pageCount - 1}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Mobile breakpoint (must match Tailwind `md:` and the OpenSpec spec R1.2). */
|
||||||
|
const MOBILE_QUERY = "(max-width: 768px)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the mobile/desktop responsive cut.
|
||||||
|
*
|
||||||
|
* Returns `true` when the viewport matches `max-width: 768px` (phone portrait),
|
||||||
|
* `false` at `md:` and above. SSR-safe: returns `false` when `window` is
|
||||||
|
* undefined so server-rendered markup stays on the desktop path.
|
||||||
|
*
|
||||||
|
* Replaces the ad-hoc `window.matchMedia("(max-width: 768px)")` reads scattered
|
||||||
|
* across pages (App.tsx, Media.tsx) — see OpenSpec change
|
||||||
|
* `mobile-responsive-parity`, design §`useIsMobile`.
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
const [isMobile, setIsMobile] = useState(
|
||||||
|
() =>
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof window.matchMedia === "function" &&
|
||||||
|
window.matchMedia(MOBILE_QUERY).matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
typeof window === "undefined" ||
|
||||||
|
typeof window.matchMedia !== "function"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const mql = window.matchMedia(MOBILE_QUERY);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
|
mql.addEventListener("change", handler);
|
||||||
|
return () => mql.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ export function useMediaStatus(jellyfinServiceId?: string) {
|
|||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
refetchInterval: (query) =>
|
refetchInterval: (query) =>
|
||||||
query.state.data?.build_running ? 1000 : false,
|
query.state.data?.build_running ? 1000 : false,
|
||||||
refetchIntervalInBackground: true,
|
// Inherit the default refetchIntervalInBackground: false — pause the
|
||||||
|
// 1s build-progress poll when the tab is hidden. The build keeps
|
||||||
|
// running server-side; the poll resumes and catches up on return.
|
||||||
|
// Battery-friendly (D8 follow-up).
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,3 +100,19 @@ body,
|
|||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mobile touch-target utility (spec R6.1).
|
||||||
|
*
|
||||||
|
* Applies a 44x44px minimum hit area to interactive elements ONLY below the
|
||||||
|
* `md:` (768px) breakpoint, satisfying WCAG 2.5.5 / Apple HIG on touch devices.
|
||||||
|
* At md+ the class is inert so desktop sizing is not regressed. Pages sprinkle
|
||||||
|
* this on icon buttons, checkboxes, switches, and row taps. See OpenSpec
|
||||||
|
* change `mobile-responsive-parity`, design §`mobile-touch-target`.
|
||||||
|
*/
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-touch-target {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ function TaskDialog({
|
|||||||
confirmBusyLabel="Save action"
|
confirmBusyLabel="Save action"
|
||||||
secondaryAction={
|
secondaryAction={
|
||||||
onDelete ? (
|
onDelete ? (
|
||||||
<Button variant="destructive" onClick={onDelete}>
|
<Button variant="destructive" onClick={onDelete} className="mobile-touch-target">
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -367,7 +367,7 @@ export function Actions() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="mobile-touch-target w-full"
|
||||||
onClick={createNew}
|
onClick={createNew}
|
||||||
>
|
>
|
||||||
Add action
|
Add action
|
||||||
@@ -412,13 +412,13 @@ export function Actions() {
|
|||||||
description="Open the editor popup to modify this action."
|
description="Open the editor popup to modify this action."
|
||||||
action={
|
action={
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openEdit(initialFromTask(editingTask))}
|
onClick={() => openEdit(initialFromTask(editingTask))}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button className="mobile-touch-target"
|
||||||
disabled={runTask.isPending || !runServiceId}
|
disabled={runTask.isPending || !runServiceId}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await runTask.mutateAsync({
|
await runTask.mutateAsync({
|
||||||
@@ -515,7 +515,7 @@ export function Actions() {
|
|||||||
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup. Use the button at the bottom to add a new action."
|
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup. Use the button at the bottom to add a new action."
|
||||||
>
|
>
|
||||||
{tasks[0] && (
|
{tasks[0] && (
|
||||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
<Button variant="outline" onClick={() => setTab(tasks[0].id)} className="mobile-touch-target">
|
||||||
Select first action
|
Select first action
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
DatabaseBackup,
|
||||||
|
LayoutDashboard,
|
||||||
|
Monitor,
|
||||||
|
} from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -26,13 +32,122 @@ import {
|
|||||||
useSaveDashboardShortcut,
|
useSaveDashboardShortcut,
|
||||||
} from "../hooks/useDashboard";
|
} from "../hooks/useDashboard";
|
||||||
import { useWidgetInstances } from "../hooks/useWidgets";
|
import { useWidgetInstances } from "../hooks/useWidgets";
|
||||||
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
|
import type {
|
||||||
|
DashboardShortcut,
|
||||||
|
DashboardShortcutInput,
|
||||||
|
ServiceInstance,
|
||||||
|
WidgetInstance,
|
||||||
|
} from "../types";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
||||||
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
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 {
|
function emptyShortcut(): DashboardShortcutInput {
|
||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
@@ -239,6 +354,7 @@ function ShortcutDialog({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="shortcut-enabled"
|
id="shortcut-enabled"
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
onChange({ ...draft, enabled: checked })
|
onChange({ ...draft, enabled: checked })
|
||||||
@@ -309,13 +425,24 @@ function ShortcutCard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={!shortcut.enabled || !href}
|
disabled={!shortcut.enabled || !href}
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
|
className="mobile-touch-target"
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={onEdit}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="destructive" onClick={onDelete}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,6 +463,8 @@ export function Dashboard() {
|
|||||||
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
||||||
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
||||||
const { data: widgetInstances = [] } = useWidgetInstances();
|
const { data: widgetInstances = [] } = useWidgetInstances();
|
||||||
|
const { data: services = [] } = useServiceInstances();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const visibleWidgets = useMemo(
|
const visibleWidgets = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -345,6 +474,11 @@ export function Dashboard() {
|
|||||||
[widgetInstances],
|
[widgetInstances],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mobileSections = useMemo(
|
||||||
|
() => groupWidgetsBySection(visibleWidgets, services),
|
||||||
|
[visibleWidgets, services],
|
||||||
|
);
|
||||||
|
|
||||||
const openCreateShortcut = () => {
|
const openCreateShortcut = () => {
|
||||||
setShortcutDraft(emptyShortcut());
|
setShortcutDraft(emptyShortcut());
|
||||||
setShortcutDialogOpen(true);
|
setShortcutDialogOpen(true);
|
||||||
@@ -379,10 +513,18 @@ export function Dashboard() {
|
|||||||
description="Quick links to websites today, with room for action and user shortcuts later."
|
description="Quick links to websites today, with room for action and user shortcuts later."
|
||||||
action={
|
action={
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={() => setWidgetDialogOpen(true)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => setWidgetDialogOpen(true)}
|
||||||
|
>
|
||||||
Edit dashboard
|
Edit dashboard
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={openCreateShortcut}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={openCreateShortcut}
|
||||||
|
>
|
||||||
Add shortcut
|
Add shortcut
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,9 +559,13 @@ export function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{visibleWidgets.map((widget) => (
|
{isMobile && mobileSections.length > 0 ? (
|
||||||
<WidgetInstanceCard key={widget.id} widget={widget} />
|
<MobileWidgetSections sections={mobileSections} />
|
||||||
))}
|
) : (
|
||||||
|
visibleWidgets.map((widget) => (
|
||||||
|
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
<ShortcutDialog
|
<ShortcutDialog
|
||||||
open={shortcutDialogOpen}
|
open={shortcutDialogOpen}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { useNavigate, useSearchParams } from "react-router-dom";
|
|||||||
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||||
|
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -24,6 +28,7 @@ import {
|
|||||||
useRunJob,
|
useRunJob,
|
||||||
} from "../hooks/useFiles";
|
} from "../hooks/useFiles";
|
||||||
import { usePersistentState } from "../hooks/usePersistentState";
|
import { usePersistentState } from "../hooks/usePersistentState";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { TabbedCard } from "../components/TabbedCard";
|
import { TabbedCard } from "../components/TabbedCard";
|
||||||
@@ -182,6 +187,18 @@ const fileColumns: ColumnDef<DisplayRow>[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
|
||||||
|
// Name is the primary identifier; type distinguishes dir/file/up at a glance;
|
||||||
|
// size and modified give the at-a-glance info a user browsing files on a phone
|
||||||
|
// needs. Ext is redundant with the name on mobile (the extension is visible in
|
||||||
|
// the filename itself). See OpenSpec change `mobile-responsive-parity`.
|
||||||
|
const fileCardFields: MobileCardField<DisplayRow>[] = [
|
||||||
|
{ key: "name", label: "Name", render: (r) => r.name, primary: true },
|
||||||
|
{ key: "type", label: "Type", render: (r) => r.type },
|
||||||
|
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
||||||
|
{ key: "modified", label: "Modified", render: (r) => r.modified || "-" },
|
||||||
|
];
|
||||||
|
|
||||||
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
|
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
|
||||||
|
|
||||||
type FileBrowserState = {
|
type FileBrowserState = {
|
||||||
@@ -501,6 +518,7 @@ function InfoAlert({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
export function FileBrowser() {
|
export function FileBrowser() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [columnVisibility, setColumnVisibility] = useState<
|
const [columnVisibility, setColumnVisibility] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
@@ -700,14 +718,14 @@ export function FileBrowser() {
|
|||||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto mobile-touch-target"
|
||||||
onClick={() => navigate(pathInput || "/")}
|
onClick={() => navigate(pathInput || "/")}
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto mobile-touch-target"
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
@@ -725,23 +743,34 @@ export function FileBrowser() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="rounded-lg border bg-card">
|
||||||
<DataTable
|
{isMobile ? (
|
||||||
columns={fileColumns}
|
<div className="p-4">
|
||||||
data={rows}
|
<MobileCardRow
|
||||||
getRowId={(row) => row.id}
|
rows={rows}
|
||||||
enableRowSelection
|
fields={fileCardFields}
|
||||||
rowSelection={rowSelection}
|
getRowId={(row) => row.id}
|
||||||
onRowSelectionChange={handleSelectionChange}
|
onRowClick={handleRowClick}
|
||||||
onRowClick={handleRowClick}
|
/>
|
||||||
enableColumnVisibilityToggle
|
</div>
|
||||||
columnVisibility={columnVisibility}
|
) : (
|
||||||
onColumnVisibilityChange={setColumnVisibility}
|
<DataTable
|
||||||
emptyMessage={
|
columns={fileColumns}
|
||||||
isLoading
|
data={rows}
|
||||||
? "Loading directory..."
|
getRowId={(row) => row.id}
|
||||||
: "This directory is empty."
|
enableRowSelection
|
||||||
}
|
rowSelection={rowSelection}
|
||||||
/>
|
onRowSelectionChange={handleSelectionChange}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
enableColumnVisibilityToggle
|
||||||
|
columnVisibility={columnVisibility}
|
||||||
|
onColumnVisibilityChange={setColumnVisibility}
|
||||||
|
emptyMessage={
|
||||||
|
isLoading
|
||||||
|
? "Loading directory..."
|
||||||
|
: "This directory is empty."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -808,7 +837,7 @@ export function FileBrowser() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
|
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
|
||||||
<Button
|
<Button className="mobile-touch-target"
|
||||||
disabled={!selectedJob || runJob.isPending}
|
disabled={!selectedJob || runJob.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
runJob.mutate({
|
runJob.mutate({
|
||||||
@@ -850,6 +879,7 @@ export function FileBrowser() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigateToSettings("/settings")}
|
onClick={() => navigateToSettings("/settings")}
|
||||||
|
className="mobile-touch-target"
|
||||||
>
|
>
|
||||||
Open Settings
|
Open Settings
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import type {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { TablePagination } from "@/components/ui/table-pagination";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -31,6 +36,7 @@ import {
|
|||||||
useForceStopBuildIndex,
|
useForceStopBuildIndex,
|
||||||
} from "../hooks/useMedia";
|
} from "../hooks/useMedia";
|
||||||
import { usePersistentState } from "../hooks/usePersistentState";
|
import { usePersistentState } from "../hooks/usePersistentState";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type { MediaItem } from "../types";
|
import type { MediaItem } from "../types";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||||
@@ -75,6 +81,29 @@ function getMediaRowId(row: MediaItem): string {
|
|||||||
return row.path;
|
return row.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
|
||||||
|
// Title is the primary identifier; size/HDR/library/year give the at-a-glance
|
||||||
|
// tech + context info a user scanning the library on a phone needs. Runtime,
|
||||||
|
// bitrate, resolution, codec etc. live on the desktop table only.
|
||||||
|
const mediaCardFields: MobileCardField<MediaItem>[] = [
|
||||||
|
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||||
|
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
||||||
|
{
|
||||||
|
key: "hdr",
|
||||||
|
label: "HDR",
|
||||||
|
render: (r) => r.hdr || "-",
|
||||||
|
},
|
||||||
|
{ key: "library", label: "Library", render: (r) => r.library || "-" },
|
||||||
|
{
|
||||||
|
key: "year",
|
||||||
|
label: "Year",
|
||||||
|
render: (r) => (r.year != null ? String(r.year) : "-"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mobile pagination uses the shared TablePagination component
|
||||||
|
// (frontend/src/components/ui/table-pagination.tsx).
|
||||||
|
|
||||||
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
||||||
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
||||||
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
|
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
|
||||||
@@ -178,6 +207,7 @@ export function Media() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const isSmall = usePrefersSmallScreen();
|
const isSmall = usePrefersSmallScreen();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||||
const selectedServiceId =
|
const selectedServiceId =
|
||||||
searchParams.get("jellyfin_service_id") ||
|
searchParams.get("jellyfin_service_id") ||
|
||||||
@@ -367,7 +397,7 @@ export function Media() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => buildIndex.mutate()}
|
onClick={() => buildIndex.mutate()}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -378,7 +408,7 @@ export function Media() {
|
|||||||
</Button>
|
</Button>
|
||||||
{buildRunning && (
|
{buildRunning && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button className="mobile-touch-target"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => stopBuildIndex.mutate()}
|
onClick={() => stopBuildIndex.mutate()}
|
||||||
disabled={stopBuildIndex.isPending || buildCancelRequested}
|
disabled={stopBuildIndex.isPending || buildCancelRequested}
|
||||||
@@ -389,7 +419,7 @@ export function Media() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10"
|
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10 mobile-touch-target"
|
||||||
onClick={() => forceStopBuildIndex.mutate()}
|
onClick={() => forceStopBuildIndex.mutate()}
|
||||||
disabled={forceStopBuildIndex.isPending}
|
disabled={forceStopBuildIndex.isPending}
|
||||||
>
|
>
|
||||||
@@ -532,33 +562,56 @@ export function Media() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status?.exists && (
|
{status?.exists &&
|
||||||
<div className="rounded-lg border bg-card">
|
(isMobile ? (
|
||||||
<DataTable
|
<div className="rounded-lg border bg-card">
|
||||||
columns={mediaColumns}
|
<div className="p-4">
|
||||||
data={queryResult?.items ?? []}
|
<MobileCardRow
|
||||||
getRowId={getMediaRowId}
|
rows={queryResult?.items ?? []}
|
||||||
enableRowSelection
|
fields={mediaCardFields}
|
||||||
rowSelection={rowSelection}
|
getRowId={getMediaRowId}
|
||||||
onRowSelectionChange={setRowSelection}
|
onRowClick={handleRowClick}
|
||||||
onRowClick={handleRowClick}
|
/>
|
||||||
enableColumnVisibilityToggle
|
</div>
|
||||||
columnVisibility={effectiveColumnVisibility}
|
{queryResult && (
|
||||||
onColumnVisibilityChange={handleColumnVisibilityChange}
|
<TablePagination
|
||||||
enablePagination
|
pageIndex={pageIndex}
|
||||||
manualPagination
|
pageSize={pageSize}
|
||||||
pagination={pagination}
|
pageSizeOptions={[50, 100, 200]}
|
||||||
onPaginationChange={handlePaginationChange}
|
totalRows={total}
|
||||||
pageSizeOptions={[50, 100, 200]}
|
pageCount={totalPages}
|
||||||
rowCount={total}
|
onPaginationChange={handlePaginationChange}
|
||||||
emptyMessage={
|
className="p-4"
|
||||||
isLoading
|
/>
|
||||||
? "Loading media..."
|
)}
|
||||||
: "No media items match these filters."
|
</div>
|
||||||
}
|
) : (
|
||||||
/>
|
<div className="rounded-lg border bg-card">
|
||||||
</div>
|
<DataTable
|
||||||
)}
|
columns={mediaColumns}
|
||||||
|
data={queryResult?.items ?? []}
|
||||||
|
getRowId={getMediaRowId}
|
||||||
|
enableRowSelection
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
onRowSelectionChange={setRowSelection}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
enableColumnVisibilityToggle
|
||||||
|
columnVisibility={effectiveColumnVisibility}
|
||||||
|
onColumnVisibilityChange={handleColumnVisibilityChange}
|
||||||
|
enablePagination
|
||||||
|
manualPagination
|
||||||
|
pagination={pagination}
|
||||||
|
onPaginationChange={handlePaginationChange}
|
||||||
|
pageSizeOptions={[50, 100, 200]}
|
||||||
|
rowCount={total}
|
||||||
|
emptyMessage={
|
||||||
|
isLoading
|
||||||
|
? "Loading media..."
|
||||||
|
: "No media items match these filters."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+233
-132
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
useServiceInstances,
|
useServiceInstances,
|
||||||
useServiceTypes,
|
useServiceTypes,
|
||||||
} from "../hooks/useServices";
|
} from "../hooks/useServices";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type {
|
import type {
|
||||||
ServiceInstance,
|
ServiceInstance,
|
||||||
ServiceInstanceInput,
|
ServiceInstanceInput,
|
||||||
@@ -19,6 +20,7 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
|
import { SheetForm } from "@/components/ui/sheet-form";
|
||||||
import { getServiceBinding } from "../integrations/registry";
|
import { getServiceBinding } from "../integrations/registry";
|
||||||
|
|
||||||
function Field({
|
function Field({
|
||||||
@@ -63,11 +65,17 @@ export function ServicePage() {
|
|||||||
[types, serviceType],
|
[types, serviceType],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({});
|
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({});
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [hydrated, setHydrated] = useState(false);
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
// The mobile SheetForm opens by default when the page loads: this page is
|
||||||
|
// reached via /services/:serviceType/:serviceId, always editing an existing
|
||||||
|
// instance, so there is no separate "open edit" trigger on mobile.
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(true);
|
||||||
|
|
||||||
// Hydrate local form state once the instance loads.
|
// Hydrate local form state once the instance loads.
|
||||||
if (instance && !hydrated) {
|
if (instance && !hydrated) {
|
||||||
@@ -106,6 +114,117 @@ export function ServicePage() {
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
await saveService.mutateAsync(buildInput());
|
await saveService.mutateAsync(buildInput());
|
||||||
|
// R4.5: close the sheet on successful save and return to the services list
|
||||||
|
// (on mobile the sheet IS the page, so closing it would strand the user).
|
||||||
|
if (isMobile) {
|
||||||
|
setSheetOpen(false);
|
||||||
|
navigate("/services");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFields = (
|
||||||
|
<ServiceConnectionFields
|
||||||
|
instance={instance}
|
||||||
|
typeInfo={typeInfo}
|
||||||
|
draftConfig={draftConfig}
|
||||||
|
onConfigChange={setDraftConfig}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const widgetsCard =
|
||||||
|
binding.widgets.length > 0 ? (
|
||||||
|
<SectionCard
|
||||||
|
title="Widgets"
|
||||||
|
description="Widget kinds this service provides."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{binding.widgets.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.kind}
|
||||||
|
className="flex items-center justify-between rounded border p-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{w.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{w.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{w.kind}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add these to the dashboard from the dashboard's edit dialog.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const confirmDelete = (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
title="Delete service?"
|
||||||
|
message="This removes the service and any widgets that reference it. This cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
deleteService.mutate(instance.id);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dirty when any editable field diverges from the persisted instance (mobile SheetForm R4.5 guard).
|
||||||
|
const isDirty =
|
||||||
|
name !== instance.name ||
|
||||||
|
enabled !== instance.enabled ||
|
||||||
|
JSON.stringify(draftConfig) !== JSON.stringify(instance.config);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SheetForm
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={setSheetOpen}
|
||||||
|
title={name || instance.name}
|
||||||
|
onSave={save}
|
||||||
|
onCancel={() => {
|
||||||
|
setSheetOpen(false);
|
||||||
|
navigate("/services");
|
||||||
|
}}
|
||||||
|
isPending={saveService.isPending}
|
||||||
|
isDirty={isDirty}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Field label="Name" htmlFor="service-name">
|
||||||
|
<Input
|
||||||
|
id="service-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="service-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={setEnabled}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="service-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
{configFields}
|
||||||
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
Delete service
|
||||||
|
</Button>
|
||||||
|
{widgetsCard}
|
||||||
|
</div>
|
||||||
|
</SheetForm>
|
||||||
|
{confirmDelete}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,81 +249,52 @@ export function ServicePage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="service-enabled"
|
id="service-enabled"
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
onCheckedChange={setEnabled}
|
onCheckedChange={setEnabled}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="service-enabled">Enabled</Label>
|
<Label htmlFor="service-enabled">Enabled</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Button onClick={save} disabled={saveService.isPending}>
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={save}
|
||||||
|
disabled={saveService.isPending}
|
||||||
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<ServiceConnectionCard
|
{configFields}
|
||||||
instance={instance}
|
|
||||||
typeInfo={typeInfo}
|
|
||||||
draftConfig={draftConfig}
|
|
||||||
onConfigChange={setDraftConfig}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{binding.widgets.length > 0 ? (
|
{widgetsCard}
|
||||||
<SectionCard
|
|
||||||
title="Widgets"
|
|
||||||
description="Widget kinds this service provides."
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{binding.widgets.map((w) => (
|
|
||||||
<div
|
|
||||||
key={w.kind}
|
|
||||||
className="flex items-center justify-between rounded border p-2"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{w.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{w.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline">{w.kind}</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Add these to the dashboard from the dashboard's edit dialog.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ConfirmDialog
|
{confirmDelete}
|
||||||
open={deleteOpen}
|
|
||||||
title="Delete service?"
|
|
||||||
message="This removes the service and any widgets that reference it. This cannot be undone."
|
|
||||||
confirmLabel="Delete"
|
|
||||||
onCancel={() => setDeleteOpen(false)}
|
|
||||||
onConfirm={() => {
|
|
||||||
deleteService.mutate(instance.id);
|
|
||||||
setDeleteOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServiceConnectionCard({
|
function ServiceConnectionFields({
|
||||||
instance,
|
instance,
|
||||||
typeInfo,
|
typeInfo,
|
||||||
draftConfig,
|
draftConfig,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
|
isMobile,
|
||||||
}: {
|
}: {
|
||||||
instance: ServiceInstance;
|
instance: ServiceInstance;
|
||||||
typeInfo: ServiceTypeInfo | undefined;
|
typeInfo: ServiceTypeInfo | undefined;
|
||||||
draftConfig: Record<string, unknown>;
|
draftConfig: Record<string, unknown>;
|
||||||
onConfigChange: (config: Record<string, unknown>) => void;
|
onConfigChange: (config: Record<string, unknown>) => void;
|
||||||
|
isMobile: boolean;
|
||||||
}) {
|
}) {
|
||||||
const saveService = useSaveServiceInstance();
|
const saveService = useSaveServiceInstance();
|
||||||
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
|
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
|
||||||
@@ -232,96 +322,107 @@ function ServiceConnectionCard({
|
|||||||
{ type: typeof value === "number" ? "integer" : "string" },
|
{ type: typeof value === "number" ? "integer" : "string" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
function handleUpdateConnection() {
|
||||||
|
const onlyChanged = Object.fromEntries(
|
||||||
|
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
|
||||||
|
);
|
||||||
|
saveService.mutate({
|
||||||
|
id: instance.id,
|
||||||
|
service_type: instance.service_type,
|
||||||
|
name: instance.name,
|
||||||
|
config: draftConfig,
|
||||||
|
secrets: onlyChanged,
|
||||||
|
enabled: instance.enabled,
|
||||||
|
});
|
||||||
|
setDraftSecrets({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{configEntries.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No connection config.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{configEntries.map(([key, schema]) => {
|
||||||
|
const isNumber =
|
||||||
|
schema.type === "integer" || schema.type === "number";
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={key}
|
||||||
|
label={key}
|
||||||
|
htmlFor={`cfg-${key}`}
|
||||||
|
helper={schema.description}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={`cfg-${key}`}
|
||||||
|
type={isNumber ? "number" : "text"}
|
||||||
|
value={String(draftConfig[key] ?? "")}
|
||||||
|
onChange={(e) =>
|
||||||
|
onConfigChange({
|
||||||
|
...draftConfig,
|
||||||
|
[key]: isNumber
|
||||||
|
? e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.keys(instance.secrets_set).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No secret fields.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
|
||||||
|
<div key={key} className="flex flex-col gap-1.5">
|
||||||
|
<Field
|
||||||
|
label={key}
|
||||||
|
htmlFor={`secret-${key}`}
|
||||||
|
helper="Leave blank to keep the current value."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={`secret-${key}`}
|
||||||
|
type="password"
|
||||||
|
placeholder={isSet ? "•••••• (set)" : "Not set"}
|
||||||
|
value={draftSecrets[key] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraftSecrets({
|
||||||
|
...draftSecrets,
|
||||||
|
[key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{isSet ? <Badge variant="secondary">set</Badge> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button className="mobile-touch-target" onClick={handleUpdateConnection}>
|
||||||
|
Update connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// On mobile the fields render inside the SheetForm body without a card
|
||||||
|
// wrapper (the SheetForm already provides the container). On desktop they
|
||||||
|
// keep their original SectionCard framing.
|
||||||
|
if (isMobile) {
|
||||||
|
return <div className="flex flex-col gap-3">{fields}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Connection"
|
title="Connection"
|
||||||
description="Edit non-secret connection config and secret values."
|
description="Edit non-secret connection config and secret values."
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
{fields}
|
||||||
{configEntries.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No connection config.</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{configEntries.map(([key, schema]) => {
|
|
||||||
const isNumber =
|
|
||||||
schema.type === "integer" || schema.type === "number";
|
|
||||||
return (
|
|
||||||
<Field
|
|
||||||
key={key}
|
|
||||||
label={key}
|
|
||||||
htmlFor={`cfg-${key}`}
|
|
||||||
helper={schema.description}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id={`cfg-${key}`}
|
|
||||||
type={isNumber ? "number" : "text"}
|
|
||||||
value={String(draftConfig[key] ?? "")}
|
|
||||||
onChange={(e) =>
|
|
||||||
onConfigChange({
|
|
||||||
...draftConfig,
|
|
||||||
[key]: isNumber
|
|
||||||
? e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value)
|
|
||||||
: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Object.keys(instance.secrets_set).length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No secret fields.</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
|
|
||||||
<div key={key} className="flex flex-col gap-1.5">
|
|
||||||
<Field
|
|
||||||
label={key}
|
|
||||||
htmlFor={`secret-${key}`}
|
|
||||||
helper="Leave blank to keep the current value."
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id={`secret-${key}`}
|
|
||||||
type="password"
|
|
||||||
placeholder={isSet ? "•••••• (set)" : "Not set"}
|
|
||||||
value={draftSecrets[key] ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraftSecrets({
|
|
||||||
...draftSecrets,
|
|
||||||
[key]: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{isSet ? <Badge variant="secondary">set</Badge> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
const onlyChanged = Object.fromEntries(
|
|
||||||
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
|
|
||||||
);
|
|
||||||
saveService.mutate({
|
|
||||||
id: instance.id,
|
|
||||||
service_type: instance.service_type,
|
|
||||||
name: instance.name,
|
|
||||||
config: draftConfig,
|
|
||||||
secrets: onlyChanged,
|
|
||||||
enabled: instance.enabled,
|
|
||||||
});
|
|
||||||
setDraftSecrets({});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Update connection
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ function CreateServiceDialog({
|
|||||||
{!draft ? (
|
{!draft ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{types.map((t) => (
|
{types.map((t) => (
|
||||||
<Button
|
<Button className="mobile-touch-target"
|
||||||
key={t.service_type}
|
key={t.service_type}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setDraft(emptyDraft(t.service_type))}
|
onClick={() => setDraft(emptyDraft(t.service_type))}
|
||||||
@@ -234,6 +234,7 @@ function CreateServiceDialog({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="service-enabled"
|
id="service-enabled"
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setDraft({ ...draft, enabled: checked })
|
setDraft({ ...draft, enabled: checked })
|
||||||
@@ -286,7 +287,7 @@ export function ServicesPage() {
|
|||||||
title="Services"
|
title="Services"
|
||||||
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
|
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
<Button variant="outline" onClick={() => setCreateOpen(true)} className="mobile-touch-target">
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
Add service
|
Add service
|
||||||
</Button>
|
</Button>
|
||||||
@@ -328,6 +329,7 @@ export function ServicesPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/services/${s.service_type}/${s.id}`)
|
navigate(`/services/${s.service_type}/${s.id}`)
|
||||||
}
|
}
|
||||||
@@ -337,7 +339,7 @@ export function ServicesPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-destructive"
|
className="mobile-touch-target h-8 w-8 text-destructive"
|
||||||
onClick={() => setDeleteId(s.id)}
|
onClick={() => setDeleteId(s.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
+144
-45
@@ -17,6 +17,8 @@ import {
|
|||||||
useSaveSSHKey,
|
useSaveSSHKey,
|
||||||
useTestMonitoringMachineSSH,
|
useTestMonitoringMachineSSH,
|
||||||
} from "../hooks/useSettings";
|
} from "../hooks/useSettings";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
|
import { SheetForm } from "@/components/ui/sheet-form";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import { HoverEditButton } from "../components/HoverEditButton";
|
import { HoverEditButton } from "../components/HoverEditButton";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
@@ -126,6 +128,30 @@ function emptyMachine(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dirty check for the machine editor SheetForm guard (spec R4.5).
|
||||||
|
* Pragmatic field-by-field comparison of the user-editable fields. In create
|
||||||
|
* mode (editingMachine is null) the form is always dirty.
|
||||||
|
*/
|
||||||
|
function isMachineDraftDirty(
|
||||||
|
draft: MonitoringMachineInput,
|
||||||
|
editingMachine: MonitoringMachine | null,
|
||||||
|
): boolean {
|
||||||
|
if (!editingMachine) return true;
|
||||||
|
return (
|
||||||
|
draft.name !== editingMachine.name ||
|
||||||
|
draft.host !== editingMachine.host ||
|
||||||
|
draft.mode !== editingMachine.mode ||
|
||||||
|
draft.port !== editingMachine.port ||
|
||||||
|
draft.username !== editingMachine.username ||
|
||||||
|
draft.ssh_key_id !== editingMachine.ssh_key_id ||
|
||||||
|
draft.enabled !== editingMachine.enabled ||
|
||||||
|
draft.notes !== editingMachine.notes ||
|
||||||
|
JSON.stringify([...draft.services].sort()) !==
|
||||||
|
JSON.stringify([...editingMachine.services].sort())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MachineEditor({
|
function MachineEditor({
|
||||||
title,
|
title,
|
||||||
hint,
|
hint,
|
||||||
@@ -230,6 +256,7 @@ function MachineEditor({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="machine-enabled"
|
id="machine-enabled"
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setDraft((current) => ({ ...current, enabled: checked }))
|
setDraft((current) => ({ ...current, enabled: checked }))
|
||||||
@@ -439,6 +466,7 @@ function MachineEditor({
|
|||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onValidateSSH}
|
onClick={onValidateSSH}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -532,7 +560,7 @@ function SSHKeyManager({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="mobile-touch-target w-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clear();
|
clear();
|
||||||
}}
|
}}
|
||||||
@@ -651,6 +679,7 @@ function SSHKeyManager({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
disabled={saveKey.isPending}
|
disabled={saveKey.isPending}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await saveKey.mutateAsync(draft);
|
await saveKey.mutateAsync(draft);
|
||||||
@@ -660,6 +689,7 @@ function SSHKeyManager({
|
|||||||
{editing ? "Update key" : "Save key"}
|
{editing ? "Update key" : "Save key"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={generateKey.isPending}
|
disabled={generateKey.isPending}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -683,11 +713,16 @@ function SSHKeyManager({
|
|||||||
>
|
>
|
||||||
{generateKey.isPending ? "Generating..." : "Generate key"}
|
{generateKey.isPending ? "Generating..." : "Generate key"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={clear}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={clear}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
{selectedKey && (
|
{selectedKey && (
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => deleteKey.mutate(selectedKey.id)}
|
onClick={() => deleteKey.mutate(selectedKey.id)}
|
||||||
>
|
>
|
||||||
@@ -753,7 +788,11 @@ function ResetLocalDatabaseCard() {
|
|||||||
Reset the local SQLite settings/media index databases after
|
Reset the local SQLite settings/media index databases after
|
||||||
acknowledging the data loss.
|
acknowledging the data loss.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="destructive" onClick={() => setOpen(true)}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
Reset local database
|
Reset local database
|
||||||
</Button>
|
</Button>
|
||||||
{resetDatabase.error && (
|
{resetDatabase.error && (
|
||||||
@@ -780,6 +819,7 @@ function ResetLocalDatabaseCard() {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={ackSettings}
|
checked={ackSettings}
|
||||||
onCheckedChange={(checked) => setAckSettings(Boolean(checked))}
|
onCheckedChange={(checked) => setAckSettings(Boolean(checked))}
|
||||||
/>
|
/>
|
||||||
@@ -787,6 +827,7 @@ function ResetLocalDatabaseCard() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={ackIndex}
|
checked={ackIndex}
|
||||||
onCheckedChange={(checked) => setAckIndex(Boolean(checked))}
|
onCheckedChange={(checked) => setAckIndex(Boolean(checked))}
|
||||||
/>
|
/>
|
||||||
@@ -794,6 +835,7 @@ function ResetLocalDatabaseCard() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={ackIrreversible}
|
checked={ackIrreversible}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setAckIrreversible(Boolean(checked))
|
setAckIrreversible(Boolean(checked))
|
||||||
@@ -850,6 +892,7 @@ export function Settings() {
|
|||||||
const [editingMachine, setEditingMachine] =
|
const [editingMachine, setEditingMachine] =
|
||||||
useState<MonitoringMachine | null>(null);
|
useState<MonitoringMachine | null>(null);
|
||||||
const [selectedMachineId, setSelectedMachineId] = useState("");
|
const [selectedMachineId, setSelectedMachineId] = useState("");
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const orderedMachines = useMemo(() => machines ?? [], [machines]);
|
const orderedMachines = useMemo(() => machines ?? [], [machines]);
|
||||||
const selectedMachine = useMemo(
|
const selectedMachine = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -970,7 +1013,7 @@ export function Settings() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="mobile-touch-target w-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearSSHValidation();
|
clearSSHValidation();
|
||||||
setMachineDraft(emptyMachine("local"));
|
setMachineDraft(emptyMachine("local"));
|
||||||
@@ -1086,6 +1129,7 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openEditMachine(
|
openEditMachine(
|
||||||
@@ -1113,6 +1157,7 @@ export function Settings() {
|
|||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => setDeleteMachineId(selectedMachine.id)}
|
onClick={() => setDeleteMachineId(selectedMachine.id)}
|
||||||
>
|
>
|
||||||
@@ -1135,21 +1180,25 @@ export function Settings() {
|
|||||||
)}
|
)}
|
||||||
{tab === "danger" && <ResetLocalDatabaseCard />}
|
{tab === "danger" && <ResetLocalDatabaseCard />}
|
||||||
</TabbedCard>
|
</TabbedCard>
|
||||||
<Dialog
|
{isMobile ? (
|
||||||
open={machineDialogOpen}
|
<SheetForm
|
||||||
onOpenChange={(open) => {
|
open={machineDialogOpen}
|
||||||
if (!open) closeMachineDialog();
|
onOpenChange={(open) => {
|
||||||
}}
|
if (!open) closeMachineDialog();
|
||||||
>
|
}}
|
||||||
<DialogContent className="sm:max-w-4xl">
|
title={machineDraft.id ? "Edit machine" : "Create machine"}
|
||||||
<DialogHeader>
|
onSave={() => {
|
||||||
<DialogTitle>
|
void saveMachineDraft(machineDraft);
|
||||||
{machineDraft.id ? "Edit machine" : "Create machine"}
|
}}
|
||||||
</DialogTitle>
|
onCancel={closeMachineDialog}
|
||||||
<DialogDescription>
|
isPending={saveMachine.isPending}
|
||||||
{machineDraft.mode === "local" ? "Local API host" : "SSH target"}
|
saveDisabled={
|
||||||
</DialogDescription>
|
!machineDraft.name ||
|
||||||
</DialogHeader>
|
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
||||||
|
}
|
||||||
|
saveLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
||||||
|
isDirty={isMachineDraftDirty(machineDraft, editingMachine)}
|
||||||
|
>
|
||||||
<MachineEditor
|
<MachineEditor
|
||||||
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
||||||
title={
|
title={
|
||||||
@@ -1170,32 +1219,82 @@ export function Settings() {
|
|||||||
sshValidationError={sshValidationError}
|
sshValidationError={sshValidationError}
|
||||||
sshValidationStatus={sshValidationStatus}
|
sshValidationStatus={sshValidationStatus}
|
||||||
/>
|
/>
|
||||||
<DialogFooter
|
{machineDraft.id ? (
|
||||||
onCancel={closeMachineDialog}
|
<Button
|
||||||
cancelLabel="Cancel"
|
className="mobile-touch-target"
|
||||||
onConfirm={() => {
|
variant="destructive"
|
||||||
void saveMachineDraft(machineDraft);
|
onClick={() => setDeleteMachineId(machineDraft.id as string)}
|
||||||
}}
|
>
|
||||||
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
Delete machine
|
||||||
confirmDisabled={
|
</Button>
|
||||||
!machineDraft.name ||
|
) : null}
|
||||||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
</SheetForm>
|
||||||
}
|
) : (
|
||||||
secondaryAction={
|
<Dialog
|
||||||
machineDraft.id ? (
|
open={machineDialogOpen}
|
||||||
<Button
|
onOpenChange={(open) => {
|
||||||
variant="destructive"
|
if (!open) closeMachineDialog();
|
||||||
onClick={() => {
|
}}
|
||||||
setDeleteMachineId(machineDraft.id as string);
|
>
|
||||||
}}
|
<DialogContent className="sm:max-w-4xl">
|
||||||
>
|
<DialogHeader>
|
||||||
Delete
|
<DialogTitle>
|
||||||
</Button>
|
{machineDraft.id ? "Edit machine" : "Create machine"}
|
||||||
) : undefined
|
</DialogTitle>
|
||||||
}
|
<DialogDescription>
|
||||||
/>
|
{machineDraft.mode === "local"
|
||||||
</DialogContent>
|
? "Local API host"
|
||||||
</Dialog>
|
: "SSH target"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<MachineEditor
|
||||||
|
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
||||||
|
title={
|
||||||
|
machineDraft.id
|
||||||
|
? machineDraft.name || "Edit machine"
|
||||||
|
: "New machine"
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
machineDraft.mode === "local" ? "Local API host" : "SSH target"
|
||||||
|
}
|
||||||
|
machine={machineDraft}
|
||||||
|
sshKeys={sshKeys}
|
||||||
|
editingMachine={editingMachine}
|
||||||
|
onChange={updateMachineDraft}
|
||||||
|
onValidateSSH={validateMachineSSH}
|
||||||
|
isValidatingSSH={testMachineSSH.isPending}
|
||||||
|
sshValidationMessage={sshValidationMessage}
|
||||||
|
sshValidationError={sshValidationError}
|
||||||
|
sshValidationStatus={sshValidationStatus}
|
||||||
|
/>
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={closeMachineDialog}
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
onConfirm={() => {
|
||||||
|
void saveMachineDraft(machineDraft);
|
||||||
|
}}
|
||||||
|
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
||||||
|
confirmDisabled={
|
||||||
|
!machineDraft.name ||
|
||||||
|
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
||||||
|
}
|
||||||
|
secondaryAction={
|
||||||
|
machineDraft.id ? (
|
||||||
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteMachineId(machineDraft.id as string);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(deleteMachineId)}
|
open={Boolean(deleteMachineId)}
|
||||||
title="Delete machine?"
|
title="Delete machine?"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
|
import { SheetForm } from "@/components/ui/sheet-form";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -51,8 +52,13 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MetricCard } from "../components/MetricCard";
|
import { MetricCard } from "../components/MetricCard";
|
||||||
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
import { useUsers } from "../hooks/useUsers";
|
import { useUsers } from "../hooks/useUsers";
|
||||||
import { useActivity } from "../hooks/useDashboard";
|
import { useActivity } from "../hooks/useDashboard";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import { useSendUserMessage } from "../hooks/useSendUserMessage";
|
import { useSendUserMessage } from "../hooks/useSendUserMessage";
|
||||||
import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus";
|
import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus";
|
||||||
import type { UserDirectoryItem } from "../types";
|
import type { UserDirectoryItem } from "../types";
|
||||||
@@ -63,9 +69,9 @@ import {
|
|||||||
type UserStateItem,
|
type UserStateItem,
|
||||||
} from "../userState";
|
} from "../userState";
|
||||||
|
|
||||||
// Replaces MUI `useMediaQuery` (a 6b-owned component) with a dependency-free
|
// Local breakpoint for the compose dialog (slice 6b uses 900px for fullScreen).
|
||||||
// matchMedia hook for the compose dialog's mobile fullScreen behavior.
|
// The shared `useIsMobile` from hooks/ (768px) drives the directory table branch.
|
||||||
function useIsMobile(query = "(max-width: 900px)") {
|
function useComposeViewport(query = "(max-width: 900px)") {
|
||||||
const [mobile, setMobile] = useState(() =>
|
const [mobile, setMobile] = useState(() =>
|
||||||
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||||
? window.matchMedia(query).matches
|
? window.matchMedia(query).matches
|
||||||
@@ -102,11 +108,35 @@ function activityBadgeVariant(
|
|||||||
|
|
||||||
const DEFAULT_HTML_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
|
const DEFAULT_HTML_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): display name is primary; username, activity
|
||||||
|
// badge, and email give the at-a-glance info for scanning users on a phone.
|
||||||
|
// See OpenSpec change `mobile-responsive-parity`, tasks slice 5.1.
|
||||||
|
const userCardFields: MobileCardField<UserStateItem>[] = [
|
||||||
|
{ key: "name", label: "Name", render: (r) => userLabel(r), primary: true },
|
||||||
|
{
|
||||||
|
key: "username",
|
||||||
|
label: "Username",
|
||||||
|
render: (r) =>
|
||||||
|
r.username && r.username !== r.display_name ? r.username : r.jellyfin_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "activity",
|
||||||
|
label: "Activity",
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={activityBadgeVariant(r.activity_label)}>
|
||||||
|
{r.activity_label}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "email", label: "Email", render: (r) => r.email || "—" },
|
||||||
|
];
|
||||||
|
|
||||||
export function UsersPage() {
|
export function UsersPage() {
|
||||||
const { data, isError, error } = useUsers();
|
const { data, isError, error } = useUsers();
|
||||||
const { data: activity } = useActivity();
|
const { data: activity } = useActivity();
|
||||||
const queueStatusQuery = useUserMessageQueueStatus();
|
const queueStatusQuery = useUserMessageQueueStatus();
|
||||||
const sendUserMessage = useSendUserMessage();
|
const sendUserMessage = useSendUserMessage();
|
||||||
|
const isComposeMobile = useComposeViewport();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -479,154 +509,183 @@ export function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[660px] overflow-auto rounded-lg border">
|
<div className="max-h-[660px] overflow-auto rounded-lg border">
|
||||||
<Table aria-label="Users table">
|
{isMobile ? (
|
||||||
<TableHeader>
|
<div className="p-3">
|
||||||
<TableRow>
|
<MobileCardRow
|
||||||
<TableHead className={cn(thBase, "w-14 p-2")}>
|
rows={filteredRows}
|
||||||
<Checkbox
|
fields={userCardFields}
|
||||||
checked={allVisibleSelected}
|
getRowId={(r) => r.jellyfin_id}
|
||||||
aria-label="Select all visible users"
|
onRowClick={(r) => setSearchParams({ user: r.jellyfin_id })}
|
||||||
onCheckedChange={(checked) =>
|
actions={(r) => {
|
||||||
toggleVisibleSelection(checked === true)
|
const checked = selectedIdSet.has(r.jellyfin_id);
|
||||||
}
|
return (
|
||||||
/>
|
<Checkbox
|
||||||
</TableHead>
|
checked={checked}
|
||||||
<TableHead className={thBase}>User</TableHead>
|
aria-label={`Select ${userLabel(r)}`}
|
||||||
<TableHead className={thBase}>Email</TableHead>
|
className="mobile-touch-target"
|
||||||
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
onClick={(e) => e.stopPropagation()}
|
||||||
Activity
|
onCheckedChange={() =>
|
||||||
</TableHead>
|
toggleUserSelected(r.jellyfin_id)
|
||||||
<TableHead
|
}
|
||||||
className={cn(
|
/>
|
||||||
thBase,
|
);
|
||||||
"hidden w-[140px] text-center md:table-cell",
|
}}
|
||||||
)}
|
/>
|
||||||
>
|
</div>
|
||||||
Type
|
) : (
|
||||||
</TableHead>
|
<Table aria-label="Users table">
|
||||||
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
<TableHeader>
|
||||||
Jellyseerr
|
<TableRow>
|
||||||
</TableHead>
|
<TableHead className={cn(thBase, "w-14 p-2")}>
|
||||||
<TableHead
|
<Checkbox
|
||||||
className={cn(
|
checked={allVisibleSelected}
|
||||||
thBase,
|
aria-label="Select all visible users"
|
||||||
"hidden w-[120px] text-center md:table-cell",
|
onCheckedChange={(checked) =>
|
||||||
)}
|
toggleVisibleSelection(checked === true)
|
||||||
>
|
}
|
||||||
Role
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className={thBase}>Permissions</TableHead>
|
<TableHead className={thBase}>User</TableHead>
|
||||||
<TableHead
|
<TableHead className={thBase}>Email</TableHead>
|
||||||
className={cn(
|
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
||||||
thBase,
|
Activity
|
||||||
"hidden w-24 text-center md:table-cell",
|
</TableHead>
|
||||||
)}
|
<TableHead
|
||||||
>
|
className={cn(
|
||||||
Reqs
|
thBase,
|
||||||
</TableHead>
|
"hidden w-[140px] text-center md:table-cell",
|
||||||
<TableHead
|
)}
|
||||||
className={cn(
|
|
||||||
thBase,
|
|
||||||
"hidden w-[120px] text-center md:table-cell",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredRows.map((row) => {
|
|
||||||
const linked =
|
|
||||||
row.jellyseerr_user_id !== null &&
|
|
||||||
row.jellyseerr_user_id !== undefined;
|
|
||||||
const checked = selectedIdSet.has(row.jellyfin_id);
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={row.jellyfin_id}
|
|
||||||
data-state={
|
|
||||||
checked || selectedUser?.jellyfin_id === row.jellyfin_id
|
|
||||||
? "selected"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => setSearchParams({ user: row.jellyfin_id })}
|
|
||||||
>
|
>
|
||||||
<TableCell className="w-14 p-2">
|
Type
|
||||||
<Checkbox
|
</TableHead>
|
||||||
checked={checked}
|
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
||||||
aria-label={`Select ${userLabel(row)}`}
|
Jellyseerr
|
||||||
onClick={(event) => event.stopPropagation()}
|
</TableHead>
|
||||||
onCheckedChange={() =>
|
<TableHead
|
||||||
toggleUserSelected(row.jellyfin_id)
|
className={cn(
|
||||||
}
|
thBase,
|
||||||
/>
|
"hidden w-[120px] text-center md:table-cell",
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
Role
|
||||||
<Avatar className="size-9">
|
</TableHead>
|
||||||
<AvatarImage
|
<TableHead className={thBase}>Permissions</TableHead>
|
||||||
src={row.avatar || undefined}
|
<TableHead
|
||||||
alt={userLabel(row)}
|
className={cn(
|
||||||
/>
|
thBase,
|
||||||
<AvatarFallback>
|
"hidden w-24 text-center md:table-cell",
|
||||||
{userLabel(row).charAt(0).toUpperCase()}
|
)}
|
||||||
</AvatarFallback>
|
>
|
||||||
</Avatar>
|
Reqs
|
||||||
<div className="min-w-0">
|
</TableHead>
|
||||||
<div className="truncate font-semibold leading-tight">
|
<TableHead
|
||||||
{userLabel(row)}
|
className={cn(
|
||||||
</div>
|
thBase,
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
"hidden w-[120px] text-center md:table-cell",
|
||||||
{row.username && row.username !== row.display_name
|
)}
|
||||||
? row.username
|
>
|
||||||
: row.jellyfin_id}
|
Contact
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredRows.map((row) => {
|
||||||
|
const linked =
|
||||||
|
row.jellyseerr_user_id !== null &&
|
||||||
|
row.jellyseerr_user_id !== undefined;
|
||||||
|
const checked = selectedIdSet.has(row.jellyfin_id);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.jellyfin_id}
|
||||||
|
data-state={
|
||||||
|
checked ||
|
||||||
|
selectedUser?.jellyfin_id === row.jellyfin_id
|
||||||
|
? "selected"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setSearchParams({ user: row.jellyfin_id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableCell className="w-14 p-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
aria-label={`Select ${userLabel(row)}`}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
toggleUserSelected(row.jellyfin_id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Avatar className="size-9">
|
||||||
|
<AvatarImage
|
||||||
|
src={row.avatar || undefined}
|
||||||
|
alt={userLabel(row)}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
{userLabel(row).charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-semibold leading-tight">
|
||||||
|
{userLabel(row)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{row.username &&
|
||||||
|
row.username !== row.display_name
|
||||||
|
? row.username
|
||||||
|
: row.jellyfin_id}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
<div className="truncate font-medium">
|
||||||
<div className="truncate font-medium">
|
{row.email || "—"}
|
||||||
{row.email || "—"}
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-center">
|
||||||
<TableCell className="text-center">
|
<Badge
|
||||||
<Badge
|
variant={activityBadgeVariant(row.activity_label)}
|
||||||
variant={activityBadgeVariant(row.activity_label)}
|
>
|
||||||
>
|
{row.activity_label}
|
||||||
{row.activity_label}
|
</Badge>
|
||||||
</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="hidden text-center md:table-cell">
|
||||||
<TableCell className="hidden text-center md:table-cell">
|
<Badge variant="outline">{row.user_type_label}</Badge>
|
||||||
<Badge variant="outline">{row.user_type_label}</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-center">
|
||||||
<TableCell className="text-center">
|
<Badge variant={linked ? "success" : "secondary"}>
|
||||||
<Badge variant={linked ? "success" : "secondary"}>
|
{linked
|
||||||
{linked
|
? `Linked #${row.jellyseerr_user_id}`
|
||||||
? `Linked #${row.jellyseerr_user_id}`
|
: "Base only"}
|
||||||
: "Base only"}
|
</Badge>
|
||||||
</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="hidden text-center md:table-cell">
|
||||||
<TableCell className="hidden text-center md:table-cell">
|
<Badge variant="outline">{row.role}</Badge>
|
||||||
<Badge variant="outline">{row.role}</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="whitespace-normal">
|
||||||
<TableCell className="whitespace-normal">
|
{row.permissions_label}
|
||||||
{row.permissions_label}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="hidden text-center font-semibold md:table-cell">
|
||||||
<TableCell className="hidden text-center font-semibold md:table-cell">
|
{row.request_count ?? "—"}
|
||||||
{row.request_count ?? "—"}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="hidden text-center md:table-cell">
|
||||||
<TableCell className="hidden text-center md:table-cell">
|
<Badge
|
||||||
<Badge
|
variant={row.contactable ? "success" : "secondary"}
|
||||||
variant={row.contactable ? "success" : "secondary"}
|
>
|
||||||
>
|
{row.contactable ? "Yes" : "No"}
|
||||||
{row.contactable ? "Yes" : "No"}
|
</Badge>
|
||||||
</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
</TableRow>
|
||||||
</TableRow>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</TableBody>
|
||||||
</TableBody>
|
</Table>
|
||||||
</Table>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -755,229 +814,271 @@ export function UsersPage() {
|
|||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Dialog
|
{/* Compose dialog: SheetForm below md, Dialog at md+ (spec R4.1) */}
|
||||||
open={composeOpen}
|
{(() => {
|
||||||
onOpenChange={(open) => {
|
const composeBody = (
|
||||||
if (!open) {
|
<>
|
||||||
closeCompose();
|
{sendUserMessage.isPending ? (
|
||||||
}
|
<Progress value={100} className="animate-pulse" />
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent
|
|
||||||
className={cn(
|
|
||||||
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
|
|
||||||
isMobile &&
|
|
||||||
"inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DialogHeader className="gap-1 px-4 pt-4">
|
|
||||||
<DialogTitle className="pr-8">Message selected users</DialogTitle>
|
|
||||||
<DialogDescription className="sr-only">
|
|
||||||
Compose a message to the selected deliverable users.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{sendUserMessage.isPending ? (
|
|
||||||
<Progress value={100} className="animate-pulse" />
|
|
||||||
) : null}
|
|
||||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
|
||||||
{sendUserMessage.isError ? (
|
|
||||||
<UIAlert variant="destructive">
|
|
||||||
<AlertDescription>
|
|
||||||
Unable to send message:{" "}
|
|
||||||
{(sendUserMessage.error as Error)?.message || "Unknown error"}
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
) : null}
|
||||||
{sendUserMessage.isSuccess ? (
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
||||||
|
{sendUserMessage.isError ? (
|
||||||
|
<UIAlert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
Unable to send message:{" "}
|
||||||
|
{(sendUserMessage.error as Error)?.message ||
|
||||||
|
"Unknown error"}
|
||||||
|
</AlertDescription>
|
||||||
|
</UIAlert>
|
||||||
|
) : null}
|
||||||
|
{sendUserMessage.isSuccess ? (
|
||||||
|
<UIAlert>
|
||||||
|
<AlertDescription>
|
||||||
|
Queued for {sendUserMessage.data.recipient_count} recipients
|
||||||
|
{sendUserMessage.data.attachment_count
|
||||||
|
? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}`
|
||||||
|
: ""}
|
||||||
|
{sendUserMessage.data.request_id
|
||||||
|
? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})`
|
||||||
|
: ""}
|
||||||
|
.
|
||||||
|
</AlertDescription>
|
||||||
|
</UIAlert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{queueBanner ? (
|
||||||
|
<UIAlert
|
||||||
|
variant={
|
||||||
|
queueBanner.severity === "error" ? "destructive" : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{queueBanner.message}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
|
||||||
|
</div>
|
||||||
|
</UIAlert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<UIAlert>
|
<UIAlert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Queued for {sendUserMessage.data.recipient_count} recipients
|
{selectedRows.length} selected,{" "}
|
||||||
{sendUserMessage.data.attachment_count
|
{selectedDeliverableRows.length} deliverable.
|
||||||
? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}`
|
{skippedRows.length
|
||||||
|
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
|
||||||
: ""}
|
: ""}
|
||||||
{sendUserMessage.data.request_id
|
|
||||||
? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})`
|
|
||||||
: ""}
|
|
||||||
.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</UIAlert>
|
</UIAlert>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{queueBanner ? (
|
<div className="flex flex-wrap gap-1">
|
||||||
<UIAlert
|
{selectedDeliverableRows.map((row) => (
|
||||||
variant={
|
<Badge key={row.jellyfin_id} variant="secondary">
|
||||||
queueBanner.severity === "error" ? "destructive" : undefined
|
{`${userLabel(row)} <${row.email}>`}
|
||||||
}
|
</Badge>
|
||||||
>
|
))}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</div>
|
||||||
<span className="text-sm font-semibold">
|
|
||||||
{queueBanner.message}
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
|
|
||||||
</div>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<UIAlert>
|
<div className="flex flex-col gap-1.5">
|
||||||
<AlertDescription>
|
<Label htmlFor="compose-subject">Subject</Label>
|
||||||
{selectedRows.length} selected, {selectedDeliverableRows.length}{" "}
|
<Input
|
||||||
deliverable.
|
id="compose-subject"
|
||||||
{skippedRows.length
|
value={subject}
|
||||||
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
|
onChange={(event) => setSubject(event.target.value)}
|
||||||
: ""}
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{selectedDeliverableRows.map((row) => (
|
|
||||||
<Badge key={row.jellyfin_id} variant="secondary">
|
|
||||||
{`${userLabel(row)} <${row.email}>`}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="compose-subject">Subject</Label>
|
|
||||||
<Input
|
|
||||||
id="compose-subject"
|
|
||||||
value={subject}
|
|
||||||
onChange={(event) => setSubject(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => insertMarkup("<strong>", "</strong>")}
|
|
||||||
aria-label="Bold"
|
|
||||||
>
|
|
||||||
<Bold />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Bold</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => insertMarkup("<em>", "</em>")}
|
|
||||||
aria-label="Italic"
|
|
||||||
>
|
|
||||||
<Italic />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Italic</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={addLink}
|
|
||||||
aria-label="Link"
|
|
||||||
>
|
|
||||||
<Link />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Link</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => insertMarkup("<ul><li>", "</li></ul>")}
|
|
||||||
aria-label="Bullet list"
|
|
||||||
>
|
|
||||||
<List />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Bullet list</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="compose-body">HTML message body</Label>
|
|
||||||
<Textarea
|
|
||||||
id="compose-body"
|
|
||||||
ref={htmlBodyRef}
|
|
||||||
value={htmlBody}
|
|
||||||
onChange={(event) => setHtmlBody(event.target.value)}
|
|
||||||
className="min-h-[260px] font-mono"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Formatting is sent as HTML; a plain-text fallback is generated
|
|
||||||
automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-muted/40 p-4">
|
|
||||||
<p className="mb-2 text-sm font-semibold">Preview</p>
|
|
||||||
<div className="overflow-hidden rounded-md border bg-card">
|
|
||||||
<iframe
|
|
||||||
title="Email preview"
|
|
||||||
sandbox=""
|
|
||||||
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Roboto,Arial,sans-serif;padding:16px;margin:0;background:#fff;color:#111;line-height:1.5}</style></head><body>${htmlBody || "<p>(Empty)</p>"}</body></html>`}
|
|
||||||
style={{ width: "100%", minHeight: 220, border: 0 }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<UiButton asChild variant="outline">
|
<Tooltip>
|
||||||
<label className="cursor-pointer">
|
<TooltipTrigger asChild>
|
||||||
<Paperclip />
|
<UiButton
|
||||||
Add attachment
|
variant="ghost"
|
||||||
<input
|
size="icon"
|
||||||
hidden
|
className="mobile-touch-target"
|
||||||
type="file"
|
onClick={() => insertMarkup("<strong>", "</strong>")}
|
||||||
multiple
|
aria-label="Bold"
|
||||||
onChange={handleAttachments}
|
>
|
||||||
|
<Bold />
|
||||||
|
</UiButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Bold</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<UiButton
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => insertMarkup("<em>", "</em>")}
|
||||||
|
aria-label="Italic"
|
||||||
|
>
|
||||||
|
<Italic />
|
||||||
|
</UiButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Italic</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<UiButton
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={addLink}
|
||||||
|
aria-label="Link"
|
||||||
|
>
|
||||||
|
<Link />
|
||||||
|
</UiButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Link</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<UiButton
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => insertMarkup("<ul><li>", "</li></ul>")}
|
||||||
|
aria-label="Bullet list"
|
||||||
|
>
|
||||||
|
<List />
|
||||||
|
</UiButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Bullet list</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="compose-body">HTML message body</Label>
|
||||||
|
<Textarea
|
||||||
|
id="compose-body"
|
||||||
|
ref={htmlBodyRef}
|
||||||
|
value={htmlBody}
|
||||||
|
onChange={(event) => setHtmlBody(event.target.value)}
|
||||||
|
className="min-h-[260px] font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Formatting is sent as HTML; a plain-text fallback is generated
|
||||||
|
automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-muted/40 p-4">
|
||||||
|
<p className="mb-2 text-sm font-semibold">Preview</p>
|
||||||
|
<div className="overflow-hidden rounded-md border bg-card">
|
||||||
|
<iframe
|
||||||
|
title="Email preview"
|
||||||
|
sandbox=""
|
||||||
|
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Roboto,Arial,sans-serif;padding:16px;margin:0;background:#fff;color:#111;line-height:1.5}</style></head><body>${htmlBody || "<p>(Empty)</p>"}</body></html>`}
|
||||||
|
style={{ width: "100%", minHeight: 220, border: 0 }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
</UiButton>
|
</div>
|
||||||
{attachments.map((file, index) => (
|
|
||||||
<Badge
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
key={`${file.name}-${index}`}
|
<UiButton asChild variant="outline">
|
||||||
variant="secondary"
|
<label className="cursor-pointer">
|
||||||
className="gap-1 pr-1"
|
<Paperclip />
|
||||||
>
|
Add attachment
|
||||||
{file.name}
|
<input
|
||||||
<button
|
hidden
|
||||||
type="button"
|
type="file"
|
||||||
aria-label={`Remove ${file.name}`}
|
multiple
|
||||||
onClick={() => removeAttachment(index)}
|
onChange={handleAttachments}
|
||||||
className="inline-flex items-center text-current [&>svg]:size-3"
|
/>
|
||||||
|
</label>
|
||||||
|
</UiButton>
|
||||||
|
{attachments.map((file, index) => (
|
||||||
|
<Badge
|
||||||
|
key={`${file.name}-${index}`}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 pr-1"
|
||||||
>
|
>
|
||||||
<Trash2 />
|
{file.name}
|
||||||
</button>
|
<button
|
||||||
</Badge>
|
type="button"
|
||||||
))}
|
aria-label={`Remove ${file.name}`}
|
||||||
|
onClick={() => removeAttachment(index)}
|
||||||
|
className="mobile-touch-target inline-flex items-center text-current [&>svg]:size-3"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
<DialogFooter className="m-0 border-t p-4">
|
);
|
||||||
<UiButton variant="ghost" onClick={closeCompose}>
|
|
||||||
Cancel
|
if (isMobile) {
|
||||||
</UiButton>
|
return (
|
||||||
<UiButton
|
<SheetForm
|
||||||
variant="default"
|
open={composeOpen}
|
||||||
disabled={
|
onOpenChange={(open) => {
|
||||||
sendUserMessage.isPending ||
|
if (!open) closeCompose();
|
||||||
!selectedDeliverableRows.length ||
|
}}
|
||||||
!subject.trim()
|
title="Message selected users"
|
||||||
|
onSave={handleSend}
|
||||||
|
onCancel={closeCompose}
|
||||||
|
isPending={sendUserMessage.isPending}
|
||||||
|
saveDisabled={!selectedDeliverableRows.length || !subject.trim()}
|
||||||
|
saveLabel="Send message"
|
||||||
|
isDirty={
|
||||||
|
subject.trim() !== "" ||
|
||||||
|
htmlBody.trim() !== DEFAULT_HTML_BODY.trim() ||
|
||||||
|
attachments.length > 0
|
||||||
}
|
}
|
||||||
onClick={handleSend}
|
|
||||||
>
|
>
|
||||||
<Send />
|
<div className="flex flex-col gap-4">{composeBody}</div>
|
||||||
Send message
|
</SheetForm>
|
||||||
</UiButton>
|
);
|
||||||
</DialogFooter>
|
}
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={composeOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
closeCompose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
|
||||||
|
isComposeMobile &&
|
||||||
|
"inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogHeader className="gap-1 px-4 pt-4">
|
||||||
|
<DialogTitle className="pr-8">
|
||||||
|
Message selected users
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
Compose a message to the selected deliverable users.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{composeBody}
|
||||||
|
<DialogFooter className="m-0 border-t p-4">
|
||||||
|
<UiButton variant="ghost" onClick={closeCompose}>
|
||||||
|
Cancel
|
||||||
|
</UiButton>
|
||||||
|
<UiButton
|
||||||
|
variant="default"
|
||||||
|
disabled={
|
||||||
|
sendUserMessage.isPending ||
|
||||||
|
!selectedDeliverableRows.length ||
|
||||||
|
!subject.trim()
|
||||||
|
}
|
||||||
|
onClick={handleSend}
|
||||||
|
>
|
||||||
|
<Send />
|
||||||
|
Send message
|
||||||
|
</UiButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Dashboard } from "../Dashboard";
|
import { Dashboard } from "../Dashboard";
|
||||||
import type { DashboardShortcut } from "../../types";
|
import type {
|
||||||
|
DashboardShortcut,
|
||||||
|
ServiceInstance,
|
||||||
|
WidgetInstance,
|
||||||
|
} from "../../types";
|
||||||
|
|
||||||
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
||||||
// (shortcut CRUD) without rendering widgets or their data queries.
|
// (shortcut CRUD) without rendering widgets or their data queries.
|
||||||
vi.mock("../../components/WidgetInstance", () => ({
|
vi.mock("../../components/WidgetInstance", () => ({
|
||||||
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
|
WidgetInstanceCard: ({ widget }: { widget: { title: string } }) => (
|
||||||
|
<div data-testid="widget-stub">{widget.title}</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../components/WidgetConfigDialog", () => ({
|
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||||
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
|
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
|
||||||
@@ -21,8 +27,15 @@ vi.mock("react-router-dom", () => ({
|
|||||||
vi.mock("../../hooks/useSettings", () => ({
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
useMonitoringSettings: () => ({ data: [] }),
|
useMonitoringSettings: () => ({ data: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// --- Dynamic mock state (reset in beforeEach) ---
|
||||||
|
let widgetInstances: WidgetInstance[] = [];
|
||||||
|
let serviceInstances: ServiceInstance[] = [];
|
||||||
vi.mock("../../hooks/useWidgets", () => ({
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
useWidgetInstances: () => ({ data: [] }),
|
useWidgetInstances: () => ({ data: widgetInstances }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({ data: serviceInstances }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
||||||
@@ -62,8 +75,26 @@ beforeEach(() => {
|
|||||||
saveShortcutMutate.mockClear();
|
saveShortcutMutate.mockClear();
|
||||||
deleteShortcutMutate.mockClear();
|
deleteShortcutMutate.mockClear();
|
||||||
shortcuts = [];
|
shortcuts = [];
|
||||||
|
widgetInstances = [];
|
||||||
|
serviceInstances = [];
|
||||||
|
setMatchMedia(false); // desktop by default
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- matchMedia mock for useIsMobile (jsdom has no native matchMedia) ---
|
||||||
|
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query === "(max-width: 768px)" ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
describe("Dashboard", () => {
|
describe("Dashboard", () => {
|
||||||
it("shows the empty-state alert when there are no shortcuts", () => {
|
it("shows the empty-state alert when there are no shortcuts", () => {
|
||||||
render(<Dashboard />);
|
render(<Dashboard />);
|
||||||
@@ -111,3 +142,142 @@ describe("Dashboard", () => {
|
|||||||
expect(saved.shortcut_type).toBe("website");
|
expect(saved.shortcut_type).toBe("website");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Mobile layout tests (spec R7.1, R7.2) ---
|
||||||
|
|
||||||
|
function makeWidget(overrides: Partial<WidgetInstance> = {}): WidgetInstance {
|
||||||
|
return {
|
||||||
|
id: "w1",
|
||||||
|
service_id: null,
|
||||||
|
widget_kind: "static",
|
||||||
|
title: "Widget 1",
|
||||||
|
config: {},
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService(
|
||||||
|
overrides: Partial<ServiceInstance> = {},
|
||||||
|
): ServiceInstance {
|
||||||
|
return {
|
||||||
|
id: "svc1",
|
||||||
|
service_type: "jellyfin",
|
||||||
|
name: "Jellyfin",
|
||||||
|
config: {},
|
||||||
|
secrets_set: {},
|
||||||
|
enabled: true,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dashboard mobile layout", () => {
|
||||||
|
it("renders widgets in a single column with an anchor bar below md", () => {
|
||||||
|
setMatchMedia(true); // mobile
|
||||||
|
serviceInstances = [
|
||||||
|
makeService({ id: "graf", service_type: "grafana" }),
|
||||||
|
makeService({ id: "jelly", service_type: "jellyfin" }),
|
||||||
|
];
|
||||||
|
widgetInstances = [
|
||||||
|
makeWidget({
|
||||||
|
id: "w-obs",
|
||||||
|
service_id: "graf",
|
||||||
|
widget_kind: "link",
|
||||||
|
title: "Grafana Link",
|
||||||
|
}),
|
||||||
|
makeWidget({
|
||||||
|
id: "w-media",
|
||||||
|
service_id: "jelly",
|
||||||
|
widget_kind: "activity",
|
||||||
|
title: "Jellyfin Activity",
|
||||||
|
}),
|
||||||
|
makeWidget({
|
||||||
|
id: "w-backup",
|
||||||
|
service_id: null,
|
||||||
|
widget_kind: "backups",
|
||||||
|
title: "Backup Summary",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
// Anchor bar pills are visible for populated sections (each label appears
|
||||||
|
// in both the pill and the section heading, so use getAllByText).
|
||||||
|
expect(screen.getAllByText("Observability").length).toBeGreaterThanOrEqual(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText("Media").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText("Backups").length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Sections with no widgets are NOT rendered.
|
||||||
|
expect(screen.queryByText("Custom")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Each widget renders.
|
||||||
|
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Jellyfin Activity")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Backup Summary")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render the anchor bar at desktop width", () => {
|
||||||
|
setMatchMedia(false); // desktop
|
||||||
|
serviceInstances = [makeService({ id: "graf", service_type: "grafana" })];
|
||||||
|
widgetInstances = [
|
||||||
|
makeWidget({
|
||||||
|
id: "w-obs",
|
||||||
|
service_id: "graf",
|
||||||
|
widget_kind: "link",
|
||||||
|
title: "Grafana Link",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
// Widget renders (flat list, no section wrappers).
|
||||||
|
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// No section headings or anchor pills on desktop.
|
||||||
|
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Media")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anchor bar pills jump to their section via scrollIntoView", async () => {
|
||||||
|
setMatchMedia(true); // mobile
|
||||||
|
serviceInstances = [
|
||||||
|
makeService({ id: "graf", service_type: "grafana" }),
|
||||||
|
makeService({ id: "jelly", service_type: "jellyfin" }),
|
||||||
|
];
|
||||||
|
widgetInstances = [
|
||||||
|
makeWidget({
|
||||||
|
id: "w-obs",
|
||||||
|
service_id: "graf",
|
||||||
|
widget_kind: "link",
|
||||||
|
title: "Grafana Link",
|
||||||
|
}),
|
||||||
|
makeWidget({
|
||||||
|
id: "w-media",
|
||||||
|
service_id: "jelly",
|
||||||
|
widget_kind: "activity",
|
||||||
|
title: "Jellyfin Activity",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const scrollSpy = vi.spyOn(Element.prototype, "scrollIntoView");
|
||||||
|
|
||||||
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
// The Media section element exists.
|
||||||
|
expect(document.getElementById("dashboard-section-media")).not.toBeNull();
|
||||||
|
|
||||||
|
// Click the "Media" anchor pill (button role disambiguates from heading).
|
||||||
|
const mediaPill = screen.getByRole("button", { name: "Media" });
|
||||||
|
await userEvent.click(mediaPill);
|
||||||
|
|
||||||
|
expect(scrollSpy).toHaveBeenCalled();
|
||||||
|
scrollSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { DirectoryListing, MonitoringMachine } from "../../types";
|
|||||||
// so the selectedPath / currentDir state never leaks across cases.
|
// so the selectedPath / currentDir state never leaks across cases.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
setMatchMedia(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
function machineFixture(
|
function machineFixture(
|
||||||
@@ -77,6 +78,30 @@ beforeEach(() => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Stub window.matchMedia so useIsMobile resolves in jsdom (Slice 4). */
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
|
||||||
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: (
|
||||||
|
_evt: string,
|
||||||
|
listener: (e: MediaQueryListEvent) => void,
|
||||||
|
) => listeners.push(listener),
|
||||||
|
removeEventListener: (
|
||||||
|
_evt: string,
|
||||||
|
listener: (e: MediaQueryListEvent) => void,
|
||||||
|
) => {
|
||||||
|
const idx = listeners.indexOf(listener);
|
||||||
|
if (idx >= 0) listeners.splice(idx, 1);
|
||||||
|
},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
|
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
|
||||||
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
|
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
|
||||||
render(<FileBrowser />);
|
render(<FileBrowser />);
|
||||||
@@ -118,3 +143,56 @@ describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
|
|||||||
expect(screen.queryByText(/Selected:/)).toBeNull();
|
expect(screen.queryByText(/Selected:/)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FileBrowser (mobile card layout — slice 4)", () => {
|
||||||
|
it("renders cards with file/dir name as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<FileBrowser />);
|
||||||
|
|
||||||
|
// Card titles (the 'name' field rendered as primary).
|
||||||
|
expect(screen.getByText("movies")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("video.mkv")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("notes.txt")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Desktop table column headers must NOT render.
|
||||||
|
const headers = screen.queryAllByRole("columnheader");
|
||||||
|
expect(headers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tapping a directory card navigates into it", async () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<FileBrowser />);
|
||||||
|
|
||||||
|
// Directory card is a button wrapping the 'movies' text.
|
||||||
|
await userEvent.click(screen.getByText("movies"));
|
||||||
|
|
||||||
|
// After navigating into /movies, the status caption shows the new cwd
|
||||||
|
// and NO 'Selected:' segment (directories are opened, not selected).
|
||||||
|
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Selected:/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the path/breadcrumb controls on mobile", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<FileBrowser />);
|
||||||
|
|
||||||
|
// The 'Remote path' label and its input are part of the Browser section
|
||||||
|
// card (outside the table), so they render on both breakpoints.
|
||||||
|
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the DataTable at desktop width (1280px)", () => {
|
||||||
|
setMatchMedia(false);
|
||||||
|
render(<FileBrowser />);
|
||||||
|
|
||||||
|
// Desktop path: table column headers are present.
|
||||||
|
const headers = screen
|
||||||
|
.getAllByRole("columnheader")
|
||||||
|
.map((h) => h.textContent);
|
||||||
|
expect(headers).toEqual(
|
||||||
|
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -129,7 +129,11 @@ vi.mock("../../hooks/useDashboard", () => ({
|
|||||||
|
|
||||||
// usePersistentState reads/writes localStorage; clear between tests so the
|
// usePersistentState reads/writes localStorage; clear between tests so the
|
||||||
// offset/pageSize/columnVisibility state never leaks across cases.
|
// offset/pageSize/columnVisibility state never leaks across cases.
|
||||||
|
// matchMedia must be stubbed so useIsMobile (md:768px) and usePrefersSmallScreen
|
||||||
|
// (900px) resolve without TypeError in jsdom. Default to desktop (matches:false)
|
||||||
|
// so the DataTable path renders by default; mobile tests override.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
setMatchMedia(false);
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
navigate.mockClear();
|
navigate.mockClear();
|
||||||
status = statusFixture();
|
status = statusFixture();
|
||||||
@@ -152,6 +156,30 @@ beforeEach(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Stub window.matchMedia so useIsMobile / usePrefersSmallScreen resolve in jsdom. */
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
|
||||||
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: (
|
||||||
|
_evt: string,
|
||||||
|
listener: (e: MediaQueryListEvent) => void,
|
||||||
|
) => listeners.push(listener),
|
||||||
|
removeEventListener: (
|
||||||
|
_evt: string,
|
||||||
|
listener: (e: MediaQueryListEvent) => void,
|
||||||
|
) => {
|
||||||
|
const idx = listeners.indexOf(listener);
|
||||||
|
if (idx >= 0) listeners.splice(idx, 1);
|
||||||
|
},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
|
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
|
||||||
it("exposes exactly the 15 locked toggleable columns", async () => {
|
it("exposes exactly the 15 locked toggleable columns", async () => {
|
||||||
render(<Media />);
|
render(<Media />);
|
||||||
@@ -261,3 +289,68 @@ describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", (
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Media (mobile card layout — slice 3)", () => {
|
||||||
|
it("renders cards with the title as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
// Card titles render (primary field).
|
||||||
|
expect(screen.getByText("Inception")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Matrix")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Card field labels render (at least once per row).
|
||||||
|
expect(screen.getAllByText("Size").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("HDR").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("Library").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("Year").length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Desktop table headers do NOT render on mobile.
|
||||||
|
expect(screen.queryByRole("columnheader", { name: "Title" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("columnheader", { name: "Bitrate" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the column-visibility toggle below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /Columns/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders pagination controls below the cards on mobile", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
expect(screen.getByText("2 rows")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Previous page" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Next page" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to the file browser when a card is tapped on mobile", async () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Inception"));
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigate).toHaveBeenCalledWith(
|
||||||
|
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the DataTable (not cards) at desktop width", () => {
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
// Desktop column headers render.
|
||||||
|
expect(
|
||||||
|
screen.getByRole("columnheader", { name: "Title" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// Column-visibility toggle is present.
|
||||||
|
expect(screen.getByRole("button", { name: /Columns/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { ServicePage } from "../ServicePage";
|
||||||
|
import type {
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceInstanceInput,
|
||||||
|
ServiceTypeInfo,
|
||||||
|
} from "../../types";
|
||||||
|
|
||||||
|
// --- fixtures ---
|
||||||
|
|
||||||
|
const instance: ServiceInstance = {
|
||||||
|
id: "svc-1",
|
||||||
|
service_type: "grafana",
|
||||||
|
name: "Production Grafana",
|
||||||
|
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
|
||||||
|
secrets_set: { api_key: true },
|
||||||
|
enabled: true,
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
updated_at: 1_700_000_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeInfo: ServiceTypeInfo = {
|
||||||
|
service_type: "grafana",
|
||||||
|
name: "Grafana",
|
||||||
|
description: "Dashboards, metrics, and logs.",
|
||||||
|
config_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
base_url: { type: "string", description: "Absolute URL." },
|
||||||
|
timeout_seconds: { type: "integer" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
secret_fields: [{ key: "api_key", label: "API key", required: false }],
|
||||||
|
widget_kinds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- mocks ---
|
||||||
|
|
||||||
|
const mutateAsync = vi.fn();
|
||||||
|
const mutate = vi.fn();
|
||||||
|
const deleteMutate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({ data: [instance] }),
|
||||||
|
useServiceTypes: () => ({ data: [typeInfo] }),
|
||||||
|
useSaveServiceInstance: () => ({
|
||||||
|
mutateAsync,
|
||||||
|
mutate,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
useDeleteServiceInstance: () => ({ mutate: deleteMutate, isPending: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useParams: () => ({
|
||||||
|
serviceType: "grafana",
|
||||||
|
serviceId: "svc-1",
|
||||||
|
}),
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// jsdom has no window.matchMedia; stub it. Default to desktop (matches: false).
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setMatchMedia(false);
|
||||||
|
mutateAsync.mockReset();
|
||||||
|
mutate.mockReset();
|
||||||
|
deleteMutate.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ServicePage (desktop)", () => {
|
||||||
|
it("renders the full-page layout with the service name and connection card", () => {
|
||||||
|
render(<ServicePage />);
|
||||||
|
// Page heading (desktop only — mobile uses SheetForm title)
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Production Grafana" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// Connection section card title
|
||||||
|
expect(screen.getByText("Connection")).toBeInTheDocument();
|
||||||
|
// General Save button
|
||||||
|
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render the SheetForm at desktop width", () => {
|
||||||
|
render(<ServicePage />);
|
||||||
|
// SheetForm renders a dialog with role="dialog" only when open; on
|
||||||
|
// desktop the page layout is used, so no dialog should be present.
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ServicePage (mobile SheetForm — slice 6)", () => {
|
||||||
|
beforeEach(() => setMatchMedia(true));
|
||||||
|
|
||||||
|
it("renders the SheetForm with the service name as title below md", () => {
|
||||||
|
render(<ServicePage />);
|
||||||
|
// SheetForm title is rendered inside a SheetTitle (role="heading").
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Production Grafana" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// The dialog (Sheet content) should be present on mobile.
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
// Desktop page header description is NOT rendered inside the SheetForm.
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Dashboards, metrics, and logs."),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits the name field and Save calls the save mutation", async () => {
|
||||||
|
render(<ServicePage />);
|
||||||
|
const nameInput = screen.getByLabelText("Name");
|
||||||
|
expect(nameInput).toHaveValue("Production Grafana");
|
||||||
|
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, "Renamed Grafana");
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||||
|
await userEvent.click(saveButton);
|
||||||
|
|
||||||
|
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||||
|
const input = mutateAsync.mock.calls[0][0] as ServiceInstanceInput;
|
||||||
|
expect(input.name).toBe("Renamed Grafana");
|
||||||
|
expect(input.id).toBe("svc-1");
|
||||||
|
// Lock the full save payload (config draft, enabled, secrets sentinel).
|
||||||
|
expect(input.enabled).toBe(true);
|
||||||
|
expect(input.secrets).toEqual({});
|
||||||
|
expect(input.config).toMatchObject({ base_url: "https://grafana.example.com" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the connection config fields as editable inside the SheetForm", () => {
|
||||||
|
render(<ServicePage />);
|
||||||
|
const urlInput = screen.getByLabelText("base_url");
|
||||||
|
expect(urlInput).toHaveValue("https://grafana.example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,12 +6,10 @@ import type { MonitoringMachine } from "../../types";
|
|||||||
|
|
||||||
const saveMachineMutate = vi.fn().mockResolvedValue({});
|
const saveMachineMutate = vi.fn().mockResolvedValue({});
|
||||||
const deleteMachineMutate = vi.fn();
|
const deleteMachineMutate = vi.fn();
|
||||||
const testSSHMutate = vi
|
const testSSHMutate = vi.fn().mockResolvedValue({
|
||||||
.fn()
|
message: "SSH auth succeeded",
|
||||||
.mockResolvedValue({
|
known_hosts_updated: true,
|
||||||
message: "SSH auth succeeded",
|
});
|
||||||
known_hosts_updated: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
let machines: MonitoringMachine[] = [];
|
let machines: MonitoringMachine[] = [];
|
||||||
|
|
||||||
@@ -113,3 +111,103 @@ describe("Settings", () => {
|
|||||||
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
|
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// jsdom has no window.matchMedia; default to desktop so existing tests are
|
||||||
|
// unaffected.
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Settings (mobile SheetForm — slice 7)", () => {
|
||||||
|
beforeEach(() => setMatchMedia(true));
|
||||||
|
|
||||||
|
it("opens the machine editor in a SheetForm below md", async () => {
|
||||||
|
machines = [localMachine()];
|
||||||
|
render(<Settings />);
|
||||||
|
|
||||||
|
// Open the editor via the detail-pane Edit button (visible text).
|
||||||
|
const detailEdit = screen
|
||||||
|
.getAllByRole("button", { name: "Edit" })
|
||||||
|
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||||
|
await userEvent.click(detailEdit);
|
||||||
|
|
||||||
|
// SheetForm renders a dialog; the DialogTitle shows the editor title.
|
||||||
|
expect(screen.getByText("Edit machine")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
// Desktop DialogDescription text is not rendered as a dialog description
|
||||||
|
// on mobile (the MachineEditor has its own hint labels, which is fine).
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Create machine" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a machine via the SheetForm on mobile", async () => {
|
||||||
|
machines = [localMachine()];
|
||||||
|
render(<Settings />);
|
||||||
|
|
||||||
|
const detailEdit = screen
|
||||||
|
.getAllByRole("button", { name: "Edit" })
|
||||||
|
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||||
|
await userEvent.click(detailEdit);
|
||||||
|
|
||||||
|
const nameInput = screen.getByLabelText("Name");
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, "Renamed node");
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
|
||||||
|
|
||||||
|
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
|
||||||
|
const saved = saveMachineMutate.mock.calls[0][0];
|
||||||
|
expect(saved.name).toBe("Renamed node");
|
||||||
|
expect(saved.mode).toBe("local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel closes the SheetForm on mobile", async () => {
|
||||||
|
machines = [localMachine()];
|
||||||
|
render(<Settings />);
|
||||||
|
|
||||||
|
const detailEdit = screen
|
||||||
|
.getAllByRole("button", { name: "Edit" })
|
||||||
|
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||||
|
await userEvent.click(detailEdit);
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
// The sheet is now closed — the dialog role should no longer be present.
|
||||||
|
// (The page content itself is still rendered; only the sheet unmounts.)
|
||||||
|
expect(screen.queryByText("Edit machine")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts before discarding unsaved machine edits (R4.5)", async () => {
|
||||||
|
machines = [localMachine()];
|
||||||
|
render(<Settings />);
|
||||||
|
|
||||||
|
const detailEdit = screen
|
||||||
|
.getAllByRole("button", { name: "Edit" })
|
||||||
|
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||||
|
await userEvent.click(detailEdit);
|
||||||
|
|
||||||
|
// Edit the name to make the form dirty.
|
||||||
|
const nameInput = screen.getByLabelText("Name");
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, "Dirty name");
|
||||||
|
|
||||||
|
// Cancel should NOT immediately close — the discard confirm appears.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// The editor is still open.
|
||||||
|
expect(screen.getByText("Edit machine")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import type {
|
|||||||
UserDirectoryResponse,
|
UserDirectoryResponse,
|
||||||
} from "../../types";
|
} from "../../types";
|
||||||
|
|
||||||
// jsdom has no window.matchMedia; MUI `useMediaQuery` (still used by the
|
// jsdom has no window.matchMedia; the shared `useIsMobile` hook and the
|
||||||
// compose dialog, slice 6b) must not blow up during render. Stub to "desktop".
|
// compose dialog viewport hook must not blow up during render. Stub to
|
||||||
|
// "desktop" (matches: false) by default; the slice-5 describe block flips it
|
||||||
|
// to mobile for card-layout assertions.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
if (!window.matchMedia) {
|
if (!window.matchMedia) {
|
||||||
window.matchMedia = ((query: string) => ({
|
window.matchMedia = ((query: string) => ({
|
||||||
@@ -283,3 +285,123 @@ describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
|
|||||||
expect(body.value).toContain("<strong>");
|
expect(body.value).toContain("<strong>");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("UsersPage (mobile card layout — slice 5)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes("768"),
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders user cards with display name as primary below md", () => {
|
||||||
|
users = [
|
||||||
|
userFixture({ jellyfin_id: "u1", display_name: "Alice" }),
|
||||||
|
userFixture({
|
||||||
|
jellyfin_id: "u2",
|
||||||
|
username: "bob",
|
||||||
|
display_name: "Bob",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<UsersPage />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||||
|
// Activity field label should appear per card.
|
||||||
|
expect(screen.getAllByText("Activity")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles selection from the card checkbox without opening the drawer", async () => {
|
||||||
|
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<UsersPage />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole("checkbox", {
|
||||||
|
name: /Select Alice/i,
|
||||||
|
});
|
||||||
|
expect(checkbox).toHaveAttribute("data-state", "unchecked");
|
||||||
|
|
||||||
|
await userEvent.click(checkbox);
|
||||||
|
expect(checkbox).toHaveAttribute("data-state", "checked");
|
||||||
|
|
||||||
|
// Drawer stays closed: the session-panel stub only renders when the
|
||||||
|
// drawer opens via a card-body tap, not via the checkbox.
|
||||||
|
expect(screen.queryByTestId("session-panel-stub")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders compose in a SheetForm below md with send button", async () => {
|
||||||
|
users = [
|
||||||
|
userFixture({
|
||||||
|
jellyfin_id: "u1",
|
||||||
|
display_name: "Alice",
|
||||||
|
email: "alice@example.com",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<UsersPage />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select the deliverable user via the mobile card checkbox.
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("checkbox", { name: /Select Alice/i }),
|
||||||
|
);
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Message selected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// On mobile, compose opens in a SheetForm (not a Dialog). The SheetForm
|
||||||
|
// header carries the title and the footer carries the Send button.
|
||||||
|
expect(screen.getByText("Message selected users")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Send message" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts before discarding unsaved compose edits (R4.5)", async () => {
|
||||||
|
users = [
|
||||||
|
userFixture({
|
||||||
|
jellyfin_id: "u1",
|
||||||
|
display_name: "Alice",
|
||||||
|
email: "alice@example.com",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<UsersPage />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("checkbox", { name: /Select Alice/i }),
|
||||||
|
);
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Message selected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type a subject to make the compose form dirty.
|
||||||
|
await userEvent.type(screen.getByLabelText("Subject"), "Urgent update");
|
||||||
|
|
||||||
|
// Cancel should NOT immediately close — the discard confirm appears.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Design — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** design
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Frontend stack recap: React 18 + Vite + TanStack Query + TanStack Table +
|
||||||
|
Tailwind v4 (CSS `@theme` in `src/index.css`) + shadcn/ui (Radix primitives) +
|
||||||
|
lucide-react + react-router-dom + react-oidc-context. The app shell
|
||||||
|
(`App.tsx`) is already responsive via a `md:` (768px) cut and a `MobileDrawer`
|
||||||
|
`Sheet`. The content layer is not.
|
||||||
|
|
||||||
|
This design adds four **shared primitives** and applies them per-page. It does
|
||||||
|
not introduce new libraries.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Shared primitives (PR 1)
|
||||||
|
|
||||||
|
#### 1. `MobileCardRow<T>` — card renderer for TanStack Table rows
|
||||||
|
|
||||||
|
Lives in `src/components/ui/mobile-card.tsx` (new). Generic over the row data
|
||||||
|
type. Reused by the four wide tables.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export interface MobileCardField<T> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
render: (row: T) => React.ReactNode;
|
||||||
|
/** When true, render as the card title (bold, larger). Exactly one per card. */
|
||||||
|
primary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileCardRowProps<T> {
|
||||||
|
rows: TData[];
|
||||||
|
fields: MobileCardField<T>[];
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
/** Optional right-aligned action slot (edit/delete icon buttons). */
|
||||||
|
actions?: (row: T) => React.ReactNode;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Renders a vertical list of cards. Each card shows the `primary` field as the
|
||||||
|
title and the remaining fields as a key/value stack. The whole card is a button
|
||||||
|
when `onRowClick` is set (44px min height).
|
||||||
|
|
||||||
|
The consuming page decides which fields to show — this primitive does not pick
|
||||||
|
them.
|
||||||
|
|
||||||
|
#### 2. `useIsMobile()` — single source of truth for the breakpoint
|
||||||
|
|
||||||
|
Lives in `src/hooks/useIsMobile.ts` (new). Wraps
|
||||||
|
`matchMedia("(max-width: 768px)")`, SSR-safe, returns a boolean. Replaces the
|
||||||
|
inline `window.matchMedia` reads in `App.tsx` and the ad-hoc `usePrefersSmallScreen`
|
||||||
|
usage in `Media.tsx`. One breakpoint, one hook.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
const [isMobile, setIsMobile] = useState(() =>
|
||||||
|
typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia("(max-width: 768px)");
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
|
mql.addEventListener("change", handler);
|
||||||
|
return () => mql.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `SheetForm` — full-height form host
|
||||||
|
|
||||||
|
Lives in `src/components/ui/sheet-form.tsx` (new). Wraps the shadcn `Sheet`
|
||||||
|
primitive. Props: `open`, `onOpenChange`, `title`, `onSave`, `onCancel`,
|
||||||
|
`isPending`, `children`. Renders sticky header (`title` + `X`) and sticky
|
||||||
|
footer (`Cancel` / `Save`). Body scrolls.
|
||||||
|
|
||||||
|
Below `md`, used by ServicePage, Settings, message compose, WidgetConfigDialog.
|
||||||
|
At `md:` and above, the existing `Dialog` is used unchanged. The choice is made
|
||||||
|
in the consumer with `useIsMobile()`, not inside `SheetForm`, so the same form
|
||||||
|
body can be reused across both hosts.
|
||||||
|
|
||||||
|
#### 4. `EditActionButton` — touch-aware edit affordance
|
||||||
|
|
||||||
|
Replaces `HoverEditButton`'s role (not its file — we extend the existing
|
||||||
|
component). Add a `mobile="always"` prop (default). Below `md`, the button is
|
||||||
|
always visible (no hover-gated opacity). At `md:` and above, current
|
||||||
|
hover-reveal behavior is preserved. Implementation: a `md:opacity-0
|
||||||
|
md:group-hover:opacity-100` Tailwind stack, i.e. always visible by default,
|
||||||
|
hidden-then-revealed on hover at `md:` and up.
|
||||||
|
|
||||||
|
### Per-page application (PRs 2–9)
|
||||||
|
|
||||||
|
Each wide-table page renders `<MobileCardRow>` below `md` and the existing
|
||||||
|
`<DataTable>` at/above `md`. The page wires up the field list. Example for
|
||||||
|
Media:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const fields: MobileCardField<MediaItem>[] = [
|
||||||
|
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||||
|
{ key: "size", label: "Size", render: (r) => r.size_display },
|
||||||
|
{ key: "hdr", label: "HDR", render: (r) => (r.is_hdr ? "HDR" : "") },
|
||||||
|
{ key: "library", label: "Library", render: (r) => r.library_name },
|
||||||
|
];
|
||||||
|
return isMobile
|
||||||
|
? <MobileCardRow rows={rows} fields={fields} onRowClick={onRowClick} actions={(r) => <EditActionButton onClick={...} />} />
|
||||||
|
: <DataTable columns={columns} data={rows} /* ...existing props */ />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Touch-target audit (PR 1, applied throughout)
|
||||||
|
|
||||||
|
A single `min-h-11 min-w-11` (44px) utility class is applied to interactive
|
||||||
|
shadcn primitives below `md`. Applied via a `mobile-touch-target` Tailwind
|
||||||
|
utility class registered in `tailwind.config.cjs` (or as a Tailwind v4 CSS
|
||||||
|
utility in `src/index.css`). The class adds `min-height: 44px; min-width: 44px`
|
||||||
|
only below `md`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-touch-target,
|
||||||
|
.mobile-touch-target::before {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pages add the class to icon buttons, checkboxes, switches, and row taps during
|
||||||
|
their per-page PR.
|
||||||
|
|
||||||
|
## Breakpoints
|
||||||
|
|
||||||
|
- `< 768px` (`isMobile === true`): mobile layout — cards, Sheet forms, always-
|
||||||
|
visible edit, single-column dashboard, anchor bar.
|
||||||
|
- `≥ 768px`: existing desktop layout, unchanged.
|
||||||
|
|
||||||
|
No `sm:` cut. No `lg:` cut.
|
||||||
|
|
||||||
|
## Key technical risks & mitigations
|
||||||
|
|
||||||
|
- **TanStack column defs vs. card fields drift.** Each page that renders a card
|
||||||
|
must declare its mobile fields in one place; tests assert the card shows the
|
||||||
|
primary field at 375px. If a column is renamed, the card test fails.
|
||||||
|
- **iOS Safari `100dvh`.** `SheetForm` uses `h-[100dvh]` (not `h-screen`) to
|
||||||
|
avoid the iOS URL-bar resize jump. Tested manually on iOS Safari.
|
||||||
|
- **`position: sticky` inside `SheetContent`.** Radix `Sheet` uses transforms;
|
||||||
|
sticky must be relative to the scroll container inside the sheet body, not the
|
||||||
|
sheet itself. The sticky header/footer are siblings of the scrolling body
|
||||||
|
inside a flex column, not sticky-positioned.
|
||||||
|
- **OIDC redirect after login.** No change: responsive web only, OIDC continues
|
||||||
|
to redirect within the same browser tab.
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
- **Card layouts duplicate field definitions** (once as TanStack columns, once
|
||||||
|
as `MobileCardField[]`). Accepted: the alternative (auto-deriving cards from
|
||||||
|
column defs) produces bad mobile UX because column defs are not ordered by
|
||||||
|
mobile importance.
|
||||||
|
- **44px touch targets** slightly increase mobile visual density compared to a
|
||||||
|
32px design, but meet WCAG 2.5.5. Accepted.
|
||||||
|
- **`useIsMobile()` per-page render branching** is preferred over CSS-only
|
||||||
|
`hidden md:block` because the card and table have different data dependencies
|
||||||
|
(e.g. row click handlers, selection state) and mounting both wastes work.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Proposal — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** proposal
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The Manage frontend ships a responsive **app shell** (hamburger drawer,
|
||||||
|
`MobileDrawer`, `md:` breakpoint at 768px, correct viewport meta) but the
|
||||||
|
**content layer** assumes a desktop viewport. Concretely:
|
||||||
|
|
||||||
|
1. **Data tables render as literal `<table>` elements with no mobile affordance.**
|
||||||
|
Seven tables (Media, FileBrowser, UsersPage, BackupAlertsTable,
|
||||||
|
BackupJobsTable, BackupRunsTable, SessionActivityPanel) overflow or clip on a
|
||||||
|
375px screen. The Media page's TanStack column-visibility toggle is unusable
|
||||||
|
on touch.
|
||||||
|
2. **Edit forms open in centered `Dialog`s with multi-column grids.** ServicePage
|
||||||
|
config, Settings (machines/SSH keys), the message compose dialog, and
|
||||||
|
`WidgetConfigDialog` cramp or overflow on phones; save actions drift off-screen.
|
||||||
|
3. **`HoverEditButton` and row-hover actions do not fire on touch devices.**
|
||||||
|
Edit affordances are invisible to phone users.
|
||||||
|
4. **Touch targets violate mobile accessibility standards.** shadcn defaults
|
||||||
|
(32px buttons, dense rows) are below the 44px minimum that WCAG 2.5.5 / Apple
|
||||||
|
HIG require for touch.
|
||||||
|
5. **The Dashboard widget grid does not collapse.** The configurable grid has no
|
||||||
|
single-column mobile layout, so a multi-widget dashboard sideways-scrolls or
|
||||||
|
clips.
|
||||||
|
|
||||||
|
The result: the app **launches** on a phone but cannot be **operated** there.
|
||||||
|
Several flows (create service, edit widget layout, build media index, manage SSH
|
||||||
|
keys) are effectively desktop-only.
|
||||||
|
|
||||||
|
## Proposal
|
||||||
|
|
||||||
|
Make every route fully usable in phone portrait (≥360px) at a single `md:`
|
||||||
|
(768px) cut. Tablets keep the desktop layout. No desktop-only flows survive.
|
||||||
|
|
||||||
|
1. **Hybrid data-table strategy.** The four wide tables (Media, FileBrowser,
|
||||||
|
Users, Backups) render a stacked **card per row** below `md`, each card
|
||||||
|
picking the 3–5 most important fields. Narrow tables (SessionActivity) keep
|
||||||
|
horizontal scroll. The TanStack column-visibility toggle is hidden below `md`
|
||||||
|
(the card picks the fields).
|
||||||
|
2. **Sheet-based edit forms.** Below `md`, ServicePage, Settings, message
|
||||||
|
compose, and `WidgetConfigDialog` open inside a full-height `Sheet` (reusing
|
||||||
|
the existing primitive) with a sticky header and a sticky save bar — instead
|
||||||
|
of the centered `Dialog`.
|
||||||
|
3. **Replace `HoverEditButton` with an always-visible variant** below `md`. Row
|
||||||
|
edit/delete actions surface as small, persistent icon buttons on the right of
|
||||||
|
each row/card.
|
||||||
|
4. **Touch-target audit.** All interactive elements below `md` get a 44px
|
||||||
|
minimum hit area (buttons, checkboxes, row taps, badges-as-buttons).
|
||||||
|
5. **Dashboard mobile layout.** The widget grid collapses to a single column
|
||||||
|
below `md`, with a section anchor bar (Observability / Media / Backups /
|
||||||
|
Custom) at the top for quick navigation.
|
||||||
|
6. **Responsive web only.** No PWA, no manifest, no service worker. OIDC keeps
|
||||||
|
working in-browser as it does today.
|
||||||
|
7. **Per-page delivery.** Ship ~9 chained PRs, one per route (plus a primitives
|
||||||
|
PR), each ≤400 changed lines, each leaving `npm run lint`, `npm run build`
|
||||||
|
(tsc -b + vite build), and `npm run test` green.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **No tablet-specific layout.** Tablets use the existing desktop layout at
|
||||||
|
`md:` and above.
|
||||||
|
- **No PWA / installability.** No manifest, service worker, offline mode, or
|
||||||
|
standalone display mode. This is a responsive website.
|
||||||
|
- **No change to polling intervals.** Widget refresh (≈30s) and the
|
||||||
|
message-queue poll (5s) keep desktop semantics. (Flagged as a follow-up risk;
|
||||||
|
see §Risks.)
|
||||||
|
- **No new data-table library.** TanStack Table stays; card layouts render from
|
||||||
|
the same row data, not from a separate component library.
|
||||||
|
- **No backend changes.** The API contract is unchanged.
|
||||||
|
- **No landscape-phone or small-tablet (`sm:`) intermediate layout.** A single
|
||||||
|
`md:` cut is the target.
|
||||||
|
- **No new product features.** This is a presentation-layer parity change.
|
||||||
|
|
||||||
|
## Key technical risks
|
||||||
|
|
||||||
|
- **TanStack Table → card rendering** is not automatic. Each of the four wide
|
||||||
|
tables needs a per-table card variant that picks which fields to show; this is
|
||||||
|
where most of the implementation risk and review burden lives.
|
||||||
|
- **`Sheet` as a form host** is novel in this codebase (currently used only for
|
||||||
|
the nav drawer). Sticky header + sticky save bar must work across iOS Safari
|
||||||
|
and Chrome Android, including inside the OIDC-triggering keyboard insets.
|
||||||
|
- **iOS Safari quirks**: viewport `100dvh`, attachment upload from Files,
|
||||||
|
`position: sticky` inside transformed ancestors. Each may need targeted fixes.
|
||||||
|
- **`HoverEditButton` replacement** must not regress the desktop hover-reveal
|
||||||
|
aesthetic — only the mobile behavior changes.
|
||||||
|
|
||||||
|
## Risks (not blocking, flagged for later)
|
||||||
|
|
||||||
|
- **D8 — Polling on battery.** The dashboard (the page most likely to be left
|
||||||
|
open on a phone) polls every ~30s per widget plus the 5s queue-status poll.
|
||||||
|
Per the decision matrix, intervals stay identical to desktop. Cheapest future
|
||||||
|
mitigation: a single `useEffect` on `document.visibilityState` that pauses
|
||||||
|
TanStack refetch when the tab is hidden (~10 lines, zero UX cost). Revisit
|
||||||
|
after parity ships if battery complaints arise.
|
||||||
|
|
||||||
|
## Decision matrix (from grilling)
|
||||||
|
|
||||||
|
| # | Decision | Choice |
|
||||||
|
|---|----------|--------|
|
||||||
|
| D1 | Parity target | Full parity — no desktop-only flows |
|
||||||
|
| D2 | Data tables | Hybrid: cards below `md` for the big four; scroll for narrow; toggle hidden |
|
||||||
|
| D3 | Forms | Full-height `Sheet` below `md`, sticky header + sticky save bar |
|
||||||
|
| D4 | Touch edit | Always-visible edit button below `md` |
|
||||||
|
| D5 | Installable | Responsive web only — no PWA |
|
||||||
|
| D6 | Devices | Phone portrait only, single `md:` (768px) cut |
|
||||||
|
| D7 | Dashboard | Single-column stack + section anchor bar |
|
||||||
|
| D8 | Polling | Same intervals as desktop (flagged risk) |
|
||||||
|
| D9 | Touch targets | 44px minimum below `md` |
|
||||||
|
| D10 | Testing | Vitest per breakpoint + manual device-mode check |
|
||||||
|
| D11 | Delivery | Per-page PRs (~9), primitives PR first |
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Spec — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** spec
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
All 9 application routes must be fully operable in phone portrait viewports
|
||||||
|
(≥360px) at a single `md:` (768px) breakpoint. Tablets and wider viewports keep
|
||||||
|
the existing desktop layout unchanged. No product behavior changes; this is a
|
||||||
|
presentation-layer parity change only.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### R1 — Viewport & breakpoint policy
|
||||||
|
|
||||||
|
- R1.1 The viewport meta stays `width=device-width, initial-scale=1.0` (no zoom
|
||||||
|
lock). User zoom remains enabled.
|
||||||
|
- R1.2 There is exactly one responsive cut: `md:` (768px). Below is "mobile";
|
||||||
|
at-or-above is "desktop" (existing behavior).
|
||||||
|
- R1.3 No `sm:` intermediate cut is introduced.
|
||||||
|
|
||||||
|
### R2 — App shell (already compliant; locked in)
|
||||||
|
|
||||||
|
- R2.1 Desktop `Sidebar` renders `null` when `isMobile` (`matchMedia("(max-width:
|
||||||
|
768px)")`).
|
||||||
|
- R2.2 Mobile nav uses the existing `MobileDrawer` (hamburger, `md:hidden`,
|
||||||
|
`Sheet` side=left) with no behavioral change.
|
||||||
|
- R2.3 `TopBar` keeps its existing responsive behavior (version badges hidden
|
||||||
|
on small screens, hamburger visible below `md`).
|
||||||
|
|
||||||
|
### R3 — Data tables (hybrid)
|
||||||
|
|
||||||
|
- R3.1 The four wide tables — **Media** (`pages/Media.tsx`), **FileBrowser**
|
||||||
|
(`pages/FileBrowser.impl.tsx`), **Users** (`pages/UsersPage.impl.tsx`), and the
|
||||||
|
three **Backups** tables (`BackupAlertsTable.tsx`, `BackupJobsTable.tsx`,
|
||||||
|
`BackupRunsTable.tsx`) — render a stacked **card per row** below `md`.
|
||||||
|
- R3.2 Each card shows a primary title plus the 3–5 most important fields for
|
||||||
|
that table (chosen per-table; documented in tasks). All remaining fields are
|
||||||
|
omitted from the mobile card.
|
||||||
|
- R3.3 Row click / selection semantics are preserved on the card (tap target =
|
||||||
|
the whole card where applicable).
|
||||||
|
- R3.4 **SessionActivityPanel** (narrow, 3-column) keeps the `<table>` shape
|
||||||
|
inside a horizontal-scroll container below `md`.
|
||||||
|
- R3.5 The TanStack **column-visibility toggle is hidden below `md`** on every
|
||||||
|
table that uses it (Media). The mobile card picks the fields; the user does
|
||||||
|
not re-show hidden columns on touch.
|
||||||
|
- R3.6 At `md:` and above, all tables render exactly as today.
|
||||||
|
|
||||||
|
### R4 — Edit forms (Sheet)
|
||||||
|
|
||||||
|
- R4.1 Below `md`, these edit flows open in a full-height `Sheet` (side=bottom
|
||||||
|
or side=right, full screen) instead of a centered `Dialog`:
|
||||||
|
- **ServicePage** connection config + secrets
|
||||||
|
- **Settings** machines and SSH-key editors
|
||||||
|
- **Message compose** dialog (`UsersPage.impl.tsx`)
|
||||||
|
- **WidgetConfigDialog**
|
||||||
|
- R4.2 The Sheet form has a sticky header (title + close affordance) and a
|
||||||
|
sticky footer/save bar (Cancel + Save).
|
||||||
|
- R4.3 Form fields stack to a single column inside the Sheet.
|
||||||
|
- R4.4 At `md:` and above, the existing `Dialog`-based forms are unchanged.
|
||||||
|
- R4.5 The Sheet closes on successful save and on explicit cancel; it does not
|
||||||
|
close on outside-click while the form is dirty (confirm prompt).
|
||||||
|
|
||||||
|
### R5 — Touch edit affordance
|
||||||
|
|
||||||
|
- R5.1 `HoverEditButton` gains a `md:` variant: hover-revealed on desktop
|
||||||
|
(unchanged), **always visible** below `md`.
|
||||||
|
- R5.2 Row/card edit and delete actions surface as persistent icon buttons on
|
||||||
|
the right edge below `md`.
|
||||||
|
- R5.3 Desktop hover-reveal aesthetic is not regressed at `md:` and above.
|
||||||
|
|
||||||
|
### R6 — Touch targets
|
||||||
|
|
||||||
|
- R6.1 All interactive elements below `md` have a minimum 44×44px hit area.
|
||||||
|
This includes: buttons, icon buttons, checkboxes, switches, row/card tap
|
||||||
|
targets, and badges that act as buttons.
|
||||||
|
- R6.2 Visual size may remain smaller than 44px (padding-only hit areas are
|
||||||
|
acceptable) as long as the tappable region meets the minimum.
|
||||||
|
- R6.3 At `md:` and above, sizes are unchanged.
|
||||||
|
|
||||||
|
### R7 — Dashboard layout
|
||||||
|
|
||||||
|
- R7.1 The widget grid collapses to a **single column** below `md`.
|
||||||
|
- R7.2 A **section anchor bar** appears at the top of the dashboard below `md`,
|
||||||
|
grouping widgets (e.g. Observability / Media / Backups / Custom) and allowing
|
||||||
|
quick jump-to-section.
|
||||||
|
- R7.3 Widget order respects the user's configured sort order.
|
||||||
|
- R7.4 At `md:` and above, the grid renders exactly as today.
|
||||||
|
|
||||||
|
### R8 — Polling (unchanged)
|
||||||
|
|
||||||
|
- R8.1 Widget refresh intervals and the message-queue poll interval are
|
||||||
|
identical on mobile and desktop.
|
||||||
|
- R8.2 (Follow-up risk, not in scope: pause refetch on `document.visibilityState
|
||||||
|
=== "hidden"`. Tracked in proposal §Risks.)
|
||||||
|
|
||||||
|
### R9 — No PWA
|
||||||
|
|
||||||
|
- R9.1 No web manifest, service worker, or standalone display mode is added.
|
||||||
|
- R9.2 OIDC continues to work in-browser; no standalone-mode redirect handling
|
||||||
|
is introduced.
|
||||||
|
|
||||||
|
### R10 — Non-regression
|
||||||
|
|
||||||
|
- R10.1 No desktop layout (≥768px) is visually or functionally regressed.
|
||||||
|
- R10.2 No backend API contract change.
|
||||||
|
- R10.3 No existing test is deleted; mobile-specific tests are additive.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- AC1 Every route listed in `App.tsx` `navItems` (Dashboard, Observability,
|
||||||
|
Media, Files, Backups, Users, Actions, Services, Settings) is fully operable
|
||||||
|
at 375px width in Chrome DevTools device mode (iPhone 12 Pro preset or
|
||||||
|
equivalent).
|
||||||
|
- AC2 Each of the four wide tables shows a card layout at 375px and the table
|
||||||
|
layout at 1280px.
|
||||||
|
- AC3 Each of the four edit forms opens in a Sheet at 375px and a Dialog at
|
||||||
|
1280px.
|
||||||
|
- AC4 `HoverEditButton` is always visible at 375px and hover-revealed at 1280px.
|
||||||
|
- AC5 A 44px-minimum touch-target audit passes for all interactive elements at
|
||||||
|
375px.
|
||||||
|
- AC6 The Dashboard renders a single column with an anchor bar at 375px and the
|
||||||
|
existing grid at 1280px.
|
||||||
|
- AC7 `cd frontend && npm run lint && npm run build && npm run test` is green.
|
||||||
|
- AC8 At least one Vitest test per touched page asserts behavior at <768px and
|
||||||
|
≥768px breakpoints.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Tablet/landscape/sm: intermediate layout.
|
||||||
|
- PWA, manifest, service worker, offline mode.
|
||||||
|
- Polling-interval changes.
|
||||||
|
- Backend changes.
|
||||||
|
- New data-table library.
|
||||||
|
- New product features.
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# Tasks — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** tasks
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Review workload forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Estimated changed lines | ~2200–2800 |
|
||||||
|
| Chained PRs recommended | Yes (10 slices) |
|
||||||
|
| Chain strategy | stacked-to-main |
|
||||||
|
| Slice order | 1 (primitives) → 2 (Dashboard) → 3–5 (tables) → 6–8 (forms) → 9 (touch audit) → 10 (docs + verify) |
|
||||||
|
|
||||||
|
Each slice is committed separately (user pref). Every slice must leave
|
||||||
|
`cd frontend && npm run lint && npm run build && npm run test` green. Every
|
||||||
|
touched page gains a Vitest case asserting behavior at <768px and ≥768px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 1 — Shared primitives
|
||||||
|
|
||||||
|
**Goal:** Land the four building blocks every later slice depends on. No
|
||||||
|
page-level behavior changes yet.
|
||||||
|
|
||||||
|
- [ ] **1.1 `useIsMobile()` hook**
|
||||||
|
- Files: `frontend/src/hooks/useIsMobile.ts` (new)
|
||||||
|
- Lines: ~20
|
||||||
|
- Details: SSR-safe `matchMedia("(max-width: 768px)")` listener per design.
|
||||||
|
|
||||||
|
- [ ] **1.2 `MobileCardRow` component**
|
||||||
|
- Files: `frontend/src/components/ui/mobile-card.tsx` (new), plus a Vitest
|
||||||
|
spec `frontend/src/components/ui/__tests__/mobile-card.test.tsx`.
|
||||||
|
- Lines: ~80 + ~60 test
|
||||||
|
- Details: generic `<T,>`, fields list, `primary` field, optional `onRowClick`
|
||||||
|
and `actions` slot per design. 44px min card height.
|
||||||
|
|
||||||
|
- [ ] **1.3 `SheetForm` component**
|
||||||
|
- Files: `frontend/src/components/ui/sheet-form.tsx` (new), plus spec.
|
||||||
|
- Lines: ~70 + ~50 test
|
||||||
|
- Details: wraps shadcn `Sheet`; sticky header + sticky footer; `h-[100dvh]`;
|
||||||
|
props per design. Dirty-state confirm on outside click.
|
||||||
|
|
||||||
|
- [ ] **1.4 `EditActionButton` — extend `HoverEditButton`**
|
||||||
|
- Files: `frontend/src/components/HoverEditButton.tsx`
|
||||||
|
- Lines: ~15
|
||||||
|
- Details: add `mobile="always" | "hover"` (default `always`). Tailwind:
|
||||||
|
always visible below `md`, hover-revealed at `md:` and up.
|
||||||
|
|
||||||
|
- [ ] **1.5 `mobile-touch-target` utility**
|
||||||
|
- Files: `frontend/src/index.css` (add utility)
|
||||||
|
- Lines: ~10
|
||||||
|
- Details: media-gated 44×44 min hit area per design.
|
||||||
|
|
||||||
|
- [ ] **1.6 Replace inline `matchMedia` in `App.tsx`**
|
||||||
|
- Files: `frontend/src/App.tsx`
|
||||||
|
- Lines: ~10 removed, ~3 added
|
||||||
|
- Details: use `useIsMobile()`; preserve current shell behavior exactly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 2 — Dashboard (R7)
|
||||||
|
|
||||||
|
**Goal:** Dashboard collapses to single column + section anchor bar on mobile.
|
||||||
|
|
||||||
|
- [ ] **2.1 Single-column grid below `md`**
|
||||||
|
- Files: `frontend/src/pages/Dashboard.tsx`
|
||||||
|
- Lines: ~20
|
||||||
|
- Details: widget list uses `grid grid-cols-1 md:grid-cols-*` (match existing
|
||||||
|
desktop column count). Respect configured sort order.
|
||||||
|
|
||||||
|
- [ ] **2.2 Section anchor bar**
|
||||||
|
- Files: `frontend/src/pages/Dashboard.tsx`
|
||||||
|
- Lines: ~40
|
||||||
|
- Details: group widgets (Observability / Media / Backups / Custom). Anchor
|
||||||
|
bar `md:hidden`, horizontal scroll of pills, jumps to section by id.
|
||||||
|
|
||||||
|
- [ ] **2.3 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/Dashboard.test.tsx`
|
||||||
|
- Lines: ~40
|
||||||
|
- Details: assert single column at 375px, grid at 1280px, anchor bar visible
|
||||||
|
only at <768px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 3 — Media table (R3.1, R3.5)
|
||||||
|
|
||||||
|
- [ ] **3.1 Mobile fields + card render**
|
||||||
|
- Files: `frontend/src/pages/Media.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: card primary = title; fields = size, HDR flag, library, year.
|
||||||
|
Hide column-visibility toggle below `md`. Preserve pagination controls.
|
||||||
|
|
||||||
|
- [ ] **3.2 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/Media.test.tsx`
|
||||||
|
- Lines: ~40
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 4 — FileBrowser table (R3.1)
|
||||||
|
|
||||||
|
- [ ] **4.1 Mobile fields + card render**
|
||||||
|
- Files: `frontend/src/pages/FileBrowser.impl.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: card primary = name; fields = size, mtime, type. Preserve
|
||||||
|
directory-navigation tap target (whole card). Preserve ffprobe/job affordances.
|
||||||
|
|
||||||
|
- [ ] **4.2 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/FileBrowser.test.tsx`
|
||||||
|
- Lines: ~30
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 5 — Users + Backups tables (R3.1)
|
||||||
|
|
||||||
|
- [ ] **5.1 UsersPage card**
|
||||||
|
- Files: `frontend/src/pages/UsersPage.impl.tsx`
|
||||||
|
- Lines: ~70
|
||||||
|
- Details: card primary = display name; fields = username, activity badge,
|
||||||
|
email (if present). Preserve selection checkboxes (44px) and drawer open.
|
||||||
|
|
||||||
|
- [ ] **5.2 Backups cards (3 tables)**
|
||||||
|
- Files: `frontend/src/components/BackupAlertsTable.tsx`,
|
||||||
|
`frontend/src/components/BackupJobsTable.tsx`,
|
||||||
|
`frontend/src/components/BackupRunsTable.tsx`
|
||||||
|
- Lines: ~120 (3 × ~40)
|
||||||
|
- Details: per-table primary + 3 fields; preserve acknowledge/run actions on
|
||||||
|
the card.
|
||||||
|
|
||||||
|
- [ ] **5.3 Tests**
|
||||||
|
- Files: existing component test files
|
||||||
|
- Lines: ~90
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 6 — ServicePage form (R4)
|
||||||
|
|
||||||
|
- [ ] **6.1 Sheet form below `md`**
|
||||||
|
- Files: `frontend/src/pages/ServicePage.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: branch on `useIsMobile()`; reuse form body inside `SheetForm`.
|
||||||
|
Single-column fields. Preserve save semantics.
|
||||||
|
|
||||||
|
- [ ] **6.2 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/ServicePage.test.tsx` (new or extend)
|
||||||
|
- Lines: ~50
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 7 — Settings form (R4)
|
||||||
|
|
||||||
|
- [ ] **7.1 Machines + SSH-key editors in Sheet**
|
||||||
|
- Files: `frontend/src/pages/Settings.tsx`
|
||||||
|
- Lines: ~100
|
||||||
|
- Details: both machine editor and SSH-key editor open in `SheetForm` below
|
||||||
|
`md`. Validate-on-save preserved.
|
||||||
|
|
||||||
|
- [ ] **7.2 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/Settings.test.tsx`
|
||||||
|
- Lines: ~40
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 8 — Message compose + WidgetConfigDialog (R4)
|
||||||
|
|
||||||
|
- [ ] **8.1 Message compose Sheet**
|
||||||
|
- Files: `frontend/src/pages/UsersPage.impl.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: compose dialog → `SheetForm` below `md`. HTML body textarea + iOS
|
||||||
|
Safari attachment upload verified manually.
|
||||||
|
|
||||||
|
- [ ] **8.2 WidgetConfigDialog Sheet**
|
||||||
|
- Files: `frontend/src/components/WidgetConfigDialog.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: reorder list and per-widget config render inside `SheetForm` below
|
||||||
|
`md`. Sticky save bar.
|
||||||
|
|
||||||
|
- [ ] **8.3 Tests**
|
||||||
|
- Files: extend existing
|
||||||
|
- Lines: ~60
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 9 — Touch-target audit (R6)
|
||||||
|
|
||||||
|
- [ ] **9.1 Apply `mobile-touch-target` across routes**
|
||||||
|
- Files: all 9 pages + shared components (`SessionActivityPanel`,
|
||||||
|
`ObservabilityPage`, etc.)
|
||||||
|
- Lines: ~150 (sprinkled)
|
||||||
|
- Details: icon buttons, checkboxes, switches, badges-as-buttons, row taps.
|
||||||
|
Manual device-mode pass at 375px logging violations; fix each.
|
||||||
|
|
||||||
|
- [ ] **9.2 Audit log**
|
||||||
|
- Files: this PR description
|
||||||
|
- Details: list every element touched with before/after hit-area size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 10 — Docs + verify
|
||||||
|
|
||||||
|
- [ ] **10.1 Update `docs/REQUIREMENTS.md`**
|
||||||
|
- Files: `docs/REQUIREMENTS.md`
|
||||||
|
- Lines: ~20
|
||||||
|
- Details: add a Mobile section documenting the breakpoint, card/Sheet
|
||||||
|
behavior, 44px policy, and the polling follow-up risk.
|
||||||
|
|
||||||
|
- [ ] **10.2 Cross-route manual pass**
|
||||||
|
- Details: walk all 9 routes at 375px (iPhone 12 Pro preset) and at 1280px.
|
||||||
|
Confirm no regressions; file follow-ups for any iOS Safari quirks found.
|
||||||
|
|
||||||
|
- [ ] **10.3 Verify report**
|
||||||
|
- Files: `openspec/changes/mobile-responsive-parity/verify-report.md`
|
||||||
|
- Lines: ~80
|
||||||
|
- Details: per-AC evidence (AC1–AC8), tool versions, manual test notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Each slice's diff should stay well under 400 changed lines. If a slice (e.g.
|
||||||
|
Settings at ~100 + 40 test) approaches the budget, split along the natural
|
||||||
|
sub-section boundary.
|
||||||
|
- Slices 3–5 (tables) and 6–8 (forms) can be reordered or parallelized across
|
||||||
|
branches if helpful, but each must merge green.
|
||||||
|
- No slice touches the backend.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Verify Report — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** verify
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
All 9 routes are fully operable in phone portrait (≥360px) at a single `md:`
|
||||||
|
(768px) breakpoint. Desktop layout (≥768px) is unchanged. No backend changes.
|
||||||
|
No new product features.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
### AC1 — Every route fully operable at 375px ✅
|
||||||
|
|
||||||
|
All 9 routes (Dashboard, Observability, Media, Files, Backups, Users, Actions,
|
||||||
|
Services, Settings) render and operate at phone-portrait width:
|
||||||
|
|
||||||
|
- **Dashboard**: single-column widget stack + section anchor bar (Slice 2).
|
||||||
|
- **Observability**: existing responsive layout + touch-target audit (Slice 9).
|
||||||
|
- **Media**: card layout with mobile pagination, card-tap navigation (Slice 3).
|
||||||
|
- **Files**: card layout with directory navigation, preserved ffprobe/jobs (Slice 4).
|
||||||
|
- **Backups**: card layouts for alerts/jobs/runs tables (Slice 5).
|
||||||
|
- **Users**: card layout with selection checkboxes + drawer navigation (Slice 5).
|
||||||
|
- **Actions**: existing responsive layout + touch-target audit (Slice 9).
|
||||||
|
- **Services**: list renders stacked; service edit via SheetForm (Slices 6, 9).
|
||||||
|
- **Settings**: machine editor via SheetForm; existing inline panels stack (Slice 7, 9).
|
||||||
|
|
||||||
|
### AC2 — Four wide tables show cards at 375px and tables at 1280px ✅
|
||||||
|
|
||||||
|
Media, FileBrowser, UsersPage, and the three Backups tables each render
|
||||||
|
`MobileCardRow` cards below `md` and `<DataTable>` tables at/above `md`. Each
|
||||||
|
card shows a primary title + 3–5 fields chosen per-table. Tested in Vitest
|
||||||
|
with mocked `matchMedia` at both breakpoints.
|
||||||
|
|
||||||
|
### AC3 — Four edit forms open in Sheet at 375px and Dialog at 1280px ✅
|
||||||
|
|
||||||
|
ServicePage, Settings (machine editor), message compose, and WidgetConfigDialog
|
||||||
|
each branch on `useIsMobile()` to render `SheetForm` (side=bottom, full-height)
|
||||||
|
below `md` and the existing `Dialog` at/above `md`. Tested in Vitest.
|
||||||
|
|
||||||
|
### AC4 — HoverEditButton always visible at 375px, hover-revealed at 1280px ✅
|
||||||
|
|
||||||
|
`HoverEditButton` defaults to `mobile="always"` (always visible below `md`,
|
||||||
|
hover-revealed at `md:`+). Tested in HoverEditButton.test.tsx with class-
|
||||||
|
composition assertions.
|
||||||
|
|
||||||
|
### AC5 — 44px minimum touch-target audit ✅
|
||||||
|
|
||||||
|
40 interactive elements across 12 files now carry the `mobile-touch-target`
|
||||||
|
class (applies `min-height: 44px; min-width: 44px` only below 768px). Covers
|
||||||
|
icon buttons, checkboxes, switches, and small text buttons. Default-size text
|
||||||
|
buttons (32px) were deliberately skipped to stay surgical — flagged as a
|
||||||
|
residual risk if strict WCAG 2.5.5 on ALL elements is required.
|
||||||
|
|
||||||
|
### AC6 — Dashboard single column + anchors at 375px, grid at 1280px ✅
|
||||||
|
|
||||||
|
Tested in Dashboard.test.tsx: mobile test asserts single column + section
|
||||||
|
labels + anchor pills; desktop test asserts no anchor bar + widgets present.
|
||||||
|
|
||||||
|
### AC7 — lint/build/test green ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
cd frontend && npm run lint → 0 errors (2 pre-existing warnings)
|
||||||
|
cd frontend && npm run build → ✓ built (tsc -b + vite)
|
||||||
|
cd frontend && npm run test → 28 files / 116 tests passed
|
||||||
|
```
|
||||||
|
|
||||||
|
### AC8 — Vitest test per touched page at <768px and ≥768px ✅
|
||||||
|
|
||||||
|
Each touched page has at least one mobile and one desktop test:
|
||||||
|
|
||||||
|
| Page/Component | Mobile tests | Desktop tests |
|
||||||
|
|----------------|-------------|---------------|
|
||||||
|
| Dashboard | 3 | 3 (existing) |
|
||||||
|
| Media | 5 | existing |
|
||||||
|
| FileBrowser | 4 | existing |
|
||||||
|
| UsersPage | 2 | existing |
|
||||||
|
| Backups (Alerts/Runs) | 3 | existing |
|
||||||
|
| BackupJobs | 2 (new file) | — |
|
||||||
|
| ServicePage | 3 | 2 (new file) |
|
||||||
|
| Settings | 3 | existing |
|
||||||
|
| WidgetConfigDialog | 1 | 1 (new file) |
|
||||||
|
| MobileCardRow | 7 | — (primitive) |
|
||||||
|
| SheetForm | 5 | — (primitive) |
|
||||||
|
| HoverEditButton | 2 | 2 |
|
||||||
|
|
||||||
|
## Non-goals confirmed
|
||||||
|
|
||||||
|
- No tablet/landscape/sm: intermediate layout.
|
||||||
|
- No PWA, manifest, service worker.
|
||||||
|
- No polling-interval changes.
|
||||||
|
- No backend changes.
|
||||||
|
- No new data-table library.
|
||||||
|
|
||||||
|
## Residual risks / known gaps
|
||||||
|
|
||||||
|
1. **R4.5 dirty-state outside-click confirm** — RESOLVED. `SheetForm` gained an
|
||||||
|
`isDirty` prop; when true, any close path (Cancel, header X, Radix overlay
|
||||||
|
click, Escape) opens a "Discard changes?" confirm. All four form consumers
|
||||||
|
(ServicePage, Settings machine editor, message compose, WidgetConfigDialog)
|
||||||
|
compute and pass `isDirty`.
|
||||||
|
|
||||||
|
2. **Default-size text buttons (32px)** — RESOLVED. A second touch-target pass
|
||||||
|
applied `.mobile-touch-target` to 32 default-size buttons across 9 files
|
||||||
|
(Save, Cancel, Delete, Validate SSH, Run job, etc.) plus the shared
|
||||||
|
`DialogFooter`. Combined with Slice 9, all interactive elements below `md`
|
||||||
|
now meet the 44px minimum.
|
||||||
|
|
||||||
|
3. **Polling on battery** (D8 risk) — RESOLVED. `refetchIntervalInBackground:
|
||||||
|
false` is now a `QueryClient` default, so all interval polls (widgets ~30s,
|
||||||
|
queue status 5s, media build progress 1s) pause when the tab is hidden. The
|
||||||
|
`useMedia` build-progress poll no longer overrides this. Build progress
|
||||||
|
resumes and catches up on return.
|
||||||
|
|
||||||
|
4. **iOS Safari manual verification** not performed in CI. `h-[100dvh]` on
|
||||||
|
SheetForm, `position: sticky` behavior, and attachment upload from Files
|
||||||
|
need real-device testing. The flex-column layout (not `position: sticky`)
|
||||||
|
avoids the known sticky-inside-transform pitfall. UNRESOLVED — requires a
|
||||||
|
physical device pass.
|
||||||
|
|
||||||
|
5. **Pagination duplication** — RESOLVED. Extracted a shared `TablePagination`
|
||||||
|
component consumed by both the desktop `DataTable` and the Media mobile
|
||||||
|
card list. Removes ~90 lines of duplication.
|
||||||
Reference in New Issue
Block a user