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

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

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>