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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.59.0"
|
version = "0.60.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.59.0"
|
version = "0.60.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
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');
|
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 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.setViewportSize(MOBILE);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
await page.getByTestId('bottom-nav-library').click();
|
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');
|
await expect(page.getByTestId('bottom-nav-library')).toHaveAttribute('aria-current', 'page');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.59.0",
|
"version": "0.60.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
'/upload': 'Mangalord | Upload',
|
'/upload': 'Mangalord | Upload',
|
||||||
'/bookmarks': 'Mangalord | Bookmarks',
|
'/bookmarks': 'Mangalord | Bookmarks',
|
||||||
'/collections': 'Mangalord | Collections',
|
'/collections': 'Mangalord | Collections',
|
||||||
|
'/library': 'Mangalord | Library',
|
||||||
'/profile': 'Mangalord | Profile',
|
'/profile': 'Mangalord | Profile',
|
||||||
'/profile/account': 'Mangalord | Account',
|
'/profile/account': 'Mangalord | Account',
|
||||||
'/profile/bookmarks': 'Mangalord | Bookmarks',
|
'/profile/bookmarks': 'Mangalord | Bookmarks',
|
||||||
@@ -53,19 +54,21 @@
|
|||||||
|
|
||||||
const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord');
|
const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord');
|
||||||
|
|
||||||
// Mobile bottom-nav tabs. Library consolidates Bookmarks / Collections /
|
// Mobile bottom-nav tabs. Library is now its own wrapper at /library
|
||||||
// History — it currently links to /bookmarks as the closest existing
|
// (Phase 5) hosting a SegmentedControl over Bookmarks / Collections /
|
||||||
// route; Phase 5 introduces /library with internal segmented sub-tabs
|
// History; the older top-level /bookmarks + /collections routes stay
|
||||||
// and this href flips at that point. Browse temporarily shares Home's
|
// active for desktop users and as deep-link targets, so they remain
|
||||||
// destination until the curated-feed split lands.
|
// in the `match` set. Browse temporarily shares Home's destination
|
||||||
|
// until the curated-feed split lands.
|
||||||
const MOBILE_TABS: BottomNavTab[] = [
|
const MOBILE_TABS: BottomNavTab[] = [
|
||||||
{ label: 'Home', href: '/', icon: House, match: [] },
|
{ label: 'Home', href: '/', icon: House, match: [] },
|
||||||
{ label: 'Browse', href: '/', icon: Search, match: [] },
|
{ label: 'Browse', href: '/', icon: Search, match: [] },
|
||||||
{
|
{
|
||||||
label: 'Library',
|
label: 'Library',
|
||||||
href: '/bookmarks',
|
href: '/library',
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
match: [
|
match: [
|
||||||
|
'/library',
|
||||||
'/bookmarks',
|
'/bookmarks',
|
||||||
'/collections',
|
'/collections',
|
||||||
'/profile/bookmarks',
|
'/profile/bookmarks',
|
||||||
|
|||||||
219
frontend/src/routes/library/+page.svelte
Normal file
219
frontend/src/routes/library/+page.svelte
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { fileUrl } from '$lib/api/client';
|
||||||
|
import { chapterLabel } from '$lib/api/chapters';
|
||||||
|
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||||
|
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
||||||
|
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
||||||
|
import BookImage from '@lucide/svelte/icons/book-image';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
type Tab = 'bookmarks' | 'collections' | 'history';
|
||||||
|
const TABS: { label: string; value: Tab }[] = [
|
||||||
|
{ label: 'Bookmarks', value: 'bookmarks' },
|
||||||
|
{ label: 'Collections', value: 'collections' },
|
||||||
|
{ label: 'History', value: 'history' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// `?tab=` is the source of truth — refreshing or sharing a link
|
||||||
|
// lands the user back on the same sub-tab. Bookmarks is the
|
||||||
|
// default so visiting /library bare doesn't add noise to the URL.
|
||||||
|
const activeTab: Tab = $derived.by(() => {
|
||||||
|
const t = $page.url.searchParams.get('tab');
|
||||||
|
return t === 'collections' || t === 'history' ? t : 'bookmarks';
|
||||||
|
});
|
||||||
|
|
||||||
|
function setTab(t: Tab) {
|
||||||
|
// goto with `replaceState: true` reliably updates the $page
|
||||||
|
// store so the activeTab $derived re-fires. `replaceState`
|
||||||
|
// alone (the shallow-routing helper) doesn't always trip the
|
||||||
|
// url-tracked dependency, leaving the view stuck on the
|
||||||
|
// previous sub-tab.
|
||||||
|
const url = new URL($page.url);
|
||||||
|
if (t === 'bookmarks') url.searchParams.delete('tab');
|
||||||
|
else url.searchParams.set('tab', t);
|
||||||
|
void goto(url.toString(), {
|
||||||
|
replaceState: true,
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Mangalord | Library</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1 class="library-heading">Library</h1>
|
||||||
|
|
||||||
|
<div class="tab-row">
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel="Library section"
|
||||||
|
value={activeTab}
|
||||||
|
options={TABS}
|
||||||
|
onchange={setTab}
|
||||||
|
testid="library-tabs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !data.authenticated}
|
||||||
|
<p class="hint" data-testid="library-signin">
|
||||||
|
<a href="/login?next=/library">Sign in</a> to see your library.
|
||||||
|
</p>
|
||||||
|
{:else if data.error}
|
||||||
|
<p class="error" role="alert" data-testid="library-error">
|
||||||
|
Couldn't load library: {data.error}
|
||||||
|
</p>
|
||||||
|
{:else if activeTab === 'bookmarks'}
|
||||||
|
{#if data.bookmarks.length === 0}
|
||||||
|
<p class="hint" data-testid="library-bookmarks-empty">No bookmarks yet.</p>
|
||||||
|
{:else}
|
||||||
|
<BookmarkList bookmarks={data.bookmarks} testid="library-bookmark-list" />
|
||||||
|
{/if}
|
||||||
|
{:else if activeTab === 'collections'}
|
||||||
|
{#if data.collections.length === 0}
|
||||||
|
<p class="hint" data-testid="library-collections-empty">
|
||||||
|
You don't have any collections yet. Open any manga and use
|
||||||
|
<strong>Add to collection</strong> to start one.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<CollectionsGrid collections={data.collections} />
|
||||||
|
{/if}
|
||||||
|
{:else if data.history.length === 0}
|
||||||
|
<p class="hint" data-testid="library-history-empty">
|
||||||
|
Nothing here yet — open any manga and a row will land here once you turn
|
||||||
|
a page.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="history-list" data-testid="library-history-list">
|
||||||
|
{#each data.history as p (p.manga_id)}
|
||||||
|
<li class="history-row">
|
||||||
|
<a
|
||||||
|
href={p.chapter_id != null
|
||||||
|
? `/manga/${p.manga_id}/chapter/${p.chapter_id}`
|
||||||
|
: `/manga/${p.manga_id}`}
|
||||||
|
class="cover-link"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{#if p.manga_cover_image_path}
|
||||||
|
<img
|
||||||
|
src={fileUrl(p.manga_cover_image_path)}
|
||||||
|
alt=""
|
||||||
|
class="cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="cover cover-placeholder">
|
||||||
|
<BookImage size={18} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
<div class="meta">
|
||||||
|
<a href="/manga/{p.manga_id}" class="title">{p.manga_title}</a>
|
||||||
|
{#if p.chapter_id != null && p.chapter_number != null}
|
||||||
|
<a
|
||||||
|
class="target"
|
||||||
|
href="/manga/{p.manga_id}/chapter/{p.chapter_id}"
|
||||||
|
>
|
||||||
|
Continue {chapterLabel({
|
||||||
|
number: p.chapter_number,
|
||||||
|
title: null
|
||||||
|
})}{#if p.page > 1} — page {p.page}{/if}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.library-heading {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-row {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-row :global(.segmented) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-row :global(.seg) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-link {
|
||||||
|
display: block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 56px;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
44
frontend/src/routes/library/+page.ts
Normal file
44
frontend/src/routes/library/+page.ts
Normal file
@@ -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<ReturnType<typeof listMyBookmarks>>['items'],
|
||||||
|
collections: [] as Awaited<ReturnType<typeof listMyCollections>>['items'],
|
||||||
|
history: [] as Awaited<ReturnType<typeof listMyReadProgress>>['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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -98,6 +98,20 @@
|
|||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.tab {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { changePassword } from '$lib/api/auth';
|
import { changePassword, logout } from '$lib/api/auth';
|
||||||
import { ApiError } from '$lib/api/client';
|
import { ApiError } from '$lib/api/client';
|
||||||
|
import { preferences } from '$lib/preferences.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
|
import Sheet from '$lib/components/Sheet.svelte';
|
||||||
|
import User from '@lucide/svelte/icons/user';
|
||||||
|
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||||
|
import KeyRound from '@lucide/svelte/icons/key-round';
|
||||||
|
import LogOut from '@lucide/svelte/icons/log-out';
|
||||||
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
|
|
||||||
let currentPassword = $state('');
|
let currentPassword = $state('');
|
||||||
let newPassword = $state('');
|
let newPassword = $state('');
|
||||||
@@ -11,6 +19,10 @@
|
|||||||
let success = $state<string | null>(null);
|
let success = $state<string | null>(null);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
|
let isMobileViewport = $state(false);
|
||||||
|
let passwordSheetOpen = $state(false);
|
||||||
|
let loggingOut = $state(false);
|
||||||
|
|
||||||
const passwordsMatch = $derived(
|
const passwordsMatch = $derived(
|
||||||
newPassword.length > 0 && newPassword === confirmPassword
|
newPassword.length > 0 && newPassword === confirmPassword
|
||||||
);
|
);
|
||||||
@@ -22,6 +34,21 @@
|
|||||||
!submitting
|
!submitting
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const memberSince = $derived(
|
||||||
|
session.user ? new Date(session.user.created_at).toLocaleDateString() : null
|
||||||
|
);
|
||||||
|
|
||||||
|
$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);
|
||||||
|
});
|
||||||
|
|
||||||
async function submit(e: SubmitEvent) {
|
async function submit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
@@ -47,20 +74,25 @@
|
|||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirror the root layout's logout flow so the user lands at /login
|
||||||
|
// with the same session-clearing semantics. Replicated here rather
|
||||||
|
// than imported because the layout's handler closes over its own
|
||||||
|
// local `loggingOut` state.
|
||||||
|
async function handleLogout() {
|
||||||
|
loggingOut = true;
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
} finally {
|
||||||
|
session.setUser(null);
|
||||||
|
preferences.clearForLogout();
|
||||||
|
loggingOut = false;
|
||||||
|
await goto('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !session.user}
|
{#snippet passwordForm()}
|
||||||
<p class="status" data-testid="account-signin">
|
|
||||||
<a href="/login?next=/profile/account">Sign in</a> to change your password.
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<section class="card">
|
|
||||||
<h2>Change password</h2>
|
|
||||||
<p class="hint">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
<form onsubmit={submit} action="javascript:void(0)" data-testid="password-form">
|
<form onsubmit={submit} action="javascript:void(0)" data-testid="password-form">
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>Current password</span>
|
<span>Current password</span>
|
||||||
@@ -114,9 +146,91 @@
|
|||||||
<p role="alert" class="form-error" data-testid="password-error">{error}</p>
|
<p role="alert" class="form-error" data-testid="password-error">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
{/snippet}
|
||||||
|
|
||||||
|
{#if !session.user}
|
||||||
|
<p class="status" data-testid="account-signin">
|
||||||
|
<a href="/login?next=/profile/account">Sign in</a> to manage your account.
|
||||||
|
</p>
|
||||||
|
{:else if isMobileViewport}
|
||||||
|
<div class="mobile-hub" data-testid="account-hub">
|
||||||
|
<header class="hub-profile" data-testid="account-hub-profile">
|
||||||
|
<div class="hub-avatar" aria-hidden="true">
|
||||||
|
<User size={28} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div class="hub-name" data-testid="account-username">
|
||||||
|
{session.user.username}
|
||||||
|
</div>
|
||||||
|
{#if memberSince}
|
||||||
|
<div class="hub-meta">Member since {memberSince}</div>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="row-group">
|
||||||
|
<a class="row" href="/profile" data-testid="account-row-profile">
|
||||||
|
<User size={18} aria-hidden="true" />
|
||||||
|
<span class="row-label">Profile</span>
|
||||||
|
<ChevronRight size={16} aria-hidden="true" class="row-chev" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="row"
|
||||||
|
href="/profile/preferences"
|
||||||
|
data-testid="account-row-preferences"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={18} aria-hidden="true" />
|
||||||
|
<span class="row-label">Preferences</span>
|
||||||
|
<ChevronRight size={16} aria-hidden="true" class="row-chev" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="row"
|
||||||
|
onclick={() => (passwordSheetOpen = true)}
|
||||||
|
data-testid="account-row-change-password"
|
||||||
|
>
|
||||||
|
<KeyRound size={18} aria-hidden="true" />
|
||||||
|
<span class="row-label">Change password</span>
|
||||||
|
<ChevronRight size={16} aria-hidden="true" class="row-chev" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="row row-destructive"
|
||||||
|
onclick={handleLogout}
|
||||||
|
disabled={loggingOut}
|
||||||
|
data-testid="account-row-logout"
|
||||||
|
>
|
||||||
|
<LogOut size={18} aria-hidden="true" />
|
||||||
|
<span class="row-label">{loggingOut ? 'Logging out…' : 'Log out'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<section class="card" data-testid="account-desktop-card">
|
||||||
|
<h2>Change password</h2>
|
||||||
|
<p class="hint">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{@render passwordForm()}
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Sheet
|
||||||
|
open={passwordSheetOpen && isMobileViewport}
|
||||||
|
title="Change password"
|
||||||
|
onClose={() => (passwordSheetOpen = false)}
|
||||||
|
testid="password-sheet"
|
||||||
|
>
|
||||||
|
<p class="hint sheet-hint">
|
||||||
|
Changing your password signs out every other device. Bot API tokens are
|
||||||
|
unaffected.
|
||||||
|
</p>
|
||||||
|
{@render passwordForm()}
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -131,6 +245,10 @@
|
|||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sheet-hint {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -166,4 +284,106 @@
|
|||||||
.status {
|
.status {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Mobile account hub (inset-grouped iOS-style list) ===== */
|
||||||
|
|
||||||
|
.mobile-hub {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-profile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-4) 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-name {
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-group {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--font-base);
|
||||||
|
min-height: 52px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover:not(:disabled) {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lucide icons render as <svg>; need :global to penetrate scoping
|
||||||
|
since we use `class={...}` on the icon, which is hashed by Svelte
|
||||||
|
only when the selector matches a local element. */
|
||||||
|
.row :global(.row-chev) {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-destructive {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-destructive:hover:not(:disabled) {
|
||||||
|
background: var(--danger-soft-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user