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]]
|
||||
name = "mangalord"
|
||||
version = "0.59.0"
|
||||
version = "0.60.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.59.0"
|
||||
version = "0.60.0"
|
||||
edition = "2021"
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.59.0",
|
||||
"version": "0.60.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
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 { preferences } from '$lib/preferences.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 newPassword = $state('');
|
||||
@@ -11,6 +19,10 @@
|
||||
let success = $state<string | null>(null);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
let isMobileViewport = $state(false);
|
||||
let passwordSheetOpen = $state(false);
|
||||
let loggingOut = $state(false);
|
||||
|
||||
const passwordsMatch = $derived(
|
||||
newPassword.length > 0 && newPassword === confirmPassword
|
||||
);
|
||||
@@ -22,6 +34,21 @@
|
||||
!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) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
@@ -47,20 +74,25 @@
|
||||
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>
|
||||
|
||||
{#if !session.user}
|
||||
<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>
|
||||
{#snippet passwordForm()}
|
||||
<form onsubmit={submit} action="javascript:void(0)" data-testid="password-form">
|
||||
<label class="form-field">
|
||||
<span>Current password</span>
|
||||
@@ -114,9 +146,91 @@
|
||||
<p role="alert" class="form-error" data-testid="password-error">{error}</p>
|
||||
{/if}
|
||||
</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}
|
||||
|
||||
<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>
|
||||
.card {
|
||||
background: var(--surface);
|
||||
@@ -131,6 +245,10 @@
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.sheet-hint {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -166,4 +284,106 @@
|
||||
.status {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user