From 785e6fbe048c299267960f81c887ff909927c2b2 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 15 May 2026 20:21:46 -0700 Subject: [PATCH 01/20] =?UTF-8?q?feat(mobile):=20foundation=20pass=20?= =?UTF-8?q?=E2=80=94=20xs=20breakpoint,=20drawer=20auto-close,=20overflow?= =?UTF-8?q?=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the groundwork for genuine mobile usability before tackling per-surface polish in follow-up work. - Add an `xs` breakpoint at 30.125em (~482px) to the Mantine theme as a phone-only line, while keeping `sm`/`md`/`lg`/`xl` at Mantine defaults so existing `visibleFrom="sm"` sites are unaffected. - Auto-close the mobile sidebar drawer on navigation. `Sidebar` now accepts an `onNavigate` prop wired from `AppLayout`'s `closeMobile`; every navigable `NavLink` plus Logout calls it. The Settings parent toggle intentionally doesn't, so opening the submenu doesn't collapse the drawer. - Global CSS for long-string overflow: `overflow-wrap: anywhere` on Title, Breadcrumbs, and Anchor surfaces, plus an override of Mantine's `white-space: nowrap` on breadcrumb items so long filenames wrap inside the row instead of pushing the layout wider than the viewport. - Phone-only typography scale: Title `data-order` 1–3 shrink via overrides of Mantine's `--title-fz` CSS variable, avoiding 10-line wrapped headings on 390px screens. - Touch-target helpers: a `.touch-target` opt-in class enforces 44×44 below `xs`, and the Header `Burger` is bumped from `sm` to `md` with a state-reflecting `aria-label`. Mantine doesn't accept responsive object literals for `Title fz` or `ActionIcon size`, so the global CSS approach substitutes for the originally planned theme component defaults. - Tighten the Book Detail FILE row (`flexShrink: 0` on label, `overflowWrap: anywhere` + `minWidth: 0` on value) so long filenames don't blow out the page on phones. Tests added for the auto-close behavior covering positive cases (Home link, Settings submenu link) and the negative case (Settings toggle). --- web/src/components/layout/AppLayout.tsx | 5 +- web/src/components/layout/Header.tsx | 3 +- web/src/components/layout/Sidebar.test.tsx | 104 ++++++++++++++++++++- web/src/components/layout/Sidebar.tsx | 28 +++++- web/src/index.css | 48 ++++++++++ web/src/pages/BookDetail.tsx | 13 ++- web/src/theme.ts | 15 +++ 7 files changed, 208 insertions(+), 8 deletions(-) diff --git a/web/src/components/layout/AppLayout.tsx b/web/src/components/layout/AppLayout.tsx index 7f9d4a66..d03ee94e 100644 --- a/web/src/components/layout/AppLayout.tsx +++ b/web/src/components/layout/AppLayout.tsx @@ -12,7 +12,8 @@ interface AppLayoutProps { } export function AppLayout({ children }: AppLayoutProps) { - const [mobileOpened, { toggle: toggleMobile }] = useDisclosure(); + const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] = + useDisclosure(); const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true); const searchInputRef = useRef(null); @@ -35,7 +36,7 @@ export function AppLayout({ children }: AppLayoutProps) { toggleDesktop={toggleDesktop} searchInputRef={searchInputRef} /> - + diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 697737cd..e7c70e2c 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -45,7 +45,8 @@ export function Header({ opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" - size="sm" + size="md" + aria-label={mobileOpened ? "Close navigation" : "Open navigation"} /> ({ @@ -585,4 +590,101 @@ describe("Sidebar Component (via AppLayout)", () => { }); }); }); + + describe("Mobile drawer auto-close (onNavigate)", () => { + function renderSidebar(onNavigate: () => void) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render( + + + + + + + + + , + ); + } + + it("calls onNavigate when the Home link is clicked", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + const mockUser: User = { + id: "1", + username: "testuser", + email: "test@example.com", + role: "reader", + emailVerified: true, + permissions: [], + }; + + useAuthStore.setState({ + user: mockUser, + token: "token", + isAuthenticated: true, + }); + + renderSidebar(onNavigate); + + await user.click(screen.getByText("Home")); + expect(onNavigate).toHaveBeenCalled(); + }); + + it("calls onNavigate when a settings submenu link is clicked", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + const mockUser: User = { + id: "1", + username: "testuser", + email: "test@example.com", + role: "reader", + emailVerified: true, + permissions: [], + }; + + useAuthStore.setState({ + user: mockUser, + token: "token", + isAuthenticated: true, + }); + + renderSidebar(onNavigate); + + // Profile is shown to all users inside Settings + await user.click(screen.getByText("Profile")); + expect(onNavigate).toHaveBeenCalled(); + }); + + it("does NOT call onNavigate when only expanding the Settings submenu", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + const mockUser: User = { + id: "1", + username: "testuser", + email: "test@example.com", + role: "reader", + emailVerified: true, + permissions: [], + }; + + useAuthStore.setState({ + user: mockUser, + token: "token", + isAuthenticated: true, + }); + + renderSidebar(onNavigate); + + // Clicking the "Settings" parent toggle expands the submenu; it is not a + // navigation event and must not collapse the drawer. + await user.click(screen.getByText("Settings")); + expect(onNavigate).not.toHaveBeenCalled(); + }); + }); }); diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index ea92801e..f1cffb91 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -57,7 +57,12 @@ import { useLibraryPreferencesStore } from "@/store/libraryPreferencesStore"; import type { Library } from "@/types"; import { PERMISSIONS } from "@/types/permissions"; -export function Sidebar() { +interface SidebarProps { + /** Called when the user taps a navigation link, so the mobile drawer can auto-close. */ + onNavigate?: () => void; +} + +export function Sidebar({ onNavigate }: SidebarProps = {}) { const appName = useAppName(); const { data: appInfo } = useAppInfo(); const navigate = useNavigate(); @@ -322,6 +327,7 @@ export function Sidebar() { const handleLogout = () => { clearAuth(); navigate("/login"); + onNavigate?.(); }; return ( @@ -335,6 +341,7 @@ export function Sidebar() { label="Home" leftSection={} active={currentPath === "/"} + onClick={onNavigate} /> {hasRecommendationPlugin && ( } active={currentPath === "/recommendations"} + onClick={onNavigate} /> )} {hasReleasePlugin && ( @@ -353,6 +361,7 @@ export function Sidebar() { leftSection={} active={currentPath.startsWith("/releases")} rightSection={} + onClick={onNavigate} /> )} } active={currentPath.startsWith("/libraries/all")} + onClick={onNavigate} rightSection={ canEditLibrary && ( @@ -498,6 +508,7 @@ export function Sidebar() { to={`/libraries/${library.id}/${getLastTab(library.id) || "recommended"}`} label={library.name} active={currentPath.startsWith(`/libraries/${library.id}/`)} + onClick={onNavigate} styles={{ root: { paddingLeft: 48 }, label: { textTransform: "capitalize" }, @@ -560,6 +571,7 @@ export function Sidebar() { label="Server" leftSection={} active={currentPath.startsWith("/settings/server")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/tasks")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/metrics")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/plugins")} + onClick={onNavigate} /> {/* Access Section */} @@ -605,6 +621,7 @@ export function Sidebar() { label="Users" leftSection={} active={currentPath.startsWith("/settings/users")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/sharing-tags")} + onClick={onNavigate} /> {/* Library Health Section */} @@ -627,6 +645,7 @@ export function Sidebar() { label="Duplicates" leftSection={} active={currentPath.startsWith("/settings/duplicates")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/book-errors")} + onClick={onNavigate} /> {/* Storage Section */} @@ -649,6 +669,7 @@ export function Sidebar() { label="Thumbnails" leftSection={} active={currentPath.startsWith("/settings/cleanup")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/pdf-cache")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/plugin-storage")} + onClick={onNavigate} /> {/* Data Export Section */} @@ -678,6 +701,7 @@ export function Sidebar() { label="Data Exports" leftSection={} active={currentPath.startsWith("/settings/exports")} + onClick={onNavigate} /> {/* Account Section */} @@ -696,6 +720,7 @@ export function Sidebar() { label="Integrations" leftSection={} active={currentPath.startsWith("/settings/integrations")} + onClick={onNavigate} /> } active={currentPath.startsWith("/settings/profile")} + onClick={onNavigate} /> diff --git a/web/src/index.css b/web/src/index.css index 98e30d48..15a231ce 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -60,6 +60,54 @@ button:focus-visible { outline: none; } +/* ============================================ + Mobile responsive helpers + ============================================ */ + +/* Prevent layout-breaking horizontal overflow from long unbreakable strings + (e.g., file names without spaces) in titles, breadcrumbs, and inline links. + Scoped to known long-string surfaces so we don't change URL/code rendering. */ +.mantine-Title-root, +.mantine-Breadcrumbs-root, +.mantine-Anchor-root { + overflow-wrap: anywhere; + word-break: break-word; +} + +/* Mantine sets `white-space: nowrap` on Breadcrumbs items, which prevents + long-but-unbreakable titles from wrapping and causes horizontal overflow on + phones. Allow wrapping inside items so the wrap-row layout works as intended. */ +.mantine-Breadcrumbs-breadcrumb { + white-space: normal; + overflow-wrap: anywhere; +} + +/* Phone-only (≤480px): bump touch targets opted in via .touch-target class. */ +@media (max-width: 30.0625em) { + .touch-target, + .touch-target .mantine-ActionIcon-root { + min-width: 44px; + min-height: 44px; + } + + /* Scale down Mantine Title sizes so page headings don't wrap to many lines + at 390px. Mantine's Title renders an h1–h6 tag and sets font size via the + `--title-fz` CSS variable; overriding the variable keeps all of Mantine's + other typography concerns (weight, line-height defaults) intact. */ + .mantine-Title-root[data-order="1"] { + --title-fz: 1.5rem; + --title-lh: 1.3; + } + .mantine-Title-root[data-order="2"] { + --title-fz: 1.25rem; + --title-lh: 1.3; + } + .mantine-Title-root[data-order="3"] { + --title-fz: 1.125rem; + --title-lh: 1.3; + } +} + @media (prefers-color-scheme: light) { :root { color: #213547; diff --git a/web/src/pages/BookDetail.tsx b/web/src/pages/BookDetail.tsx index 6dba96a2..e043032a 100644 --- a/web/src/pages/BookDetail.tsx +++ b/web/src/pages/BookDetail.tsx @@ -1019,12 +1019,19 @@ export function BookDetail() { )} {/* File Path */} - - + + FILE - + {book.filePath.split("/").pop() || book.filePath} diff --git a/web/src/theme.ts b/web/src/theme.ts index c15bc679..246a1f55 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -45,6 +45,21 @@ export const theme = createTheme({ xl: "2rem", }, + // Breakpoints (em, matching Mantine's default scheme). + // + // We override `xs` to a phone-only line at ~482px (30.125em). This is below the + // common iPhone Pro Max portrait width (~430px) but above smaller phones, giving + // us a clean "phone vs tablet" cutoff. `sm` (768px) is kept at Mantine's default + // so existing `visibleFrom="sm"` / `hiddenFrom="sm"` sites are unaffected; new + // phone-tight behavior should use `xs` instead. + breakpoints: { + xs: "30.125em", + sm: "48em", + md: "62em", + lg: "75em", + xl: "88em", + }, + // Custom properties for layout other: { sidebarWidth: 240, From b6f219b62cb3aaa0e431a55312712f7c9977395e Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 15 May 2026 20:49:40 -0700 Subject: [PATCH 02/20] feat(mobile): full-screen search sheet below the xs breakpoint Restores the ability to search from the mobile UI. Below 482px the header renders a search ActionIcon that opens a top-anchored Drawer with an auto-focused input and grouped Series/Books results. Selecting a result navigates and closes the sheet; pressing Enter on a 2+ char query routes to the full search page. SearchInput's visibleFrom moves from sm to xs, so tablet portrait (482-767px) keeps the inline combobox rather than degrading to icon only. A new SearchResultItem module factors out SeriesResultContent and BookResultContent so the desktop Combobox.Option rows and the mobile UnstyledButton rows render identically and can't drift. The mobile sheet intentionally does not nest a Mantine Combobox inside the Drawer (portal and focus-trap conflicts), and arrow-key navigation is not useful on touch. Includes unit tests for the new sheet's open/close, query gating, result selection, Enter routing, loading and empty states. --- web/src/components/layout/Header.tsx | 29 ++- .../search/MobileSearchSheet.module.css | 38 ++++ .../search/MobileSearchSheet.test.tsx | 214 ++++++++++++++++++ .../components/search/MobileSearchSheet.tsx | 150 ++++++++++++ web/src/components/search/SearchInput.tsx | 49 +--- .../components/search/SearchResultItem.tsx | 57 +++++ web/src/components/search/index.ts | 1 + 7 files changed, 491 insertions(+), 47 deletions(-) create mode 100644 web/src/components/search/MobileSearchSheet.module.css create mode 100644 web/src/components/search/MobileSearchSheet.test.tsx create mode 100644 web/src/components/search/MobileSearchSheet.tsx create mode 100644 web/src/components/search/SearchResultItem.tsx diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index e7c70e2c..2ce9a739 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -6,9 +6,14 @@ import { Text, useComputedColorScheme, } from "@mantine/core"; -import { IconMenu2, IconMoon, IconSun } from "@tabler/icons-react"; +import { useDisclosure } from "@mantine/hooks"; +import { IconMenu2, IconMoon, IconSearch, IconSun } from "@tabler/icons-react"; import type { RefObject } from "react"; -import { SearchInput, type SearchInputHandle } from "@/components/search"; +import { + MobileSearchSheet, + SearchInput, + type SearchInputHandle, +} from "@/components/search"; import { useAppName } from "@/hooks/useAppName"; import { useUserPreferencesStore } from "@/store/userPreferencesStore"; @@ -28,6 +33,10 @@ export function Header({ const appName = useAppName(); const computedColorScheme = useComputedColorScheme("dark"); const setPreference = useUserPreferencesStore((state) => state.setPreference); + const [ + searchSheetOpened, + { open: openSearchSheet, close: closeSearchSheet }, + ] = useDisclosure(false); const toggleColorScheme = () => { // Toggle between light and dark (not system) for explicit user action @@ -65,6 +74,17 @@ export function Header({ + + + + + + ); } diff --git a/web/src/components/search/MobileSearchSheet.module.css b/web/src/components/search/MobileSearchSheet.module.css new file mode 100644 index 00000000..672d96b4 --- /dev/null +++ b/web/src/components/search/MobileSearchSheet.module.css @@ -0,0 +1,38 @@ +.body { + display: flex; + flex-direction: column; + height: calc(100% - 60px); +} + +.option { + display: block; + width: 100%; + padding: 10px 8px; + border-radius: var(--mantine-radius-sm); + min-height: 56px; +} + +.option:hover, +.option:active { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); +} + +.footer { + display: block; + width: 100%; + padding: 10px 8px; + margin-top: 4px; + border-top: 1px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); +} + +.footer:hover, +.footer:active { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); +} diff --git a/web/src/components/search/MobileSearchSheet.test.tsx b/web/src/components/search/MobileSearchSheet.test.tsx new file mode 100644 index 00000000..b08231e5 --- /dev/null +++ b/web/src/components/search/MobileSearchSheet.test.tsx @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, userEvent, waitFor } from "@/test/utils"; +import { MobileSearchSheet } from "./MobileSearchSheet"; + +vi.mock("@/hooks/useSearch", () => ({ + useSearch: vi.fn(), +})); + +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +import { useSearch } from "@/hooks/useSearch"; + +const mockResults = { + series: [ + { + id: "s1", + title: "Alpha Series", + bookCount: 3, + createdAt: "2024-01-01T00:00:00Z", + libraryId: "lib-1", + libraryName: "Comics", + updatedAt: "2024-01-01T00:00:00Z", + }, + ], + books: [ + { + id: "b1", + title: "First Book", + libraryId: "lib-1", + libraryName: "Comics", + seriesName: "Gamma Series", + seriesId: "s1", + filePath: "/path/first.cbz", + fileSize: 1000, + fileHash: "hash1", + fileFormat: "cbz", + pageCount: 100, + analyzed: true, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + deleted: false, + }, + ], +}; + +describe("MobileSearchSheet", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useSearch).mockReturnValue({ + results: { series: [], books: [] }, + isLoading: false, + error: null, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not render input when closed", () => { + renderWithProviders(); + expect( + screen.queryByPlaceholderText("Search series and books..."), + ).not.toBeInTheDocument(); + }); + + it("renders input when opened", () => { + renderWithProviders(); + expect( + screen.getByPlaceholderText("Search series and books..."), + ).toBeInTheDocument(); + }); + + it("does not render result groups when query is below the minimum length", async () => { + vi.mocked(useSearch).mockReturnValue({ + results: mockResults, + isLoading: false, + error: null, + }); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "t"); + + expect(screen.queryByText("Alpha Series")).not.toBeInTheDocument(); + }); + + it("shows series and book results when query length is at least 2", async () => { + vi.mocked(useSearch).mockReturnValue({ + results: mockResults, + isLoading: false, + error: null, + }); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "te"); + + await waitFor(() => { + expect(screen.getByText("Alpha Series")).toBeInTheDocument(); + expect(screen.getByText("First Book")).toBeInTheDocument(); + }); + }); + + it("navigates and closes when a series result is clicked", async () => { + vi.mocked(useSearch).mockReturnValue({ + results: mockResults, + isLoading: false, + error: null, + }); + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "alpha"); + + await waitFor(() => { + expect(screen.getByText("Alpha Series")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Alpha Series")); + + expect(mockNavigate).toHaveBeenCalledWith("/series/s1"); + expect(onClose).toHaveBeenCalled(); + }); + + it("navigates and closes when a book result is clicked", async () => { + vi.mocked(useSearch).mockReturnValue({ + results: mockResults, + isLoading: false, + error: null, + }); + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "book"); + + await waitFor(() => { + expect(screen.getByText("First Book")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("First Book")); + + expect(mockNavigate).toHaveBeenCalledWith("/books/b1"); + expect(onClose).toHaveBeenCalled(); + }); + + it("navigates to /search and closes on Enter when query is long enough", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "hello"); + await user.keyboard("{Enter}"); + + expect(mockNavigate).toHaveBeenCalledWith("/search?q=hello"); + expect(onClose).toHaveBeenCalled(); + }); + + it("does not navigate on Enter when query is too short", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "a"); + await user.keyboard("{Enter}"); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("shows the loading state while searching", async () => { + vi.mocked(useSearch).mockReturnValue({ + results: { series: [], books: [] }, + isLoading: true, + error: null, + }); + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "te"); + + await waitFor(() => { + expect(screen.getByText("Searching...")).toBeInTheDocument(); + }); + }); + + it("shows a no-results message when the query has no matches", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("Search series and books..."); + await user.type(input, "te"); + + await waitFor(() => { + expect(screen.getByText("No results found")).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/components/search/MobileSearchSheet.tsx b/web/src/components/search/MobileSearchSheet.tsx new file mode 100644 index 00000000..ffbcbf2d --- /dev/null +++ b/web/src/components/search/MobileSearchSheet.tsx @@ -0,0 +1,150 @@ +import { + Drawer, + Group, + Loader, + ScrollArea, + Stack, + Text, + TextInput, + UnstyledButton, +} from "@mantine/core"; +import { IconSearch } from "@tabler/icons-react"; +import { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useSearch } from "@/hooks/useSearch"; +import classes from "./MobileSearchSheet.module.css"; +import { BookResultContent, SeriesResultContent } from "./SearchResultItem"; + +interface MobileSearchSheetProps { + opened: boolean; + onClose: () => void; +} + +export function MobileSearchSheet({ opened, onClose }: MobileSearchSheetProps) { + const [query, setQuery] = useState(""); + const navigate = useNavigate(); + const { results, isLoading } = useSearch(query); + + const series = results?.series ?? []; + const books = results?.books ?? []; + const hasResults = series.length > 0 || books.length > 0; + const showResults = query.trim().length >= 2; + const showMoreLink = series.length > 5 || books.length > 5; + + useEffect(() => { + if (!opened) { + setQuery(""); + } + }, [opened]); + + const handleNavigate = useCallback( + (path: string) => { + onClose(); + navigate(path); + }, + [navigate, onClose], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" && query.trim().length >= 2) { + event.preventDefault(); + handleNavigate(`/search?q=${encodeURIComponent(query.trim())}`); + } + }, + [query, handleNavigate], + ); + + return ( + + + : + } + value={query} + onChange={(event) => setQuery(event.currentTarget.value)} + onKeyDown={handleKeyDown} + size="md" + aria-label="Search query" + /> + + {showResults && ( + + {isLoading ? ( + + + + Searching... + + + ) : !hasResults ? ( + + No results found + + ) : ( + + {series.length > 0 && ( + + + Series + + {series.slice(0, 5).map((s) => ( + handleNavigate(`/series/${s.id}`)} + > + + + ))} + + )} + {books.length > 0 && ( + + + Books + + {books.slice(0, 5).map((b) => ( + handleNavigate(`/books/${b.id}`)} + > + + + ))} + + )} + {showMoreLink && ( + + handleNavigate( + `/search?q=${encodeURIComponent(query.trim())}`, + ) + } + > + + See all results + + + )} + + )} + + )} + + + ); +} diff --git a/web/src/components/search/SearchInput.tsx b/web/src/components/search/SearchInput.tsx index a49f06d2..ca07c1e2 100644 --- a/web/src/components/search/SearchInput.tsx +++ b/web/src/components/search/SearchInput.tsx @@ -1,10 +1,8 @@ import { Combobox, Group, - Image, Loader, ScrollArea, - Stack, Text, TextInput, useCombobox, @@ -22,6 +20,7 @@ import { useNavigate } from "react-router-dom"; import { useSearch } from "@/hooks/useSearch"; import type { Book, Series } from "@/types"; import classes from "./SearchInput.module.css"; +import { BookResultContent, SeriesResultContent } from "./SearchResultItem"; interface SearchInputProps { placeholder?: string; @@ -128,25 +127,7 @@ export const SearchInput = forwardRef( key={series.id} className={classes.option} > - - {series.title} - - - {series.title} - - - {series.bookCount} book{series.bookCount !== 1 ? "s" : ""} - - - + ); @@ -156,29 +137,7 @@ export const SearchInput = forwardRef( key={book.id} className={classes.option} > - - {book.title} - - - {book.number !== undefined && book.number !== null - ? `${book.number} - ${book.title}` - : book.title} - - {book.seriesName && ( - - {book.seriesName} - - )} - - + ); @@ -204,7 +163,7 @@ export const SearchInput = forwardRef( } }} onBlur={() => combobox.closeDropdown()} - visibleFrom="sm" + visibleFrom="xs" w={width} /> diff --git a/web/src/components/search/SearchResultItem.tsx b/web/src/components/search/SearchResultItem.tsx new file mode 100644 index 00000000..4b8e2726 --- /dev/null +++ b/web/src/components/search/SearchResultItem.tsx @@ -0,0 +1,57 @@ +import { Group, Image, Stack, Text } from "@mantine/core"; +import type { Book, Series } from "@/types"; + +const FALLBACK_THUMB = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='56'%3E%3Crect fill='%23333' width='40' height='56'/%3E%3C/svg%3E"; + +export function SeriesResultContent({ series }: { series: Series }) { + return ( + + {series.title} + + + {series.title} + + + {series.bookCount} book{series.bookCount !== 1 ? "s" : ""} + + + + ); +} + +export function BookResultContent({ book }: { book: Book }) { + return ( + + {book.title} + + + {book.number !== undefined && book.number !== null + ? `${book.number} - ${book.title}` + : book.title} + + {book.seriesName && ( + + {book.seriesName} + + )} + + + ); +} diff --git a/web/src/components/search/index.ts b/web/src/components/search/index.ts index db1d8f35..5dfc4b79 100644 --- a/web/src/components/search/index.ts +++ b/web/src/components/search/index.ts @@ -1,2 +1,3 @@ +export { MobileSearchSheet } from "./MobileSearchSheet"; export type { SearchInputHandle } from "./SearchInput"; export { SearchInput } from "./SearchInput"; From 79e7c2122199e5108624c13475cf463cc69d68a3 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 15 May 2026 21:26:08 -0700 Subject: [PATCH 03/20] feat(mobile): reader polish with overflow menu and bottom navigation bar Below the xs breakpoint, ReaderToolbar collapses fit mode, page layout, prev/next book, and fullscreen into a single overflow Menu, keeping only close, title, settings, and the menu trigger in the top bar. Touch targets bumped to size "xl" and toolbar padding now respects env(safe-area-inset-*) for installed-PWA standalone mode. Introduces MobileReaderBottomBar (mounted by ComicReader and PdfReader, self-gated on the xs breakpoint) that restores prev/next/page-count/ slider as a sticky bottom strip. Page-count tap opens a jump-to-page modal with a numeric input clamped to the page range, useful when the slider thumb is too narrow for precise long jumps on a phone viewport. EPUB reader surfaces its TOC, bookmarks, and search through a new mobileMenuItems toolbar slot. The EPUB drawer bodies stay mounted on mobile (display:none on their wrapper) so the overflow-menu items can still toggle them via their portaled content. ComicReader pinch-zoom posture now respects the active fit mode: the "original" mode allows native pinch-zoom while scaling modes use "manipulation" so the gesture does not fight the fit logic. PdfReader always allows pinch-zoom since small PDF text commonly needs it. The PdfReader search bar width is clamped to never overflow narrow viewports. Includes tests for the new bottom bar and the toolbar's mobile mode (matchMedia override per test). Real-device iPhone verification is deferred and tracked in the implementation plan's known limitations. --- web/src/components/reader/ComicReader.tsx | 30 +- web/src/components/reader/EpubReader.tsx | 47 ++- .../reader/MobileReaderBottomBar.test.tsx | 250 ++++++++++++ .../reader/MobileReaderBottomBar.tsx | 239 ++++++++++++ web/src/components/reader/PdfReader.tsx | 21 +- .../components/reader/ReaderToolbar.test.tsx | 153 +++++++- web/src/components/reader/ReaderToolbar.tsx | 355 +++++++++++++----- web/src/components/reader/index.ts | 1 + 8 files changed, 1000 insertions(+), 96 deletions(-) create mode 100644 web/src/components/reader/MobileReaderBottomBar.test.tsx create mode 100644 web/src/components/reader/MobileReaderBottomBar.tsx diff --git a/web/src/components/reader/ComicReader.tsx b/web/src/components/reader/ComicReader.tsx index ba56fd03..9dc45f4b 100644 --- a/web/src/components/reader/ComicReader.tsx +++ b/web/src/components/reader/ComicReader.tsx @@ -22,6 +22,7 @@ import { useSeriesReaderSettings, useTouchNav, } from "./hooks"; +import { MobileReaderBottomBar } from "./MobileReaderBottomBar"; import { PageTransitionWrapper } from "./PageTransitionWrapper"; import { ReaderSettings } from "./ReaderSettings"; import { ReaderToolbar } from "./ReaderToolbar"; @@ -753,6 +754,24 @@ export function ComicReader({ isContinuousScroll={isContinuousScroll} /> + {/* Phone-only bottom navigation. Hidden in continuous/webtoon modes + where pages are scrolled rather than navigated. */} + {!isContinuousScroll && ( + + )} + {/* Boundary notification */} state.toolbarVisible); const isFullscreen = useReaderStore((state) => state.isFullscreen); + const adjacentBooks = useReaderStore((state) => state.adjacentBooks); const autoHideToolbar = useReaderStore( (state) => state.settings.autoHideToolbar, ); @@ -886,6 +901,10 @@ export function EpubReader({ onClose={onClose} onOpenSettings={() => setSettingsOpened(true)} showPageNavigation={false} + prevBook={adjacentBooks?.prev} + nextBook={adjacentBooks?.next} + onPrevBook={canGoPrevBook ? goToPrevBook : undefined} + onNextBook={canGoNextBook ? goToNextBook : undefined} leftActions={ } + mobileMenuItems={ + <> + + EPUB + } + onClick={() => setTocOpened(true)} + > + Table of contents + + } + onClick={() => setBookmarksOpened(true)} + > + Bookmarks + + } + onClick={() => setSearchOpened(true)} + > + Search + + + } /> {/* Boundary notification for series navigation */} diff --git a/web/src/components/reader/MobileReaderBottomBar.test.tsx b/web/src/components/reader/MobileReaderBottomBar.test.tsx new file mode 100644 index 00000000..3e93c704 --- /dev/null +++ b/web/src/components/reader/MobileReaderBottomBar.test.tsx @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useReaderStore } from "@/store/readerStore"; +import { fireEvent, renderWithProviders, screen, waitFor } from "@/test/utils"; +import { MobileReaderBottomBar } from "./MobileReaderBottomBar"; + +/** + * Force the phone breakpoint by reporting `matches: true` for max-width + * media queries. The shared test setup mocks matchMedia to always return + * `matches: false`, which is the desktop default. The MobileReaderBottomBar + * self-gates on `useMediaQuery("(max-width: 30.0625em)")` so without this + * override it would render nothing. + */ +function forceMobileViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("max-width"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function forceDesktopViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +const DEFAULT_SETTINGS = { + fitMode: "screen" as const, + webtoonFitMode: "width" as const, + pageLayout: "single" as const, + readingDirection: "ltr" as const, + backgroundColor: "black" as const, + pdfMode: "streaming" as const, + pdfSpreadMode: "single" as const, + pdfContinuousScroll: false, + autoHideToolbar: true, + toolbarHideDelay: 3000, + epubTheme: "light" as const, + epubFontSize: 100, + epubFontFamily: "default" as const, + epubLineHeight: 150, + epubMargin: 10, + epubSpread: "auto" as const, + preloadPages: 1, + doublePageShowWideAlone: true, + doublePageStartOnOdd: true, + pageTransition: "slide" as const, + transitionDuration: 200, + webtoonSidePadding: 0, + webtoonPageGap: 0, + autoAdvanceToNextBook: false, +}; + +function resetStore(overrides: Record = {}) { + useReaderStore.setState({ + settings: DEFAULT_SETTINGS, + currentPage: 5, + totalPages: 20, + isLoading: false, + toolbarVisible: true, + isFullscreen: false, + currentBookId: "book-123", + readingDirectionOverride: null, + adjacentBooks: null, + boundaryState: "none", + pageOrientations: {}, + lastNavigationDirection: null, + preloadedImages: new Set(), + ...overrides, + }); +} + +describe("MobileReaderBottomBar", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + describe("desktop viewport", () => { + beforeEach(() => { + forceDesktopViewport(); + }); + + it("renders nothing above the xs breakpoint", () => { + renderWithProviders(); + + // Top-bar slider is the only one in desktop ReaderToolbar; this + // component should bail out entirely so it doesn't duplicate it. + expect(screen.queryByRole("slider")).not.toBeInTheDocument(); + expect(screen.queryByText("5 / 20")).not.toBeInTheDocument(); + }); + }); + + describe("phone viewport", () => { + beforeEach(() => { + forceMobileViewport(); + }); + + it("renders the page counter and slider", () => { + renderWithProviders(); + + expect(screen.getByText("5 / 20")).toBeInTheDocument(); + expect(screen.getByRole("slider")).toBeInTheDocument(); + }); + + it("renders nothing when totalPages is 0", () => { + resetStore({ totalPages: 0, currentPage: 0 }); + renderWithProviders(); + + expect(screen.queryByRole("slider")).not.toBeInTheDocument(); + }); + + it("calls the provided onNextPage when right chevron is tapped", () => { + const onNextPage = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByLabelText("Next page")); + + expect(onNextPage).toHaveBeenCalledTimes(1); + }); + + it("calls the provided onPrevPage when left chevron is tapped", () => { + const onPrevPage = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByLabelText("Previous page")); + + expect(onPrevPage).toHaveBeenCalledTimes(1); + }); + + it("falls back to the store actions when no handlers are provided", () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("Next page")); + + // Store's nextPage clamps at totalPages, so currentPage 5 → 6. + expect(useReaderStore.getState().currentPage).toBe(6); + }); + + it("disables the prev chevron on page 1", () => { + resetStore({ currentPage: 1 }); + renderWithProviders(); + + expect(screen.getByLabelText("Previous page")).toBeDisabled(); + }); + + it("disables the next chevron on the last page", () => { + resetStore({ currentPage: 20 }); + renderWithProviders(); + + expect(screen.getByLabelText("Next page")).toBeDisabled(); + }); + + it("swaps prev/next semantics in RTL reading mode", () => { + resetStore({ + settings: { ...DEFAULT_SETTINGS, readingDirection: "rtl" }, + }); + const onNextPage = vi.fn(); + const onPrevPage = vi.fn(); + renderWithProviders( + , + ); + + // In RTL the visual "previous page" chevron is on the right, so the + // left chevron should advance to the next page. + fireEvent.click(screen.getByLabelText("Next page")); + expect(onNextPage).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByLabelText("Previous page")); + expect(onPrevPage).toHaveBeenCalledTimes(1); + }); + + it("opens the jump-to-page modal when the page counter is tapped", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("Jump to page")); + + await waitFor(() => { + // Modal renders a heading with the title "Go to page". + expect( + screen.getByRole("dialog", { name: /go to page/i }), + ).toBeInTheDocument(); + }); + }); + + it("jumps to the page entered in the modal when Go is pressed", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("Jump to page")); + + await waitFor(() => { + expect( + screen.getByRole("dialog", { name: /go to page/i }), + ).toBeInTheDocument(); + }); + + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "12" } }); + fireEvent.click(screen.getByRole("button", { name: "Go" })); + + expect(useReaderStore.getState().currentPage).toBe(12); + }); + + it("clamps the jump value to the valid page range", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("Jump to page")); + await waitFor(() => { + expect( + screen.getByRole("dialog", { name: /go to page/i }), + ).toBeInTheDocument(); + }); + + const input = screen.getByRole("textbox"); + // Try to jump way past the end of the book. + fireEvent.change(input, { target: { value: "999" } }); + fireEvent.click(screen.getByRole("button", { name: "Go" })); + + expect(useReaderStore.getState().currentPage).toBe(20); + }); + }); +}); diff --git a/web/src/components/reader/MobileReaderBottomBar.tsx b/web/src/components/reader/MobileReaderBottomBar.tsx new file mode 100644 index 00000000..8c1f94cd --- /dev/null +++ b/web/src/components/reader/MobileReaderBottomBar.tsx @@ -0,0 +1,239 @@ +import { + ActionIcon, + Box, + Button, + Group, + Modal, + NumberInput, + Slider, + Text, + Transition, +} from "@mantine/core"; +import { useDisclosure, useMediaQuery } from "@mantine/hooks"; +import { + IconChevronLeft, + IconChevronRight, + IconKeyboardShow, +} from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import { + selectEffectiveReadingDirection, + selectProgressPercent, + useReaderStore, +} from "@/store/readerStore"; + +interface MobileReaderBottomBarProps { + /** Whether the bar is visible (mirrors the toolbar's visibility). */ + visible: boolean; + /** + * Optional custom prev/next handlers. When omitted we fall back to the + * reader store actions, matching the same default used by `ReaderToolbar`. + * Comic / PDF readers pass their spread- or boundary-aware variants here. + */ + onPrevPage?: () => void; + onNextPage?: () => void; +} + +/** + * Bottom navigation bar shown below the `xs` breakpoint (phones). + * + * The desktop `ReaderToolbar` packs nine controls into a single row plus a + * full-width slider beneath, which overflows on a 390px viewport. On phones + * the toolbar drops the slider and most controls; this bar restores them in + * the standard mobile-reader pattern: prev / page-count tap / next / slider. + * + * Tap on the page-count opens a "Go to page" modal with a numeric input — + * faster than dragging the slider when jumping a long distance. + */ +export function MobileReaderBottomBar({ + visible, + onPrevPage, + onNextPage, +}: MobileReaderBottomBarProps) { + const currentPage = useReaderStore((state) => state.currentPage); + const totalPages = useReaderStore((state) => state.totalPages); + const progressPercent = useReaderStore(selectProgressPercent); + const readingDirection = useReaderStore(selectEffectiveReadingDirection); + const setPage = useReaderStore((state) => state.setPage); + const storeNextPage = useReaderStore((state) => state.nextPage); + const storePrevPage = useReaderStore((state) => state.prevPage); + + const handleNext = onNextPage ?? storeNextPage; + const handlePrev = onPrevPage ?? storePrevPage; + + // Chevrons mirror the reading direction so the visual cue matches the + // direction of progression (RTL keeps "next" on the left). + const isRtl = readingDirection === "rtl"; + const onLeftClick = isRtl ? handleNext : handlePrev; + const onRightClick = isRtl ? handlePrev : handleNext; + const leftDisabled = isRtl ? currentPage >= totalPages : currentPage <= 1; + const rightDisabled = isRtl ? currentPage <= 1 : currentPage >= totalPages; + + const [jumpOpened, jumpHandlers] = useDisclosure(false); + const [jumpValue, setJumpValue] = useState(currentPage); + + // Reset the modal input each time it opens so it always reflects the + // current page rather than a stale value from a previous open. + useEffect(() => { + if (jumpOpened) { + setJumpValue(currentPage); + } + }, [jumpOpened, currentPage]); + + const submitJump = () => { + const target = Math.max(1, Math.min(totalPages, Math.round(jumpValue))); + setPage(target); + jumpHandlers.close(); + }; + + // Phone-only: above the xs breakpoint the desktop `ReaderToolbar` already + // shows the slider, so this bar would be duplicative. xs = 30.125em. + const isMobile = useMediaQuery("(max-width: 30.0625em)") ?? false; + + if (totalPages <= 0 || !isMobile) { + return null; + } + + return ( + <> + + {(styles) => ( + + + + + + + + + + + setPage(isRtl ? totalPages + 1 - val : val) + } + onChangeEnd={() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }} + size="md" + style={{ + flex: 1, + minWidth: 0, + transform: isRtl ? "scaleX(-1)" : "none", + }} + label={(value) => `Page ${value}`} + styles={{ + track: { + backgroundColor: "var(--mantine-color-dark-4)", + }, + bar: { + backgroundColor: "var(--mantine-color-blue-6)", + }, + thumb: { + backgroundColor: "var(--mantine-color-blue-6)", + borderColor: "var(--mantine-color-blue-6)", + }, + label: { + transform: isRtl ? "scaleX(-1)" : "none", + }, + }} + /> + + {progressPercent}% + + + + + + + + + + )} + + + + + setJumpValue(typeof val === "number" ? val : Number(val) || 1) + } + min={1} + max={totalPages} + autoFocus + data-autofocus + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitJump(); + } + }} + /> + + Page 1–{totalPages} + + + + + + + + ); +} diff --git a/web/src/components/reader/PdfReader.tsx b/web/src/components/reader/PdfReader.tsx index bd28abf2..e3a0de88 100644 --- a/web/src/components/reader/PdfReader.tsx +++ b/web/src/components/reader/PdfReader.tsx @@ -21,6 +21,7 @@ import { useSeriesNavigation, useTouchNav, } from "./hooks"; +import { MobileReaderBottomBar } from "./MobileReaderBottomBar"; import { PdfContinuousScrollReader } from "./PdfContinuousScrollReader"; import { PdfReaderSettings } from "./PdfReaderSettings"; import { ReaderToolbar } from "./ReaderToolbar"; @@ -703,6 +704,16 @@ export function PdfReader({ onCycleFitMode={cyclePdfZoom} /> + {/* Phone-only bottom navigation. Hidden in continuous scroll mode + where the page-counter / slider don't apply (user scrolls). */} + {!pdfContinuousScroll && ( + + )} + {/* Boundary notification */} setSearchText(e.target.value)} - style={{ width: 300 }} + style={{ width: "min(300px, calc(100vw - 32px))" }} autoFocus onKeyDown={(e) => { if (e.key === "Escape") { @@ -778,7 +789,13 @@ export function PdfReader({ {pageError ? (
diff --git a/web/src/components/reader/ReaderToolbar.test.tsx b/web/src/components/reader/ReaderToolbar.test.tsx index ecb960cf..5b1ea0c3 100644 --- a/web/src/components/reader/ReaderToolbar.test.tsx +++ b/web/src/components/reader/ReaderToolbar.test.tsx @@ -1,8 +1,42 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useReaderStore } from "@/store/readerStore"; -import { fireEvent, renderWithProviders, screen } from "@/test/utils"; +import { fireEvent, renderWithProviders, screen, waitFor } from "@/test/utils"; import { ReaderToolbar } from "./ReaderToolbar"; +function forceMobileViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("max-width"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function forceDesktopViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + describe("ReaderToolbar", () => { const defaultProps = { title: "Test Book", @@ -13,6 +47,9 @@ describe("ReaderToolbar", () => { beforeEach(() => { vi.clearAllMocks(); + // Most tests run in desktop mode; mobile tests opt in via + // forceMobileViewport(). + forceDesktopViewport(); // Reset store to default state useReaderStore.setState({ settings: { @@ -207,4 +244,118 @@ describe("ReaderToolbar", () => { expect(slider).toHaveAttribute("aria-valuemax", "10"); }); }); + + describe("mobile (phone) viewport", () => { + beforeEach(() => { + forceMobileViewport(); + }); + + it("hides the inline slider on phones", () => { + // On phones the bottom slider row is dropped from the toolbar — the + // MobileReaderBottomBar takes over. The inline page-counter ("5 / 10") + // is also moved out of the top bar to keep it within 390px viewports. + renderWithProviders(); + + expect(screen.queryByRole("slider")).not.toBeInTheDocument(); + expect(screen.queryByText("5 / 10")).not.toBeInTheDocument(); + }); + + it("renders close, title, settings, and a single overflow trigger", () => { + renderWithProviders(); + + expect(screen.getByLabelText("Close reader")).toBeInTheDocument(); + expect(screen.getByText("Test Book")).toBeInTheDocument(); + expect(screen.getByLabelText("Reader settings")).toBeInTheDocument(); + expect(screen.getByLabelText("More reader options")).toBeInTheDocument(); + }); + + it("opens the overflow menu and exposes fit-mode + fullscreen", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("More reader options")); + + await waitFor(() => { + expect(screen.getByText(/Fit:/)).toBeInTheDocument(); + }); + expect( + screen.getByText(/Fullscreen|Exit fullscreen/), + ).toBeInTheDocument(); + }); + + it("cycles the fit mode from the overflow menu", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("More reader options")); + await waitFor(() => { + expect(screen.getByText(/Fit:/)).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText(/Fit:/)); + + expect(useReaderStore.getState().settings.fitMode).toBe("width"); + }); + + it("toggles fullscreen from the overflow menu", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByLabelText("More reader options")); + await waitFor(() => { + expect(screen.getByText(/Fullscreen/)).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText(/Fullscreen/)); + + expect(useReaderStore.getState().isFullscreen).toBe(true); + }); + + it("calls onPrevBook from the overflow menu when provided", async () => { + const onPrevBook = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByLabelText("More reader options")); + await waitFor(() => { + expect(screen.getByText(/Previous: Vol\. 1/)).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText(/Previous: Vol\. 1/)); + + expect(onPrevBook).toHaveBeenCalledTimes(1); + }); + + it("renders custom mobileMenuItems in the overflow menu", async () => { + renderWithProviders( + + EPUB action + + } + />, + ); + + fireEvent.click(screen.getByLabelText("More reader options")); + await waitFor(() => { + expect(screen.getByTestId("custom-mobile-action")).toBeInTheDocument(); + }); + }); + + it("keeps leftActions mounted (display:none) so portaled drawers survive", () => { + const leftMarker = ( +
left actions
+ ); + renderWithProviders( + , + ); + + // The element is in the DOM tree but visually hidden by display:none on + // its wrapper. The important contract: it's NOT unmounted, so any + // portaled drawer body inside leftActions keeps responding to parent + // `opened` state when triggered from the mobile overflow menu. + expect(screen.getByTestId("left-actions-marker")).toBeInTheDocument(); + }); + }); }); diff --git a/web/src/components/reader/ReaderToolbar.tsx b/web/src/components/reader/ReaderToolbar.tsx index e6ddec39..5ab93113 100644 --- a/web/src/components/reader/ReaderToolbar.tsx +++ b/web/src/components/reader/ReaderToolbar.tsx @@ -2,11 +2,13 @@ import { ActionIcon, Box, Group, + Menu, Slider, Text, Tooltip, Transition, } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; import { IconArrowAutofitDown, IconArrowAutofitHeight, @@ -17,6 +19,7 @@ import { IconBook, IconChevronLeft, IconChevronRight, + IconDotsVertical, IconFile, IconPhoto, IconPlayerSkipBack, @@ -47,6 +50,12 @@ interface ReaderToolbarProps { leftActions?: React.ReactNode; /** Additional actions to render in the right section (before settings) */ rightActions?: React.ReactNode; + /** + * Additional menu items to render in the mobile overflow menu. + * Used to surface format-specific actions (e.g. TOC / bookmarks / search + * for EPUB) that don't fit in the phone-sized top bar. + */ + mobileMenuItems?: React.ReactNode; /** Series navigation: previous book info */ prevBook?: { title: string } | null; /** Series navigation: next book info */ @@ -77,16 +86,31 @@ const FIT_MODE_LABELS: Record = { original: "Original Size", }; +function getFitModeIcon(fitMode: FitMode, size: number) { + switch (fitMode) { + case "screen": + return ; + case "width": + return ; + case "width-shrink": + return ; + case "height": + return ; + case "original": + return ; + } +} + /** * Toolbar component for the reader. * - * Shows: - * - Book title - * - Page navigation controls - * - Progress slider - * - Fit mode indicator - * - Fullscreen toggle - * - Settings button + * Above the `xs` breakpoint: shows title, page nav, slider, fit-mode, + * page-layout, fullscreen, and settings inline. + * + * Below `xs` (phones): drops the inline slider row and collapses secondary + * actions (prev/next book, fit mode, page layout, fullscreen) into a single + * overflow `Menu`. Page navigation and the slider move to + * `MobileReaderBottomBar`, which is rendered separately by the parent reader. */ export function ReaderToolbar({ title, @@ -96,6 +120,7 @@ export function ReaderToolbar({ showPageNavigation = true, leftActions, rightActions, + mobileMenuItems, prevBook, nextBook, onPrevBook, @@ -124,6 +149,10 @@ export function ReaderToolbar({ const fitMode = fitModeProp ?? globalFitMode; const cycleFitMode = onCycleFitMode ?? globalCycleFitMode; + // Phone-only: drop the slider row from the top bar and collapse + // secondary actions into an overflow menu. xs breakpoint = 30.125em. + const isMobile = useMediaQuery("(max-width: 30.0625em)") ?? false; + // Adjust navigation based on reading direction. // Only RTL reverses the chevrons; LTR, TTB, and webtoon all use // left=previous, right=next (matching the natural page order). @@ -135,6 +164,16 @@ export function ReaderToolbar({ const leftDisabled = isRtl ? currentPage >= totalPages : currentPage <= 1; const rightDisabled = isRtl ? currentPage <= 1 : currentPage >= totalPages; + const actionIconSize = isMobile ? "xl" : "lg"; + const iconSize = isMobile ? 22 : 20; + const overrideColor = hasSeriesOverride ? "blue" : "gray"; + const showLayoutToggle = + showPageNavigation && + !!onTogglePageLayout && + !!pageLayout && + pageLayout !== "continuous" && + !isContinuousScroll; + return ( {(styles) => ( @@ -149,31 +188,55 @@ export function ReaderToolbar({ background: "linear-gradient(to bottom, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.7) 70%, rgba(0,0,0,0) 100%)", padding: "12px 16px", + // Respect iOS notch / status bar when installed as PWA in + // standalone mode. Falls back to 0 on browsers without the var. + paddingTop: "calc(12px + env(safe-area-inset-top, 0px))", + paddingLeft: "calc(16px + env(safe-area-inset-left, 0px))", + paddingRight: "calc(16px + env(safe-area-inset-right, 0px))", }} > {/* Top row: Title, controls, close */} - + {/* Left: Close button, title, and custom actions */} - + - + - + {title} - {leftActions} + {/* leftActions stays mounted so portaled drawer bodies (EPUB + TOC/bookmarks) can still respond to parent-controlled opened + state on mobile. Only the trigger UI is visually hidden. */} + {leftActions && ( + + {leftActions} + + )} - {/* Center: Navigation controls */} - {showPageNavigation && ( - + {/* Center: Navigation controls (desktop only — mobile gets a bottom bar) */} + {!isMobile && showPageNavigation && ( + {/* Previous book button */} {onPrevBook && ( - + )} @@ -201,9 +265,10 @@ export function ReaderToolbar({ color="gray" onClick={onLeftClick} disabled={leftDisabled} - size="lg" + size={actionIconSize} + aria-label={leftTooltip} > - + @@ -221,9 +286,10 @@ export function ReaderToolbar({ color="gray" onClick={onRightClick} disabled={rightDisabled} - size="lg" + size={actionIconSize} + aria-label={rightTooltip} > - + @@ -239,98 +305,207 @@ export function ReaderToolbar({ color="gray" onClick={onNextBook} disabled={!nextBook} - size="lg" + size={actionIconSize} + aria-label="Next book" > - + )} )} - {/* Right: Actions */} - - {showPageNavigation && ( - - - {fitMode === "screen" && } - {fitMode === "width" && } - {fitMode === "width-shrink" && ( - - )} - {fitMode === "height" && ( - - )} - {fitMode === "original" && } - - + {/* Right: Actions. + rightActions stays mounted in both layouts so portaled drawer + bodies (e.g. EPUB bookmarks/search) keep responding to + parent-controlled `opened` state when their trigger UI is + hidden on mobile. */} + + {rightActions && ( + + {rightActions} + )} + {isMobile ? ( + /* Mobile: collapse secondary actions into an overflow menu. + Settings stays as its own button because it's the highest- + traffic non-navigation action. */ + <> + {onOpenSettings && ( + + + + + + )} + + + + + + + + {showPageNavigation && ( + + Fit: {FIT_MODE_LABELS[fitMode]} + + )} + {showLayoutToggle && ( + + ) : ( + + ) + } + onClick={onTogglePageLayout} + > + Layout:{" "} + {pageLayout === "single" ? "Single" : "Double"} + + )} + + ) : ( + + ) + } + onClick={toggleFullscreen} + > + {isFullscreen ? "Exit fullscreen" : "Fullscreen"} + + {onPrevBook && ( + } + onClick={onPrevBook} + disabled={!prevBook} + > + {prevBook + ? `Previous: ${prevBook.title}` + : "No previous book"} + + )} + {onNextBook && ( + } + onClick={onNextBook} + disabled={!nextBook} + > + {nextBook + ? `Next: ${nextBook.title}` + : "No next book"} + + )} + {mobileMenuItems} + + + + ) : ( + <> + {showPageNavigation && ( + + + {getFitModeIcon(fitMode, iconSize)} + + + )} + + {/* Page layout toggle - only show for paginated modes */} + {showLayoutToggle && ( + + + {pageLayout === "single" ? ( + + ) : ( + + )} + + + )} - {/* Page layout toggle - only show for paginated modes (not continuous/webtoon) */} - {showPageNavigation && - onTogglePageLayout && - pageLayout && - pageLayout !== "continuous" && - !isContinuousScroll && ( - {pageLayout === "single" ? ( - + {isFullscreen ? ( + ) : ( - + )} - )} - {rightActions} - - - - {isFullscreen ? ( - - ) : ( - + {onOpenSettings && ( + + + + + )} - - - - {onOpenSettings && ( - - - - - + )} - {/* Bottom row: Progress slider (only for page-based readers) */} - {showPageNavigation && ( + {/* Bottom row: Progress slider (desktop only — phones use + MobileReaderBottomBar so the top bar stays compact) */} + {!isMobile && showPageNavigation && ( Date: Fri, 15 May 2026 22:16:11 -0700 Subject: [PATCH 04/20] feat(mobile): collapse admin tables to mobile-friendly cards below xs Introduce a shared primitive (web/src/components/ui) that renders a Mantine Table above the xs breakpoint and a stack of label/value Cards below it. Also export MOBILE_MEDIA_QUERY so callers that don't fit the data-driven model can switch trees with useMediaQuery at the same breakpoint. Migrate every admin Table on the frontend so phones can manage settings without horizontal clipping: - Direct ResponsiveTable ports: UsersSettings, PluginStorageSettings, SharingTagsSettings, DuplicatesSettings (inner book table per group), SeriesExportsSettings, TasksSettings (both task list and by-type stats), and the ServerSettings change-history modal. - Refactor ReleaseTrackingSettings: split the row component into pure SourceCell / PluginCell / LastPollCell / StatusCell and a stateful CronCell so the column-based model fits. - useMediaQuery splits where colSpan-expandable rows or stateful inline editors don't fit the data-driven model: PluginsSettings, the two MetricsSettings tables (with Collapse-based details in the mobile cards), the ServerSettings per-category settings table, and the shared ReleasesTable (which also gives the Series Releases panel the same mobile treatment). Includes tests for the new component. All existing settings/releases tests still pass. --- web/src/components/releases/ReleasesTable.tsx | 160 ++++ .../components/ui/ResponsiveTable.test.tsx | 371 ++++++++ web/src/components/ui/ResponsiveTable.tsx | 237 +++++ web/src/components/ui/index.ts | 6 + web/src/pages/settings/DuplicatesSettings.tsx | 144 +-- web/src/pages/settings/MetricsSettings.tsx | 859 +++++++++++------- .../pages/settings/PluginStorageSettings.tsx | 68 +- web/src/pages/settings/PluginsSettings.tsx | 511 +++++++---- .../settings/ReleaseTrackingSettings.tsx | 449 ++++----- .../pages/settings/SeriesExportsSettings.tsx | 220 +++-- web/src/pages/settings/ServerSettings.tsx | 361 ++++++-- .../pages/settings/SharingTagsSettings.tsx | 177 ++-- web/src/pages/settings/TasksSettings.tsx | 310 ++++--- web/src/pages/settings/UsersSettings.tsx | 192 ++-- 14 files changed, 2776 insertions(+), 1289 deletions(-) create mode 100644 web/src/components/ui/ResponsiveTable.test.tsx create mode 100644 web/src/components/ui/ResponsiveTable.tsx create mode 100644 web/src/components/ui/index.ts diff --git a/web/src/components/releases/ReleasesTable.tsx b/web/src/components/releases/ReleasesTable.tsx index 32c10cd1..eb778b5e 100644 --- a/web/src/components/releases/ReleasesTable.tsx +++ b/web/src/components/releases/ReleasesTable.tsx @@ -2,6 +2,7 @@ import { ActionIcon, Anchor, Badge, + Card, Checkbox, Group, Stack, @@ -9,6 +10,7 @@ import { Text, Tooltip, } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; import { IconCheck, IconExternalLink, @@ -18,6 +20,7 @@ import { import { format } from "date-fns"; import { Link } from "react-router-dom"; import type { ReleaseLedgerEntry, ReleaseSource } from "@/api/releases"; +import { MOBILE_MEDIA_QUERY } from "@/components/ui"; import { MediaUrlIcon } from "./MediaUrlIcon"; const STATE_BADGE: Record = { @@ -96,6 +99,163 @@ export function ReleasesTable({ entries.length > 0 && entries.every((e) => selected.has(e.id)); const someSelected = entries.some((e) => selected.has(e.id)) && !allSelected; + // Below xs the wide release table clips off the side. Render a stack of + // cards instead — each card carries the same controls and shows series / + // chapter / source on its own line. useMediaQuery (rather than CSS-only + // `visibleFrom`) keeps only one DOM tree mounted so tests that query the + // row checkboxes / actions don't see duplicate matches. + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; + + if (isMobile) { + return ( + + + + + {selected.size > 0 ? `${selected.size} selected` : "Select all"} + + + {entries.map((entry) => { + const stateInfo = STATE_BADGE[entry.state] ?? { + color: "gray", + label: entry.state, + }; + const isSelected = selected.has(entry.id); + const source = sourceById.get(entry.sourceId); + const sourceLabel = + source?.displayName ?? `${entry.sourceId.slice(0, 8)}…`; + return ( + + + + { + const shiftKey = + event.nativeEvent instanceof MouseEvent && + event.nativeEvent.shiftKey; + onToggleOne(entry.id, shiftKey); + }} + /> + {showSeriesColumn ? ( + + {entry.seriesTitle.length > 0 + ? entry.seriesTitle + : `${entry.seriesId.slice(0, 8)}…`} + + ) : ( + + {formatChapterVolume(entry)} + + )} + + + {stateInfo.label} + + + + {showSeriesColumn && ( + + {formatChapterVolume(entry)} + + )} + + {sourceLabel} + {entry.groupOrUploader && + entry.groupOrUploader !== sourceLabel + ? ` · ${entry.groupOrUploader}` + : ""} + {entry.language ? ` · ${entry.language}` : ""} + + + Observed {format(new Date(entry.observedAt), "yyyy-MM-dd")} + + + + + + + + + {entry.mediaUrl && ( + + )} + {entry.state === "announced" && ( + <> + + onMarkAcquired(entry.id)} + aria-label="Mark acquired" + > + + + + + onDismiss(entry.id)} + aria-label="Dismiss" + > + + + + + )} + + onDelete(entry.id)} + aria-label="Delete" + > + + + + + + ); + })} + + ); + } + return ( diff --git a/web/src/components/ui/ResponsiveTable.test.tsx b/web/src/components/ui/ResponsiveTable.test.tsx new file mode 100644 index 00000000..1ad12c3d --- /dev/null +++ b/web/src/components/ui/ResponsiveTable.test.tsx @@ -0,0 +1,371 @@ +import { ActionIcon, Text } from "@mantine/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, within } from "@/test/utils"; +import { ResponsiveTable, type ResponsiveTableColumn } from "./ResponsiveTable"; + +interface Row { + id: string; + name: string; + email: string; + role: string; +} + +const ROWS: Row[] = [ + { id: "1", name: "Alice", email: "alice@example.com", role: "admin" }, + { id: "2", name: "Bob", email: "bob@example.com", role: "reader" }, +]; + +const COLUMNS: ResponsiveTableColumn[] = [ + { + key: "name", + header: "Name", + accessor: (row) => {row.name}, + mobilePrimary: true, + }, + { key: "email", header: "Email", accessor: (row) => row.email }, + { key: "role", header: "Role", accessor: (row) => row.role }, +]; + +function forceMobileViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("max-width"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function forceDesktopViewport() { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +describe("ResponsiveTable", () => { + beforeEach(() => { + forceDesktopViewport(); + }); + + describe("desktop layout", () => { + it("renders a Mantine Table with headers", () => { + renderWithProviders( + row.id} + data-testid="rt" + />, + ); + + const table = screen.getByRole("table"); + expect(table).toBeInTheDocument(); + const headers = within(table).getAllByRole("columnheader"); + expect(headers).toHaveLength(3); + expect(headers[0]).toHaveTextContent("Name"); + expect(headers[1]).toHaveTextContent("Email"); + expect(headers[2]).toHaveTextContent("Role"); + }); + + it("renders cell content via the column accessor", () => { + renderWithProviders( + row.id} + />, + ); + + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("bob@example.com")).toBeInTheDocument(); + expect(screen.getByText("admin")).toBeInTheDocument(); + }); + + it("renders rowActions as the last column with a default header", () => { + const onDelete = vi.fn(); + renderWithProviders( + row.id} + rowActions={(row) => ( + + )} + />, + ); + + expect(screen.getByText("Actions")).toBeInTheDocument(); + expect(screen.getByText("Delete Alice")).toBeInTheDocument(); + expect(screen.getByText("Delete Bob")).toBeInTheDocument(); + }); + + it("respects a custom rowActionsHeader", () => { + renderWithProviders( + row.id} + rowActions={() => x} + rowActionsHeader="" + />, + ); + + expect(screen.queryByText("Actions")).not.toBeInTheDocument(); + }); + + it("skips columns with hideOnDesktop", () => { + renderWithProviders( + "mobile-only-value", + hideOnDesktop: true, + }, + ]} + getRowKey={(row) => row.id} + />, + ); + + expect(screen.queryByText("Mobile only")).not.toBeInTheDocument(); + expect(screen.queryByText("mobile-only-value")).not.toBeInTheDocument(); + }); + + it("renders emptyState in place of the table when data is empty", () => { + renderWithProviders( + row.id} + emptyState={
No users
} + />, + ); + + expect(screen.getByText("No users")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); + + it("renders an empty table (no emptyState) when data is empty and no fallback is given", () => { + renderWithProviders( + row.id} + />, + ); + + const table = screen.getByRole("table"); + expect(table).toBeInTheDocument(); + // Header row only; no body rows. + expect(within(table).getAllByRole("row")).toHaveLength(1); + }); + }); + + describe("mobile layout", () => { + beforeEach(() => { + forceMobileViewport(); + }); + + it("renders a stack of Cards instead of a Table", () => { + renderWithProviders( + row.id} + data-testid="rt" + />, + ); + + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.getByTestId("rt")).toBeInTheDocument(); + }); + + it("renders the primary column without a label on the card", () => { + renderWithProviders( + row.id} + />, + ); + + // Primary column ("name") renders the value; the literal header text + // should not also appear because mobilePrimary suppresses the label. + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.queryByText("Name")).not.toBeInTheDocument(); + }); + + it("renders non-primary columns as label/value pairs", () => { + renderWithProviders( + row.id} + />, + ); + + // Both columns + both rows = labels appear once per row. + expect(screen.getAllByText("Email")).toHaveLength(2); + expect(screen.getAllByText("Role")).toHaveLength(2); + expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + expect(screen.getByText("reader")).toBeInTheDocument(); + }); + + it("uses mobileLabel when provided instead of header", () => { + renderWithProviders( + "value", + }, + ]} + getRowKey={(row) => row.id} + />, + ); + + expect(screen.queryByText("Long Desktop Header")).not.toBeInTheDocument(); + expect(screen.getAllByText("Short")).toHaveLength(2); + }); + + it("skips columns with hideOnMobile", () => { + renderWithProviders( + "should-not-render", + hideOnMobile: true, + }, + ]} + getRowKey={(row) => row.id} + />, + ); + + expect(screen.queryByText("Internal")).not.toBeInTheDocument(); + expect(screen.queryByText("should-not-render")).not.toBeInTheDocument(); + }); + + it("renders rowActions inside each card", () => { + const onDelete = vi.fn(); + renderWithProviders( + row.id} + rowActions={(row) => ( + onDelete(row.id)} + > + x + + )} + />, + ); + + expect(screen.getByLabelText("delete Alice")).toBeInTheDocument(); + expect(screen.getByLabelText("delete Bob")).toBeInTheDocument(); + }); + + it("uses renderMobileCard to override the card body", () => { + renderWithProviders( + row.id} + renderMobileCard={(row) => ( +
{row.name} custom
+ )} + />, + ); + + expect(screen.getByTestId("custom-1")).toHaveTextContent("Alice custom"); + expect(screen.getByTestId("custom-2")).toHaveTextContent("Bob custom"); + // The default label/value rows must not appear. + expect(screen.queryByText("Email")).not.toBeInTheDocument(); + }); + + it("keeps rowActions footer when renderMobileCard is used", () => { + renderWithProviders( + row.id} + renderMobileCard={(row) =>
{row.name}
} + rowActions={(row) => action-{row.id}} + />, + ); + + expect(screen.getByText("action-1")).toBeInTheDocument(); + expect(screen.getByText("action-2")).toBeInTheDocument(); + }); + + it("renders emptyState in place of cards when data is empty", () => { + renderWithProviders( + row.id} + emptyState={
Nothing here
} + />, + ); + + expect(screen.getByText("Nothing here")).toBeInTheDocument(); + }); + + it("renders mobileFullWidth columns as label + full-width value block", () => { + renderWithProviders( + ( + + A very long string that should occupy the full card width. + + ), + mobileFullWidth: true, + }, + ]} + getRowKey={(row) => row.id} + />, + ); + + expect(screen.getAllByText("Description")).toHaveLength(2); + expect(screen.getAllByTestId("long-value")).toHaveLength(2); + }); + }); +}); diff --git a/web/src/components/ui/ResponsiveTable.tsx b/web/src/components/ui/ResponsiveTable.tsx new file mode 100644 index 00000000..a33c4b29 --- /dev/null +++ b/web/src/components/ui/ResponsiveTable.tsx @@ -0,0 +1,237 @@ +import { + Box, + type BoxProps, + Card, + type CardProps, + Group, + Stack, + Table, + type TableProps, + type TableTdProps, + type TableThProps, + Text, +} from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import type { CSSProperties, ReactNode } from "react"; + +/** + * Mobile breakpoint used by the responsive table. Matches the `xs` value in + * `web/src/theme.ts` (30.125em). The `0.0625em` (~1px) deduction guards against + * sub-pixel rounding so the query fires exactly when Mantine considers the + * viewport below `xs`. + */ +export const MOBILE_MEDIA_QUERY = "(max-width: 30.0625em)"; + +export interface ResponsiveTableColumn { + /** Stable react key for this column. */ + key: string; + /** Header cell content. Doubles as the mobile card label when `mobileLabel` is not set. */ + header: ReactNode; + /** Returns the cell content for a given row. */ + accessor: (row: T, rowIndex: number) => ReactNode; + /** Override the label shown next to the value on mobile (defaults to `header`). */ + mobileLabel?: ReactNode; + /** Skip the column on mobile. Use for columns better expressed elsewhere on a card. */ + hideOnMobile?: boolean; + /** Skip the column on desktop. Useful for mobile-only summary lines. */ + hideOnDesktop?: boolean; + /** + * Render the value as a card header on mobile — no label, larger emphasis, + * placed before the label/value rows. Useful for the primary identifier + * (e.g. user name, plugin name). + */ + mobilePrimary?: boolean; + /** Hide the label on the mobile card; render the value full-width. */ + mobileFullWidth?: boolean; + /** Props applied to the desktop ``. */ + thProps?: Omit; + /** Props applied to the desktop ``. */ + tdProps?: Omit; +} + +export interface ResponsiveTableProps { + /** Row data. */ + data: T[]; + /** Column definitions. */ + columns: ResponsiveTableColumn[]; + /** Stable react key per row. */ + getRowKey: (row: T, index: number) => string; + /** + * Optional per-row actions. On desktop the actions render as the last cell + * of each row. On mobile they render as a footer at the bottom of each card. + */ + rowActions?: (row: T, rowIndex: number) => ReactNode; + /** Header text for the actions column on desktop. */ + rowActionsHeader?: ReactNode; + /** Props applied to the desktop `
`. */ + tableProps?: Omit; + /** Props applied to each mobile ``. */ + cardProps?: Omit; + /** Wrapper props for the desktop table. */ + desktopWrapperProps?: BoxProps; + /** Wrapper props for the mobile stack. */ + mobileWrapperProps?: BoxProps; + /** Rendered (in both layouts) when `data` is empty. */ + emptyState?: ReactNode; + /** + * Custom mobile card body. If provided, replaces the default label/value + * list. `rowActions`, if present, is still appended below the body. + */ + renderMobileCard?: (row: T, rowIndex: number) => ReactNode; + /** + * Optional `data-testid` applied to both the desktop table and the mobile + * stack. Useful for visual regression tests that need to address both + * layouts. + */ + "data-testid"?: string; +} + +const PRIMARY_TEXT_STYLE: CSSProperties = { + minWidth: 0, + wordBreak: "break-word", +}; + +const VALUE_BOX_STYLE: CSSProperties = { + minWidth: 0, + textAlign: "right", + flex: 1, +}; + +const FULL_WIDTH_VALUE_STYLE: CSSProperties = { + minWidth: 0, +}; + +/** + * Renders a data table that gracefully degrades to a stack of cards below the + * `xs` breakpoint. Above `xs` (≥ 30.125em) the standard Mantine `
` is + * used. Below `xs` each row becomes a `` with stacked label/value rows + * and row actions in a footer. + * + * For pages with a bespoke mobile layout (e.g. expandable details rows, + * multi-line primary content), pass `renderMobileCard` to override the + * default body. + */ +export function ResponsiveTable({ + data, + columns, + getRowKey, + rowActions, + rowActionsHeader = "Actions", + tableProps, + cardProps, + desktopWrapperProps, + mobileWrapperProps, + emptyState, + renderMobileCard, + "data-testid": dataTestid, +}: ResponsiveTableProps) { + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; + + if (data.length === 0 && emptyState !== undefined) { + return <>{emptyState}; + } + + if (isMobile) { + return ( + + + {data.map((row, idx) => ( + + {renderMobileCard ? ( + renderMobileCard(row, idx) + ) : ( + + {columns + .filter((col) => !col.hideOnMobile) + .map((col) => { + const value = col.accessor(row, idx); + if (col.mobilePrimary) { + return ( + + {value} + + ); + } + if (col.mobileFullWidth) { + return ( + + + {col.mobileLabel ?? col.header} + + {value} + + ); + } + return ( + + + {col.mobileLabel ?? col.header} + + {value} + + ); + })} + + )} + {rowActions ? ( + + {rowActions(row, idx)} + + ) : null} + + ))} + + + ); + } + + return ( + +
+ + + {columns + .filter((col) => !col.hideOnDesktop) + .map((col) => ( + + {col.header} + + ))} + {rowActions ? {rowActionsHeader} : null} + + + + {data.map((row, idx) => ( + + {columns + .filter((col) => !col.hideOnDesktop) + .map((col) => ( + + {col.accessor(row, idx)} + + ))} + {rowActions ? ( + + + {rowActions(row, idx)} + + + ) : null} + + ))} + +
+
+ ); +} diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts new file mode 100644 index 00000000..27475e3f --- /dev/null +++ b/web/src/components/ui/index.ts @@ -0,0 +1,6 @@ +export { + MOBILE_MEDIA_QUERY, + ResponsiveTable, + type ResponsiveTableColumn, + type ResponsiveTableProps, +} from "./ResponsiveTable"; diff --git a/web/src/pages/settings/DuplicatesSettings.tsx b/web/src/pages/settings/DuplicatesSettings.tsx index deb8165a..9c5ab0d5 100644 --- a/web/src/pages/settings/DuplicatesSettings.tsx +++ b/web/src/pages/settings/DuplicatesSettings.tsx @@ -9,7 +9,6 @@ import { Group, Loader, Stack, - Table, Text, Title, Tooltip, @@ -27,6 +26,7 @@ import { useEffect, useRef, useState } from "react"; import { api } from "@/api/client"; import { type DuplicateGroup, duplicatesApi } from "@/api/duplicates"; import { AppLink } from "@/components/common/AppLink"; +import { ResponsiveTable } from "@/components/ui"; import { useTaskProgress } from "@/hooks/useTaskProgress"; import type { Book } from "@/types"; @@ -87,77 +87,93 @@ function DuplicateGroupCard({
{expanded && ( - - - - Book - Library - Series - Path - Size - - - - {books.map((book, index) => ( - - + + data={books} + columns={[ + { + key: "book", + header: "Book", + mobilePrimary: true, + thProps: { style: { width: "20%" } }, + accessor: (book) => ( + + {book.title} + + ), + }, + { + key: "library", + header: "Library", + thProps: { style: { width: "15%" } }, + accessor: (book) => ( + + {book.libraryName || "-"} + + ), + }, + { + key: "series", + header: "Series", + thProps: { style: { width: "15%" } }, + accessor: (book) => + book.seriesId ? ( - {book.title} + {book.seriesName || "-"} - - - - {book.libraryName || "-"} - - - - {book.seriesId ? ( - - {book.seriesName || "-"} - - ) : ( - - - - - )} - - - - - {book.filePath} - - - - - - {book.fileSize - ? `${(book.fileSize / 1024 / 1024).toFixed(2)} MB` - : "-"} + ) : ( + + - - - - ))} - -
+ ), + }, + { + key: "path", + header: "Path", + thProps: { style: { width: "35%" } }, + mobileFullWidth: true, + accessor: (book) => ( + + + {book.filePath} + + + ), + }, + { + key: "size", + header: "Size", + thProps: { style: { width: "15%" } }, + accessor: (book) => ( + + {book.fileSize + ? `${(book.fileSize / 1024 / 1024).toFixed(2)} MB` + : "-"} + + ), + }, + ]} + getRowKey={(book, index) => `${book.id}-${index}`} + tableProps={{ layout: "fixed" }} + /> )} diff --git a/web/src/pages/settings/MetricsSettings.tsx b/web/src/pages/settings/MetricsSettings.tsx index d05f9c48..6e1be8bd 100644 --- a/web/src/pages/settings/MetricsSettings.tsx +++ b/web/src/pages/settings/MetricsSettings.tsx @@ -4,6 +4,7 @@ import { Button, Card, Center, + Collapse, Grid, Group, Loader, @@ -216,147 +217,7 @@ function TaskTypeRow({ metrics }: { metrics: TaskTypeMetricsDto }) { {opened && ( - - -
- - Succeeded - - - {metrics.succeeded.toLocaleString()} - -
-
- - Failed - - 0 ? "red" : undefined} - > - {metrics.failed.toLocaleString()} - -
-
- - Retried - - 0 ? "yellow" : undefined} - > - {metrics.retried.toLocaleString()} - -
-
- - Error Rate - - 5 - ? "red" - : metrics.errorRatePct > 1 - ? "yellow" - : undefined - } - > - {metrics.errorRatePct.toFixed(2)}% - -
-
- - Min Duration - - - {formatDuration(metrics.minDurationMs)} - -
-
- - Max Duration - - - {formatDuration(metrics.maxDurationMs)} - -
-
- - P50 Duration - - - {formatDuration(metrics.p50DurationMs)} - -
-
- - P95 Duration - - - {formatDuration(metrics.p95DurationMs)} - -
-
- - Avg Queue Wait - - - {formatDuration(metrics.avgQueueWaitMs)} - -
-
- - Bytes Processed - - - {formatBytes(metrics.bytesProcessed)} - -
-
- - Throughput - - - {metrics.throughputPerSec.toFixed(1)}/sec - -
- {metrics.lastErrorAt && ( -
- - Last Error At - - - {new Date(metrics.lastErrorAt).toLocaleString()} - -
- )} -
- {metrics.lastError && ( - - - - - Last Error - - - - {metrics.lastError} - - - )} -
+
)} @@ -364,6 +225,246 @@ function TaskTypeRow({ metrics }: { metrics: TaskTypeMetricsDto }) { ); } +function TaskTypeDetails({ metrics }: { metrics: TaskTypeMetricsDto }) { + return ( + + +
+ + Succeeded + + + {metrics.succeeded.toLocaleString()} + +
+
+ + Failed + + 0 ? "red" : undefined}> + {metrics.failed.toLocaleString()} + +
+
+ + Retried + + 0 ? "yellow" : undefined} + > + {metrics.retried.toLocaleString()} + +
+
+ + Error Rate + + 5 + ? "red" + : metrics.errorRatePct > 1 + ? "yellow" + : undefined + } + > + {metrics.errorRatePct.toFixed(2)}% + +
+
+ + Min Duration + + + {formatDuration(metrics.minDurationMs)} + +
+
+ + Max Duration + + + {formatDuration(metrics.maxDurationMs)} + +
+
+ + P50 Duration + + + {formatDuration(metrics.p50DurationMs)} + +
+
+ + P95 Duration + + + {formatDuration(metrics.p95DurationMs)} + +
+
+ + Avg Queue Wait + + + {formatDuration(metrics.avgQueueWaitMs)} + +
+
+ + Bytes Processed + + + {formatBytes(metrics.bytesProcessed)} + +
+
+ + Throughput + + + {metrics.throughputPerSec.toFixed(1)}/sec + +
+ {metrics.lastErrorAt && ( +
+ + Last Error At + + + {new Date(metrics.lastErrorAt).toLocaleString()} + +
+ )} +
+ {metrics.lastError && ( + + + + + Last Error + + + + {metrics.lastError} + + + )} +
+ ); +} + +function TaskTypeMobileCard({ metrics }: { metrics: TaskTypeMetricsDto }) { + const [opened, { toggle }] = useDisclosure(false); + const successRate = + metrics.executed > 0 + ? ((metrics.succeeded / metrics.executed) * 100).toFixed(1) + : "0"; + const successColor = + Number.parseFloat(successRate) >= 95 + ? "green" + : Number.parseFloat(successRate) >= 80 + ? "yellow" + : "red"; + + return ( + + + + {opened ? ( + + ) : ( + + )} + + {metrics.taskType.replace(/_/g, " ")} + + + {metrics.lastError ? ( + } + > + {metrics.failed} errors + + ) : ( + + Healthy + + )} + + +
+ + Executed + + + {metrics.executed.toLocaleString()} + +
+
+ + Success rate + + + + {successRate}% + +
+
+ + Avg duration + + + {formatDuration(metrics.avgDurationMs)} + +
+
+ + P50 / P95 + + + {formatDuration(metrics.p50DurationMs)} /{" "} + {formatDuration(metrics.p95DurationMs)} + +
+
+ + + + + +
+ ); +} + // Inventory tab content function InventoryTab({ metrics }: { metrics: MetricsDto }) { return ( @@ -603,29 +704,41 @@ function TaskMetricsTab({ metrics }: { metrics: TaskMetricsResponse }) { Task Performance by Type - - - - Task Type - Executed - Success Rate - Avg Duration - P50 / P95 - Items Processed - Status - - - - {[...byType] - .sort((a, b) => a.taskType.localeCompare(b.taskType)) - .map((taskMetrics) => ( - - ))} - -
+ + + + + Task Type + Executed + Success Rate + Avg Duration + P50 / P95 + Items Processed + Status + + + + {[...byType] + .sort((a, b) => a.taskType.localeCompare(b.taskType)) + .map((taskMetrics) => ( + + ))} + +
+
+ + {[...byType] + .sort((a, b) => a.taskType.localeCompare(b.taskType)) + .map((taskMetrics) => ( + + ))} + )} @@ -704,150 +817,240 @@ function PluginMetricsRow({ metrics }: { metrics: PluginMetricsDto }) { {opened && ( - - -
- - Succeeded - - - {(metrics.requestsSuccess ?? 0).toLocaleString()} - -
-
- - Failed - - 0 ? "red" : undefined} - > - {(metrics.requestsFailed ?? 0).toLocaleString()} - -
-
- - Error Rate - - 10 - ? "red" - : (metrics.errorRatePct ?? 0) > 5 - ? "yellow" - : undefined - } - > - {(metrics.errorRatePct ?? 0).toFixed(2)}% - -
-
- - Rate Limit Hits + + + + )} + + ); +} + +function PluginMetricsDetails({ metrics }: { metrics: PluginMetricsDto }) { + return ( + + +
+ + Succeeded + + + {(metrics.requestsSuccess ?? 0).toLocaleString()} + +
+
+ + Failed + + 0 ? "red" : undefined} + > + {(metrics.requestsFailed ?? 0).toLocaleString()} + +
+
+ + Error Rate + + 10 + ? "red" + : (metrics.errorRatePct ?? 0) > 5 + ? "yellow" + : undefined + } + > + {(metrics.errorRatePct ?? 0).toFixed(2)}% + +
+
+ + Rate Limit Hits + + 0 ? "yellow" : undefined} + > + {(metrics.rateLimitRejections ?? 0).toLocaleString()} + +
+ {metrics.lastSuccess && ( +
+ + Last Success + + + {new Date(metrics.lastSuccess).toLocaleString()} + +
+ )} + {metrics.lastFailure && ( +
+ + Last Failure + + + {new Date(metrics.lastFailure).toLocaleString()} + +
+ )} +
+ + {/* Method breakdown */} + {metrics.byMethod && Object.keys(metrics.byMethod).length > 0 && ( + + + By Method + + + {Object.entries(metrics.byMethod).map(([method, methodMetrics]) => ( + + + + {method} - 0 - ? "yellow" - : undefined - } - > - {(metrics.rateLimitRejections ?? 0).toLocaleString()} + + {methodMetrics.requestsTotal} calls + + + + + {methodMetrics.requestsSuccess} ok -
- {metrics.lastSuccess && ( -
- - Last Success + {(methodMetrics.requestsFailed ?? 0) > 0 && ( + + {methodMetrics.requestsFailed} failed - - {new Date(metrics.lastSuccess).toLocaleString()} - -
- )} - {metrics.lastFailure && ( -
- - Last Failure - - - {new Date(metrics.lastFailure).toLocaleString()} - -
- )} -
- - {/* Method breakdown */} - {metrics.byMethod && Object.keys(metrics.byMethod).length > 0 && ( - - - By Method + )} + + avg {formatDuration(methodMetrics.avgDurationMs)} - - {Object.entries(metrics.byMethod).map( - ([method, methodMetrics]) => ( - - - - {method} - - - {methodMetrics.requestsTotal} calls - - - - - {methodMetrics.requestsSuccess} ok - - {(methodMetrics.requestsFailed ?? 0) > 0 && ( - - {methodMetrics.requestsFailed} failed - - )} - - avg {formatDuration(methodMetrics.avgDurationMs)} - - - - ), - )} - - - )} - - {/* Failure breakdown */} - {metrics.failureCounts && - Object.keys(metrics.failureCounts).length > 0 && ( - - - Failures by Type - - - {Object.entries(metrics.failureCounts).map( - ([code, count]) => ( - - {code}: {count} - - ), - )} - - - )} -
-
-
+
+ + ))} + + )} - + + {/* Failure breakdown */} + {metrics.failureCounts && + Object.keys(metrics.failureCounts).length > 0 && ( + + + Failures by Type + + + {Object.entries(metrics.failureCounts).map(([code, count]) => ( + + {code}: {count} + + ))} + + + )} + + ); +} + +function PluginMetricsMobileCard({ metrics }: { metrics: PluginMetricsDto }) { + const [opened, { toggle }] = useDisclosure(false); + const successRate = + metrics.requestsTotal > 0 + ? ( + ((metrics.requestsSuccess ?? 0) / metrics.requestsTotal) * + 100 + ).toFixed(1) + : "0"; + const successColor = + Number.parseFloat(successRate) >= 95 + ? "green" + : Number.parseFloat(successRate) >= 80 + ? "yellow" + : "red"; + const healthColor = + metrics.healthStatus === "healthy" + ? "green" + : metrics.healthStatus === "degraded" + ? "yellow" + : metrics.healthStatus === "unhealthy" + ? "red" + : "gray"; + + return ( + + + + {opened ? ( + + ) : ( + + )} + + {metrics.pluginName} + + + + {metrics.healthStatus} + + + +
+ + Requests + + + {metrics.requestsTotal.toLocaleString()} + +
+
+ + Success rate + + + + {successRate}% + +
+
+ + Avg duration + + + {formatDuration(metrics.avgDurationMs ?? 0)} + +
+
+ + Rate limited + + + {(metrics.rateLimitRejections ?? 0).toLocaleString()} + +
+
+ + + + + +
); } @@ -990,25 +1193,37 @@ function PluginMetricsTab({ metrics }: { metrics: PluginMetricsResponse }) { Plugin Performance - - - - Plugin - Requests - Success Rate - Avg Duration - Rate Limited - Health - - - - {[...plugins] - .sort((a, b) => a.pluginName.localeCompare(b.pluginName)) - .map((plugin) => ( - - ))} - -
+ + + + + Plugin + Requests + Success Rate + Avg Duration + Rate Limited + Health + + + + {[...plugins] + .sort((a, b) => a.pluginName.localeCompare(b.pluginName)) + .map((plugin) => ( + + ))} + +
+
+ + {[...plugins] + .sort((a, b) => a.pluginName.localeCompare(b.pluginName)) + .map((plugin) => ( + + ))} + ) : ( diff --git a/web/src/pages/settings/PluginStorageSettings.tsx b/web/src/pages/settings/PluginStorageSettings.tsx index 236a89f2..856ebe5e 100644 --- a/web/src/pages/settings/PluginStorageSettings.tsx +++ b/web/src/pages/settings/PluginStorageSettings.tsx @@ -9,7 +9,6 @@ import { Modal, SimpleGrid, Stack, - Table, Text, Title, } from "@mantine/core"; @@ -30,6 +29,7 @@ import type { PluginStorageStatsDto, } from "@/api/pluginStorage"; import { pluginStorageApi } from "@/api/pluginStorage"; +import { ResponsiveTable, type ResponsiveTableColumn } from "@/components/ui"; function formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; @@ -123,6 +123,25 @@ export function PluginStorageSettings() { const hasPlugins = (stats?.plugins.length || 0) > 0; + const pluginStorageColumns: ResponsiveTableColumn[] = [ + { + key: "name", + header: "Plugin Name", + mobilePrimary: true, + accessor: (plugin) => {plugin.pluginName}, + }, + { + key: "fileCount", + header: "File Count", + accessor: (plugin) => plugin.fileCount.toLocaleString(), + }, + { + key: "size", + header: "Size", + accessor: (plugin) => formatBytes(plugin.totalBytes), + }, + ]; + if (isLoading) { return ( @@ -192,37 +211,22 @@ export function PluginStorageSettings() { Per-Plugin Storage {hasPlugins ? ( - - - - Plugin Name - File Count - Size - Actions - - - - {stats?.plugins.map((plugin) => ( - - - {plugin.pluginName} - - {plugin.fileCount.toLocaleString()} - {formatBytes(plugin.totalBytes)} - - setCleanupTarget(plugin)} - aria-label={`Delete storage for ${plugin.pluginName}`} - > - - - - - ))} - -
+ plugin.pluginName} + tableProps={{ striped: true, highlightOnHover: true }} + rowActions={(plugin) => ( + setCleanupTarget(plugin)} + aria-label={`Delete storage for ${plugin.pluginName}`} + > + + + )} + /> ) : ( No plugins have stored any files yet. )} diff --git a/web/src/pages/settings/PluginsSettings.tsx b/web/src/pages/settings/PluginsSettings.tsx index 3447bd7a..f601d8bf 100644 --- a/web/src/pages/settings/PluginsSettings.tsx +++ b/web/src/pages/settings/PluginsSettings.tsx @@ -19,7 +19,7 @@ import { Tooltip, } from "@mantine/core"; import { useForm } from "@mantine/form"; -import { useDisclosure } from "@mantine/hooks"; +import { useDisclosure, useMediaQuery } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; import { IconAlertCircle, @@ -43,6 +43,7 @@ import { pluginsApi, } from "@/api/plugins"; import { PluginConfigModal } from "@/components/forms/PluginConfigModal"; +import { MOBILE_MEDIA_QUERY } from "@/components/ui"; import { type OfficialPlugin, OfficialPlugins, @@ -84,6 +85,12 @@ export function PluginsSettings() { const plugins = pluginsResponse?.plugins ?? []; + // Below xs we render a card stack instead of the wide Table. Using + // useMediaQuery here (rather than `visibleFrom`/`hiddenFrom`) ensures only + // one DOM tree is rendered at a time so tests that query for plugin names + // don't see duplicate matches. + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; + // Fetch libraries for the library filter dropdown const { data: libraries = [] } = useQuery({ queryKey: ["libraries"], @@ -425,173 +432,359 @@ export function PluginsSettings() { Failed to load plugins. Please try again. ) : plugins.length > 0 ? ( - - - - - - - Plugin - Command - Status - Health - Actions - - - - {plugins.map((plugin) => ( - + <> + {!isMobile && ( + + +
+ - - toggleRowExpansion(plugin.id)} - > - {expandedRows.has(plugin.id) ? ( - - ) : ( - - )} - - - - - -
- {plugin.displayName} - - {plugin.name} - -
-
-
- - {plugin.command} - - - - plugin.enabled - ? disableMutation.mutate(plugin.id) - : enableMutation.mutate(plugin.id) - } - disabled={ - enableMutation.isPending || - disableMutation.isPending - } - /> - - - - - {plugin.healthStatus} - - {plugin.failureCount > 0 && ( - - - {plugin.failureCount} - - - )} - - - - - + + Plugin + Command + Status + Health + Actions +
+
+ + {plugins.map((plugin) => ( + + + testMutation.mutate(plugin.id)} - loading={ - testMutation.isPending && - testMutation.variables === plugin.id + size="sm" + onClick={() => toggleRowExpansion(plugin.id)} + aria-label={ + expandedRows.has(plugin.id) + ? "Collapse details" + : "Expand details" } > - + {expandedRows.has(plugin.id) ? ( + + ) : ( + + )} - - {plugin.failureCount > 0 && ( - - - resetFailuresMutation.mutate(plugin.id) - } - loading={ - resetFailuresMutation.isPending && - resetFailuresMutation.variables === - plugin.id + + + + +
+ {plugin.displayName} + + {plugin.name} + +
+
+
+ + {plugin.command} + + + + plugin.enabled + ? disableMutation.mutate(plugin.id) + : enableMutation.mutate(plugin.id) + } + disabled={ + enableMutation.isPending || + disableMutation.isPending + } + /> + + + + - - - - )} - - setConfigPlugin(plugin)} - > - - - - - handleEditPlugin(plugin)} - > - - - - - handleDeletePlugin(plugin)} - > - - - - - -
- - - - + {plugin.failureCount > 0 && ( + + + {plugin.failureCount} + + + )} + + + + + + + testMutation.mutate(plugin.id) + } + loading={ + testMutation.isPending && + testMutation.variables === plugin.id + } + aria-label="Test connection" + > + + + + {plugin.failureCount > 0 && ( + + + resetFailuresMutation.mutate(plugin.id) + } + loading={ + resetFailuresMutation.isPending && + resetFailuresMutation.variables === + plugin.id + } + aria-label="Reset failures" + > + + + + )} + + setConfigPlugin(plugin)} + aria-label="Configure plugin" + > + + + + + handleEditPlugin(plugin)} + aria-label="Edit plugin" + > + + + + + handleDeletePlugin(plugin)} + > + + + + + + + + + + + + + + + +
+ ))} +
+
+
+
+ )} + {isMobile && ( + + {plugins.map((plugin) => ( + + + + +
+ + {plugin.displayName} + + + {plugin.name} + +
+
+ + plugin.enabled + ? disableMutation.mutate(plugin.id) + : enableMutation.mutate(plugin.id) + } + disabled={ + enableMutation.isPending || disableMutation.isPending + } + aria-label={ + plugin.enabled ? "Disable plugin" : "Enable plugin" + } + /> +
+ + + + Command: + + + {plugin.command} + + + + + {plugin.healthStatus} + + {plugin.failureCount > 0 && ( + + + {plugin.failureCount} + + + )} + + + + + + + testMutation.mutate(plugin.id)} + loading={ + testMutation.isPending && + testMutation.variables === plugin.id + } + aria-label="Test connection" + > + + + + {plugin.failureCount > 0 && ( + + + resetFailuresMutation.mutate(plugin.id) + } + loading={ + resetFailuresMutation.isPending && + resetFailuresMutation.variables === plugin.id + } + aria-label="Reset failures" > - -
- - - - - ))} - - - - + + + + )} + + setConfigPlugin(plugin)} + aria-label="Configure plugin" + > + + + + + handleEditPlugin(plugin)} + aria-label="Edit plugin" + > + + + + + handleDeletePlugin(plugin)} + aria-label="Delete plugin" + > + + + +
+
+ + + + + + + ))} + + )} + ) : ( } diff --git a/web/src/pages/settings/ReleaseTrackingSettings.tsx b/web/src/pages/settings/ReleaseTrackingSettings.tsx index d96624d5..693b6d5b 100644 --- a/web/src/pages/settings/ReleaseTrackingSettings.tsx +++ b/web/src/pages/settings/ReleaseTrackingSettings.tsx @@ -11,7 +11,6 @@ import { MultiSelect, Stack, Switch, - Table, TagsInput, Text, Title, @@ -38,6 +37,7 @@ import { pluginsApi } from "@/api/plugins"; import type { ReleaseSource } from "@/api/releases"; import { settingsApi } from "@/api/settings"; import { CronInput } from "@/components/forms/CronInput"; +import { ResponsiveTable } from "@/components/ui"; import { usePollAllReleaseSourcesNow, usePollReleaseSourceNow, @@ -228,62 +228,116 @@ export function ReleaseTrackingSettings() { ) : ( - - - - - Source - Plugin - Interval - Last poll - Status - Enabled - - - - - {(sourcesQuery.data ?? []).map((source) => ( - - update.mutate({ - sourceId: source.id, - update: { enabled }, - }) - } - onCronScheduleChange={(cronSchedule) => - update.mutate({ - sourceId: source.id, - // Send `null` to clear the override and revert to - // inheriting the server-wide default. - update: { cronSchedule }, - }) - } - onPollNow={() => { - addId(setPollingIds, source.id); - pollNow.mutate(source.id, { - onSettled: () => removeId(setPollingIds, source.id), - }); - }} - pollNowPending={pollingIds.has(source.id)} - onReset={() => { - if ( - window.confirm( - `Reset "${source.displayName}"?\n\nThis deletes every release ledger row for this source and clears its poll state (etag, last poll time). User-managed settings (enabled, interval, name) are preserved. The next poll will re-record everything as new.\n\nThis cannot be undone.`, - ) - ) { - addId(setResettingIds, source.id); - reset.mutate(source.id, { - onSettled: () => removeId(setResettingIds, source.id), - }); + + + data={sourcesQuery.data ?? []} + columns={[ + { + key: "source", + header: "Source", + mobilePrimary: true, + accessor: (source) => , + }, + { + key: "plugin", + header: "Plugin", + accessor: (source) => , + }, + { + key: "interval", + header: "Interval", + mobileFullWidth: true, + accessor: (source) => ( + + update.mutate({ + sourceId: source.id, + update: { cronSchedule }, + }) + } + /> + ), + }, + { + key: "lastPoll", + header: "Last poll", + mobileFullWidth: true, + accessor: (source) => , + }, + { + key: "status", + header: "Status", + accessor: (source) => , + }, + { + key: "enabled", + header: "Enabled", + accessor: (source) => ( + + update.mutate({ + sourceId: source.id, + update: { + enabled: event.currentTarget.checked, + }, + }) } - }} - resetPending={resettingIds.has(source.id)} - /> - ))} - -
+ aria-label="Enable source" + /> + ), + }, + ]} + getRowKey={(source) => source.id} + tableProps={{ verticalSpacing: "sm" }} + rowActions={(source) => ( + <> + + { + addId(setPollingIds, source.id); + pollNow.mutate(source.id, { + onSettled: () => removeId(setPollingIds, source.id), + }); + }} + disabled={!source.enabled || pollingIds.has(source.id)} + loading={pollingIds.has(source.id)} + aria-label="Poll now" + > + + + + + { + if ( + window.confirm( + `Reset "${source.displayName}"?\n\nThis deletes every release ledger row for this source and clears its poll state (etag, last poll time). User-managed settings (enabled, interval, name) are preserved. The next poll will re-record everything as new.\n\nThis cannot be undone.`, + ) + ) { + addId(setResettingIds, source.id); + reset.mutate(source.id, { + onSettled: () => + removeId(setResettingIds, source.id), + }); + } + }} + loading={resettingIds.has(source.id)} + aria-label="Reset source" + > + + + + + )} + rowActionsHeader="" + />
)} @@ -609,26 +663,92 @@ function NotificationPreferencesCard() { ); } -interface RowProps { - source: ReleaseSource; - onToggle: (enabled: boolean) => void; - /** `null` clears the override and reverts to the server-wide default. */ - onCronScheduleChange: (cronSchedule: string | null) => void; - onPollNow: () => void; - pollNowPending: boolean; - onReset: () => void; - resetPending: boolean; +function SourceCell({ source }: { source: ReleaseSource }) { + return ( + + + {source.displayName} + + + {source.sourceKey} + + + ); } -function ReleaseSourceRow({ +function PluginCell({ source }: { source: ReleaseSource }) { + return ( + + {source.pluginId} + + ); +} + +function LastPollCell({ source }: { source: ReleaseSource }) { + const lastPolled = source.lastPolledAt + ? formatDistanceToNow(new Date(source.lastPolledAt), { addSuffix: true }) + : "—"; + return ( + + {lastPolled} + {source.lastSummary && ( + + {source.lastSummary} + + )} + + ); +} + +function StatusCell({ source }: { source: ReleaseSource }) { + if (source.lastError) { + return ( + + + Errored + + + ); + } + if (source.lastPolledAt) { + // Wrap the OK badge in a tooltip carrying `lastSummary` so users can + // see *why* a poll returned nothing (no tracked series, 304, dropped + // below threshold, etc.) without grepping logs. + return ( + + + OK + + + ); + } + return ( + + Never polled + + ); +} + +function CronCell({ source, - onToggle, onCronScheduleChange, - onPollNow, - pollNowPending, - onReset, - resetPending, -}: RowProps) { +}: { + source: ReleaseSource; + /** `null` clears the override and reverts to the server-wide default. */ + onCronScheduleChange: (cronSchedule: string | null) => void; +}) { // Truthy `cronSchedule` means the row has a per-source override; render the // editor inline. The server omits the field entirely (rather than sending // `null`) when the row is inheriting, so accept both `null` and `undefined` @@ -639,10 +759,6 @@ function ReleaseSourceRow({ source.cronSchedule || source.effectiveCronSchedule, ); - const lastPolled = source.lastPolledAt - ? formatDistanceToNow(new Date(source.lastPolledAt), { addSuffix: true }) - : "—"; - const commitDraft = () => { const trimmed = draft.trim(); if (!trimmed) { @@ -663,145 +779,48 @@ function ReleaseSourceRow({ setDraft(source.effectiveCronSchedule); }; - return ( - - - - - {source.displayName} - - - {source.sourceKey} - - - - - - {source.pluginId} - - - - {isOverriding ? ( - - - - Reset to default - - - ) : ( - - - {describeCron(source.effectiveCronSchedule)}{" "} - - (Default) - - - { - setIsOverriding(true); - setDraft(source.effectiveCronSchedule); - }} - > - Override - - - )} - - - - {lastPolled} - {source.lastSummary && ( - - {source.lastSummary} - - )} - - - - {source.lastError ? ( - - - Errored - - - ) : source.lastPolledAt ? ( - // Wrap the OK badge in a tooltip carrying `lastSummary` so users - // can see *why* a poll returned nothing (no tracked series, 304, - // dropped below threshold, etc.) without grepping logs. - - - OK - - - ) : ( - - Never polled - - )} - - - onToggle(event.currentTarget.checked)} - aria-label="Enable source" + if (isOverriding) { + return ( + + - - - - - - - - - - - - - - - - + + Reset to default + + + ); + } + + return ( + + + {describeCron(source.effectiveCronSchedule)}{" "} + + (Default) + + + { + setIsOverriding(true); + setDraft(source.effectiveCronSchedule); + }} + > + Override + + ); } diff --git a/web/src/pages/settings/SeriesExportsSettings.tsx b/web/src/pages/settings/SeriesExportsSettings.tsx index 1c727a22..e5054541 100644 --- a/web/src/pages/settings/SeriesExportsSettings.tsx +++ b/web/src/pages/settings/SeriesExportsSettings.tsx @@ -11,7 +11,6 @@ import { Radio, SegmentedControl, Stack, - Table, Text, Title, Tooltip, @@ -27,7 +26,8 @@ import { import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { librariesApi } from "@/api/libraries"; -import type { ExportFieldDto } from "@/api/seriesExports"; +import type { ExportFieldDto, SeriesExportDto } from "@/api/seriesExports"; +import { ResponsiveTable, type ResponsiveTableColumn } from "@/components/ui"; import { useCreateSeriesExport, useDeleteSeriesExport, @@ -602,6 +602,82 @@ export function SeriesExportsSettings() { deleteMutation.mutate(id); }; + const exportColumns: ResponsiveTableColumn[] = [ + { + key: "created", + header: "Created", + mobilePrimary: true, + accessor: (exp) => ( + + {new Date(exp.createdAt).toLocaleString()} + + ), + }, + { + key: "type", + header: "Type", + accessor: (exp) => , + }, + { + key: "format", + header: "Format", + accessor: (exp) => ( + + {exp.format.toUpperCase()} + + ), + }, + { + key: "status", + header: "Status", + accessor: (exp) => ( + <> + + {exp.error && ( + + + {exp.error} + + + )} + + ), + }, + { + key: "libraries", + header: "Libraries", + mobileFullWidth: true, + accessor: (exp) => ( + + ), + }, + { + key: "rows", + header: "Rows", + accessor: (exp) => {exp.rowCount ?? "-"}, + }, + { + key: "size", + header: "Size", + accessor: (exp) => ( + {formatBytes(exp.fileSizeBytes ?? null)} + ), + }, + { + key: "expires", + header: "Expires", + accessor: (exp) => ( + {new Date(exp.expiresAt).toLocaleDateString()} + ), + }, + ]; + return ( @@ -634,105 +710,47 @@ export function SeriesExportsSettings() { ) : ( - - - - - Created - Type - Format - Status - Libraries - Rows - Size - Expires - Actions - - - - {exports.map((exp) => ( - - - - {new Date(exp.createdAt).toLocaleString()} - - - - - - - - {exp.format.toUpperCase()} - - - - - {exp.error && ( - - - {exp.error} - - - )} - - - - - - {exp.rowCount ?? "-"} - - - - {formatBytes(exp.fileSizeBytes ?? null)} - - - - - {new Date(exp.expiresAt).toLocaleDateString()} - - - - - {exp.status === "completed" && ( - - handleDownload(exp)} - > - - - - )} - - handleDelete(exp.id)} - > - - - - - - - ))} - -
+ + exp.id} + tableProps={{ striped: true, highlightOnHover: true }} + rowActions={(exp) => ( + <> + {exp.status === "completed" && ( + + handleDownload(exp)} + aria-label="Download export" + > + + + + )} + + handleDelete(exp.id)} + aria-label="Delete export" + > + + + + + )} + /> )} diff --git a/web/src/pages/settings/ServerSettings.tsx b/web/src/pages/settings/ServerSettings.tsx index af0f3f13..89ae3ef9 100644 --- a/web/src/pages/settings/ServerSettings.tsx +++ b/web/src/pages/settings/ServerSettings.tsx @@ -19,7 +19,7 @@ import { Title, Tooltip, } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; +import { useDisclosure, useMediaQuery } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; import { IconAlertCircle, @@ -41,6 +41,7 @@ import { } from "@/api/settings"; import { TemplateEditor } from "@/components/forms/TemplateEditor"; import { TemplateSelector } from "@/components/forms/TemplateSelector"; +import { MOBILE_MEDIA_QUERY, ResponsiveTable } from "@/components/ui"; import { brandingQueryKey } from "@/hooks/useAppName"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; @@ -197,6 +198,144 @@ function SettingRow({ ); } +// Setting card for the mobile layout. Mirrors `SettingRow` but stacks the +// key/value/actions vertically and lets the value editor occupy the full +// card width below xs. +function SettingMobileCard({ + setting, + onUpdate, + onReset, + onViewHistory, +}: { + setting: SettingDto; + onUpdate: (key: string, value: string) => void; + onReset: (key: string) => void; + onViewHistory: (key: string) => void; +}) { + const [localValue, setLocalValue] = useState(setting.value); + const [isEditing, setIsEditing] = useState(false); + + const handleSave = () => { + if (localValue !== setting.value) { + onUpdate(setting.key, localValue); + } + setIsEditing(false); + }; + + const handleCancel = () => { + setLocalValue(setting.value); + setIsEditing(false); + }; + + const renderInput = () => { + switch (setting.valueType) { + case "boolean": + return ( + { + const newValue = String(e.currentTarget.checked); + setLocalValue(newValue); + onUpdate(setting.key, newValue); + }} + /> + ); + case "integer": + return ( + setLocalValue(String(value))} + min={setting.minValue ?? undefined} + max={setting.maxValue ?? undefined} + onBlur={handleSave} + w="100%" + /> + ); + default: + return isEditing ? ( + + setLocalValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") handleCancel(); + }} + autoFocus + /> + + + + + + ) : ( + + setIsEditing(true)} + > + {setting.isSensitive ? "••••••••" : localValue || "(empty)"} + + + + ); + } + }; + + return ( + + + + + + {setting.key} + + + {setting.description} + + + + {setting.valueType} + + + {renderInput()} + + + onViewHistory(setting.key)} + aria-label="View history" + > + + + + + onReset(setting.key)} + disabled={setting.value === setting.defaultValue} + aria-label="Reset to default" + > + + + + + + + ); +} + // Template setting key constant const CUSTOM_METADATA_TEMPLATE_KEY = "display.custom_metadata_template"; @@ -312,6 +451,10 @@ function SettingsCategorySection({ onViewHistory: (key: string) => void; }) { const [opened, { toggle }] = useDisclosure(true); + // Below xs the four-column settings table clips on the right. Render a + // stack of `SettingMobileCard` instead — only one DOM tree is mounted so + // tests still address a single matching row per setting. + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; return ( @@ -328,18 +471,10 @@ function SettingsCategorySection({ )} - - - - Setting - Value - Type - Actions - - - + {isMobile ? ( + {settings.map((setting) => ( - ))} - -
+ + ) : ( + + + + Setting + Value + Type + Actions + + + + {settings.map((setting) => ( + + ))} + +
+ )}
); @@ -535,90 +692,98 @@ export function ServerSettings() { ) : history && history.length > 0 ? ( - - - - Previous Value - New Value - Changed At - Reason - Actions - - - - {history.map((entry: SettingHistoryDto, index: number) => { - // Get the current setting value to check if restore is needed - const currentValue = settings?.find( - (s) => s.key === historyKey, - )?.value; - const canRestore = - entry.oldValue !== null && entry.oldValue !== currentValue; - - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: History entries have no unique ID - - - - {entry.oldValue ?? "(empty)"} - - - - - {entry.newValue} - - - - {new Date(entry.changedAt).toLocaleString()} - - {entry.changeReason || "-"} - - {canRestore ? ( - - { - if (historyKey) { - updateSettingMutation.mutate({ - key: historyKey, - value: entry.oldValue as string, - }); - } - }} - loading={updateSettingMutation.isPending} - > - - - - ) : ( - - - - - )} - - - ); - })} - -
+ + data={history.map((entry, index) => { + const currentValue = settings?.find( + (s) => s.key === historyKey, + )?.value; + return { + ...entry, + __index: index, + __canRestore: + entry.oldValue !== null && entry.oldValue !== currentValue, + }; + })} + columns={[ + { + key: "old", + header: "Previous Value", + mobileFullWidth: true, + accessor: (entry) => ( + + {entry.oldValue ?? "(empty)"} + + ), + }, + { + key: "new", + header: "New Value", + mobileFullWidth: true, + accessor: (entry) => ( + + {entry.newValue} + + ), + }, + { + key: "changedAt", + header: "Changed At", + accessor: (entry) => new Date(entry.changedAt).toLocaleString(), + }, + { + key: "reason", + header: "Reason", + accessor: (entry) => entry.changeReason || "-", + }, + ]} + getRowKey={(entry) => `${entry.__index}`} + rowActions={(entry) => + entry.__canRestore ? ( + + { + if (historyKey) { + updateSettingMutation.mutate({ + key: historyKey, + value: entry.oldValue as string, + }); + } + }} + loading={updateSettingMutation.isPending} + aria-label="Restore to this value" + > + + + + ) : ( + + - + + ) + } + /> ) : ( No history available for this setting. diff --git a/web/src/pages/settings/SharingTagsSettings.tsx b/web/src/pages/settings/SharingTagsSettings.tsx index f72586b7..67062912 100644 --- a/web/src/pages/settings/SharingTagsSettings.tsx +++ b/web/src/pages/settings/SharingTagsSettings.tsx @@ -10,7 +10,6 @@ import { Loader, Modal, Stack, - Table, Text, Textarea, TextInput, @@ -30,6 +29,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { Link } from "react-router-dom"; import { type SharingTagDto, sharingTagsApi } from "@/api/sharingTags"; +import { ResponsiveTable, type ResponsiveTableColumn } from "@/components/ui"; export function SharingTagsSettings() { const queryClient = useQueryClient(); @@ -166,6 +166,65 @@ export function SharingTagsSettings() { setDeleteModalOpened(true); }; + const sharingTagColumns: ResponsiveTableColumn[] = [ + { + key: "tag", + header: "Tag", + mobilePrimary: true, + accessor: (tag) => ( + + + {tag.name} + + ), + }, + { + key: "description", + header: "Description", + mobileFullWidth: true, + accessor: (tag) => ( + + {tag.description || "No description"} + + ), + }, + { + key: "series", + header: "Series", + accessor: (tag) => ( + + + {tag.seriesCount} series + + + ), + }, + { + key: "users", + header: "Users", + accessor: (tag) => ( + + + {tag.userCount} users + + + ), + }, + { + key: "created", + header: "Created", + accessor: (tag) => new Date(tag.createdAt).toLocaleDateString(), + }, + ]; + return ( @@ -194,93 +253,35 @@ export function SharingTagsSettings() { Failed to load sharing tags. Please try again.
) : sharingTags && sharingTags.length > 0 ? ( - - - - - Tag - Description - Series - Users - Created - Actions - - - - {sharingTags.map((tag) => ( - - - - - {tag.name} - - - - - {tag.description || "No description"} - - - - - - {tag.seriesCount} series - - - - - - - {tag.userCount} users - - - - - {new Date(tag.createdAt).toLocaleDateString()} - - - - - handleEditTag(tag)} - > - - - - - handleDeleteTag(tag)} - > - - - - - - - ))} - -
+ + tag.id} + rowActions={(tag) => ( + <> + + handleEditTag(tag)} + aria-label={`Edit ${tag.name}`} + > + + + + + handleDeleteTag(tag)} + aria-label={`Delete ${tag.name}`} + > + + + + + )} + /> ) : ( } color="gray" variant="light"> diff --git a/web/src/pages/settings/TasksSettings.tsx b/web/src/pages/settings/TasksSettings.tsx index 0182f2ad..747669ce 100644 --- a/web/src/pages/settings/TasksSettings.tsx +++ b/web/src/pages/settings/TasksSettings.tsx @@ -12,7 +12,6 @@ import { Select, SimpleGrid, Stack, - Table, Text, Title, Tooltip, @@ -34,6 +33,7 @@ import { fetchTasksByStatus, subscribeToTaskProgress, } from "@/api/tasks"; +import { ResponsiveTable } from "@/components/ui"; import type { TaskProgressEvent, TaskResponse } from "@/types"; // Stat card component @@ -65,8 +65,19 @@ function StatCard({ ); } -// Task row component -function TaskRow({ +function getTaskStatusColor(status: string): string { + return ( + { + pending: "yellow", + processing: "blue", + completed: "green", + failed: "red", + cancelled: "gray", + }[status] || "gray" + ); +} + +function TaskActions({ task, onCancel, onRetry, @@ -77,75 +88,45 @@ function TaskRow({ onRetry: () => void; onUnlock: () => void; }) { - const statusColor = - { - pending: "yellow", - processing: "blue", - completed: "green", - failed: "red", - cancelled: "gray", - }[task.status] || "gray"; - return ( - - - - {task.id.slice(0, 8)}... - - - - {task.taskType} - - - {task.status} - - - - {task.attempts}/{task.maxAttempts} - - - - {new Date(task.createdAt).toLocaleString()} - - - {task.lastError ? ( - - - {task.lastError} - - - ) : ( - - - - - )} - - - - {task.status === "pending" && ( - - - - - - )} - {task.status === "failed" && ( - - - - - - )} - {task.lockedBy && task.status === "processing" && ( - - - - - - )} - - - + <> + {task.status === "pending" && ( + + + + + + )} + {task.status === "failed" && ( + + + + + + )} + {task.lockedBy && task.status === "processing" && ( + + + + + + )} + ); } @@ -509,30 +490,86 @@ export function TasksSettings() { ) : tasks && tasks.length > 0 ? ( - - - - ID - Type - Status - Attempts - Created - Error - Actions - - - - {tasks.map((task: TaskResponse) => ( - cancelTaskMutation.mutate(task.id)} - onRetry={() => retryTaskMutation.mutate(task.id)} - onUnlock={() => unlockTaskMutation.mutate(task.id)} - /> - ))} - -
+ + data={tasks} + columns={[ + { + key: "id", + header: "ID", + accessor: (task) => ( + + {task.id.slice(0, 8)}... + + ), + }, + { + key: "type", + header: "Type", + mobilePrimary: true, + accessor: (task) => ( + {task.taskType} + ), + }, + { + key: "status", + header: "Status", + accessor: (task) => ( + + {task.status} + + ), + }, + { + key: "attempts", + header: "Attempts", + accessor: (task) => ( + + {task.attempts}/{task.maxAttempts} + + ), + }, + { + key: "created", + header: "Created", + accessor: (task) => ( + + {new Date(task.createdAt).toLocaleString()} + + ), + }, + { + key: "error", + header: "Error", + mobileFullWidth: true, + accessor: (task) => + task.lastError ? ( + + + {task.lastError} + + + ) : ( + + - + + ), + }, + ]} + getRowKey={(task) => task.id} + rowActions={(task) => ( + cancelTaskMutation.mutate(task.id)} + onRetry={() => retryTaskMutation.mutate(task.id)} + onUnlock={() => unlockTaskMutation.mutate(task.id)} + /> + )} + /> ) : ( No tasks found. @@ -546,34 +583,61 @@ export function TasksSettings() { By Task Type - - - - Type - Pending - Processing - Completed - Failed - Total - - - - {Object.entries(stats.byType) - .sort(([typeA], [typeB]) => typeA.localeCompare(typeB)) - .map(([type, typeStats]) => ( - - - {type} - - {typeStats.pending} - {typeStats.processing} - {typeStats.completed} - {typeStats.failed} - {typeStats.total} - - ))} - -
+ + data={Object.entries(stats.byType) + .sort(([typeA], [typeB]) => typeA.localeCompare(typeB)) + .map(([type, typeStats]) => ({ + type, + pending: typeStats.pending, + processing: typeStats.processing, + completed: typeStats.completed, + failed: typeStats.failed, + total: typeStats.total, + }))} + columns={[ + { + key: "type", + header: "Type", + mobilePrimary: true, + accessor: (row) => ( + {row.type} + ), + }, + { + key: "pending", + header: "Pending", + accessor: (row) => row.pending, + }, + { + key: "processing", + header: "Processing", + accessor: (row) => row.processing, + }, + { + key: "completed", + header: "Completed", + accessor: (row) => row.completed, + }, + { + key: "failed", + header: "Failed", + accessor: (row) => row.failed, + }, + { + key: "total", + header: "Total", + accessor: (row) => row.total, + }, + ]} + getRowKey={(row) => row.type} + />
)} diff --git a/web/src/pages/settings/UsersSettings.tsx b/web/src/pages/settings/UsersSettings.tsx index 114b9c95..f2c77135 100644 --- a/web/src/pages/settings/UsersSettings.tsx +++ b/web/src/pages/settings/UsersSettings.tsx @@ -15,7 +15,6 @@ import { Select, Stack, Switch, - Table, Text, TextInput, Title, @@ -41,6 +40,7 @@ import { useSearchParams } from "react-router-dom"; import { sharingTagsApi } from "@/api/sharingTags"; import { type UserDto, type UserListParams, usersApi } from "@/api/users"; import { PermissionPicker } from "@/components/common"; +import { ResponsiveTable, type ResponsiveTableColumn } from "@/components/ui"; import { UserSharingTagGrants } from "@/components/users"; import { useAuthStore } from "@/store/authStore"; import { type Permission, ROLE_PERMISSIONS } from "@/types/permissions"; @@ -310,6 +310,80 @@ export function UsersSettings() { const totalPages = usersResponse?.totalPages ?? 1; const showPagination = total > PAGE_SIZE; + const userColumns: ResponsiveTableColumn[] = [ + { + key: "user", + header: "User", + mobileLabel: "User", + mobilePrimary: true, + accessor: (user) => ( + + +
+ {user.username} + {user.id === currentUser?.id && ( + + (You) + + )} +
+
+ ), + }, + { + key: "email", + header: "Email", + accessor: (user) => ( + + {user.email} + + ), + }, + { + key: "role", + header: "Role", + accessor: (user) => ( + + {user.role === "admin" + ? "Admin" + : user.role === "maintainer" + ? "Maintainer" + : "Reader"} + + ), + }, + { + key: "status", + header: "Status", + accessor: (user) => ( + + {user.isActive ? "Active" : "Inactive"} + + ), + }, + { + key: "created", + header: "Created", + accessor: (user) => new Date(user.createdAt).toLocaleDateString(), + }, + { + key: "lastLogin", + header: "Last Login", + accessor: (user) => + user.lastLoginAt + ? new Date(user.lastLoginAt).toLocaleString() + : "Never", + }, + ]; + return ( @@ -436,92 +510,36 @@ export function UsersSettings() { )} - - - - - User - Email - Role - Status - Created - Last Login - Actions - - - - {users.map((user: UserDto) => ( - - - - -
- {user.username} - {user.id === currentUser?.id && ( - - (You) - - )} -
-
-
- {user.email} - - - {user.role === "admin" - ? "Admin" - : user.role === "maintainer" - ? "Maintainer" - : "Reader"} - - - - - {user.isActive ? "Active" : "Inactive"} - - - - {new Date(user.createdAt).toLocaleDateString()} - - - {user.lastLoginAt - ? new Date(user.lastLoginAt).toLocaleString() - : "Never"} - - - - - handleEditUser(user)} - > - - - - - handleDeleteUser(user)} - disabled={user.id === currentUser?.id} - > - - - - - -
- ))} -
-
+ + user.id} + rowActions={(user) => ( + <> + + handleEditUser(user)} + aria-label={`Edit ${user.username}`} + > + + + + + handleDeleteUser(user)} + disabled={user.id === currentUser?.id} + aria-label={`Delete ${user.username}`} + > + + + + + )} + /> {/* Bottom Pagination */} From 29abbd81ccab85aefa3d617e5efd65a18e3fde84 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 16 May 2026 11:54:57 -0700 Subject: [PATCH 05/20] feat(mobile): library toolbar stacking and alphabet jump picker below xs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the library page usable on a 390px viewport — fix the two outstanding audit findings without changing the desktop layout. LibraryToolbar: below the shared MOBILE_MEDIA_QUERY breakpoint, switch from a single `` row to a `` with the tabs on top and the page-size/sort/filter icon controls right-aligned underneath. Keeps every control one tap away rather than hiding them behind an overflow menu. AlphabetFilter: below the same breakpoint, render a Mantine `` picker. + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY) ?? false; + const handleClick = (letter: AlphabetLetter) => { if (letter === "ALL") { onSelect(null); @@ -96,6 +102,46 @@ export function AlphabetFilter({ const allCount = getCount("ALL"); const hasFilter = selected !== null; + if (isMobile) { + const options: { value: string; label: string; disabled?: boolean }[] = [ + { + value: "ALL", + label: + allCount !== undefined ? `All series (${allCount})` : "All series", + }, + ...LETTERS.map((letter) => { + const count = getCount(letter); + const hasCount = count !== undefined && count > 0; + const isEmpty = counts !== undefined && !hasCount; + return { + value: letter, + label: hasCount ? `${letter} (${count})` : letter, + disabled: isEmpty, + }; + }), + ]; + + return ( +