diff --git a/frontend/src/pages/Applications.tsx b/frontend/src/pages/Applications.tsx index 67e0837..e00b72e 100644 --- a/frontend/src/pages/Applications.tsx +++ b/frontend/src/pages/Applications.tsx @@ -1,16 +1,8 @@ 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 { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { TabsTrigger } from "@/components/ui/tabs"; import { Media } from "./Media"; import { useCounts, useLibraries } from "../hooks/useDashboard"; import { useMonitoringSettings } from "../hooks/useSettings"; @@ -37,110 +29,67 @@ function JellyfinLibraryStats() { title="Library stats" description="Compact Jellyfin summary for the selected machine." action={ - + + {selectedMachineId ? "Selected machine" : "Default machine"} + } > - +
{counts ? ( - - - - - - Total - - - {( - counts.movies + - counts.series + - counts.episodes - ).toLocaleString()} - - - - - - - - - Movies - - - {counts.movies.toLocaleString()} - - - - - - - - - Series - - - {counts.series.toLocaleString()} - - - - - - - - - Episodes - - - {counts.episodes.toLocaleString()} - - - - - +
+
+ Total +
+ {( + counts.movies + + counts.series + + counts.episodes + ).toLocaleString()} +
+
+
+ Movies +
+ {counts.movies.toLocaleString()} +
+
+
+ Series +
+ {counts.series.toLocaleString()} +
+
+
+ Episodes +
+ {counts.episodes.toLocaleString()} +
+
+
) : null} {libraries?.length ? ( - +
{libraries.map((library) => ( - - - - - - {library.library} - - - Total {library.total.toLocaleString()} · Movies{" "} - {library.movies.toLocaleString()} · Series{" "} - {library.series.toLocaleString()} - - - - - +
+
+ + {library.library} + + + Total {library.total.toLocaleString()} · Movies{" "} + {library.movies.toLocaleString()} · Series{" "} + {library.series.toLocaleString()} + +
+
))} - +
) : null} - +
); } @@ -149,39 +98,41 @@ export function Applications() { const [tab, setTab] = useState("jellyfin"); return ( - - - - Applications - - +
+
+

Applications

+

Browse application-specific tools from a compact tabbed workspace. - - +

+
, - , + + Jellyfin + , + + Nextcloud + , ]} > {tab === "jellyfin" ? ( - +
- +
) : ( - - - +
+ + Nextcloud support will be added in a future update. - - - + + +
)}
- +
); } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index bb10297..c08ae7f 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,27 +1,25 @@ 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 { - Alert, - Box, - Button, - Card, - CardContent, - Chip, Dialog, DialogContent, + DialogHeader, DialogTitle, - FormControl, - FormControlLabel, - FormHelperText, - Grid, - InputLabel, - MenuItem, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, - Stack, - Switch, - TextField, - Typography, -} from "@mui/material"; -import { useNavigate } from "react-router-dom"; + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { useActivity, useDashboardShortcuts, @@ -32,6 +30,7 @@ import { useMonitoringSettings } from "../hooks/useSettings"; import type { DashboardShortcut, DashboardShortcutInput } from "../types"; import { NowPlaying } from "../components/NowPlaying"; import { SectionCard } from "../components/SectionCard"; +import { ConfirmDialog } from "../components/ConfirmDialog"; import { DialogFooter } from "../components/DialogFooter"; import BackupDashboardWidget from "../components/BackupDashboardWidget"; @@ -71,6 +70,28 @@ function shortcutHref(shortcut: DashboardShortcut): string { return `/users?user=${encodeURIComponent(shortcut.user_id)}`; } +function Field({ + label, + htmlFor, + helper, + children, +}: { + label: string; + htmlFor: string; + helper?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {helper ? ( +

{helper}

+ ) : null} +
+ ); +} + function ShortcutDialog({ open, draft, @@ -85,129 +106,155 @@ function ShortcutDialog({ onSave: () => void; }) { return ( - - {draft.id ? "Edit shortcut" : "New shortcut"} - - - - - onChange({ ...draft, label: e.target.value })} - /> - - - onChange({ ...draft, icon: e.target.value })} - helperText="Emoji or glyph" - /> - - - - Type - + onChange({ ...draft, label: e.target.value }) + } + /> + + +
+ + onChange({ ...draft, icon: e.target.value })} + /> + +
+
+ + - - Website opens a URL. Saved actions jump to a task. Users - deep-link. - - - - + +
+ {draft.shortcut_type === "website" ? ( - onChange({ ...draft, url: e.target.value })} - helperText="https:// is added if missing." - /> + htmlFor="shortcut-url" + helper="https:// is added if missing." + > + onChange({ ...draft, url: e.target.value })} + /> + ) : draft.shortcut_type === "action" ? ( - - - + + onChange({ ...draft, task_id: e.target.value }) } - helperText="Saved action ID." /> - - - + + onChange({ ...draft, machine_id: e.target.value }) } - helperText="Optional machine target." /> - - + + ) : ( - onChange({ ...draft, user_id: e.target.value })} - helperText="Jellyfin user ID." - /> - )} - - onChange({ ...draft, notes: e.target.value })} - /> - + - onChange({ ...draft, enabled: e.target.checked }) + onChange({ ...draft, user_id: e.target.value }) } /> - } - label="Enabled" - /> -
+ + )} + + + onChange({ ...draft, notes: e.target.value })} + /> + +
+ + onChange({ ...draft, enabled: checked }) + } + /> + +
+ +
-
); } @@ -237,72 +284,42 @@ function ShortcutCard({ : shortcut.user_id || "No user configured"; return ( - - - - + +
+
+
{shortcut.label}
+
+ {subtitle} +
+
+
+ {shortcut.icon ? ( +
+ {shortcut.icon} +
+ ) : null} + {shortcut.shortcut_type} +
+
+ {shortcut.notes ? ( +

{shortcut.notes}

+ ) : null} +
+ - - - - + Open + + + +
); @@ -360,40 +377,41 @@ export function Dashboard() { }; return ( - +
+ } > {shortcuts.length ? ( - +
{shortcuts.map((shortcut) => ( - - { - const href = shortcutHref(shortcut); - if (shortcut.shortcut_type === "website") { - window.open(href, "_blank", "noopener,noreferrer"); - } else if (href) { - navigate(href); - } - }} - onEdit={() => openEditShortcut(shortcut)} - onDelete={() => setDeleteShortcutId(shortcut.id)} - /> - + { + const href = shortcutHref(shortcut); + if (shortcut.shortcut_type === "website") { + window.open(href, "_blank", "noopener,noreferrer"); + } else if (href) { + navigate(href); + } + }} + onEdit={() => openEditShortcut(shortcut)} + onDelete={() => setDeleteShortcutId(shortcut.id)} + /> ))} - +
) : ( - - No shortcuts yet. Add a website now, then add action or user - shortcuts later. + + + No shortcuts yet. Add a website now, then add action or user + shortcuts later. + )}
@@ -403,25 +421,23 @@ export function Dashboard() { description="Live sessions and idle users from Jellyfin." action={ jellyfinMachines.length > 1 ? ( - - setActiveJellyfinMachineId(value)} + > + + + + {jellyfinMachines.map((m) => ( - + {m.name} - + ))} - - + + ) : jellyfinMachines.length === 1 ? ( - + {jellyfinMachines[0].name} ) : null } > @@ -444,31 +460,19 @@ export function Dashboard() { onClose={() => setShortcutDialogOpen(false)} onSave={saveShortcutDraft} /> - setDeleteShortcutId(null)} - fullWidth - maxWidth="xs" - > - Delete shortcut? - - - This cannot be undone. The shortcut will be removed from the - dashboard. - - - setDeleteShortcutId(null)} - onConfirm={() => { - if (deleteShortcutId) { - deleteShortcut.mutate(deleteShortcutId); - } - setDeleteShortcutId(null); - }} - confirmLabel="Delete" - confirmColor="error" - /> - - + title="Delete shortcut?" + message="This cannot be undone. The shortcut will be removed from the dashboard." + confirmLabel="Delete" + onCancel={() => setDeleteShortcutId(null)} + onConfirm={() => { + if (deleteShortcutId) { + deleteShortcut.mutate(deleteShortcutId); + } + setDeleteShortcutId(null); + }} + /> +
); } diff --git a/frontend/src/pages/__tests__/Applications.test.tsx b/frontend/src/pages/__tests__/Applications.test.tsx new file mode 100644 index 0000000..693e323 --- /dev/null +++ b/frontend/src/pages/__tests__/Applications.test.tsx @@ -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: () =>
Media
, +})); + +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(); + + // 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(); + }); +}); diff --git a/frontend/src/pages/__tests__/Dashboard.test.tsx b/frontend/src/pages/__tests__/Dashboard.test.tsx new file mode 100644 index 0000000..f149a0b --- /dev/null +++ b/frontend/src/pages/__tests__/Dashboard.test.tsx @@ -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: () =>
, +})); +vi.mock("../../components/BackupDashboardWidget", () => ({ + default: () =>
, +})); + +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 { + 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(); + 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(); + + 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(); + + 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"); + }); +}); diff --git a/openspec/changes/web-ui-rework/apply-progress.md b/openspec/changes/web-ui-rework/apply-progress.md index c8ac1cf..fa11c6b 100644 --- a/openspec/changes/web-ui-rework/apply-progress.md +++ b/openspec/changes/web-ui-rework/apply-progress.md @@ -498,3 +498,121 @@ Dashboard + Applications surface** (depends on slices 2 + 3; reuses Overall change `applyState` remains **blocked** on missing domain specs (legacy flat `spec.md`); does not block Slice 3 (done) but should be resolved before `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 `` 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 `

` (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 +`` **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 `

` **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. diff --git a/openspec/changes/web-ui-rework/tasks.md b/openspec/changes/web-ui-rework/tasks.md index 8d30c9c..afcb25b 100644 --- a/openspec/changes/web-ui-rework/tasks.md +++ b/openspec/changes/web-ui-rework/tasks.md @@ -163,11 +163,11 @@ Each slice section restates this gate as its final task. > `BackupDashboardWidget` from slice 3). Split 4a (Applications) → 4b (Dashboard) if > 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). -- [ ] 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. -- [ ] 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] 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/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] Preserve the Dashboard → Media navigation and shortcut deep-links under the reconciled `/media` route. +- [x] Add component tests for the migrated Dashboard (shortcut create/save/delete flow) and Applications (library stats render). +- [x] **Exit gate:** Dashboard + Applications MUI-free and visually consistent; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green. ---