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:
MechaCat02
2026-06-07 11:35:07 +02:00
parent f5842510b7
commit 1e3fd27308
10 changed files with 790 additions and 25 deletions

View 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>

View 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;
}
};