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

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