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,169 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { changePassword } from '$lib/api/auth';
import { ApiError } from '$lib/api/client';
import { session } from '$lib/session.svelte';
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let submitting = $state(false);
let success = $state<string | null>(null);
let error: string | null = $state(null);
const passwordsMatch = $derived(
newPassword.length > 0 && newPassword === confirmPassword
);
const canSubmit = $derived(
Boolean(session.user) &&
currentPassword.length > 0 &&
newPassword.length >= 8 &&
passwordsMatch &&
!submitting
);
async function submit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmit) return;
submitting = true;
success = null;
error = null;
try {
await changePassword({
current_password: currentPassword,
new_password: newPassword
});
success = 'Password updated. Other devices have been signed out.';
currentPassword = '';
newPassword = '';
confirmPassword = '';
} catch (e) {
if (e instanceof ApiError && e.status === 401 && !session.user) {
await goto('/login');
return;
}
error = e instanceof Error ? e.message : String(e);
} finally {
submitting = false;
}
}
</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>
<form onsubmit={submit} action="javascript:void(0)" data-testid="password-form">
<label class="form-field">
<span>Current password</span>
<input
type="password"
bind:value={currentPassword}
autocomplete="current-password"
required
data-testid="current-password"
/>
</label>
<label class="form-field">
<span>New password</span>
<input
type="password"
bind:value={newPassword}
autocomplete="new-password"
minlength="8"
required
data-testid="new-password"
/>
</label>
<label class="form-field">
<span>Confirm new password</span>
<input
type="password"
bind:value={confirmPassword}
autocomplete="new-password"
minlength="8"
required
data-testid="confirm-password"
/>
{#if confirmPassword.length > 0 && !passwordsMatch}
<span class="field-error" role="alert" data-testid="mismatch">
Passwords don't match.
</span>
{/if}
</label>
<button
class="primary"
type="submit"
disabled={!canSubmit}
data-testid="password-submit"
>
{submitting ? 'Updating…' : 'Update password'}
</button>
{#if success}
<p class="success" data-testid="password-success">{success}</p>
{/if}
{#if error}
<p role="alert" class="form-error" data-testid="password-error">{error}</p>
{/if}
</form>
</section>
{/if}
<style>
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
max-width: 32rem;
}
.hint {
color: var(--text-muted);
font-size: var(--font-sm);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.primary {
background: var(--primary);
color: var(--primary-contrast);
border-color: var(--primary);
margin-top: var(--space-1);
align-self: flex-start;
}
.primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.field-error {
color: var(--danger);
font-size: var(--font-sm);
}
.form-error {
color: var(--danger);
}
.success {
color: var(--success);
}
.status {
color: var(--text-muted);
}
</style>