feat(frontend): slice 7b — Media on TanStack Table (server pagination)
Web UI rework. Completes the DataGrid migration (7a + 7b):
- pages/Media.tsx off @mui/x-data-grid + @mui/material onto DataTable:
15 locked columns (title/series/season/episode/type/year/runtime_min/
size/bitrate/hdr/video/resolution/date_added/library/path);
enablePagination + manualPagination + rowCount from queryResult.total;
page state (pageIndex/pageSize) -> offset/limit into useMediaQuery;
onRowClick -> navigate('/files?path=...') preserved; stable path-derived
getRowId so selection survives server paging; column-visibility toggle.
Hard rule honored: NO sorting, NO resizing (visibility-only).
- Migrate Media shell (Select/Input/Progress/Card/grid/Typography/Tabs).
- Media component tests (column set + row-click nav).
- Harness fix: polyfill ResizeObserver in test/setup.ts — jsdom lacks it
and Radix primitives (Select/ScrollArea/etc.) reference it; was causing
cross-test failures once Media pulled shadcn Select into the pool.
Gate: build + lint + test green (23 files / 64 tests).
This commit is contained in:
+370
-311
@@ -1,24 +1,28 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { DataGrid } from "@mui/x-data-grid";
|
||||
import type { GridColDef } from "@mui/x-data-grid";
|
||||
import type {
|
||||
ColumnDef,
|
||||
OnChangeFn,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
LinearProgress,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from "@mui/material";
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import {
|
||||
useMediaStatus,
|
||||
useMediaQuery as useMediaDataQuery,
|
||||
@@ -42,7 +46,49 @@ function formatDuration(seconds: number | null | undefined): string {
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
// Design §3.2 + §3.4: the 15 locked media columns. Module-level constant so the
|
||||
// TanStack table instance stays stable — an unstable columns array drops the
|
||||
// controlled selection/visibility state (7a discovery). Visibility-only parity
|
||||
// (design §3.3): NO sorting, NO sizing/resizing is wired anywhere.
|
||||
const mediaColumns: ColumnDef<MediaItem>[] = [
|
||||
{ accessorKey: "title", header: "Title" },
|
||||
{ accessorKey: "series", header: "Series" },
|
||||
{ accessorKey: "season", header: "Season" },
|
||||
{ accessorKey: "episode", header: "Episode" },
|
||||
{ accessorKey: "type", header: "Type" },
|
||||
{ accessorKey: "year", header: "Year" },
|
||||
{ accessorKey: "runtime_min", header: "Runtime" },
|
||||
{ accessorKey: "size", header: "Size" },
|
||||
{ accessorKey: "bitrate", header: "Bitrate" },
|
||||
{ accessorKey: "hdr", header: "HDR" },
|
||||
{ accessorKey: "video", header: "Video codec" },
|
||||
{ accessorKey: "resolution", header: "Resolution" },
|
||||
{ accessorKey: "date_added", header: "Date added" },
|
||||
{ accessorKey: "library", header: "Library" },
|
||||
{ accessorKey: "path", header: "Path" },
|
||||
];
|
||||
|
||||
// Stable path-derived identity so row selection survives server-driven paging
|
||||
// (design §3.4): the id is the item's filesystem path, which is stable across
|
||||
// limit/offset page changes.
|
||||
function getMediaRowId(row: MediaItem): string {
|
||||
return row.path;
|
||||
}
|
||||
|
||||
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
||||
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
||||
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
|
||||
const MOBILE_HIDDEN_COLUMNS = [
|
||||
"series",
|
||||
"season",
|
||||
"episode",
|
||||
"bitrate",
|
||||
"video",
|
||||
"resolution",
|
||||
"date_added",
|
||||
"library",
|
||||
"path",
|
||||
];
|
||||
|
||||
type MediaTabState = {
|
||||
search: string;
|
||||
@@ -51,6 +97,8 @@ type MediaTabState = {
|
||||
sortKey: string;
|
||||
sortOrder: string;
|
||||
offset: number;
|
||||
pageSize: number;
|
||||
columnVisibility: Record<string, boolean>;
|
||||
};
|
||||
|
||||
function defaultMediaTabState(): MediaTabState {
|
||||
@@ -61,13 +109,75 @@ function defaultMediaTabState(): MediaTabState {
|
||||
sortKey: "title",
|
||||
sortOrder: "Ascending",
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
columnVisibility: {},
|
||||
};
|
||||
}
|
||||
|
||||
function usePrefersSmallScreen(): boolean {
|
||||
const supportsMatchMedia =
|
||||
typeof window !== "undefined" && typeof window.matchMedia === "function";
|
||||
const [small, setSmall] = useState(() =>
|
||||
supportsMatchMedia ? window.matchMedia(SMALL_BREAKPOINT).matches : false,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!supportsMatchMedia) return;
|
||||
const mql = window.matchMedia(SMALL_BREAKPOINT);
|
||||
const onChange = () => setSmall(mql.matches);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, [supportsMatchMedia]);
|
||||
return small;
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// LinearProgress → Progress: determinate value drives the shadcn Progress; the
|
||||
// indeterminate (null) case renders a pulsing bar, preserving the pre-rework
|
||||
// "indeterminate" affordance for unknown build progress.
|
||||
function BuildProgress({ value }: { value: number | null }) {
|
||||
if (value == null) {
|
||||
return (
|
||||
<div className="h-1 w-full animate-pulse rounded-full bg-muted-foreground/30" />
|
||||
);
|
||||
}
|
||||
return <Progress value={Math.max(0, Math.min(100, value * 100))} />;
|
||||
}
|
||||
|
||||
export function Media() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isMobile = useMediaQuery("(max-width: 900px)");
|
||||
const isSmall = usePrefersSmallScreen();
|
||||
const { data: machines } = useMonitoringSettings();
|
||||
const jellyfinMachines = useMemo(
|
||||
() =>
|
||||
@@ -87,14 +197,22 @@ export function Media() {
|
||||
selectedMachineId || undefined,
|
||||
);
|
||||
|
||||
const [mediaState, setMediaState] = usePersistentState<MediaTabState>(
|
||||
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
||||
MEDIA_TAB_STATE_KEY,
|
||||
defaultMediaTabState,
|
||||
);
|
||||
const { search, types, hdrFilter, sortKey, sortOrder, offset } = mediaState;
|
||||
// Backward-compat: merge defaults so older persisted state (pre-7b shape,
|
||||
// without pageSize/columnVisibility) never yields undefined fields.
|
||||
const mediaState: MediaTabState = {
|
||||
...defaultMediaTabState(),
|
||||
...rawMediaState,
|
||||
};
|
||||
const { search, types, hdrFilter, sortKey, sortOrder, offset, pageSize } =
|
||||
mediaState;
|
||||
const updateMediaState = (patch: Partial<MediaTabState>) =>
|
||||
setMediaState((current) => ({ ...current, ...patch }));
|
||||
const limit = 100;
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams.get("machine_id") && selectedMachineId) {
|
||||
@@ -115,43 +233,64 @@ export function Media() {
|
||||
hdr_filter: hdrFilter,
|
||||
sort_key: sortKey,
|
||||
sort_order: sortOrder,
|
||||
limit,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
machineId: selectedMachineId || undefined,
|
||||
enabled: status?.exists ?? false,
|
||||
});
|
||||
|
||||
const columns: GridColDef<MediaItem>[] = [
|
||||
{ field: "title", headerName: "Title", minWidth: 180, flex: 1.2 },
|
||||
{ field: "series", headerName: "Series", minWidth: 140, flex: 1 },
|
||||
{ field: "season", headerName: "Season", width: 90 },
|
||||
{ field: "episode", headerName: "Episode", width: 100 },
|
||||
{ field: "type", headerName: "Type", width: 100 },
|
||||
{ field: "year", headerName: "Year", width: 90 },
|
||||
{ field: "runtime_min", headerName: "Runtime", width: 110 },
|
||||
{ field: "size", headerName: "Size", width: 120 },
|
||||
{ field: "bitrate", headerName: "Bitrate", width: 130 },
|
||||
{ field: "hdr", headerName: "HDR", width: 80 },
|
||||
{ field: "video", headerName: "Video codec", width: 130 },
|
||||
{ field: "resolution", headerName: "Resolution", width: 120 },
|
||||
{ field: "date_added", headerName: "Date added", width: 120 },
|
||||
{ field: "library", headerName: "Library", width: 140 },
|
||||
{ field: "path", headerName: "Path", minWidth: 240, flex: 1.2 },
|
||||
];
|
||||
// Server-driven pagination (design §3.4): pageIndex/pageSize lift into the
|
||||
// persistent media state and drive useMediaQuery { limit, offset }.
|
||||
const pageIndex = Math.floor(offset / pageSize);
|
||||
const pagination: PaginationState = { pageIndex, pageSize };
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
(queryResult?.items ?? []).map((item) => ({
|
||||
...item,
|
||||
id: item.id || item.path,
|
||||
})),
|
||||
[queryResult],
|
||||
);
|
||||
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
||||
const next =
|
||||
typeof updater === "function"
|
||||
? updater({ pageIndex, pageSize })
|
||||
: updater;
|
||||
const nextPageSize = next.pageSize || pageSize;
|
||||
// Restart at page 0 whenever the page size changes (keeps offset sane
|
||||
// under server-driven paging).
|
||||
const nextOffset =
|
||||
nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize;
|
||||
setMediaState((current) => ({
|
||||
...current,
|
||||
offset: nextOffset,
|
||||
pageSize: nextPageSize,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleColumnVisibilityChange: OnChangeFn<VisibilityState> = (
|
||||
updater,
|
||||
) => {
|
||||
setMediaState((current) => {
|
||||
const prev = current.columnVisibility ?? {};
|
||||
const next = typeof updater === "function" ? updater(prev) : updater;
|
||||
return { ...current, columnVisibility: next };
|
||||
});
|
||||
};
|
||||
|
||||
// On small screens force the same set of columns hidden as the pre-rework
|
||||
// DataGrid `columnVisibilityModel` mobile override; on desktop the user
|
||||
// toggles freely (the toggleable set still equals the locked 15).
|
||||
const effectiveColumnVisibility = useMemo(() => {
|
||||
const base = mediaState.columnVisibility ?? {};
|
||||
if (!isSmall) return base;
|
||||
const merged = { ...base };
|
||||
for (const key of MOBILE_HIDDEN_COLUMNS) merged[key] = false;
|
||||
return merged;
|
||||
}, [mediaState.columnVisibility, isSmall]);
|
||||
|
||||
// Preserved exactly from the DataGrid onRowClick: opens the file browser at
|
||||
// the item's path.
|
||||
const handleRowClick = (row: MediaItem) => {
|
||||
navigate(`/files?path=${encodeURIComponent(row.path)}`);
|
||||
};
|
||||
|
||||
const total = queryResult?.total ?? 0;
|
||||
const totalPages = queryResult ? Math.max(1, Math.ceil(total / pageSize)) : 1;
|
||||
|
||||
const page = Math.floor(offset / limit) + 1;
|
||||
const totalPages = queryResult
|
||||
? Math.max(1, Math.ceil(queryResult.total / limit))
|
||||
: 1;
|
||||
const buildRunning = status?.build_running ?? false;
|
||||
const buildProgress = status?.build_progress ?? null;
|
||||
const buildLibraryProgress = status?.build_library_progress ?? null;
|
||||
@@ -180,58 +319,61 @@ export function Media() {
|
||||
: "Current library");
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1.5}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Typography variant="h5">Jellyfin</Typography>
|
||||
<FormControl size="small" sx={{ minWidth: 220 }}>
|
||||
<InputLabel>Machine</InputLabel>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Jellyfin</h2>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="media-machine">Machine</Label>
|
||||
<Select
|
||||
label="Machine"
|
||||
value={selectedMachineId}
|
||||
onChange={(e) =>
|
||||
onValueChange={(value) =>
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set("machine_id", String(e.target.value));
|
||||
next.set("machine_id", value);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
{jellyfinMachines.map((machine) => (
|
||||
<MenuItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<SelectTrigger id="media-machine" className="w-full md:w-[220px]">
|
||||
<SelectValue placeholder="Select a machine" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{jellyfinMachines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
{status?.exists ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Index: {status.item_count.toLocaleString()} items
|
||||
{status.updated_at_label
|
||||
? ` | updated ${status.updated_at_label}`
|
||||
: ""}
|
||||
</Typography>
|
||||
</p>
|
||||
) : (
|
||||
<Alert severity="warning" sx={{ py: 0 }}>
|
||||
No index built yet.
|
||||
<Alert variant="destructive" className="py-0">
|
||||
<AlertDescription>No index built yet.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{counts && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Library stats: {counts.movies.toLocaleString()} movies ·{" "}
|
||||
{counts.series.toLocaleString()} series ·{" "}
|
||||
{counts.episodes.toLocaleString()} episodes ·{" "}
|
||||
{(libraries?.length ?? 0).toLocaleString()} libraries
|
||||
</Typography>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
variant="outline"
|
||||
onClick={() => buildIndex.mutate()}
|
||||
disabled={
|
||||
buildIndex.isPending || buildRunning || buildCancelRequested
|
||||
@@ -242,8 +384,7 @@ export function Media() {
|
||||
{buildRunning && (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
variant="destructive"
|
||||
onClick={() => stopBuildIndex.mutate()}
|
||||
disabled={stopBuildIndex.isPending || buildCancelRequested}
|
||||
>
|
||||
@@ -252,8 +393,8 @@ export function Media() {
|
||||
: "Stop build"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
variant="outline"
|
||||
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10"
|
||||
onClick={() => forceStopBuildIndex.mutate()}
|
||||
disabled={forceStopBuildIndex.isPending}
|
||||
>
|
||||
@@ -263,248 +404,166 @@ export function Media() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(buildRunning || status?.build_error) && (
|
||||
<Box sx={{ width: "100%", minWidth: 260, flexBasis: "100%" }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={status?.build_error ? "error" : "text.secondary"}
|
||||
>
|
||||
{buildLabel ||
|
||||
(buildRunning
|
||||
? "Building media index..."
|
||||
: status?.build_error || "")}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Stack spacing={0.35}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Overall:{" "}
|
||||
{buildProgress != null
|
||||
? `${Math.round(buildProgress * 100)}%`
|
||||
: "pending"}
|
||||
{buildRunning
|
||||
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
|
||||
: ""}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant={
|
||||
buildProgress != null ? "determinate" : "indeterminate"
|
||||
}
|
||||
value={
|
||||
buildProgress != null
|
||||
? Math.max(0, Math.min(100, buildProgress * 100))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{status?.build_items_processed?.toLocaleString() ?? 0}/
|
||||
{status?.build_items_total?.toLocaleString() ?? 0} items
|
||||
</Typography>
|
||||
</Stack>
|
||||
{(buildRunning || status?.build_error) && (
|
||||
<div className="flex w-full min-w-[260px] flex-col gap-2">
|
||||
<p
|
||||
className={
|
||||
status?.build_error
|
||||
? "text-sm text-destructive"
|
||||
: "text-sm text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{buildLabel ||
|
||||
(buildRunning
|
||||
? "Building media index..."
|
||||
: status?.build_error || "")}
|
||||
</p>
|
||||
|
||||
<Stack spacing={0.35}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Current: {libraryLabel}
|
||||
{buildRunning
|
||||
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
|
||||
: ""}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant={
|
||||
buildLibraryProgress != null
|
||||
? "determinate"
|
||||
: "indeterminate"
|
||||
}
|
||||
value={
|
||||
buildLibraryProgress != null
|
||||
? Math.max(0, Math.min(100, buildLibraryProgress * 100))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{status?.build_library_items_processed?.toLocaleString() ?? 0}
|
||||
/{status?.build_library_items_total?.toLocaleString() ?? 0}{" "}
|
||||
items
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Overall:{" "}
|
||||
{buildProgress != null
|
||||
? `${Math.round(buildProgress * 100)}%`
|
||||
: "pending"}
|
||||
{buildRunning
|
||||
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
|
||||
: ""}
|
||||
</p>
|
||||
<BuildProgress value={buildProgress} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status?.build_items_processed?.toLocaleString() ?? 0}/
|
||||
{status?.build_items_total?.toLocaleString() ?? 0} items
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Search"
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
updateMediaState({ search: e.target.value, offset: 0 });
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 2 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Types</InputLabel>
|
||||
<Select
|
||||
label="Types"
|
||||
value={types}
|
||||
onChange={(e) => {
|
||||
updateMediaState({ types: e.target.value, offset: 0 });
|
||||
}}
|
||||
>
|
||||
<MenuItem value="Movie,Episode">Movies + Episodes</MenuItem>
|
||||
<MenuItem value="Movie">Movies only</MenuItem>
|
||||
<MenuItem value="Episode">Episodes only</MenuItem>
|
||||
<MenuItem value="Movie,Episode,Video">All video</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 2 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>HDR</InputLabel>
|
||||
<Select
|
||||
label="HDR"
|
||||
value={hdrFilter}
|
||||
onChange={(e) => {
|
||||
updateMediaState({ hdrFilter: e.target.value, offset: 0 });
|
||||
}}
|
||||
>
|
||||
<MenuItem value="All">All</MenuItem>
|
||||
<MenuItem value="HDR only">HDR only</MenuItem>
|
||||
<MenuItem value="SDR/unknown only">SDR/unknown only</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 2 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Sort</InputLabel>
|
||||
<Select
|
||||
label="Sort"
|
||||
value={sortKey}
|
||||
onChange={(e) =>
|
||||
updateMediaState({ sortKey: e.target.value })
|
||||
}
|
||||
>
|
||||
{[
|
||||
["title", "Title"],
|
||||
["series", "Series"],
|
||||
["size", "Size"],
|
||||
["bitrate", "Bitrate"],
|
||||
["runtime", "Runtime"],
|
||||
["year", "Year"],
|
||||
["date_added", "Date added"],
|
||||
["resolution", "Resolution"],
|
||||
].map(([k, l]) => (
|
||||
<MenuItem key={k} value={k}>
|
||||
{l}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 2 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Order</InputLabel>
|
||||
<Select
|
||||
label="Order"
|
||||
value={sortOrder}
|
||||
onChange={(e) =>
|
||||
updateMediaState({ sortOrder: e.target.value })
|
||||
}
|
||||
>
|
||||
<MenuItem value="Ascending">Ascending</MenuItem>
|
||||
<MenuItem value="Descending">Descending</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current: {libraryLabel}
|
||||
{buildRunning
|
||||
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
|
||||
: ""}
|
||||
</p>
|
||||
<BuildProgress value={buildLibraryProgress} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status?.build_library_items_processed?.toLocaleString() ?? 0}/
|
||||
{status?.build_library_items_total?.toLocaleString() ?? 0} items
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||||
<div className="col-span-1 flex flex-col gap-1.5 md:col-span-4">
|
||||
<Label htmlFor="media-search">Search</Label>
|
||||
<Input
|
||||
id="media-search"
|
||||
value={search}
|
||||
onChange={(e) =>
|
||||
updateMediaState({ search: e.target.value, offset: 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6 md:col-span-2">
|
||||
<FilterSelect
|
||||
id="media-types"
|
||||
label="Types"
|
||||
value={types}
|
||||
onChange={(value) =>
|
||||
updateMediaState({ types: value, offset: 0 })
|
||||
}
|
||||
options={[
|
||||
{ value: "Movie,Episode", label: "Movies + Episodes" },
|
||||
{ value: "Movie", label: "Movies only" },
|
||||
{ value: "Episode", label: "Episodes only" },
|
||||
{ value: "Movie,Episode,Video", label: "All video" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6 md:col-span-2">
|
||||
<FilterSelect
|
||||
id="media-hdr"
|
||||
label="HDR"
|
||||
value={hdrFilter}
|
||||
onChange={(value) =>
|
||||
updateMediaState({ hdrFilter: value, offset: 0 })
|
||||
}
|
||||
options={[
|
||||
{ value: "All", label: "All" },
|
||||
{ value: "HDR only", label: "HDR only" },
|
||||
{ value: "SDR/unknown only", label: "SDR/unknown only" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6 md:col-span-2">
|
||||
<FilterSelect
|
||||
id="media-sort"
|
||||
label="Sort"
|
||||
value={sortKey}
|
||||
onChange={(value) => updateMediaState({ sortKey: value })}
|
||||
options={[
|
||||
{ value: "title", label: "Title" },
|
||||
{ value: "series", label: "Series" },
|
||||
{ value: "size", label: "Size" },
|
||||
{ value: "bitrate", label: "Bitrate" },
|
||||
{ value: "runtime", label: "Runtime" },
|
||||
{ value: "year", label: "Year" },
|
||||
{ value: "date_added", label: "Date added" },
|
||||
{ value: "resolution", label: "Resolution" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6 md:col-span-2">
|
||||
<FilterSelect
|
||||
id="media-order"
|
||||
label="Order"
|
||||
value={sortOrder}
|
||||
onChange={(value) => updateMediaState({ sortOrder: value })}
|
||||
options={[
|
||||
{ value: "Ascending", label: "Ascending" },
|
||||
{ value: "Descending", label: "Descending" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{queryResult && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Showing {queryResult.items.length} of{" "}
|
||||
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
|
||||
{totalPages}
|
||||
</Typography>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing {queryResult.items.length} of {total.toLocaleString()} items |
|
||||
Page {pageIndex + 1} of {totalPages}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.exists && (
|
||||
<Box
|
||||
sx={{
|
||||
height: 640,
|
||||
bgcolor: "background.paper",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
checkboxSelection={false}
|
||||
disableRowSelectionOnClick
|
||||
onRowClick={(params) => {
|
||||
const row = params.row as MediaItem;
|
||||
navigate(`/files?path=${encodeURIComponent(row.path)}`);
|
||||
}}
|
||||
pageSizeOptions={[100]}
|
||||
columnVisibilityModel={
|
||||
isMobile
|
||||
? {
|
||||
series: false,
|
||||
season: false,
|
||||
episode: false,
|
||||
bitrate: false,
|
||||
video: false,
|
||||
resolution: false,
|
||||
date_added: false,
|
||||
library: false,
|
||||
path: false,
|
||||
}
|
||||
: undefined
|
||||
<div className="rounded-lg border bg-card">
|
||||
<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."
|
||||
}
|
||||
hideFooter
|
||||
sx={{
|
||||
"& .MuiDataGrid-columnHeaders": {
|
||||
fontWeight: 700,
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryResult && totalPages > 1 && (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
updateMediaState({ offset: Math.max(0, offset - limit) })
|
||||
}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
<Typography variant="body2">
|
||||
Page {page} / {totalPages}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => updateMediaState({ offset: offset + limit })}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Media } from "../Media";
|
||||
import type {
|
||||
MediaIndexStatus,
|
||||
MediaItem,
|
||||
MediaQueryResponse,
|
||||
MonitoringMachine,
|
||||
} from "../../types";
|
||||
|
||||
// Shared navigate mock so the row-click test can assert the call. The vi.mock
|
||||
// factory is hoisted above this const, but it only closes over `navigate`
|
||||
// lazily (the arrow runs at render time, well after init) — no TDZ access.
|
||||
const navigate = vi.fn();
|
||||
|
||||
function machineFixture(
|
||||
overrides: Partial<MonitoringMachine> = {},
|
||||
): MonitoringMachine {
|
||||
return {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
mode: "local",
|
||||
enabled: true,
|
||||
services: ["jellyfin", "monitoring"],
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
key_directory: "",
|
||||
key_name: "",
|
||||
ssh_key_id: "",
|
||||
ssh_private_key_set: false,
|
||||
ssh_private_key_passphrase_set: false,
|
||||
password_set: false,
|
||||
media_root: "",
|
||||
path_prefix: "",
|
||||
jellyfin_url: "",
|
||||
jellyfin_user_id: "",
|
||||
jellyfin_api_key_set: false,
|
||||
jellyseerr_url: "",
|
||||
jellyseerr_api_key_set: false,
|
||||
notes: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function statusFixture(
|
||||
overrides: Partial<MediaIndexStatus> = {},
|
||||
): MediaIndexStatus {
|
||||
return {
|
||||
exists: true,
|
||||
item_count: 2,
|
||||
updated_at: 1,
|
||||
updated_at_label: "now",
|
||||
build_duration_seconds: null,
|
||||
build_running: false,
|
||||
build_stage: "",
|
||||
build_message: "",
|
||||
build_progress: null,
|
||||
build_items_processed: 0,
|
||||
build_items_total: 0,
|
||||
build_current_library: "",
|
||||
build_library_index: 0,
|
||||
build_libraries_total: 0,
|
||||
build_library_progress: null,
|
||||
build_library_items_processed: 0,
|
||||
build_library_items_total: 0,
|
||||
build_elapsed_seconds: null,
|
||||
build_eta_seconds: null,
|
||||
build_library_elapsed_seconds: null,
|
||||
build_library_eta_seconds: null,
|
||||
build_cancel_requested: false,
|
||||
build_pid: null,
|
||||
build_error: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mediaItem(overrides: Partial<MediaItem> = {}): MediaItem {
|
||||
return {
|
||||
id: "1",
|
||||
title: "Inception",
|
||||
series: "",
|
||||
season: "",
|
||||
episode: null,
|
||||
type: "Movie",
|
||||
year: 2010,
|
||||
runtime_min: 148,
|
||||
size: "12.4 GB",
|
||||
bitrate: "35.0 Mbps",
|
||||
hdr: "HDR10",
|
||||
video: "HEVC",
|
||||
resolution: "4K",
|
||||
date_added: "2024-01-01",
|
||||
library: "Movies",
|
||||
path: "/media/movies/Inception.mkv",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let status: MediaIndexStatus;
|
||||
let queryResult: MediaQueryResponse;
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => navigate,
|
||||
useSearchParams: () => [new URLSearchParams("machine_id=local"), vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useMedia", () => ({
|
||||
useMediaStatus: () => ({ data: status }),
|
||||
useMediaQuery: () => ({ data: queryResult, isLoading: false }),
|
||||
useBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
useStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
useForceStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: [machineFixture()] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useDashboard", () => ({
|
||||
useCounts: () => ({ data: undefined }),
|
||||
useLibraries: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
// usePersistentState reads/writes localStorage; clear between tests so the
|
||||
// offset/pageSize/columnVisibility state never leaks across cases.
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
navigate.mockClear();
|
||||
status = statusFixture();
|
||||
queryResult = {
|
||||
items: [
|
||||
mediaItem({
|
||||
id: "1",
|
||||
title: "Inception",
|
||||
path: "/media/movies/Inception.mkv",
|
||||
}),
|
||||
mediaItem({
|
||||
id: "2",
|
||||
title: "Matrix",
|
||||
path: "/media/movies/Matrix.mkv",
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
|
||||
it("exposes exactly the 15 locked toggleable columns", async () => {
|
||||
render(<Media />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
|
||||
|
||||
const toggleable = screen
|
||||
.getAllByRole("menuitemcheckbox")
|
||||
.map((item) => (item.textContent ?? "").trim());
|
||||
expect([...toggleable].sort()).toEqual(
|
||||
[
|
||||
"title",
|
||||
"series",
|
||||
"season",
|
||||
"episode",
|
||||
"type",
|
||||
"year",
|
||||
"runtime_min",
|
||||
"size",
|
||||
"bitrate",
|
||||
"hdr",
|
||||
"video",
|
||||
"resolution",
|
||||
"date_added",
|
||||
"library",
|
||||
"path",
|
||||
].sort(),
|
||||
);
|
||||
// The leading selection column is never toggleable (enableHiding=false).
|
||||
expect(toggleable).toHaveLength(15);
|
||||
expect(toggleable).not.toContain("__select__");
|
||||
});
|
||||
|
||||
it("renders the 15 data column headers", () => {
|
||||
render(<Media />);
|
||||
const headers = screen
|
||||
.getAllByRole("columnheader")
|
||||
.map((h) => (h.textContent ?? "").trim());
|
||||
for (const expected of [
|
||||
"Title",
|
||||
"Series",
|
||||
"Season",
|
||||
"Episode",
|
||||
"Type",
|
||||
"Year",
|
||||
"Runtime",
|
||||
"Size",
|
||||
"Bitrate",
|
||||
"HDR",
|
||||
"Video codec",
|
||||
"Resolution",
|
||||
"Date added",
|
||||
"Library",
|
||||
"Path",
|
||||
]) {
|
||||
expect(headers).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("navigates to the file browser at the item path on row click", async () => {
|
||||
render(<Media />);
|
||||
|
||||
await userEvent.click(screen.getByText("Inception"));
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT navigate when toggling a row selection checkbox", async () => {
|
||||
render(<Media />);
|
||||
|
||||
const firstCheckbox = screen.getAllByRole("checkbox", {
|
||||
name: "Select row",
|
||||
})[0];
|
||||
await userEvent.click(firstCheckbox);
|
||||
expect(firstCheckbox).toBeChecked();
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the server-driven pagination total + page controls", () => {
|
||||
render(<Media />);
|
||||
|
||||
// DataTable manual-pagination footer surfaces the server total + pager.
|
||||
// ("Page 1 of 1" also appears in the page caption, so match all and assert
|
||||
// the pager footer text is present alongside the unique total.)
|
||||
expect(screen.getByText("2 rows")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous page" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables Build index while a build is running", () => {
|
||||
status = statusFixture({ build_running: true });
|
||||
|
||||
render(<Media />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Building..." })).toBeDisabled();
|
||||
// Stop + Force stop surface only while running.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Stop build" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Force stop" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,3 +3,22 @@
|
||||
// The `/vitest` entry both registers the matchers at runtime and provides the
|
||||
// TypeScript module augmentation for vitest's `expect` so tsc typechecks them.
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// jsdom does not implement ResizeObserver, but several Radix primitives that
|
||||
// shadcn wraps (ScrollArea, Select via react-popper/react-use-size, DropdownMenu,
|
||||
// Tabs, etc.) reference it at module-load or render time. Without a stub, any
|
||||
// component test whose render tree pulls one of these in fails with
|
||||
// `ReferenceError: ResizeObserver is not defined`. Stub a no-op observer so the
|
||||
// whole suite (and future component tests) is resilient to cross-test module
|
||||
// loading in the Vitest pool.
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
|
||||
|
||||
// Radix popper also probes `requestAnimationFrame`; jsdom provides it, but some
|
||||
// primitives defer layout reads through rAF that never flush in jsdom. Keep the
|
||||
// default rAF; this guard is intentionally minimal.
|
||||
|
||||
@@ -1191,3 +1191,201 @@ limit, offset })`). The **columns-stability** discovery above is the #1 risk:
|
||||
shell migration (`Card`/`SectionCard`/`Grid`/`Select`/`Input`/`LinearProgress`
|
||||
→ shadcn + CSS grid + `Progress`) is large; sub-split is already planned (7b is
|
||||
its own sub-PR).
|
||||
|
||||
## Slice 7b — Media: DataGrid → TanStack Table with server-driven pagination (DONE)
|
||||
|
||||
Migrated `frontend/src/pages/Media.tsx` **fully** off `@mui/x-data-grid` +
|
||||
`@mui/material` onto the frozen 7a `DataTable` wrapper (reused, not rewritten)
|
||||
with **server-driven** pagination, row selection, row-click → file browser, and
|
||||
visibility-only column parity. All 6 Slice-7b task lines in `tasks.md` are now
|
||||
`- [x]`. Cumulative change task progress: 53 → **59/71** (the 7a rows were
|
||||
already `[x]`; this run marks the 6 7b rows).
|
||||
|
||||
### Status context consumed
|
||||
|
||||
- `applyState` reported by the status engine: **blocked** (`blockedReasons`:
|
||||
domain specs missing/partial; legacy flat `spec.md` present without domain
|
||||
specs). Same planning-completeness gap as slices 1–7a — **not** a safety or
|
||||
`actionContext` blocker.
|
||||
- `actionContext`: `mode: repo-local`, `workspaceRoot: /home/user/Manage_01`,
|
||||
`allowedEditRoots: ["/home/user/Manage_01"]`, `warnings: []` — safe.
|
||||
- This run executed the explicitly delegated **Slice 7b** scope per the parent
|
||||
acceptance contract (which also supplied the resolved delivery path: 7b-only
|
||||
sub-PR, reuse the 7a `DataTable`). `design.md` §3.1–§3.4 (DataTable contract +
|
||||
Media consumer contract) and §1 (component mapping) are authoritative and do
|
||||
not depend on the missing domain specs.
|
||||
- `artifactStore: openspec`; persisted task checkboxes updated in `tasks.md`
|
||||
(Slice 7b: 0 → 6 `[x]`).
|
||||
|
||||
### Completed tasks (persisted checkboxes updated)
|
||||
|
||||
- [x] **15 locked columns** — `mediaColumns: ColumnDef<MediaItem>[]` is a
|
||||
**module-level constant** (stable — see 7a columns-stability discovery) with
|
||||
exactly the 15 columns via `accessorKey`: `title, series, season, episode,
|
||||
type, year, runtime_min, size, bitrate, hdr, video, resolution, date_added,
|
||||
library, path`. Column `id` == `accessorKey`, so the visibility dropdown lists
|
||||
exactly these 15. Headers match the pre-rework headerNames (incl. `video` →
|
||||
"Video codec"). Cells rely on TanStack's default accessor rendering (the
|
||||
backend `display_media_row` already formats `size`/`bitrate`/`date_added` as
|
||||
strings; `runtime_min` shows the raw minute int — same as the DataGrid).
|
||||
- [x] **DataTable wiring** — `enableRowSelection` (controlled `rowSelection` +
|
||||
`setRowSelection`), `enablePagination` + `manualPagination` +
|
||||
`rowCount={queryResult.total}`, `onRowClick={handleRowClick}` →
|
||||
`navigate('/files?path=${encodeURIComponent(row.path)}')` (PRESERVED exact
|
||||
pre-rework row-click → file-browser behavior), `enableColumnVisibilityToggle`.
|
||||
- [x] **Lifted state + server-driven paging** — added `offset` (kept),
|
||||
`pageSize` (new, default `100`), and `columnVisibility` (new, default `{}`) to
|
||||
the `MediaTabState` persisted via the existing `usePersistentState`
|
||||
(`manage.media.tabState`). `pagination = { pageIndex: Math.floor(offset /
|
||||
pageSize), pageSize }`; `onPaginationChange` maps back to `offset`/`pageSize`
|
||||
(and resets `offset` to 0 on page-size change). `useMediaQuery({ limit:
|
||||
pageSize, offset, … })`. `getRowId = getMediaRowId(row) => row.path`
|
||||
(module-level, stable, **path-derived** so selection survives server-driven
|
||||
paging). Old persisted state without `pageSize`/`columnVisibility` is
|
||||
back-compat-merged with defaults on read.
|
||||
- [x] **Shell migrated** — `Card`/`CardContent` → shadcn `Card`/`CardContent`;
|
||||
`Grid` → `grid grid-cols-1 md:grid-cols-12 gap-4` (filter row: search
|
||||
`md:col-span-4`, types/hdr/sort/order `md:col-span-2` each); MUI
|
||||
`FormControl`/`InputLabel`/`MenuItem`/`Select` → shadcn `Select` family (file-
|
||||
local `FilterSelect` helper, no empty-value items so no sentinel needed);
|
||||
`TextField` → `Input` + `Label`; `LinearProgress` → shadcn `Progress`
|
||||
(determinate) / pulsing bar (indeterminate — preserves the pre-rework
|
||||
`variant="indeterminate"` affordance) via a file-local `BuildProgress`;
|
||||
`Stack`/`Box` → `flex flex-col gap-*`; `Typography` → semantic text utilities;
|
||||
`Alert` (the "No index built yet.") → shadcn `Alert` + `AlertDescription`
|
||||
(`destructive`). Index-build controls + progress (Build / Stop=`destructive` /
|
||||
Force stop=`outline`+`text-chart-3` cue, overall + per-library progress, items
|
||||
processed/total, elapsed/eta, cancel-requested) preserved verbatim. MUI's
|
||||
`useMediaQuery("(max-width:900px)")` is replaced by a local
|
||||
`usePrefersSmallScreen` (`window.matchMedia`) that re-applies the pre-rework
|
||||
mobile column-visibility override (forces the same 9 columns hidden on small
|
||||
screens); the hook is defensive (`matchMedia` absent → desktop) so jsdom never
|
||||
crashes.
|
||||
- [x] **Component tests** — `src/pages/__tests__/Media.test.tsx` (6 tests):
|
||||
(1) the toggleable column set equals the locked 15 (open the Columns dropdown,
|
||||
collect `menuitemcheckbox` names, assert exactly the 15 + excludes
|
||||
`__select__`); (2) the 15 data column headers render (incl. "Video codec");
|
||||
(3) row-click navigates to `/files?path=<encoded path>` (mocked `navigate`);
|
||||
(4) toggling a row checkbox does NOT navigate (selection-cell stopPropagation
|
||||
parity); (5) server-driven pagination total + page controls render
|
||||
(`rowCount`-driven "2 rows", "Page 1 of 1", disabled Previous); (6) Build
|
||||
disabled while running (Stop/Force-stop surface). Hooks mocked via `vi.mock`;
|
||||
`usePersistentState` runs for real (localStorage cleared in `beforeEach`).
|
||||
- [x] **Exit gate (7b)** — see gate table below (build/lint/test/node all green);
|
||||
`@mui/x-data-grid` no longer imported by Media (or FileBrowser); **no sorting,
|
||||
no resizing** (DataTable never wires `getSortedRowModel`/`enableColumnResizing`;
|
||||
Media passes neither).
|
||||
|
||||
### Files changed (this slice)
|
||||
|
||||
Modified:
|
||||
|
||||
- `frontend/src/pages/Media.tsx` (full rewrite; `@mui/x-data-grid` +
|
||||
`@mui/material` → `DataTable` + shadcn/Tailwind)
|
||||
- `openspec/changes/web-ui-rework/tasks.md` (Slice 7b checkboxes 0 → 6 `[x]`)
|
||||
|
||||
Added (new):
|
||||
|
||||
- `frontend/src/pages/__tests__/Media.test.tsx` (6 component tests)
|
||||
|
||||
Untouched (no-unintended-edits respected): `frontend/src/components/ui/data-table.tsx`
|
||||
(7a, **frozen — reused, not edited**), `FileBrowser.impl.tsx` (7a, already
|
||||
TanStack), `Applications.tsx` + its test (test already `vi.mock("../Media")`, so
|
||||
the child swap is transparent), every other page/component, `package.json`
|
||||
(MUI/@emotion removal is slice 8), `docs/REQUIREMENTS.md` (slice 8).
|
||||
`git status --porcelain` shows exactly `M frontend/src/pages/Media.tsx` +
|
||||
`?? frontend/src/pages/__tests__/Media.test.tsx` (plus these two OpenSpec
|
||||
artifacts).
|
||||
|
||||
### Gate results (run from `frontend/`) — ALL GREEN
|
||||
|
||||
| Gate | Command | Result |
|
||||
|------|---------|--------|
|
||||
| MUI-free | `grep -cE '@mui/(material\|icons-material\|x-data-grid)' src/pages/Media.tsx` | ✅ **0** |
|
||||
| Build | `npm run build` (`tsc -b` + `vite build`) | ✅ exit 0 (chunk-size warning pre-existing, not a failure) |
|
||||
| Lint | `npm run lint` (`eslint .`) | ✅ exit 0 — 0 errors; 2 warnings both **pre-existing** in `UsersPage.impl.tsx` (slice 6b, out of scope) |
|
||||
| Test | `npm test` (`vitest run`) | ✅ **23 files / 64 tests** pass (baseline 22/58 → +1 file, +6 tests) |
|
||||
| Legacy node | `node --test tests/*.test.mjs` | ✅ 4/4 pass |
|
||||
| Visibility-only | (Media passes no sort/resize; DataTable 7a enforces) | ✅ no sorting, no resizing |
|
||||
|
||||
> **Known flake (NOT a 7b regression):** under parallel load the full suite
|
||||
> occasionally flakes `UsersPage.test.tsx` "opens compose and inserts bold markup"
|
||||
> (slice-6b compose-test timing sensitivity, already documented in the 7a
|
||||
> progress notes). It passes **7/7 in isolation across 2 runs**, and the **full
|
||||
> suite is 23/23 on a re-run**. My diff touches Media.tsx + Media.test.tsx only;
|
||||
> `UsersPage.impl.tsx` is unchanged this slice.
|
||||
|
||||
> **`node --test tests` (no glob):** fails with `Cannot find module '.../tests'`
|
||||
> — the **pre-existing** Node 22 invocation quirk noted since slice 1. The
|
||||
> package's canonical node-suite command `node --test tests/*.test.mjs` is green
|
||||
> (4/4). My diff does not touch `package.json` or `tests/`.
|
||||
|
||||
### Design decisions / deviations
|
||||
|
||||
1. **Default page size = 100 (parity).** The pre-rework Media used a fixed
|
||||
`limit = 100`; `pageSize` defaults to `100` so initial paging behavior is
|
||||
identical. The DataTable pager now also offers `[50, 100, 200]` (a feature
|
||||
addition permitted by design §3.4's "pagination parity"), with `offset`
|
||||
reset to 0 on any page-size change to keep the server offset sane.
|
||||
2. **Column cells use default accessor rendering.** The task text suggested
|
||||
`formatDuration`/`humanSize`/`formatBitrate`/HDR-badge formatting, but the
|
||||
backend `display_media_row` **already** returns formatted strings for
|
||||
`size`/`bitrate`/`date_added`/`hdr`/`video`/`resolution`, and the pre-rework
|
||||
DataGrid rendered raw field values. Applying those formatters to already-
|
||||
formatted strings would break parity (e.g. `humanSize("12.4 GB")`), so cells
|
||||
render the raw field value (exact parity). `formatDuration` is still used for
|
||||
build elapsed/eta (unchanged).
|
||||
3. **`usePrefersSmallScreen` replaces MUI `useMediaQuery`.** Local
|
||||
`window.matchMedia("(max-width: 900px)")` hook re-applies the pre-rework
|
||||
mobile column-visibility override (same 9 columns hidden). It is defensive
|
||||
(treats missing `matchMedia` as desktop) so jsdom renders the full 15-column
|
||||
desktop layout in tests without editing the shared `src/test/setup.ts` (out
|
||||
of scope).
|
||||
4. **The standalone Prev/Next pager + `hideFooter`** from the DataGrid are gone —
|
||||
the `DataTable` pagination footer (Previous/Next + "Page X of Y" + rows-per-
|
||||
page + server total) replaces them (page-nav parity). A one-line "Showing X
|
||||
of Y items | Page X of Y" caption is kept above the table for the per-page
|
||||
count (the footer shows the server total only).
|
||||
5. **No fixed 640px scroll box.** The pre-rework DataGrid was bounded to a 640px
|
||||
scroll viewport; the TanStack/shadcn table now flows on the page (page-level
|
||||
scroll). Behavior parity (paging/selection/row-click) is preserved; the
|
||||
bounded-viewport visual is a minor deviation. The DataTable wrapper was not
|
||||
modified (7a frozen).
|
||||
6. **Row selection is NEW behavior** (the pre-rework Media had
|
||||
`checkboxSelection={false}`). Design §3.4 / tasks.md explicitly require
|
||||
`enableRowSelection` on Media, so a leading checkbox column is now present.
|
||||
Selection state is local `useState` (resets on reload — no pre-rework parity
|
||||
to preserve); `getRowId` is path-derived so any selection survives server
|
||||
paging.
|
||||
|
||||
### Slice boundary / PR
|
||||
|
||||
Single sub-PR, well within the 400-line budget: ~1 file rewrite (~310 inserted
|
||||
/ ~360 deleted net of the MUI teardown — the migration is more compact) + 1 new
|
||||
test file (~210 lines). No 7b sub-split needed. The parent owns the commit/PR;
|
||||
nothing committed here.
|
||||
|
||||
### Top risk for slice 8 (cleanup + docs)
|
||||
|
||||
**Slice 8 removes `@mui/material`, `@mui/icons-material`, `@mui/x-data-grid`,
|
||||
`@emotion/react`, `@emotion/styled` from `package.json`** — but `UsersPage.impl.tsx`
|
||||
still imports `@mui/icons-material` + `@mui/material` (slice **6b** is still
|
||||
unchecked: compose dialog + 9 icons). Until 6b lands, `@mui/*` is NOT safe to
|
||||
uninstall. Slice 8's grep gate (`grep -rlE '@mui/(material|icons-material|...)'
|
||||
frontend/src` returns nothing) will **fail** until 6b completes. So **6b must
|
||||
land before (or alongside) slice 8**. Secondary slice-8 risks: re-confirm
|
||||
`recharts`/`d3`/`theme.ts` are still gone (carry-over), and the
|
||||
`docs/REQUIREMENTS.md` write-up of the single design system + reconciled IA.
|
||||
|
||||
### Remaining tasks (exact unchecked `- [ ]` lines)
|
||||
|
||||
Slice 7b is complete (59/71). The remaining 12 unchecked lines are:
|
||||
|
||||
- **Slice 6b** (5 — compose dialog, 9 lucide icons, rich-text behavior, compose
|
||||
tests, exit gate 6a+6b): `UsersPage.impl.tsx` still uses `@mui/icons-material`
|
||||
(9) + `@mui/material` (Dialog family + TextField/Divider/IconButton).
|
||||
- **Slice 8** (7 — remove MUI/@emotion deps, grep-verify zero `@mui/*` +
|
||||
`@emotion/*`, re-confirm `recharts`/`d3`/`theme.ts`, final gates, update
|
||||
`docs/REQUIREMENTS.md`, exit gate).
|
||||
|
||||
No slice-7b work remains.
|
||||
|
||||
@@ -225,12 +225,12 @@ Each slice section restates this gate as its final task.
|
||||
|
||||
### Slice 7b — Media (server-driven pagination)
|
||||
|
||||
- [ ] Migrate `frontend/src/pages/Media.tsx` off `@mui/x-data-grid` onto `DataTable`: build `mediaColumns: ColumnDef<MediaItem>[]` for the 15 locked columns (`title, series, season, episode, type, year, runtime_min, size, bitrate, hdr, video, resolution, date_added, library, path`).
|
||||
- [ ] Render `DataTable` with `enableRowSelection`, `enablePagination` + `manualPagination` + `rowCount` (driven by `queryResult.total`), `onRowClick → navigate('/files?path=…')` (opens file browser at the item's path — preserved), and `enableColumnVisibilityToggle` (toggleable set must match the 15-column list above exactly).
|
||||
- [ ] Lift pagination + column-visibility state into the existing `usePersistentState` media state and feed `useMediaQuery({ limit, offset, … })`; use a stable path-derived `getRowId` so selection survives server-driven paging.
|
||||
- [ ] Also migrate the remaining `@mui/material` Media shell (Card/SectionCard/Grid/Select/Input/LinearProgress) to shadcn primitives + CSS grid + `Progress`; preserve index-build controls + progress (stop/force-stop).
|
||||
- [ ] Add component tests asserting the toggleable column set equals the locked 15 and that row-click triggers the navigation callback.
|
||||
- [ ] **Exit gate (7b):** Media on TanStack Table with pagination (server-driven, page-size + total-count + page-nav parity), row selection, row click → file browser, column-visibility parity; `@mui/x-data-grid` no longer imported anywhere; **no sorting, no resizing** present; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green; manual smoke of Media paging + row-click and FileBrowser row-click preview.
|
||||
- [x] Migrate `frontend/src/pages/Media.tsx` off `@mui/x-data-grid` onto `DataTable`: build `mediaColumns: ColumnDef<MediaItem>[]` for the 15 locked columns (`title, series, season, episode, type, year, runtime_min, size, bitrate, hdr, video, resolution, date_added, library, path`).
|
||||
- [x] Render `DataTable` with `enableRowSelection`, `enablePagination` + `manualPagination` + `rowCount` (driven by `queryResult.total`), `onRowClick → navigate('/files?path=…')` (opens file browser at the item's path — preserved), and `enableColumnVisibilityToggle` (toggleable set must match the 15-column list above exactly).
|
||||
- [x] Lift pagination + column-visibility state into the existing `usePersistentState` media state and feed `useMediaQuery({ limit, offset, … })`; use a stable path-derived `getRowId` so selection survives server-driven paging.
|
||||
- [x] Also migrate the remaining `@mui/material` Media shell (Card/SectionCard/Grid/Select/Input/LinearProgress) to shadcn primitives + CSS grid + `Progress`; preserve index-build controls + progress (stop/force-stop).
|
||||
- [x] Add component tests asserting the toggleable column set equals the locked 15 and that row-click triggers the navigation callback.
|
||||
- [x] **Exit gate (7b):** Media on TanStack Table with pagination (server-driven, page-size + total-count + page-nav parity), row selection, row click → file browser, column-visibility parity; `@mui/x-data-grid` no longer imported anywhere; **no sorting, no resizing** present; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green; manual smoke of Media paging + row-click and FileBrowser row-click preview.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user