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

2
backend/Cargo.lock generated
View File

@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.59.0"
version = "0.60.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.59.0"
version = "0.60.0"
edition = "2021"
default-run = "mangalord"

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

View File

@@ -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');
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.59.0",
"version": "0.60.0",
"private": true,
"type": "module",
"scripts": {

View File

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

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

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>