feat(frontend): slice 4 — migrate Dashboard + Applications to shadcn/Tailwind
Web UI rework. - Migrate pages/Dashboard.tsx off @mui (shortcut CRUD dialogs, machine switcher, BackupDashboardWidget mount; shortcuts reuse ConfirmDialog/ DialogFooter from slice 2) - Migrate pages/Applications.tsx shell off @mui (tabs + library counts); keeps <Media/> child intact (Media.tsx still MUI, deferred to slice 7 with its DataGrid) - Behavioral tests for both pages Gate: build + lint + test green.
This commit is contained in:
@@ -1,16 +1,8 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Chip,
|
|
||||||
Grid,
|
|
||||||
Stack,
|
|
||||||
Tab,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Media } from "./Media";
|
import { Media } from "./Media";
|
||||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||||
@@ -37,110 +29,67 @@ function JellyfinLibraryStats() {
|
|||||||
title="Library stats"
|
title="Library stats"
|
||||||
description="Compact Jellyfin summary for the selected machine."
|
description="Compact Jellyfin summary for the selected machine."
|
||||||
action={
|
action={
|
||||||
<Chip
|
<Badge variant="outline">
|
||||||
label={selectedMachineId ? "Selected machine" : "Default machine"}
|
{selectedMachineId ? "Selected machine" : "Default machine"}
|
||||||
variant="outlined"
|
</Badge>
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Stack spacing={1.25}>
|
<div className="flex flex-col gap-2">
|
||||||
{counts ? (
|
{counts ? (
|
||||||
<Grid container spacing={1}>
|
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||||
<Card variant="outlined">
|
<span className="text-xs text-muted-foreground">Total</span>
|
||||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
<div className="text-base leading-tight font-extrabold">
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Total
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
|
||||||
>
|
|
||||||
{(
|
{(
|
||||||
counts.movies +
|
counts.movies +
|
||||||
counts.series +
|
counts.series +
|
||||||
counts.episodes
|
counts.episodes
|
||||||
).toLocaleString()}
|
).toLocaleString()}
|
||||||
</Typography>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||||
</Grid>
|
<span className="text-xs text-muted-foreground">Movies</span>
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<div className="text-base leading-tight font-extrabold">
|
||||||
<Card variant="outlined">
|
|
||||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Movies
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
|
||||||
>
|
|
||||||
{counts.movies.toLocaleString()}
|
{counts.movies.toLocaleString()}
|
||||||
</Typography>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||||
</Grid>
|
<span className="text-xs text-muted-foreground">Series</span>
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<div className="text-base leading-tight font-extrabold">
|
||||||
<Card variant="outlined">
|
|
||||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Series
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
|
||||||
>
|
|
||||||
{counts.series.toLocaleString()}
|
{counts.series.toLocaleString()}
|
||||||
</Typography>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||||
</Grid>
|
<span className="text-xs text-muted-foreground">Episodes</span>
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<div className="text-base leading-tight font-extrabold">
|
||||||
<Card variant="outlined">
|
|
||||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Episodes
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
|
||||||
>
|
|
||||||
{counts.episodes.toLocaleString()}
|
{counts.episodes.toLocaleString()}
|
||||||
</Typography>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{libraries?.length ? (
|
{libraries?.length ? (
|
||||||
<Grid container spacing={1}>
|
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||||
{libraries.map((library) => (
|
{libraries.map((library) => (
|
||||||
<Grid key={library.library} size={{ xs: 12, md: 6 }}>
|
<div
|
||||||
<Card variant="outlined">
|
key={library.library}
|
||||||
<CardContent sx={{ py: 1.1, px: 1.5 }}>
|
className="rounded-lg border bg-card px-3 py-2"
|
||||||
<Stack spacing={0.5}>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle2"
|
|
||||||
sx={{ fontWeight: 700 }}
|
|
||||||
noWrap
|
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="truncate text-sm font-semibold">
|
||||||
{library.library}
|
{library.library}
|
||||||
</Typography>
|
</span>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<span className="text-sm text-muted-foreground">
|
||||||
Total {library.total.toLocaleString()} · Movies{" "}
|
Total {library.total.toLocaleString()} · Movies{" "}
|
||||||
{library.movies.toLocaleString()} · Series{" "}
|
{library.movies.toLocaleString()} · Series{" "}
|
||||||
{library.series.toLocaleString()}
|
{library.series.toLocaleString()}
|
||||||
</Typography>
|
</span>
|
||||||
</Stack>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -149,39 +98,41 @@ export function Applications() {
|
|||||||
const [tab, setTab] = useState("jellyfin");
|
const [tab, setTab] = useState("jellyfin");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2.25}>
|
<div className="flex flex-col gap-4">
|
||||||
<Box>
|
<div>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 800 }}>
|
<h1 className="text-lg font-semibold">Applications</h1>
|
||||||
Applications
|
<p className="text-sm text-muted-foreground">
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Browse application-specific tools from a compact tabbed workspace.
|
Browse application-specific tools from a compact tabbed workspace.
|
||||||
</Typography>
|
</p>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<TabbedCard
|
<TabbedCard
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
tabs={[
|
tabs={[
|
||||||
<Tab key="jellyfin" value="jellyfin" label="Jellyfin" />,
|
<TabsTrigger key="jellyfin" value="jellyfin">
|
||||||
<Tab key="nextcloud" value="nextcloud" label="Nextcloud" />,
|
Jellyfin
|
||||||
|
</TabsTrigger>,
|
||||||
|
<TabsTrigger key="nextcloud" value="nextcloud">
|
||||||
|
Nextcloud
|
||||||
|
</TabsTrigger>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{tab === "jellyfin" ? (
|
{tab === "jellyfin" ? (
|
||||||
<Stack spacing={2}>
|
<div className="flex flex-col gap-4">
|
||||||
<JellyfinLibraryStats />
|
<JellyfinLibraryStats />
|
||||||
<Media />
|
<Media />
|
||||||
</Stack>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card variant="outlined">
|
<div className="rounded-lg border bg-card p-3">
|
||||||
<CardContent sx={{ p: 1.5 }}>
|
<Alert>
|
||||||
<Alert severity="info">
|
<AlertDescription>
|
||||||
Nextcloud support will be added in a future update.
|
Nextcloud support will be added in a future update.
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</TabbedCard>
|
</TabbedCard>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-174
@@ -1,27 +1,25 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Chip,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
FormControl,
|
} from "@/components/ui/dialog";
|
||||||
FormControlLabel,
|
import { Input } from "@/components/ui/input";
|
||||||
FormHelperText,
|
import { Label } from "@/components/ui/label";
|
||||||
Grid,
|
import {
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
SelectContent,
|
||||||
Switch,
|
SelectItem,
|
||||||
TextField,
|
SelectTrigger,
|
||||||
Typography,
|
SelectValue,
|
||||||
} from "@mui/material";
|
} from "@/components/ui/select";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
useActivity,
|
useActivity,
|
||||||
useDashboardShortcuts,
|
useDashboardShortcuts,
|
||||||
@@ -32,6 +30,7 @@ import { useMonitoringSettings } from "../hooks/useSettings";
|
|||||||
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
||||||
import { NowPlaying } from "../components/NowPlaying";
|
import { NowPlaying } from "../components/NowPlaying";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import BackupDashboardWidget from "../components/BackupDashboardWidget";
|
import BackupDashboardWidget from "../components/BackupDashboardWidget";
|
||||||
|
|
||||||
@@ -71,6 +70,28 @@ function shortcutHref(shortcut: DashboardShortcut): string {
|
|||||||
return `/users?user=${encodeURIComponent(shortcut.user_id)}`;
|
return `/users?user=${encodeURIComponent(shortcut.user_id)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
helper,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
htmlFor: string;
|
||||||
|
helper?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor={htmlFor}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{helper ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{helper}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ShortcutDialog({
|
function ShortcutDialog({
|
||||||
open,
|
open,
|
||||||
draft,
|
draft,
|
||||||
@@ -85,129 +106,155 @@ function ShortcutDialog({
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
<Dialog
|
||||||
<DialogTitle>{draft.id ? "Edit shortcut" : "New shortcut"}</DialogTitle>
|
open={open}
|
||||||
<DialogContent dividers>
|
onOpenChange={(next) => {
|
||||||
<Stack spacing={1.25} sx={{ pt: 0.25 }}>
|
if (!next) onClose();
|
||||||
<Grid container spacing={1}>
|
}}
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 5 }}>
|
>
|
||||||
<TextField
|
<DialogContent className="sm:max-w-lg">
|
||||||
fullWidth
|
<DialogHeader>
|
||||||
size="small"
|
<DialogTitle>
|
||||||
label="Label"
|
{draft.id ? "Edit shortcut" : "New shortcut"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
|
||||||
|
<div className="flex flex-col gap-1.5 sm:col-span-5">
|
||||||
|
<Field label="Label" htmlFor="shortcut-label">
|
||||||
|
<Input
|
||||||
|
id="shortcut-label"
|
||||||
value={draft.label}
|
value={draft.label}
|
||||||
onChange={(e) => onChange({ ...draft, label: e.target.value })}
|
onChange={(e) =>
|
||||||
|
onChange({ ...draft, label: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Field>
|
||||||
<Grid size={{ xs: 12, sm: 3, md: 2 }}>
|
</div>
|
||||||
<TextField
|
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||||
fullWidth
|
<Field
|
||||||
size="small"
|
|
||||||
label="Icon"
|
label="Icon"
|
||||||
|
htmlFor="shortcut-icon"
|
||||||
|
helper="Emoji or glyph"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="shortcut-icon"
|
||||||
value={draft.icon}
|
value={draft.icon}
|
||||||
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
|
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
|
||||||
helperText="Emoji or glyph"
|
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Field>
|
||||||
<Grid size={{ xs: 12, sm: 3, md: 5 }}>
|
</div>
|
||||||
<FormControl fullWidth size="small">
|
<div className="flex flex-col gap-1.5 sm:col-span-5">
|
||||||
<InputLabel>Type</InputLabel>
|
<Field
|
||||||
<Select
|
|
||||||
label="Type"
|
label="Type"
|
||||||
|
htmlFor="shortcut-type"
|
||||||
|
helper="Website opens a URL. Saved actions jump to a task. Users deep-link."
|
||||||
|
>
|
||||||
|
<Select
|
||||||
value={draft.shortcut_type}
|
value={draft.shortcut_type}
|
||||||
onChange={(e) =>
|
onValueChange={(value) =>
|
||||||
onChange({
|
onChange({
|
||||||
...draft,
|
...draft,
|
||||||
shortcut_type: e.target
|
shortcut_type:
|
||||||
.value as DashboardShortcutInput["shortcut_type"],
|
value as DashboardShortcutInput["shortcut_type"],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MenuItem value="website">Website</MenuItem>
|
<SelectTrigger id="shortcut-type" className="w-full">
|
||||||
<MenuItem value="action">Saved action</MenuItem>
|
<SelectValue />
|
||||||
<MenuItem value="user">User</MenuItem>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="website">Website</SelectItem>
|
||||||
|
<SelectItem value="action">Saved action</SelectItem>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormHelperText>
|
</Field>
|
||||||
Website opens a URL. Saved actions jump to a task. Users
|
</div>
|
||||||
deep-link.
|
</div>
|
||||||
</FormHelperText>
|
|
||||||
</FormControl>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{draft.shortcut_type === "website" ? (
|
{draft.shortcut_type === "website" ? (
|
||||||
<TextField
|
<Field
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label="Website URL"
|
label="Website URL"
|
||||||
|
htmlFor="shortcut-url"
|
||||||
|
helper="https:// is added if missing."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="shortcut-url"
|
||||||
value={draft.url}
|
value={draft.url}
|
||||||
onChange={(e) => onChange({ ...draft, url: e.target.value })}
|
onChange={(e) => onChange({ ...draft, url: e.target.value })}
|
||||||
helperText="https:// is added if missing."
|
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
) : draft.shortcut_type === "action" ? (
|
) : draft.shortcut_type === "action" ? (
|
||||||
<Grid container spacing={1.25}>
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Field
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label="Task ID"
|
label="Task ID"
|
||||||
|
htmlFor="shortcut-task"
|
||||||
|
helper="Saved action ID."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="shortcut-task"
|
||||||
value={draft.task_id}
|
value={draft.task_id}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({ ...draft, task_id: e.target.value })
|
onChange({ ...draft, task_id: e.target.value })
|
||||||
}
|
}
|
||||||
helperText="Saved action ID."
|
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Field>
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Field
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label="Machine ID"
|
label="Machine ID"
|
||||||
|
htmlFor="shortcut-machine"
|
||||||
|
helper="Optional machine target."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="shortcut-machine"
|
||||||
value={draft.machine_id}
|
value={draft.machine_id}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({ ...draft, machine_id: e.target.value })
|
onChange({ ...draft, machine_id: e.target.value })
|
||||||
}
|
}
|
||||||
helperText="Optional machine target."
|
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Field>
|
||||||
</Grid>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TextField
|
<Field
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label="User ID"
|
label="User ID"
|
||||||
|
htmlFor="shortcut-user"
|
||||||
|
helper="Jellyfin user ID."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="shortcut-user"
|
||||||
value={draft.user_id}
|
value={draft.user_id}
|
||||||
onChange={(e) => onChange({ ...draft, user_id: e.target.value })}
|
onChange={(e) =>
|
||||||
helperText="Jellyfin user ID."
|
onChange({ ...draft, user_id: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TextField
|
<Field label="Notes" htmlFor="shortcut-notes">
|
||||||
fullWidth
|
<Input
|
||||||
size="small"
|
id="shortcut-notes"
|
||||||
label="Notes"
|
|
||||||
value={draft.notes}
|
value={draft.notes}
|
||||||
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
|
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
</Field>
|
||||||
control={
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
|
id="shortcut-enabled"
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
onChange={(e) =>
|
onCheckedChange={(checked) =>
|
||||||
onChange({ ...draft, enabled: e.target.checked })
|
onChange({ ...draft, enabled: checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
<Label htmlFor="shortcut-enabled">Enabled</Label>
|
||||||
label="Enabled"
|
</div>
|
||||||
/>
|
</div>
|
||||||
</Stack>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogFooter
|
<DialogFooter
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
onConfirm={onSave}
|
onConfirm={onSave}
|
||||||
confirmLabel="Save shortcut"
|
confirmLabel="Save shortcut"
|
||||||
confirmBusyLabel="Save shortcut"
|
confirmBusyLabel="Save shortcut"
|
||||||
/>
|
/>
|
||||||
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -237,72 +284,42 @@ function ShortcutCard({
|
|||||||
: shortcut.user_id || "No user configured";
|
: shortcut.user_id || "No user configured";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined" sx={{ height: "100%" }}>
|
<Card className="h-full">
|
||||||
<CardContent sx={{ p: 1.25 }}>
|
<CardContent className="flex flex-col gap-3 p-3">
|
||||||
<Stack spacing={1}>
|
<div className="flex flex-row items-start justify-between gap-2">
|
||||||
<Stack
|
<div className="min-w-0">
|
||||||
direction="row"
|
<div className="truncate font-semibold">{shortcut.label}</div>
|
||||||
spacing={1}
|
<div className="truncate text-sm text-muted-foreground">
|
||||||
sx={{ justifyContent: "space-between", alignItems: "flex-start" }}
|
|
||||||
>
|
|
||||||
<Box sx={{ minWidth: 0 }}>
|
|
||||||
<Typography sx={{ fontWeight: 700 }} noWrap>
|
|
||||||
{shortcut.label}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" noWrap>
|
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Typography>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
<div className="flex flex-row items-center gap-2">
|
||||||
{shortcut.icon ? (
|
{shortcut.icon ? (
|
||||||
<Box
|
<div className="grid size-8 place-items-center rounded-md bg-muted text-lg">
|
||||||
sx={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 1.5,
|
|
||||||
display: "grid",
|
|
||||||
placeItems: "center",
|
|
||||||
bgcolor: "action.hover",
|
|
||||||
fontSize: 18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{shortcut.icon}
|
{shortcut.icon}
|
||||||
</Box>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Chip
|
<Badge variant="outline">{shortcut.shortcut_type}</Badge>
|
||||||
size="small"
|
</div>
|
||||||
variant="outlined"
|
</div>
|
||||||
label={shortcut.shortcut_type}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
{shortcut.notes ? (
|
{shortcut.notes ? (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<p className="text-xs text-muted-foreground">{shortcut.notes}</p>
|
||||||
{shortcut.notes}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
) : null}
|
||||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
<div className="flex flex-row flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="sm"
|
||||||
variant="contained"
|
|
||||||
disabled={!shortcut.enabled || !href}
|
disabled={!shortcut.enabled || !href}
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" variant="outlined" onClick={onEdit}>
|
<Button size="sm" variant="outline" onClick={onEdit}>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="sm" variant="destructive" onClick={onDelete}>
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
onClick={onDelete}
|
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</div>
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -360,21 +377,21 @@ export function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2.25}>
|
<div className="flex flex-col gap-4">
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Shortcuts"
|
title="Shortcuts"
|
||||||
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={
|
||||||
<Button variant="outlined" onClick={openCreateShortcut}>
|
<Button variant="outline" onClick={openCreateShortcut}>
|
||||||
Add shortcut
|
Add shortcut
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{shortcuts.length ? (
|
{shortcuts.length ? (
|
||||||
<Grid container spacing={1.25}>
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{shortcuts.map((shortcut) => (
|
{shortcuts.map((shortcut) => (
|
||||||
<Grid key={shortcut.id} size={{ xs: 12, md: 6, lg: 4 }}>
|
|
||||||
<ShortcutCard
|
<ShortcutCard
|
||||||
|
key={shortcut.id}
|
||||||
shortcut={shortcut}
|
shortcut={shortcut}
|
||||||
onOpen={() => {
|
onOpen={() => {
|
||||||
const href = shortcutHref(shortcut);
|
const href = shortcutHref(shortcut);
|
||||||
@@ -387,13 +404,14 @@ export function Dashboard() {
|
|||||||
onEdit={() => openEditShortcut(shortcut)}
|
onEdit={() => openEditShortcut(shortcut)}
|
||||||
onDelete={() => setDeleteShortcutId(shortcut.id)}
|
onDelete={() => setDeleteShortcutId(shortcut.id)}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Alert severity="info">
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
No shortcuts yet. Add a website now, then add action or user
|
No shortcuts yet. Add a website now, then add action or user
|
||||||
shortcuts later.
|
shortcuts later.
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -403,25 +421,23 @@ export function Dashboard() {
|
|||||||
description="Live sessions and idle users from Jellyfin."
|
description="Live sessions and idle users from Jellyfin."
|
||||||
action={
|
action={
|
||||||
jellyfinMachines.length > 1 ? (
|
jellyfinMachines.length > 1 ? (
|
||||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
|
||||||
<Select
|
<Select
|
||||||
value={selectedJellyfinId}
|
value={selectedJellyfinId}
|
||||||
onChange={(e) => setActiveJellyfinMachineId(e.target.value)}
|
onValueChange={(value) => setActiveJellyfinMachineId(value)}
|
||||||
sx={{ fontSize: "0.8rem" }}
|
|
||||||
>
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
{jellyfinMachines.map((m) => (
|
{jellyfinMachines.map((m) => (
|
||||||
<MenuItem key={m.id} value={m.id}>
|
<SelectItem key={m.id} value={m.id}>
|
||||||
{m.name}
|
{m.name}
|
||||||
</MenuItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
|
||||||
) : jellyfinMachines.length === 1 ? (
|
) : jellyfinMachines.length === 1 ? (
|
||||||
<Chip
|
<Badge variant="outline">{jellyfinMachines[0].name}</Badge>
|
||||||
label={jellyfinMachines[0].name}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -444,20 +460,11 @@ export function Dashboard() {
|
|||||||
onClose={() => setShortcutDialogOpen(false)}
|
onClose={() => setShortcutDialogOpen(false)}
|
||||||
onSave={saveShortcutDraft}
|
onSave={saveShortcutDraft}
|
||||||
/>
|
/>
|
||||||
<Dialog
|
<ConfirmDialog
|
||||||
open={Boolean(deleteShortcutId)}
|
open={Boolean(deleteShortcutId)}
|
||||||
onClose={() => setDeleteShortcutId(null)}
|
title="Delete shortcut?"
|
||||||
fullWidth
|
message="This cannot be undone. The shortcut will be removed from the dashboard."
|
||||||
maxWidth="xs"
|
confirmLabel="Delete"
|
||||||
>
|
|
||||||
<DialogTitle>Delete shortcut?</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
This cannot be undone. The shortcut will be removed from the
|
|
||||||
dashboard.
|
|
||||||
</Typography>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogFooter
|
|
||||||
onCancel={() => setDeleteShortcutId(null)}
|
onCancel={() => setDeleteShortcutId(null)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (deleteShortcutId) {
|
if (deleteShortcutId) {
|
||||||
@@ -465,10 +472,7 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
setDeleteShortcutId(null);
|
setDeleteShortcutId(null);
|
||||||
}}
|
}}
|
||||||
confirmLabel="Delete"
|
|
||||||
confirmColor="error"
|
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</div>
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { Applications } from "../Applications";
|
||||||
|
|
||||||
|
// Applications still embeds the MUI Media child (migrated in slice 7). Mock it
|
||||||
|
// so this slice-4 test stays focused on the migrated Applications shell and
|
||||||
|
// does not pull the still-MUI DataGrid into the jsdom render.
|
||||||
|
vi.mock("../Media", () => ({
|
||||||
|
Media: () => <div data-testid="media-child">Media</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
|
useMonitoringSettings: () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "m1",
|
||||||
|
name: "Main",
|
||||||
|
enabled: true,
|
||||||
|
services: ["jellyfin"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useDashboard", () => ({
|
||||||
|
useCounts: () => ({
|
||||||
|
data: { movies: 10, series: 5, episodes: 100 },
|
||||||
|
}),
|
||||||
|
useLibraries: () => ({
|
||||||
|
data: [
|
||||||
|
{ library: "Movies", total: 10, movies: 10, series: 0 },
|
||||||
|
{ library: "Shows", total: 5, movies: 0, series: 5 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Applications", () => {
|
||||||
|
it("renders the Jellyfin library counts grid and tabs, and keeps the Media child", () => {
|
||||||
|
render(<Applications />);
|
||||||
|
|
||||||
|
// Library stats header.
|
||||||
|
expect(screen.getByText("Library stats")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Counts: Total = 10 + 5 + 100 = 115, plus the per-type counts.
|
||||||
|
expect(screen.getByText("115")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Episodes")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Library rows render their per-library totals (unique strings).
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Total 10 · Movies 10 · Series 0/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Total 5 · Movies 0 · Series 5/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Tabs present.
|
||||||
|
expect(screen.getByRole("tab", { name: "Jellyfin" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("tab", { name: "Nextcloud" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// The still-MUI Media child is rendered unchanged inside the Jellyfin tab.
|
||||||
|
expect(screen.getByTestId("media-child")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { Dashboard } from "../Dashboard";
|
||||||
|
import type { DashboardShortcut } from "../../types";
|
||||||
|
|
||||||
|
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
||||||
|
// (shortcut CRUD) without rendering the session panel or the backup query.
|
||||||
|
vi.mock("../../components/NowPlaying", () => ({
|
||||||
|
NowPlaying: () => <div data-testid="now-playing-stub" />,
|
||||||
|
}));
|
||||||
|
vi.mock("../../components/BackupDashboardWidget", () => ({
|
||||||
|
default: () => <div data-testid="backup-widget-stub" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const navigate = vi.fn();
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useNavigate: () => navigate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
|
useMonitoringSettings: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
||||||
|
const deleteShortcutMutate = vi.fn();
|
||||||
|
|
||||||
|
let shortcuts: DashboardShortcut[] = [];
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useDashboard", () => ({
|
||||||
|
useActivity: () => ({ data: undefined }),
|
||||||
|
useDashboardShortcuts: () => ({ data: shortcuts }),
|
||||||
|
useSaveDashboardShortcut: () => ({ mutateAsync: saveShortcutMutate }),
|
||||||
|
useDeleteDashboardShortcut: () => ({ mutate: deleteShortcutMutate }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function websiteShortcut(
|
||||||
|
overrides: Partial<DashboardShortcut> = {},
|
||||||
|
): DashboardShortcut {
|
||||||
|
return {
|
||||||
|
id: "s1",
|
||||||
|
label: "Wiki",
|
||||||
|
shortcut_type: "website",
|
||||||
|
enabled: true,
|
||||||
|
icon: "📚",
|
||||||
|
url: "example.com",
|
||||||
|
task_id: "",
|
||||||
|
machine_id: "",
|
||||||
|
user_id: "",
|
||||||
|
notes: "Team wiki",
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
...overrides,
|
||||||
|
} as DashboardShortcut;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navigate.mockReset();
|
||||||
|
saveShortcutMutate.mockClear();
|
||||||
|
deleteShortcutMutate.mockClear();
|
||||||
|
shortcuts = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard", () => {
|
||||||
|
it("shows the empty-state alert when there are no shortcuts", () => {
|
||||||
|
render(<Dashboard />);
|
||||||
|
expect(screen.getByText(/No shortcuts yet/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Add shortcut" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a shortcut card and deletes it via the confirm dialog", async () => {
|
||||||
|
shortcuts = [websiteShortcut()];
|
||||||
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Wiki")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Open the delete confirm.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
expect(screen.getByText("Delete shortcut?")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Confirm deletion -> delete mutation fires with the shortcut id.
|
||||||
|
const dialogs = screen.getAllByRole("button", { name: "Delete" });
|
||||||
|
// The card "Delete" plus the confirm "Delete"; confirm is the last one.
|
||||||
|
await userEvent.click(dialogs[dialogs.length - 1]);
|
||||||
|
expect(deleteShortcutMutate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deleteShortcutMutate).toHaveBeenCalledWith("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a shortcut via the dialog and saves it", async () => {
|
||||||
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Add shortcut" }));
|
||||||
|
|
||||||
|
// Edit dialog opens in "New shortcut" mode.
|
||||||
|
expect(screen.getByText("New shortcut")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Fill the label and save.
|
||||||
|
await userEvent.type(screen.getByLabelText("Label"), "Grafana");
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Save shortcut" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(saveShortcutMutate).toHaveBeenCalledTimes(1);
|
||||||
|
const saved = saveShortcutMutate.mock.calls[0][0];
|
||||||
|
expect(saved.label).toBe("Grafana");
|
||||||
|
expect(saved.shortcut_type).toBe("website");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -498,3 +498,121 @@ Dashboard + Applications surface** (depends on slices 2 + 3; reuses
|
|||||||
Overall change `applyState` remains **blocked** on missing domain specs (legacy
|
Overall change `applyState` remains **blocked** on missing domain specs (legacy
|
||||||
flat `spec.md`); does not block Slice 3 (done) but should be resolved before
|
flat `spec.md`); does not block Slice 3 (done) but should be resolved before
|
||||||
`sdd-verify`/archive.
|
`sdd-verify`/archive.
|
||||||
|
|
||||||
|
## Slice 4 — Dashboard + Applications/Media surface (DONE)
|
||||||
|
|
||||||
|
Scope: migrate exactly two pages off MUI onto shadcn/ui + Tailwind —
|
||||||
|
`frontend/src/pages/Applications.tsx` and `frontend/src/pages/Dashboard.tsx`.
|
||||||
|
The still-MUI `<Media/>` child (DataGrid, slice 7) is **left untouched**;
|
||||||
|
only the Applications shell around it was migrated. All 5 Slice 4 task
|
||||||
|
checkboxes in `tasks.md` are now `- [x]` (38/71 → **33+5 = 38/71** overall;
|
||||||
|
remaining unchecked = Slices 5–8).
|
||||||
|
|
||||||
|
### Files changed
|
||||||
|
|
||||||
|
- `frontend/src/pages/Applications.tsx` — full rewrite (MUI → shadcn).
|
||||||
|
- `frontend/src/pages/Dashboard.tsx` — full rewrite (MUI → shadcn).
|
||||||
|
- `frontend/src/pages/__tests__/Applications.test.tsx` — new (1 test).
|
||||||
|
- `frontend/src/pages/__tests__/Dashboard.test.tsx` — new (3 tests).
|
||||||
|
- `openspec/changes/web-ui-rework/tasks.md` — 5 Slice 4 checkboxes `- [ ]` → `- [x]`.
|
||||||
|
|
||||||
|
No other files touched (App.tsx, Media.tsx, FileBrowser.impl.tsx,
|
||||||
|
Settings/Actions/UsersPage.impl.tsx, components/*, package.json all unchanged).
|
||||||
|
|
||||||
|
### Component mapping applied (per design §1)
|
||||||
|
|
||||||
|
Applications shell: `Alert`→`Alert`+`AlertDescription`; `Chip`→`Badge variant="outline"`;
|
||||||
|
`Card`/`CardContent` stat tiles → bordered `rounded-lg border bg-card` divs
|
||||||
|
(per design §1 Paper row, which permits bordered-surface div **or** Card);
|
||||||
|
`Grid`→responsive CSS grid (`grid-cols-2 md:grid-cols-4` counts,
|
||||||
|
`md:grid-cols-2` libraries); `Stack`→`flex flex-col gap-*`; `Tab`→`TabsTrigger`;
|
||||||
|
`Typography`→semantic text utilities.
|
||||||
|
|
||||||
|
Dashboard: `Alert`→`Alert`+`AlertDescription`; `Button`→shadcn `Button` (Open=`default`,
|
||||||
|
Edit=`outline`, Delete=`destructive` to preserve the `color="error"` cue);
|
||||||
|
`Card`/`CardContent`→shadcn `Card`/`CardContent`; `Chip`→`Badge variant="outline"`;
|
||||||
|
Dialog family→shadcn `Dialog`/`DialogContent`/`DialogHeader`/`DialogTitle`;
|
||||||
|
the **delete-confirm flow now reuses the shared `ConfirmDialog`** (slice 2)
|
||||||
|
instead of a raw MUI `Dialog`+`DialogFooter`; `Select`/`MenuItem`/`FormControl`/
|
||||||
|
`InputLabel`→shadcn `Select` family (Type picker + Jellyfin machine switcher);
|
||||||
|
`Switch`→shadcn `Switch` (`onCheckedChange`); `TextField`+`FormControlLabel`+
|
||||||
|
`FormHelperText`→`Input`+`Label`+muted `<p>` (factored into a local `Field` helper);
|
||||||
|
`Grid`→CSS grid; `Stack`→`flex`; `Typography`→text utilities.
|
||||||
|
|
||||||
|
### Parity preserved
|
||||||
|
|
||||||
|
- Dashboard shortcut CRUD (website/action/user types) — create/edit dialog,
|
||||||
|
open (website = `window.open`, action/user = `navigate`), edit, delete-confirm.
|
||||||
|
- Jellyfin machine switcher (Select when >1 machine, Badge when 1, nothing when 0).
|
||||||
|
- `NowPlaying` + `BackupDashboardWidget` composition unchanged (both reused as-is).
|
||||||
|
- Shortcut deep-links (`/actions?task=…`, `/users?user=…`) are byte-for-byte
|
||||||
|
preserved; the page mounts at the reconciled `/media` route (App.tsx, slice 3,
|
||||||
|
untouched). No backend contract changes.
|
||||||
|
- Applications tabs (Jellyfin/Nextcloud) + Jellyfin library counts grid preserved.
|
||||||
|
|
||||||
|
### How the still-MUI Media child is handled
|
||||||
|
|
||||||
|
`Applications.tsx` keeps `import { Media } from "./Media";` and renders
|
||||||
|
`<Media />` **exactly as before** inside the Jellyfin tab. Only the Applications
|
||||||
|
*shell* (tabs, library-counts grid, Nextcloud alert, header) was migrated.
|
||||||
|
`pages/Media.tsx` is unchanged and still imports `@mui/x-data-grid`/`@mui/material`
|
||||||
|
— that is expected and is removed in slice 7. The page compiles because Media.tsx
|
||||||
|
is untouched; the slice-4 Applications test mocks the child (`vi.mock("../Media")`)
|
||||||
|
so it does not pull the DataGrid into jsdom.
|
||||||
|
|
||||||
|
### Commands run + gates
|
||||||
|
|
||||||
|
- `grep -nE '@mui/(material|icons-material|x-data-grid)' Dashboard.tsx Applications.tsx`
|
||||||
|
→ **BOTH-MUI-FREE**.
|
||||||
|
- `npx tsc --noEmit` → exit 0.
|
||||||
|
- `npm run build` → exit 0 (built in 1.07s).
|
||||||
|
- `npm run lint` → exit 0 (0 errors; the 2 warnings are pre-existing in
|
||||||
|
`UsersPage.impl.tsx`, slice 6 — not this slice's files).
|
||||||
|
- `npm test` (vitest) → exit 0 (**17 files / 34 tests** pass; +4 new page tests).
|
||||||
|
- `npm run test:node` (`node --test tests/*.test.mjs`) → exit 0 (4/4).
|
||||||
|
|
||||||
|
**Note on `node --test tests`:** the Slice 4 gate text and the overall-change
|
||||||
|
exit gate list `node --test tests`, but on Node v22 that bare form resolves
|
||||||
|
`tests` as a single CommonJS module (`Cannot find module '…/tests'`) and fails
|
||||||
|
for **every** slice, including the pre-slice-4 baseline — it is a Node
|
||||||
|
invocation quirk, not a regression. The package's canonical node-suite command
|
||||||
|
is `npm run test:node` = `node --test tests/*.test.mjs`, which is green (4/4).
|
||||||
|
The slice is therefore gate-green under the package's own scripts.
|
||||||
|
|
||||||
|
### Deviations from design
|
||||||
|
|
||||||
|
- Stat/library tiles in Applications use bordered `div` surfaces instead of
|
||||||
|
nested shadcn `Card`s — explicitly permitted by design §1 ("Paper → bordered
|
||||||
|
surface `<div>` **or** `Card`"). Keeps the already-Card-wrapped `SectionCard`
|
||||||
|
interior light and avoids heavy nested-card chrome.
|
||||||
|
- Dashboard delete-confirm dialog switched from a raw MUI `Dialog`+`DialogFooter`
|
||||||
|
to the shared `ConfirmDialog` (slice 2). Behavior (title/message/confirm/cancel,
|
||||||
|
error cue) is identical and reuses an already-migrated shared component as the
|
||||||
|
task instructs.
|
||||||
|
|
||||||
|
### Slice boundary / PR
|
||||||
|
|
||||||
|
Single slice, well under the 400-line budget: ~2 page rewrites (~430 inserted /
|
||||||
|
~360 deleted across the two files) + 2 new test files (~150 lines). No 4a/4b
|
||||||
|
split needed. The parent owns the commit/PR; nothing committed here.
|
||||||
|
|
||||||
|
### Top risk for slice 5
|
||||||
|
|
||||||
|
**`Settings.tsx` and `Actions.tsx`** are the form-heavy pair (18 + 19 MUI
|
||||||
|
components each, incl. SSH-key management, SSH test/validation feedback,
|
||||||
|
saved-task editor with machine selection + run history, danger-zone reset). The
|
||||||
|
`ConfirmDialog`/`DialogFooter`/`HoverEditButton`/`SectionCard`/`SelectionRailCard`/
|
||||||
|
`TabbedCard` reuse pattern is now proven (Dashboard reuses ConfirmDialog cleanly);
|
||||||
|
the main slice-5 risk is preserving the controlled-`useState` form behavior + SSH
|
||||||
|
validation messages without introducing a form library, and keeping the
|
||||||
|
`@testing-library` tests exercisable without live SSH. Keep all form state as
|
||||||
|
plain `useState`; mirror the Dashboard `Field` helper for `Input`+`Label`+
|
||||||
|
helper-text triples.
|
||||||
|
|
||||||
|
### Structured status note
|
||||||
|
|
||||||
|
Overall change `applyState` is still reported **blocked** by the status engine
|
||||||
|
(domain specs missing/partial; legacy flat `spec.md`). This does not block the
|
||||||
|
Slice 4 migration itself — `design.md` §1 provided the authoritative component
|
||||||
|
mapping and `actionContext` is `repo-local` with `allowedEditRoots` covering the
|
||||||
|
workspace. Should be resolved before `sdd-verify`/archive, per the slice-3 note.
|
||||||
|
|||||||
@@ -163,11 +163,11 @@ Each slice section restates this gate as its final task.
|
|||||||
> `BackupDashboardWidget` from slice 3). Split 4a (Applications) → 4b (Dashboard) if
|
> `BackupDashboardWidget` from slice 3). Split 4a (Applications) → 4b (Dashboard) if
|
||||||
> over 400.
|
> over 400.
|
||||||
|
|
||||||
- [ ] Migrate `frontend/src/pages/Applications.tsx` (Alert/Box/Card/CardContent/Chip/Grid/Stack/Tab/Typography → `Alert`/`Card`/`Badge`/responsive CSS grid/`Tabs`; Jellyfin library stats + Media tab preserved).
|
- [x] Migrate `frontend/src/pages/Applications.tsx` (Alert/Box/Card/CardContent/Chip/Grid/Stack/Tab/Typography → `Alert`/`Card`/`Badge`/responsive CSS grid/`Tabs`; Jellyfin library stats + Media tab preserved).
|
||||||
- [ ] Migrate `frontend/src/pages/Dashboard.tsx` (20 MUI components: Alert/Box/Button/Card/CardContent/Chip/Dialog/DialogContent/DialogTitle/FormControl/FormControlLabel/FormHelperText/Grid/InputLabel/MenuItem/Select/Stack/Switch/TextField/Typography → shadcn `Card`/CSS grid/`Dialog`/`Select`/`Switch`/`Input`+`Label`/`Badge`; shortcut CRUD (website/action/users), machine picker, NowPlaying + BackupDashboardWidget composition, comfortable density).
|
- [x] Migrate `frontend/src/pages/Dashboard.tsx` (20 MUI components: Alert/Box/Button/Card/CardContent/Chip/Dialog/DialogContent/DialogTitle/FormControl/FormControlLabel/FormHelperText/Grid/InputLabel/MenuItem/Select/Stack/Switch/TextField/Typography → shadcn `Card`/CSS grid/`Dialog`/`Select`/`Switch`/`Input`+`Label`/`Badge`; shortcut CRUD (website/action/users), machine picker, NowPlaying + BackupDashboardWidget composition, comfortable density).
|
||||||
- [ ] Preserve the Dashboard → Media navigation and shortcut deep-links under the reconciled `/media` route.
|
- [x] Preserve the Dashboard → Media navigation and shortcut deep-links under the reconciled `/media` route.
|
||||||
- [ ] Add component tests for the migrated Dashboard (shortcut create/save/delete flow) and Applications (library stats render).
|
- [x] Add component tests for the migrated Dashboard (shortcut create/save/delete flow) and Applications (library stats render).
|
||||||
- [ ] **Exit gate:** Dashboard + Applications MUI-free and visually consistent; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green.
|
- [x] **Exit gate:** Dashboard + Applications MUI-free and visually consistent; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user