Mobile Media table: stacked cards + mobile pagination (Slice 3)
Below md, the Media DataTable renders as MobileCardRow cards: title as primary, plus size/HDR/library/year (3-5 fields, null-safe). Card tap navigates to /files?path=... (same handleRowClick as desktop). The TanStack column-visibility toggle is absent below md (the card picks the fields). Pagination is preserved via a standalone MediaMobilePagination component that mirrors DataTablePagination semantics (rows count, page-size select, page indicator, prev/next with correct disabled states) off the raw PaginationState. The duplication is flagged tech debt -- extracting a shared TablePagination is a follow-up, out of scope for this slice. Desktop (md+) is byte-for-byte identical: the isMobile===false branch renders the same DataTable with the same props. enableRowSelection state is vestigial (no batch consumer on either path); navigation is the correct primary mobile interaction. Tests: 5 new covering mobile cards + hidden column toggle + pagination + card-tap navigation, and desktop DataTable + column toggle. matchMedia mocked per-breakpoint. 94 tests pass; lint/build green. Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 3).
This commit is contained in:
@@ -9,6 +9,10 @@ import type {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -31,6 +35,7 @@ import {
|
|||||||
useForceStopBuildIndex,
|
useForceStopBuildIndex,
|
||||||
} from "../hooks/useMedia";
|
} from "../hooks/useMedia";
|
||||||
import { usePersistentState } from "../hooks/usePersistentState";
|
import { usePersistentState } from "../hooks/usePersistentState";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type { MediaItem } from "../types";
|
import type { MediaItem } from "../types";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||||
@@ -75,6 +80,116 @@ function getMediaRowId(row: MediaItem): string {
|
|||||||
return row.path;
|
return row.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
|
||||||
|
// Title is the primary identifier; size/HDR/library/year give the at-a-glance
|
||||||
|
// tech + context info a user scanning the library on a phone needs. Runtime,
|
||||||
|
// bitrate, resolution, codec etc. live on the desktop table only.
|
||||||
|
const mediaCardFields: MobileCardField<MediaItem>[] = [
|
||||||
|
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||||
|
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
||||||
|
{
|
||||||
|
key: "hdr",
|
||||||
|
label: "HDR",
|
||||||
|
render: (r) => r.hdr || "-",
|
||||||
|
},
|
||||||
|
{ key: "library", label: "Library", render: (r) => r.library || "-" },
|
||||||
|
{
|
||||||
|
key: "year",
|
||||||
|
label: "Year",
|
||||||
|
render: (r) => (r.year != null ? String(r.year) : "-"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Standalone pagination for the mobile card layout. The DataTable renders its
|
||||||
|
// own pagination internally; this mirrors that UI (rows count, page-size
|
||||||
|
// select, page indicator, prev/next) but works off the raw pagination state
|
||||||
|
// instead of a TanStack table instance. See spec R3.3.
|
||||||
|
function MediaMobilePagination({
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
pageSizeOptions,
|
||||||
|
totalRows,
|
||||||
|
pageCount,
|
||||||
|
onPaginationChange,
|
||||||
|
}: {
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageSizeOptions: number[];
|
||||||
|
totalRows: number;
|
||||||
|
pageCount: number;
|
||||||
|
onPaginationChange: OnChangeFn<PaginationState>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 p-4 text-sm">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-muted-foreground">Rows per page</span>
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onPaginationChange(() => ({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: Number(value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
size="sm"
|
||||||
|
className="w-[70px]"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{pageSizeOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={String(option)}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Page {pageIndex + 1} of {pageCount}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
onPaginationChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageIndex: Math.max(0, prev.pageIndex - 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={pageIndex <= 0}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
onPaginationChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageIndex: prev.pageIndex + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={pageIndex >= pageCount - 1}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
||||||
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
||||||
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
|
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
|
||||||
@@ -178,6 +293,7 @@ export function Media() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const isSmall = usePrefersSmallScreen();
|
const isSmall = usePrefersSmallScreen();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||||
const selectedServiceId =
|
const selectedServiceId =
|
||||||
searchParams.get("jellyfin_service_id") ||
|
searchParams.get("jellyfin_service_id") ||
|
||||||
@@ -532,7 +648,29 @@ export function Media() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status?.exists && (
|
{status?.exists &&
|
||||||
|
(isMobile ? (
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<div className="p-4">
|
||||||
|
<MobileCardRow
|
||||||
|
rows={queryResult?.items ?? []}
|
||||||
|
fields={mediaCardFields}
|
||||||
|
getRowId={getMediaRowId}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{queryResult && (
|
||||||
|
<MediaMobilePagination
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageSize={pageSize}
|
||||||
|
pageSizeOptions={[50, 100, 200]}
|
||||||
|
totalRows={total}
|
||||||
|
pageCount={totalPages}
|
||||||
|
onPaginationChange={handlePaginationChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="rounded-lg border bg-card">
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={mediaColumns}
|
columns={mediaColumns}
|
||||||
@@ -558,7 +696,7 @@ export function Media() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,11 @@ vi.mock("../../hooks/useDashboard", () => ({
|
|||||||
|
|
||||||
// usePersistentState reads/writes localStorage; clear between tests so the
|
// usePersistentState reads/writes localStorage; clear between tests so the
|
||||||
// offset/pageSize/columnVisibility state never leaks across cases.
|
// offset/pageSize/columnVisibility state never leaks across cases.
|
||||||
|
// matchMedia must be stubbed so useIsMobile (md:768px) and usePrefersSmallScreen
|
||||||
|
// (900px) resolve without TypeError in jsdom. Default to desktop (matches:false)
|
||||||
|
// so the DataTable path renders by default; mobile tests override.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
setMatchMedia(false);
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
navigate.mockClear();
|
navigate.mockClear();
|
||||||
status = statusFixture();
|
status = statusFixture();
|
||||||
@@ -152,6 +156,30 @@ beforeEach(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Stub window.matchMedia so useIsMobile / usePrefersSmallScreen resolve in jsdom. */
|
||||||
|
function setMatchMedia(matches: boolean) {
|
||||||
|
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
|
||||||
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: query.includes("768") ? matches : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: (
|
||||||
|
_evt: string,
|
||||||
|
listener: (e: MediaQueryListEvent) => void,
|
||||||
|
) => listeners.push(listener),
|
||||||
|
removeEventListener: (
|
||||||
|
_evt: string,
|
||||||
|
listener: (e: MediaQueryListEvent) => void,
|
||||||
|
) => {
|
||||||
|
const idx = listeners.indexOf(listener);
|
||||||
|
if (idx >= 0) listeners.splice(idx, 1);
|
||||||
|
},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
|
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
|
||||||
it("exposes exactly the 15 locked toggleable columns", async () => {
|
it("exposes exactly the 15 locked toggleable columns", async () => {
|
||||||
render(<Media />);
|
render(<Media />);
|
||||||
@@ -261,3 +289,68 @@ describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", (
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Media (mobile card layout — slice 3)", () => {
|
||||||
|
it("renders cards with the title as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
// Card titles render (primary field).
|
||||||
|
expect(screen.getByText("Inception")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Matrix")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Card field labels render (at least once per row).
|
||||||
|
expect(screen.getAllByText("Size").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("HDR").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("Library").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("Year").length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Desktop table headers do NOT render on mobile.
|
||||||
|
expect(screen.queryByRole("columnheader", { name: "Title" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("columnheader", { name: "Bitrate" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the column-visibility toggle below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /Columns/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders pagination controls below the cards on mobile", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
expect(screen.getByText("2 rows")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Previous page" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Next page" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to the file browser when a card is tapped on mobile", async () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Inception"));
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigate).toHaveBeenCalledWith(
|
||||||
|
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the DataTable (not cards) at desktop width", () => {
|
||||||
|
render(<Media />);
|
||||||
|
|
||||||
|
// Desktop column headers render.
|
||||||
|
expect(
|
||||||
|
screen.getByRole("columnheader", { name: "Title" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// Column-visibility toggle is present.
|
||||||
|
expect(screen.getByRole("button", { name: /Columns/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user