feat(frontend): mobile catalog with sheet filters + sort (0.57.0)
Phase 2 of the mobile redesign: the catalog at `/` adapts to phone viewports without disrupting the desktop layout. The same filter form renders inline on desktop and inside a bottom sheet on mobile, and a dedicated sort sheet replaces the inline `<select>` below 640px. - MangaCard gains optional `unreadCount` and `progress` props that render a top-right pill badge and a bottom progress overlay on the cover. Both are no-ops when omitted, so existing callers don't change. Counts past 99 cap at "99+". - The filter form body is extracted into a Svelte 5 snippet and rendered conditionally — inline desktop panel OR mobile sheet, never both — so no testid is duplicated and the focus trap can't double- fire. A matchMedia listener tracks the 640px breakpoint and drives the snippet target. - Mobile catalog adds: Filter / Sort chip buttons, an always-visible active-filter chip row with per-facet remove buttons, full-width search, and a 2-column grid. - Auto-expanding the filter panel from URL params is now desktop-only — on mobile the active-filter chips signal applied facets without hiding the catalog under a scrim on first paint. - Vitest suite covers MangaCard badge / overlay behavior including clamp / hide edge cases. Playwright spec covers the mobile filter + sort sheet flow at 390px viewport, with a hydration gate to keep click dispatch from racing the SSR'd-but-not-yet-reactive button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.56.0"
|
version = "0.57.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.56.0"
|
version = "0.57.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
178
frontend/e2e/mobile-list-search.spec.ts
Normal file
178
frontend/e2e/mobile-list-search.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.56.0",
|
"version": "0.57.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,13 +8,39 @@
|
|||||||
manga,
|
manga,
|
||||||
authors = [],
|
authors = [],
|
||||||
genres = [],
|
genres = [],
|
||||||
|
unreadCount,
|
||||||
|
progress,
|
||||||
testid
|
testid
|
||||||
}: {
|
}: {
|
||||||
manga: Manga;
|
manga: Manga;
|
||||||
authors?: AuthorRef[];
|
authors?: AuthorRef[];
|
||||||
genres?: GenreRef[];
|
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;
|
testid?: string;
|
||||||
} = $props();
|
} = $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
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<li class="manga-card" data-testid={testid}>
|
<li class="manga-card" data-testid={testid}>
|
||||||
@@ -31,6 +57,24 @@
|
|||||||
<BookImage size={36} aria-hidden="true" />
|
<BookImage size={36} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if unreadLabel}
|
||||||
|
<span
|
||||||
|
class="unread-badge"
|
||||||
|
data-testid="unread-badge"
|
||||||
|
aria-label="{unreadLabel} unread chapter{unreadLabel === '1' ? '' : 's'}"
|
||||||
|
>
|
||||||
|
{unreadLabel}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if progressPct != null}
|
||||||
|
<span class="cover-progress" data-testid="cover-progress" aria-hidden="true">
|
||||||
|
<span
|
||||||
|
class="cover-progress-fill"
|
||||||
|
data-testid="cover-progress-fill"
|
||||||
|
style="width: {progressPct}%;"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<a href="/manga/{manga.id}" class="title">{manga.title}</a>
|
<a href="/manga/{manga.id}" class="title">{manga.title}</a>
|
||||||
@@ -54,6 +98,45 @@
|
|||||||
.cover-link {
|
.cover-link {
|
||||||
display: block;
|
display: block;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-1);
|
||||||
|
right: var(--space-1);
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-contrast);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-progress {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border-bottom-left-radius: var(--radius-md);
|
||||||
|
border-bottom-right-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-progress-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover {
|
.cover {
|
||||||
|
|||||||
65
frontend/src/lib/components/MangaCard.svelte.test.ts
Normal file
65
frontend/src/lib/components/MangaCard.svelte.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||||
|
import MangaCard from './MangaCard.svelte';
|
||||||
|
import type { Manga } from '$lib/api/client';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const baseManga: Manga = {
|
||||||
|
id: 'm1',
|
||||||
|
title: 'Berserk',
|
||||||
|
status: 'ongoing',
|
||||||
|
alt_titles: [],
|
||||||
|
description: null,
|
||||||
|
cover_image_path: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MangaCard badges & overlays', () => {
|
||||||
|
it('does not render an unread badge when unreadCount is omitted', () => {
|
||||||
|
render(MangaCard, { props: { manga: baseManga } });
|
||||||
|
expect(screen.queryByTestId('unread-badge')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render an unread badge when unreadCount is 0', () => {
|
||||||
|
render(MangaCard, { props: { manga: baseManga, unreadCount: 0 } });
|
||||||
|
expect(screen.queryByTestId('unread-badge')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the unread badge with the count when unreadCount > 0', () => {
|
||||||
|
render(MangaCard, { props: { manga: baseManga, unreadCount: 7 } });
|
||||||
|
const badge = screen.getByTestId('unread-badge');
|
||||||
|
expect(badge.textContent?.trim()).toBe('7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the unread badge at 99+ for very large counts', () => {
|
||||||
|
render(MangaCard, { props: { manga: baseManga, unreadCount: 250 } });
|
||||||
|
const badge = screen.getByTestId('unread-badge');
|
||||||
|
expect(badge.textContent?.trim()).toBe('99+');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a progress bar when progress is omitted', () => {
|
||||||
|
render(MangaCard, { props: { manga: baseManga } });
|
||||||
|
expect(screen.queryByTestId('cover-progress')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a progress bar at exactly 0 (no reads yet)', () => {
|
||||||
|
render(MangaCard, { props: { manga: baseManga, progress: 0 } });
|
||||||
|
expect(screen.queryByTestId('cover-progress')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the progress overlay clamped to 0..1 and reflects the value as a width style', () => {
|
||||||
|
const { rerender } = render(MangaCard, {
|
||||||
|
props: { manga: baseManga, progress: 0.4 }
|
||||||
|
});
|
||||||
|
const bar = screen.getByTestId('cover-progress-fill');
|
||||||
|
expect((bar as HTMLElement).style.width).toBe('40%');
|
||||||
|
|
||||||
|
rerender({ manga: baseManga, progress: 1.5 });
|
||||||
|
expect((screen.getByTestId('cover-progress-fill') as HTMLElement).style.width).toBe('100%');
|
||||||
|
|
||||||
|
rerender({ manga: baseManga, progress: -0.2 });
|
||||||
|
expect(screen.queryByTestId('cover-progress')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,15 +14,17 @@
|
|||||||
import Chip from '$lib/components/Chip.svelte';
|
import Chip from '$lib/components/Chip.svelte';
|
||||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||||
import Pager from '$lib/components/Pager.svelte';
|
import Pager from '$lib/components/Pager.svelte';
|
||||||
|
import Sheet from '$lib/components/Sheet.svelte';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||||
|
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
let mangas: MangaCardData[] = $state([]);
|
let mangas: MangaCardData[] = $state([]);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let sort: MangaSort = $state('recent');
|
let sort = $state<MangaSort>('recent');
|
||||||
let statusFilter = $state<'' | MangaStatus>('');
|
let statusFilter = $state<'' | MangaStatus>('');
|
||||||
let selectedGenres = $state<Genre[]>([]);
|
let selectedGenres = $state<Genre[]>([]);
|
||||||
let selectedTags = $state<Tag[]>([]);
|
let selectedTags = $state<Tag[]>([]);
|
||||||
@@ -36,15 +38,24 @@
|
|||||||
let suggestSeq = 0;
|
let suggestSeq = 0;
|
||||||
const tagSuggestListId = 'tag-filter-suggest-list';
|
const tagSuggestListId = 'tag-filter-suggest-list';
|
||||||
let filtersOpen = $state(false);
|
let filtersOpen = $state(false);
|
||||||
|
let sortSheetOpen = $state(false);
|
||||||
let total: number | null = $state(null);
|
let total: number | null = $state(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let currentPage = $state(1);
|
let currentPage = $state(1);
|
||||||
|
|
||||||
|
// Tracks whether the viewport sits below the project's single 640px
|
||||||
|
// breakpoint. Drives the inline-panel-vs-bottom-sheet split for both
|
||||||
|
// filters and sort, so the same form body never renders twice and
|
||||||
|
// duplicate testids / focus-trap interference don't occur.
|
||||||
|
let isMobileViewport = $state(false);
|
||||||
|
|
||||||
const activeFilterCount = $derived(
|
const activeFilterCount = $derived(
|
||||||
(statusFilter ? 1 : 0) + selectedGenres.length + selectedTags.length
|
(statusFilter ? 1 : 0) + selectedGenres.length + selectedTags.length
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sortLabel = $derived(sort === 'title' ? 'Title (A→Z)' : 'Recent');
|
||||||
|
|
||||||
const totalPages = $derived(
|
const totalPages = $derived(
|
||||||
total != null && total > 0 ? Math.ceil(total / PAGE_SIZE) : 1
|
total != null && total > 0 ? Math.ceil(total / PAGE_SIZE) : 1
|
||||||
);
|
);
|
||||||
@@ -135,8 +146,15 @@
|
|||||||
}
|
}
|
||||||
const pageParam = Number(url.searchParams.get('page') ?? '1');
|
const pageParam = Number(url.searchParams.get('page') ?? '1');
|
||||||
currentPage = Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
|
currentPage = Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
|
||||||
// Open the filters panel if anything is active so the user can see why.
|
// Auto-expand the inline filters panel on desktop so the user can
|
||||||
if (statusFilter || selectedGenres.length || selectedTags.length) {
|
// see why results are filtered. On phones the active-filter chip
|
||||||
|
// row carries that signal already, and auto-opening the sheet would
|
||||||
|
// hide the catalog behind a scrim on first paint.
|
||||||
|
if (
|
||||||
|
(statusFilter || selectedGenres.length || selectedTags.length) &&
|
||||||
|
browser &&
|
||||||
|
!window.matchMedia('(max-width: 640px)').matches
|
||||||
|
) {
|
||||||
filtersOpen = true;
|
filtersOpen = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +242,21 @@
|
|||||||
resetAndReload();
|
resetAndReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearStatus() {
|
||||||
|
statusFilter = '';
|
||||||
|
resetAndReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSort(next: MangaSort) {
|
||||||
|
if (next === sort) {
|
||||||
|
sortSheetOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sort = next;
|
||||||
|
sortSheetOpen = false;
|
||||||
|
resetAndReload();
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
allGenres = await listGenres();
|
allGenres = await listGenres();
|
||||||
@@ -233,8 +266,142 @@
|
|||||||
await hydrateFromUrl();
|
await hydrateFromUrl();
|
||||||
await load();
|
await load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track viewport in a separate $effect so the listener cleans up on
|
||||||
|
// unmount without entangling with the data-load lifecycle above.
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const mql = window.matchMedia('(max-width: 640px)');
|
||||||
|
isMobileViewport = mql.matches;
|
||||||
|
const onChange = (e: MediaQueryListEvent) => {
|
||||||
|
isMobileViewport = e.matches;
|
||||||
|
};
|
||||||
|
mql.addEventListener('change', onChange);
|
||||||
|
return () => mql.removeEventListener('change', onChange);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet filterFormContent()}
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Status</span>
|
||||||
|
<div class="status-row">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="status"
|
||||||
|
value=""
|
||||||
|
bind:group={statusFilter}
|
||||||
|
onchange={onStatusChange}
|
||||||
|
/>
|
||||||
|
<span>Any</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="status"
|
||||||
|
value="ongoing"
|
||||||
|
bind:group={statusFilter}
|
||||||
|
onchange={onStatusChange}
|
||||||
|
/>
|
||||||
|
<span>Ongoing</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="status"
|
||||||
|
value="completed"
|
||||||
|
bind:group={statusFilter}
|
||||||
|
onchange={onStatusChange}
|
||||||
|
/>
|
||||||
|
<span>Completed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Genres (all must match)</span>
|
||||||
|
<div class="filter-chip-row">
|
||||||
|
{#each allGenres as g (g.id)}
|
||||||
|
{@const on = selectedGenres.some((x) => x.id === g.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="genre-pill"
|
||||||
|
class:active={on}
|
||||||
|
onclick={() => toggleGenre(g)}
|
||||||
|
data-testid={`genre-filter-${g.name}`}
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Tags (all must match)</span>
|
||||||
|
{#if selectedTags.length > 0}
|
||||||
|
<div class="filter-chip-row">
|
||||||
|
{#each selectedTags as t (t.id)}
|
||||||
|
<Chip
|
||||||
|
label={t.name}
|
||||||
|
variant="primary"
|
||||||
|
onRemove={() => removeTag(t)}
|
||||||
|
removeLabel={`Remove tag ${t.name}`}
|
||||||
|
testid={`tag-filter-chip-${t.name}`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="tag-search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
bind:value={tagDraft}
|
||||||
|
oninput={onTagDraftInput}
|
||||||
|
onkeydown={onTagFilterKeydown}
|
||||||
|
placeholder="Type to find a tag"
|
||||||
|
maxlength="64"
|
||||||
|
aria-label="Find a tag"
|
||||||
|
aria-controls={tagSuggestListId}
|
||||||
|
aria-expanded={tagSuggestions.length > 0}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-activedescendant={tagSuggestHighlight >= 0
|
||||||
|
? `${tagSuggestListId}-opt-${tagSuggestHighlight}`
|
||||||
|
: undefined}
|
||||||
|
data-testid="tag-filter-input"
|
||||||
|
/>
|
||||||
|
{#if tagSuggestions.length > 0}
|
||||||
|
<ul class="tag-suggestions" role="listbox" id={tagSuggestListId}>
|
||||||
|
{#each tagSuggestions as s, i (s.id)}
|
||||||
|
<li
|
||||||
|
id={`${tagSuggestListId}-opt-${i}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === tagSuggestHighlight}
|
||||||
|
class:active={i === tagSuggestHighlight}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex="-1"
|
||||||
|
onmouseenter={() => (tagSuggestHighlight = i)}
|
||||||
|
onclick={() => pickTag(s)}
|
||||||
|
data-testid={`tag-filter-suggestion-${s.name}`}
|
||||||
|
>
|
||||||
|
<Plus size={12} aria-hidden="true" />
|
||||||
|
{s.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeFilterCount > 0}
|
||||||
|
<button type="button" class="clear" onclick={clearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<h1>Mangas</h1>
|
<h1>Mangas</h1>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -268,130 +435,56 @@
|
|||||||
<span class="filter-count">{activeFilterCount}</span>
|
<span class="filter-count">{activeFilterCount}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sort-chip mobile-only"
|
||||||
|
onclick={() => (sortSheetOpen = true)}
|
||||||
|
data-testid="sort-chip"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={16} aria-hidden="true" />
|
||||||
|
<span>{sortLabel}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if filtersOpen}
|
|
||||||
<div class="filters-panel" id="filters-panel" data-testid="filters-panel">
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">Status</span>
|
|
||||||
<div class="status-row">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="status"
|
|
||||||
value=""
|
|
||||||
bind:group={statusFilter}
|
|
||||||
onchange={onStatusChange}
|
|
||||||
/>
|
|
||||||
<span>Any</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="status"
|
|
||||||
value="ongoing"
|
|
||||||
bind:group={statusFilter}
|
|
||||||
onchange={onStatusChange}
|
|
||||||
/>
|
|
||||||
<span>Ongoing</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="status"
|
|
||||||
value="completed"
|
|
||||||
bind:group={statusFilter}
|
|
||||||
onchange={onStatusChange}
|
|
||||||
/>
|
|
||||||
<span>Completed</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-group">
|
{#if activeFilterCount > 0}
|
||||||
<span class="filter-label">Genres (all must match)</span>
|
<div class="active-filters" data-testid="active-filters">
|
||||||
<div class="filter-chip-row">
|
{#if statusFilter}
|
||||||
{#each allGenres as g (g.id)}
|
<Chip
|
||||||
{@const on = selectedGenres.some((x) => x.id === g.id)}
|
label={statusFilter === 'ongoing' ? 'Ongoing' : 'Completed'}
|
||||||
<button
|
variant="primary"
|
||||||
type="button"
|
onRemove={clearStatus}
|
||||||
class="genre-pill"
|
removeLabel="Clear status filter"
|
||||||
class:active={on}
|
testid="active-filter-status"
|
||||||
onclick={() => toggleGenre(g)}
|
/>
|
||||||
data-testid={`genre-filter-${g.name}`}
|
|
||||||
>
|
|
||||||
{g.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-group">
|
|
||||||
<span class="filter-label">Tags (all must match)</span>
|
|
||||||
{#if selectedTags.length > 0}
|
|
||||||
<div class="filter-chip-row">
|
|
||||||
{#each selectedTags as t (t.id)}
|
|
||||||
<Chip
|
|
||||||
label={t.name}
|
|
||||||
variant="primary"
|
|
||||||
onRemove={() => removeTag(t)}
|
|
||||||
removeLabel={`Remove tag ${t.name}`}
|
|
||||||
testid={`tag-filter-chip-${t.name}`}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="tag-search">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
role="combobox"
|
|
||||||
bind:value={tagDraft}
|
|
||||||
oninput={onTagDraftInput}
|
|
||||||
onkeydown={onTagFilterKeydown}
|
|
||||||
placeholder="Type to find a tag"
|
|
||||||
maxlength="64"
|
|
||||||
aria-label="Find a tag"
|
|
||||||
aria-controls={tagSuggestListId}
|
|
||||||
aria-expanded={tagSuggestions.length > 0}
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-activedescendant={tagSuggestHighlight >= 0
|
|
||||||
? `${tagSuggestListId}-opt-${tagSuggestHighlight}`
|
|
||||||
: undefined}
|
|
||||||
data-testid="tag-filter-input"
|
|
||||||
/>
|
|
||||||
{#if tagSuggestions.length > 0}
|
|
||||||
<ul class="tag-suggestions" role="listbox" id={tagSuggestListId}>
|
|
||||||
{#each tagSuggestions as s, i (s.id)}
|
|
||||||
<li
|
|
||||||
id={`${tagSuggestListId}-opt-${i}`}
|
|
||||||
role="option"
|
|
||||||
aria-selected={i === tagSuggestHighlight}
|
|
||||||
class:active={i === tagSuggestHighlight}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabindex="-1"
|
|
||||||
onmouseenter={() => (tagSuggestHighlight = i)}
|
|
||||||
onclick={() => pickTag(s)}
|
|
||||||
data-testid={`tag-filter-suggestion-${s.name}`}
|
|
||||||
>
|
|
||||||
<Plus size={12} aria-hidden="true" />
|
|
||||||
{s.name}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if activeFilterCount > 0}
|
|
||||||
<button type="button" class="clear" onclick={clearFilters}>
|
|
||||||
Clear filters
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{#each selectedGenres as g (g.id)}
|
||||||
|
<Chip
|
||||||
|
label={g.name}
|
||||||
|
variant="primary"
|
||||||
|
onRemove={() => toggleGenre(g)}
|
||||||
|
removeLabel={`Remove genre ${g.name}`}
|
||||||
|
testid={`active-filter-genre-${g.name}`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#each selectedTags as t (t.id)}
|
||||||
|
<Chip
|
||||||
|
label={t.name}
|
||||||
|
variant="primary"
|
||||||
|
onRemove={() => removeTag(t)}
|
||||||
|
removeLabel={`Remove tag ${t.name}`}
|
||||||
|
testid={`active-filter-tag-${t.name}`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="config-row">
|
|
||||||
|
{#if filtersOpen && !isMobileViewport}
|
||||||
|
<div class="filters-panel" id="filters-panel" data-testid="filters-panel">
|
||||||
|
{@render filterFormContent()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="config-row desktop-only">
|
||||||
<label class="sort">
|
<label class="sort">
|
||||||
<span>Sort</span>
|
<span>Sort</span>
|
||||||
<select bind:value={sort} onchange={onSortChange} data-testid="sort-select">
|
<select bind:value={sort} onchange={onSortChange} data-testid="sort-select">
|
||||||
@@ -402,6 +495,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<Sheet
|
||||||
|
open={filtersOpen && isMobileViewport}
|
||||||
|
title="Filter"
|
||||||
|
onClose={() => (filtersOpen = false)}
|
||||||
|
testid="filter-sheet"
|
||||||
|
>
|
||||||
|
{@render filterFormContent()}
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<Sheet
|
||||||
|
open={sortSheetOpen && isMobileViewport}
|
||||||
|
title="Sort"
|
||||||
|
onClose={() => (sortSheetOpen = false)}
|
||||||
|
testid="sort-sheet"
|
||||||
|
>
|
||||||
|
<div class="sort-options">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-mobile"
|
||||||
|
value="recent"
|
||||||
|
aria-label="Recent"
|
||||||
|
checked={sort === 'recent'}
|
||||||
|
onchange={() => pickSort('recent')}
|
||||||
|
/>
|
||||||
|
<span>Recent</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-mobile"
|
||||||
|
value="title"
|
||||||
|
aria-label="Title (A→Z)"
|
||||||
|
checked={sort === 'title'}
|
||||||
|
onchange={() => pickSort('title')}
|
||||||
|
/>
|
||||||
|
<span>Title (A→Z)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="status" data-testid="loading">Loading…</p>
|
<p class="status" data-testid="loading">Loading…</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
@@ -476,6 +610,29 @@
|
|||||||
text-align: center;
|
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 {
|
.filters-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -611,6 +768,20 @@
|
|||||||
width: auto;
|
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 {
|
.icon-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -653,4 +824,33 @@
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: var(--space-4);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user