feat(frontend): mobile account hub + library wrapper (0.60.0)
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) <noreply@anthropic.com>
This commit is contained in:
244
frontend/e2e/mobile-account-library.spec.ts
Normal file
244
frontend/e2e/mobile-account-library.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user