feat: /profile dashboard with tabbed preferences, account, bookmarks, collections (0.18.0)

Tabbed user dashboard at `/profile` that absorbs `/settings` and
surfaces bookmarks + collections in one place.

- New `/profile` shell with tabs: Overview (counts), Preferences
  (theme + reader prefs, ported from /settings; works for guests
  via localStorage), Account (password change; auth-gated),
  Bookmarks, Collections. Guest tab list is filtered to what they
  can actually use.
- `/settings` is a 308 redirect to `/profile/preferences` so old
  bookmarks land cleanly. The "Settings" link in the top nav is
  replaced by a Profile link between Upload and Bookmarks; Bookmarks
  + Collections stay as shortcuts per the user spec.
- Extracts `lib/components/BookmarkList.svelte` and
  `lib/components/CollectionsGrid.svelte` so the top-level
  /bookmarks + /collections routes and the new profile tabs render
  the same UI without duplication. Both layers use a three-state
  load (authenticated / guest / error) to handle network hiccups
  inline.
- Deep links preserved via `?next=` on every sign-in CTA.

88 frontend unit tests + svelte-check clean; 12 of 12 e2e tests in
profile.spec.ts and reader-mode.spec.ts pass (8 other e2e failures
predate this branch and stay flagged for cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-17 17:59:29 +02:00
parent 274cc819ca
commit 7560d59616
21 changed files with 1060 additions and 613 deletions

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import { page } from '$app/stores';
import { session } from '$lib/session.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 Bookmark from '@lucide/svelte/icons/bookmark';
import FolderOpen from '@lucide/svelte/icons/folder-open';
let { children } = $props();
type Tab = {
href: string;
label: string;
icon: typeof User;
testid: string;
/** Subroutes that genuinely have nothing for guests are hidden
* until the user signs in. Preferences is shown to everyone
* because theme + reader settings work device-locally. */
guestVisible: boolean;
};
const tabs: Tab[] = [
{ href: '/profile', label: 'Overview', icon: User, testid: 'tab-overview', guestVisible: true },
{ href: '/profile/preferences', label: 'Preferences', icon: SlidersHorizontal, testid: 'tab-preferences', guestVisible: true },
{ href: '/profile/account', label: 'Account', icon: KeyRound, testid: 'tab-account', guestVisible: false },
{ href: '/profile/bookmarks', label: 'Bookmarks', icon: Bookmark, testid: 'tab-bookmarks', guestVisible: false },
{ href: '/profile/collections', label: 'Collections', icon: FolderOpen, testid: 'tab-collections', guestVisible: false }
];
const visibleTabs = $derived(
tabs.filter((t) => session.user || t.guestVisible)
);
</script>
<svelte:head>
<title>Profile — Mangalord</title>
</svelte:head>
<header class="profile-header">
<h1>Profile</h1>
{#if !session.loaded}
<p class="welcome" data-testid="profile-loading">Loading session…</p>
{:else if session.user}
<p class="welcome">
Signed in as <strong>{session.user.username}</strong>
</p>
{:else}
<p class="welcome" data-testid="profile-guest">
Browsing as a guest — sign in to access bookmarks, collections,
and account settings.
</p>
{/if}
</header>
<nav class="tabs" aria-label="Profile sections">
{#each visibleTabs as t (t.href)}
{@const active =
t.href === '/profile'
? $page.url.pathname === '/profile'
: $page.url.pathname === t.href ||
$page.url.pathname.startsWith(t.href + '/')}
<a
class="tab"
class:active
href={t.href}
aria-current={active ? 'page' : undefined}
data-testid={t.testid}
>
<t.icon size={16} aria-hidden="true" />
<span>{t.label}</span>
</a>
{/each}
</nav>
<section class="content">
{@render children()}
</section>
<style>
.profile-header {
margin-bottom: var(--space-3);
}
.profile-header h1 {
margin: 0 0 var(--space-1);
}
.welcome {
color: var(--text-muted);
margin: 0;
font-size: var(--font-sm);
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-4);
border-bottom: 1px solid var(--border);
}
.tab {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
color: var(--text-muted);
font-size: var(--font-sm);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition:
color var(--transition),
border-color var(--transition);
}
.tab:hover {
color: var(--text);
text-decoration: none;
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
font-weight: var(--weight-medium);
}
.content {
margin-top: var(--space-3);
}
</style>