diff --git a/frontend/src/pages/Media.tsx b/frontend/src/pages/Media.tsx index e6dd536..3bbf835 100644 --- a/frontend/src/pages/Media.tsx +++ b/frontend/src/pages/Media.tsx @@ -9,6 +9,10 @@ import type { } from "@tanstack/react-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 { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -31,6 +35,7 @@ import { useForceStopBuildIndex, } from "../hooks/useMedia"; import { usePersistentState } from "../hooks/usePersistentState"; +import { useIsMobile } from "../hooks/useIsMobile"; import type { MediaItem } from "../types"; import { useServiceInstances } from "../hooks/useServices"; import { useCounts, useLibraries } from "../hooks/useDashboard"; @@ -75,6 +80,116 @@ function getMediaRowId(row: MediaItem): string { 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[] = [ + { 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; +}) { + return ( +
+
+ {`${totalRows} row${totalRows === 1 ? "" : "s"}`} +
+
+
+ Rows per page + +
+ + Page {pageIndex + 1} of {pageCount} + +
+ + +
+
+
+ ); +} + const MEDIA_TAB_STATE_KEY = "manage.media.tabState"; const SMALL_BREAKPOINT = "(max-width: 900px)"; // Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override. @@ -178,6 +293,7 @@ export function Media() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const isSmall = usePrefersSmallScreen(); + const isMobile = useIsMobile(); const { data: jellyfinServices = [] } = useServiceInstances("jellyfin"); const selectedServiceId = searchParams.get("jellyfin_service_id") || @@ -532,33 +648,55 @@ export function Media() {

)} - {status?.exists && ( -
- -
- )} + {status?.exists && + (isMobile ? ( +
+
+ +
+ {queryResult && ( + + )} +
+ ) : ( +
+ +
+ ))} ); } diff --git a/frontend/src/pages/__tests__/Media.test.tsx b/frontend/src/pages/__tests__/Media.test.tsx index 47ff5e1..571dafe 100644 --- a/frontend/src/pages/__tests__/Media.test.tsx +++ b/frontend/src/pages/__tests__/Media.test.tsx @@ -129,7 +129,11 @@ vi.mock("../../hooks/useDashboard", () => ({ // usePersistentState reads/writes localStorage; clear between tests so the // 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(() => { + setMatchMedia(false); window.localStorage.clear(); navigate.mockClear(); 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)", () => { it("exposes exactly the 15 locked toggleable columns", async () => { render(); @@ -261,3 +289,68 @@ describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", ( ).toBeInTheDocument(); }); }); + +describe("Media (mobile card layout — slice 3)", () => { + it("renders cards with the title as primary below md", () => { + setMatchMedia(true); + render(); + + // 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(); + + expect(screen.queryByRole("button", { name: /Columns/ })).toBeNull(); + }); + + it("renders pagination controls below the cards on mobile", () => { + setMatchMedia(true); + render(); + + 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(); + + 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(); + + // Desktop column headers render. + expect( + screen.getByRole("columnheader", { name: "Title" }), + ).toBeInTheDocument(); + // Column-visibility toggle is present. + expect(screen.getByRole("button", { name: /Columns/ })).toBeInTheDocument(); + }); +});