From 1e3fd27308f7624ce26ac93c8f7feeaf76b692b4 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 7 Jun 2026 11:35:07 +0200 Subject: [PATCH] feat(frontend): mobile account hub + library wrapper (0.60.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 (final) of the mobile redesign: /profile/account becomes an inset-grouped iOS-style hub on mobile and the new /library route hosts a SegmentedControl over Bookmarks / Collections / History. Profile- layout horizontal tabs hide below 640px. Desktop layout is unchanged. - New /library/+page.ts loads bookmarks, collections, and read-history in parallel so segmented-control tabs swap instantly without a new round trip. 401 → unauthenticated path with sign-in prompt. - New /library/+page.svelte renders the SegmentedControl with ?tab= as the source of truth. Bookmarks reuses the existing BookmarkList, Collections reuses CollectionsGrid, History inlines a slim cover + title + "Continue Ch. N" row list. `goto(..., { replaceState: true })` drives the URL — plain `replaceState` from $app/navigation doesn't reliably re-trigger $page-derived state. - BottomNav's Library tab now points at /library (Phase 1 placeholder was /bookmarks). The Phase 1 mobile-chrome spec is updated to expect the new target and mocks the additional library data endpoints. - /profile/+layout.svelte hides the horizontal tabs below 640px — the bottom-nav Library/Account tabs plus the account hub carry mobile cross-section navigation. - /profile/account/+page.svelte gains a mobile hub: a centered avatar + username + "Member since" header, a row group with Profile / Preferences / Change password rows (the last opening a bottom Sheet hosting the existing password form snippet), and a separate group with a red "Log out" row that reuses the layout's logout flow (logout API → session.setUser(null) → preferences.clearForLogout → goto('/login')). Desktop keeps the inline card with the form. - matchMedia gates the hub vs. desktop card so the password form testids never duplicate on the page — the snippet is rendered in exactly one mount at a time. - 9 Playwright tests cover the Library nav handoff, segmented-control sub-tab swap with URL, sign-in CTAs on both routes, account hub composition, password sheet open flow, logout flow (POST /auth/ logout + redirect to /login), profile tabs hiding on mobile, and the desktop regression where the inline card is the only password surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/mobile-account-library.spec.ts | 244 +++++++++++++++++ frontend/e2e/mobile-chrome.spec.ts | 25 +- frontend/package.json | 2 +- frontend/src/routes/+layout.svelte | 15 +- frontend/src/routes/library/+page.svelte | 219 ++++++++++++++++ frontend/src/routes/library/+page.ts | 44 ++++ frontend/src/routes/profile/+layout.svelte | 14 + .../src/routes/profile/account/+page.svelte | 248 +++++++++++++++++- 10 files changed, 790 insertions(+), 25 deletions(-) create mode 100644 frontend/e2e/mobile-account-library.spec.ts create mode 100644 frontend/src/routes/library/+page.svelte create mode 100644 frontend/src/routes/library/+page.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 09b01fa..d3813f7 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.59.0" +version = "0.60.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 83607b3..abb8d58 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.59.0" +version = "0.60.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/mobile-account-library.spec.ts b/frontend/e2e/mobile-account-library.spec.ts new file mode 100644 index 0000000..04d562e --- /dev/null +++ b/frontend/e2e/mobile-account-library.spec.ts @@ -0,0 +1,244 @@ +import { test, expect, type Page } from '@playwright/test'; + +// Phase 5: Account becomes an inset-grouped hub on mobile (Profile / +// Preferences / Change password + red Log out at the bottom) and +// /library hosts a SegmentedControl over Bookmarks / Collections / +// History. Profile-layout horizontal tabs are hidden on mobile. Desktop +// is unchanged. + +const MOBILE = { width: 390, height: 844 } as const; +const DESKTOP = { width: 1280, height: 720 } as const; + +async function mockSession( + page: Page, + opts: { authed?: boolean; logoutCalls?: { count: number } } = {} +) { + const authed = opts.authed ?? false; + await page.route('**/api/v1/auth/config', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }) + ); + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: authed ? 200 : 401, + contentType: 'application/json', + body: authed + ? JSON.stringify({ + user: { + id: 'u1', + username: 'fabian', + created_at: '2026-01-05T00:00:00Z', + is_admin: false + } + }) + : JSON.stringify({ + error: { code: 'unauthenticated', message: 'unauthenticated' } + }) + }) + ); + await page.route('**/api/v1/auth/me/preferences', (route) => + route.fulfill({ + status: authed ? 200 : 401, + contentType: 'application/json', + body: authed + ? JSON.stringify({ + reader_mode: 'single', + reader_page_gap: 'none', + updated_at: '2026-01-05T00:00:00Z' + }) + : JSON.stringify({ error: { code: 'x', message: 'x' } }) + }) + ); + await page.route('**/api/v1/auth/logout', (route) => { + if (opts.logoutCalls) opts.logoutCalls.count += 1; + return route.fulfill({ status: 204, body: '' }); + }); +} + +async function mockLibraryData(page: Page, opts: { authed?: boolean } = {}) { + const status = opts.authed ? 200 : 401; + const emptyPage = JSON.stringify({ + items: [], + page: { limit: 50, offset: 0, total: 0 } + }); + const unauth = JSON.stringify({ + error: { code: 'unauthenticated', message: 'unauthenticated' } + }); + await page.route('**/api/v1/me/bookmarks*', (route) => + route.fulfill({ + status, + contentType: 'application/json', + body: opts.authed ? emptyPage : unauth + }) + ); + await page.route('**/api/v1/me/collections*', (route) => + route.fulfill({ + status, + contentType: 'application/json', + body: opts.authed ? emptyPage : unauth + }) + ); + await page.route('**/api/v1/me/read-progress*', (route) => + route.fulfill({ + status, + contentType: 'application/json', + body: opts.authed ? emptyPage : unauth + }) + ); +} + +test.describe('mobile library', () => { + test('phone viewport: BottomNav Library tab navigates to /library', async ({ + page + }) => { + await mockSession(page); + await mockLibraryData(page); + await page.route('**/api/v1/mangas*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [], + page: { limit: 50, offset: 0, total: 0 } + }) + }) + ); + await page.setViewportSize(MOBILE); + await page.goto('/'); + + await page.getByTestId('bottom-nav-library').click(); + await expect(page).toHaveURL(/\/library$/); + await expect(page.getByTestId('library-tabs')).toBeVisible(); + }); + + test('phone viewport: SegmentedControl swaps Library sub-tabs and updates ?tab=', async ({ + page + }) => { + await mockSession(page, { authed: true }); + await mockLibraryData(page, { authed: true }); + await page.setViewportSize(MOBILE); + await page.goto('/library'); + + // Default sub-tab is bookmarks — URL has no `tab=` param. + await expect(page).toHaveURL(/\/library$/); + await expect(page.getByTestId('library-bookmarks-empty')).toBeVisible(); + + await page + .getByTestId('library-tabs') + .getByRole('radio', { name: 'Collections' }) + .click(); + await expect(page).toHaveURL(/\?tab=collections$/); + await expect(page.getByTestId('library-collections-empty')).toBeVisible(); + + await page + .getByTestId('library-tabs') + .getByRole('radio', { name: 'History' }) + .click(); + await expect(page).toHaveURL(/\?tab=history$/); + await expect(page.getByTestId('library-history-empty')).toBeVisible(); + + // Returning to Bookmarks clears the param entirely. + await page + .getByTestId('library-tabs') + .getByRole('radio', { name: 'Bookmarks' }) + .click(); + await expect(page).toHaveURL(/\/library$/); + }); + + test('phone viewport on /library unauth: sign-in prompt, no list', async ({ + page + }) => { + await mockSession(page); + await mockLibraryData(page); + await page.setViewportSize(MOBILE); + await page.goto('/library'); + + await expect(page.getByTestId('library-signin')).toBeVisible(); + await expect(page.getByTestId('library-bookmarks-empty')).toHaveCount(0); + }); +}); + +test.describe('mobile account hub', () => { + test('phone viewport authed: inset-grouped hub renders with profile / preferences / change-password / logout rows', async ({ + page + }) => { + await mockSession(page, { authed: true }); + await page.setViewportSize(MOBILE); + await page.goto('/profile/account'); + + await expect(page.getByTestId('account-hub')).toBeVisible(); + await expect(page.getByTestId('account-username')).toHaveText('fabian'); + await expect(page.getByTestId('account-row-profile')).toBeVisible(); + await expect(page.getByTestId('account-row-preferences')).toBeVisible(); + await expect(page.getByTestId('account-row-change-password')).toBeVisible(); + await expect(page.getByTestId('account-row-logout')).toBeVisible(); + // Desktop card MUST not render — only the hub view is active + // on mobile so we don't double-up the password form testids. + await expect(page.getByTestId('account-desktop-card')).toHaveCount(0); + }); + + test('phone viewport authed: Change password row opens the bottom sheet', async ({ + page + }) => { + await mockSession(page, { authed: true }); + await page.setViewportSize(MOBILE); + await page.goto('/profile/account'); + + await expect(page.getByTestId('password-sheet')).toBeHidden(); + await page.getByTestId('account-row-change-password').click(); + await expect(page.getByTestId('password-sheet')).toBeVisible(); + await expect( + page.getByTestId('password-sheet').getByTestId('current-password') + ).toBeVisible(); + }); + + test('phone viewport authed: Log out row fires /auth/logout and routes to /login', async ({ + page + }) => { + const logoutCalls = { count: 0 }; + await mockSession(page, { authed: true, logoutCalls }); + await page.setViewportSize(MOBILE); + await page.goto('/profile/account'); + + await page.getByTestId('account-row-logout').click(); + + await expect(page).toHaveURL(/\/login$/); + expect(logoutCalls.count).toBe(1); + }); + + test('phone viewport unauth: sign-in CTA, no hub', async ({ page }) => { + await mockSession(page); + await page.setViewportSize(MOBILE); + await page.goto('/profile/account'); + + await expect(page.getByTestId('account-signin')).toBeVisible(); + await expect(page.getByTestId('account-hub')).toHaveCount(0); + }); + + test('phone viewport authed: profile horizontal tabs are hidden', async ({ + page + }) => { + await mockSession(page, { authed: true }); + await page.setViewportSize(MOBILE); + await page.goto('/profile/account'); + + await expect(page.getByTestId('tab-account')).toBeHidden(); + }); + + test('desktop viewport authed: existing password card visible, hub is not', async ({ + page + }) => { + await mockSession(page, { authed: true }); + await page.setViewportSize(DESKTOP); + await page.goto('/profile/account'); + + await expect(page.getByTestId('account-desktop-card')).toBeVisible(); + await expect(page.getByTestId('password-form')).toBeVisible(); + await expect(page.getByTestId('account-hub')).toHaveCount(0); + // Desktop profile tabs remain. + await expect(page.getByTestId('tab-account')).toBeVisible(); + }); +}); diff --git a/frontend/e2e/mobile-chrome.spec.ts b/frontend/e2e/mobile-chrome.spec.ts index 0cc9ff8..7036150 100644 --- a/frontend/e2e/mobile-chrome.spec.ts +++ b/frontend/e2e/mobile-chrome.spec.ts @@ -63,13 +63,34 @@ test.describe('mobile chrome', () => { await expect(home).toHaveAttribute('aria-current', 'page'); }); - test('phone viewport: tapping Library navigates to /bookmarks and marks itself active', async ({ page }) => { + test('phone viewport: tapping Library navigates to /library and marks itself active', async ({ page }) => { await mockAnonymous(page); + await page.route('**/api/v1/me/bookmarks*', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'x', message: 'x' } }) + }) + ); + await page.route('**/api/v1/me/collections*', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'x', message: 'x' } }) + }) + ); + await page.route('**/api/v1/me/read-progress*', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'x', message: 'x' } }) + }) + ); await page.setViewportSize(MOBILE); await page.goto('/'); await page.getByTestId('bottom-nav-library').click(); - await expect(page).toHaveURL(/\/bookmarks$/); + await expect(page).toHaveURL(/\/library$/); await expect(page.getByTestId('bottom-nav-library')).toHaveAttribute('aria-current', 'page'); }); diff --git a/frontend/package.json b/frontend/package.json index 432228f..aeaba1c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.59.0", + "version": "0.60.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 57de468..391944d 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -39,6 +39,7 @@ '/upload': 'Mangalord | Upload', '/bookmarks': 'Mangalord | Bookmarks', '/collections': 'Mangalord | Collections', + '/library': 'Mangalord | Library', '/profile': 'Mangalord | Profile', '/profile/account': 'Mangalord | Account', '/profile/bookmarks': 'Mangalord | Bookmarks', @@ -53,19 +54,21 @@ const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord'); - // Mobile bottom-nav tabs. Library consolidates Bookmarks / Collections / - // History — it currently links to /bookmarks as the closest existing - // route; Phase 5 introduces /library with internal segmented sub-tabs - // and this href flips at that point. Browse temporarily shares Home's - // destination until the curated-feed split lands. + // Mobile bottom-nav tabs. Library is now its own wrapper at /library + // (Phase 5) hosting a SegmentedControl over Bookmarks / Collections / + // History; the older top-level /bookmarks + /collections routes stay + // active for desktop users and as deep-link targets, so they remain + // in the `match` set. Browse temporarily shares Home's destination + // until the curated-feed split lands. const MOBILE_TABS: BottomNavTab[] = [ { label: 'Home', href: '/', icon: House, match: [] }, { label: 'Browse', href: '/', icon: Search, match: [] }, { label: 'Library', - href: '/bookmarks', + href: '/library', icon: BookOpen, match: [ + '/library', '/bookmarks', '/collections', '/profile/bookmarks', diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte new file mode 100644 index 0000000..3d87dd6 --- /dev/null +++ b/frontend/src/routes/library/+page.svelte @@ -0,0 +1,219 @@ + + + + Mangalord | Library + + +

Library

+ +
+ +
+ +{#if !data.authenticated} +

+ Sign in to see your library. +

+{:else if data.error} + +{:else if activeTab === 'bookmarks'} + {#if data.bookmarks.length === 0} +

No bookmarks yet.

+ {:else} + + {/if} +{:else if activeTab === 'collections'} + {#if data.collections.length === 0} +

+ You don't have any collections yet. Open any manga and use + Add to collection to start one. +

+ {:else} + + {/if} +{:else if data.history.length === 0} +

+ Nothing here yet — open any manga and a row will land here once you turn + a page. +

+{:else} + +{/if} + + diff --git a/frontend/src/routes/library/+page.ts b/frontend/src/routes/library/+page.ts new file mode 100644 index 0000000..14d17d5 --- /dev/null +++ b/frontend/src/routes/library/+page.ts @@ -0,0 +1,44 @@ +import { ApiError } from '$lib/api/client'; +import { listMyBookmarks } from '$lib/api/bookmarks'; +import { listMyCollections } from '$lib/api/collections'; +import { listMyReadProgress } from '$lib/api/read_progress'; +import type { PageLoad } from './$types'; + +export const ssr = false; + +/** + * Loads bookmarks + collections + reading-history in one shot so the + * Library segmented control can swap between sub-tabs without firing a + * second round trip per tap. 401 → unauthenticated path; the page + * surfaces a sign-in prompt and renders empty lists. + */ +export const load: PageLoad = async () => { + const empty = { + authenticated: true, + bookmarks: [] as Awaited>['items'], + collections: [] as Awaited>['items'], + history: [] as Awaited>['items'], + error: null as string | null + }; + try { + const [bookmarks, collections, history] = await Promise.all([ + listMyBookmarks(), + listMyCollections({ limit: 200 }), + listMyReadProgress({ limit: 100 }) + ]); + return { + ...empty, + bookmarks: bookmarks.items, + collections: collections.items, + history: history.items + }; + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + return { ...empty, authenticated: false }; + } + if (e instanceof ApiError) { + return { ...empty, error: e.message }; + } + throw e; + } +}; diff --git a/frontend/src/routes/profile/+layout.svelte b/frontend/src/routes/profile/+layout.svelte index f6d0e8e..1d1db32 100644 --- a/frontend/src/routes/profile/+layout.svelte +++ b/frontend/src/routes/profile/+layout.svelte @@ -98,6 +98,20 @@ border-bottom: 1px solid var(--border); } + /* Mobile uses the bottom-nav Account/Library tabs plus the account + hub for cross-section navigation. The horizontal tab strip would + compete with the bottom nav and wrap awkwardly at narrow widths, + so hide it below the breakpoint and let pages stand on their own. */ + @media (max-width: 640px) { + .tabs { + display: none; + } + + .profile-header { + margin-bottom: var(--space-2); + } + } + .tab { display: inline-flex; align-items: center; diff --git a/frontend/src/routes/profile/account/+page.svelte b/frontend/src/routes/profile/account/+page.svelte index 8dfd5bb..c5513ad 100644 --- a/frontend/src/routes/profile/account/+page.svelte +++ b/frontend/src/routes/profile/account/+page.svelte @@ -1,8 +1,16 @@ -{#if !session.user} -

- Sign in to change your password. -

-{:else} -
-

Change password

-

- Changing your password signs out every other device using this account. - Bot API tokens keep working — revoke them individually from the bot-token - list if you want to invalidate them too. -

+{#snippet passwordForm()}
+{/snippet} + +{#if !session.user} +

+ Sign in to manage your account. +

+{:else if isMobileViewport} +
+
+ +
+ {session.user.username} +
+ {#if memberSince} +
Member since {memberSince}
+ {/if} +
+ +
+ + + + + +
+ +
+ +
+
+{:else} +
+

Change password

+

+ Changing your password signs out every other device using this account. + Bot API tokens keep working — revoke them individually from the bot-token + list if you want to invalidate them too. +

+ {@render passwordForm()} +
{/if} + (passwordSheetOpen = false)} + testid="password-sheet" +> +

+ Changing your password signs out every other device. Bot API tokens are + unaffected. +

+ {@render passwordForm()} +
+