diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5f63575..aeac6db 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.56.0" +version = "0.57.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5ee5a97..cac1ade 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.56.0" +version = "0.57.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/mobile-list-search.spec.ts b/frontend/e2e/mobile-list-search.spec.ts new file mode 100644 index 0000000..950047e --- /dev/null +++ b/frontend/e2e/mobile-list-search.spec.ts @@ -0,0 +1,178 @@ +import { test, expect, type Page } from '@playwright/test'; + +// Phase 2: the catalog (/) gets a mobile chrome — search input full +// width, Filter and Sort as chip buttons that open bottom sheets, and +// the active-filter row exposes selected facets as removable chips. The +// inline desktop filter panel is hidden on mobile. + +const MOBILE = { width: 390, height: 844 } as const; +const DESKTOP = { width: 1280, height: 720 } as const; + +async function mockAnonymous(page: Page) { + await page.route('**/api/v1/auth/config', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }); + }); + await page.route('**/api/v1/auth/me', async (route) => { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } }) + }); + }); +} + +async function mockCatalog( + page: Page, + opts: { genres?: { id: string; name: string }[]; capture?: (url: URL) => void } = {} +) { + const genres = opts.genres ?? [ + { id: 'g-action', name: 'Action' }, + { id: 'g-romance', name: 'Romance' } + ]; + await page.route('**/api/v1/genres*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(genres) + }); + }); + await page.route('**/api/v1/mangas*', async (route) => { + const url = new URL(route.request().url()); + opts.capture?.(url); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [], + page: { limit: 50, offset: 0, total: 0 } + }) + }); + }); +} + +test.describe('mobile catalog chrome', () => { + test('phone viewport: Sort chip is visible, inline desktop select is hidden', async ({ + page + }) => { + await mockAnonymous(page); + await mockCatalog(page); + await page.setViewportSize(MOBILE); + await page.goto('/'); + + await expect(page.getByTestId('sort-chip')).toBeVisible(); + await expect(page.getByTestId('sort-select')).toBeHidden(); + }); + + test('desktop viewport: inline Sort select is visible, mobile chip is hidden', async ({ + page + }) => { + await mockAnonymous(page); + await mockCatalog(page); + await page.setViewportSize(DESKTOP); + await page.goto('/'); + + await expect(page.getByTestId('sort-select')).toBeVisible(); + await expect(page.getByTestId('sort-chip')).toBeHidden(); + }); + + // `empty` only renders after onMount's listMangas() resolves, which + // means hydration is complete and click handlers are attached. Without + // this gate, Playwright dispatches the click against the SSR'd static + // button before Svelte takes over and the state mutation is dropped. + async function waitForHydration(page: Page) { + await expect(page.getByTestId('empty')).toBeVisible(); + } + + test('phone viewport: Filter chip opens the bottom sheet, not the inline panel', async ({ + page + }) => { + await mockAnonymous(page); + await mockCatalog(page); + await page.setViewportSize(MOBILE); + await page.goto('/'); + await waitForHydration(page); + + await expect(page.getByTestId('filter-sheet')).toBeHidden(); + await page.getByTestId('filters-toggle').click(); + + await expect(page.getByTestId('filter-sheet')).toBeVisible(); + // Inline panel must not render on mobile — the snippet gated by + // !isMobileViewport means the same form is never on the page twice. + await expect(page.getByTestId('filters-panel')).toHaveCount(0); + }); + + test('phone viewport: picking a genre updates URL + shows an active-filter chip', async ({ + page + }) => { + // The API uses `genre_id` (singular, comma-joined) while the URL + // we surface to the user uses `genres` — verify both paths. + let lastGenreIdParam: string | null = null; + await mockAnonymous(page); + await mockCatalog(page, { + capture: (url) => { + lastGenreIdParam = url.searchParams.get('genre_id'); + } + }); + await page.setViewportSize(MOBILE); + await page.goto('/'); + await waitForHydration(page); + + await page.getByTestId('filters-toggle').click(); + await page.getByTestId('genre-filter-Action').click(); + + await expect(page).toHaveURL(/genres=g-action/); + await expect(page.getByTestId('active-filter-genre-Action')).toBeVisible(); + expect(lastGenreIdParam).toBe('g-action'); + }); + + test('phone viewport: removing an active-filter chip clears that facet', async ({ + page + }) => { + await mockAnonymous(page); + await mockCatalog(page); + await page.setViewportSize(MOBILE); + await page.goto('/?genres=g-action'); + await waitForHydration(page); + + // The chip appears after hydrateFromUrl resolves the genre id. + const chip = page.getByTestId('active-filter-genre-Action'); + await expect(chip).toBeVisible(); + + // Chip's remove button has an aria-label "Remove genre Action". + await chip.getByRole('button', { name: 'Remove genre Action' }).click(); + + await expect(chip).toHaveCount(0); + await expect(page).toHaveURL((url) => !url.search.includes('genres=')); + }); + + test('phone viewport: Sort sheet swaps the sort and dismisses on pick', async ({ + page + }) => { + let lastSortParam: string | null = null; + await mockAnonymous(page); + await mockCatalog(page, { + capture: (url) => { + lastSortParam = url.searchParams.get('sort'); + } + }); + await page.setViewportSize(MOBILE); + await page.goto('/'); + await waitForHydration(page); + + await page.getByTestId('sort-chip').click(); + await expect(page.getByTestId('sort-sheet')).toBeVisible(); + + // Use click(), not check(): the onchange handler closes the sheet + // synchronously so the radio is gone before check() can verify the + // `checked` state. + await page.getByTestId('sort-sheet').getByRole('radio', { name: /Title/ }).click(); + + await expect(page.getByTestId('sort-sheet')).toBeHidden(); + await expect(page).toHaveURL(/sort=title/); + expect(lastSortParam).toBe('title'); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index c808836..aa4ed38 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.56.0", + "version": "0.57.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/MangaCard.svelte b/frontend/src/lib/components/MangaCard.svelte index 7831c0c..917f62e 100644 --- a/frontend/src/lib/components/MangaCard.svelte +++ b/frontend/src/lib/components/MangaCard.svelte @@ -8,13 +8,39 @@ manga, authors = [], genres = [], + unreadCount, + progress, testid }: { manga: Manga; authors?: AuthorRef[]; genres?: GenreRef[]; + /** + * Number of unread chapters since the user's last read position. + * Shown as a small badge in the cover's top-right corner; 0 or + * undefined hides the badge. Counts past 99 cap at "99+". + */ + unreadCount?: number; + /** + * Fraction read [0..1]. Rendered as a thin bar overlaid on the + * bottom edge of the cover. Out-of-range values clamp; non- + * positive values hide the bar entirely. + */ + progress?: number; testid?: string; } = $props(); + + const unreadLabel = $derived( + unreadCount && unreadCount > 0 + ? unreadCount > 99 + ? '99+' + : String(unreadCount) + : null + ); + + const progressPct = $derived( + progress != null && progress > 0 ? Math.min(progress, 1) * 100 : null + );
Loading…
{:else if error} @@ -476,6 +610,29 @@ text-align: center; } + .sort-chip { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-3); + height: 36px; + background: var(--surface); + border: 1px solid var(--border-strong); + color: var(--text); + cursor: pointer; + } + + .sort-chip:hover { + background: var(--surface-elevated); + border-color: var(--primary); + } + + .active-filters { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + } + .filters-panel { display: flex; flex-direction: column; @@ -611,6 +768,20 @@ width: auto; } + .sort-options { + display: flex; + flex-direction: column; + gap: var(--space-3); + } + + .sort-options label { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) 0; + cursor: pointer; + } + .icon-btn { display: inline-flex; align-items: center; @@ -653,4 +824,33 @@ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--space-4); } + + /* Mobile-only helpers. Defaults flip below the 640px breakpoint + so the same chrome doesn't render twice. */ + .mobile-only { + display: none; + } + + @media (max-width: 640px) { + .desktop-only { + display: none; + } + + .mobile-only { + display: inline-flex; + } + + .search { + /* Catalog search should fill the available width on phones — + the 28rem cap is desktop-only. */ + max-width: none; + } + + .manga-grid { + /* Lock to two columns on phones; the auto-fill default + packs 2-3 unpredictably at narrow widths. */ + grid-template-columns: repeat(2, 1fr); + gap: var(--space-3); + } + }