Files
libnovel/ui/src/routes/profile/+page.svelte
root b95c811898
All checks were successful
Release / Test backend (push) Successful in 4m12s
Release / Check ui (push) Successful in 1m53s
Release / Docker (push) Successful in 5m46s
Release / Gitea Release (push) Successful in 35s
feat: web push notifications for new chapters
- Service worker (src/service-worker.ts) handles push events and
  notification clicks, navigating to the book page on tap
- Web app manifest (manifest.webmanifest) linked in app.html
- Profile page: push notification toggle (subscribe/unsubscribe)
  using the browser Notification + PushManager API with VAPID
- API route POST/DELETE /api/push-subscription proxies to backend
- Go backend: push_subscriptions PocketBase collection storage
  methods (SavePushSubscription, DeletePushSubscription,
  ListPushSubscriptionsByBook) in storage/store.go
- handlers_push.go: GET vapid-public-key, POST/DELETE subscription
- webpush package: VAPID-signed sends via webpush-go, SendToBook
  fans out to all users who have the book in their library
- Runner fires push to subscribers whenever ChaptersScraped > 0
  after a successful book scrape
- Config: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT env vars
- domain.ScrapeResult gets a Slug field; orchestrator populates it
2026-04-11 14:59:21 +05:00

956 lines
40 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { enhance } from '$app/forms';
import { untrack, getContext } from 'svelte';
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
import type { AudioMode } from '$lib/audio.svelte';
import { browser } from '$app/environment';
import { page } from '$app/state';
import { cn } from '$lib/utils';
import * as m from '$lib/paraglide/messages.js';
let { data, form }: { data: PageData; form: ActionData } = $props();
// ── Polar checkout ───────────────────────────────────────────────────────────
const manageUrl = `https://polar.sh/libnovel/portal`;
let checkoutLoading = $state<'monthly' | 'annual' | null>(null);
let checkoutError = $state('');
async function startCheckout(product: 'monthly' | 'annual') {
checkoutLoading = product;
checkoutError = '';
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product })
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
checkoutError = body.message ?? `Checkout failed (${res.status}). Please try again.`;
return;
}
const { url } = await res.json() as { url: string };
window.location.href = url;
} catch {
checkoutError = 'Network error. Please try again.';
} finally {
checkoutLoading = null;
}
}
// ── Avatar ───────────────────────────────────────────────────────────────────
const justSubscribed = $derived(browser && page.url.searchParams.get('subscribed') === '1');
let avatarUrl = $state<string | null>(untrack(() => data.avatarUrl ?? null));
let avatarUploading = $state(false);
let avatarError = $state('');
let fileInput: HTMLInputElement | null = null;
let cropFile = $state<File | null>(null);
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (fileInput) fileInput.value = '';
cropFile = file;
}
async function handleCropConfirm(blob: Blob, mimeType: string) {
cropFile = null;
avatarUploading = true;
avatarError = '';
try {
const res = await fetch('/api/profile/avatar', {
method: 'POST',
headers: { 'Content-Type': mimeType },
body: blob
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
avatarError = body.message ?? `Upload failed (${res.status})`;
return;
}
const result = await res.json() as { avatar_url: string | null };
avatarUrl = result.avatar_url;
} catch {
avatarError = 'Network error during upload';
} finally {
avatarUploading = false;
}
}
function handleCropCancel() { cropFile = null; }
// ── Settings state ────────────────────────────────────────────────────────────
// All changes are written directly into audioStore / theme context.
// The layout's debounced $effect owns the single PUT /api/settings call.
// We only maintain a local saveStatus indicator here.
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
let selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
const THEMES: { id: string; label: () => string; swatch: string; light?: boolean }[] = [
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
{ id: 'forest', label: () => m.profile_theme_forest(), swatch: '#4ade80' },
{ id: 'mono', label: () => m.profile_theme_mono(), swatch: '#f4f4f5' },
{ id: 'cyber', label: () => m.profile_theme_cyber(), swatch: '#22d3ee' },
{ id: 'light', label: () => m.profile_theme_light(), swatch: '#d97706', light: true },
{ id: 'light-slate', label: () => m.profile_theme_light_slate(), swatch: '#4f46e5', light: true },
{ id: 'light-rose', label: () => m.profile_theme_light_rose(), swatch: '#e11d48', light: true },
];
const FONTS = [
{ id: 'system', label: () => m.profile_font_system() },
{ id: 'serif', label: () => m.profile_font_serif() },
{ id: 'mono', label: () => m.profile_font_mono() },
];
const FONT_SIZES = [
{ value: 0.9, label: () => m.profile_text_size_sm() },
{ value: 1.0, label: () => m.profile_text_size_md() },
{ value: 1.15, label: () => m.profile_text_size_lg() },
{ value: 1.3, label: () => m.profile_text_size_xl() },
];
// Local save-status indicator — layout's effect does the actual debounced save.
type SaveStatus = 'idle' | 'saving' | 'saved';
let saveStatus = $state<SaveStatus>('idle');
let savedTimer = 0;
let initialized = false;
function markSaved() {
saveStatus = 'saving';
clearTimeout(savedTimer);
savedTimer = setTimeout(() => {
saveStatus = 'saved';
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
}, 900) as unknown as number;
}
// Propagate all settings changes into audioStore / context immediately.
// Layout effect watches these and persists to the server (debounced 800ms).
$effect(() => {
const t = selectedTheme;
const ff = selectedFontFamily;
const fs = selectedFontSize;
const v = audioStore.voice;
const sp = audioStore.speed;
const an = audioStore.autoNext;
const ac = audioStore.announceChapter;
const am = audioStore.audioMode;
if (settingsCtx) {
settingsCtx.current = t;
settingsCtx.fontFamily = ff;
settingsCtx.fontSize = fs;
}
if (!initialized) { initialized = true; return; }
void v; void sp; void an; void ac; void am;
markSaved();
});
$effect(() => { if (settingsCtx) settingsCtx.current = selectedTheme; });
$effect(() => { if (settingsCtx) settingsCtx.fontFamily = selectedFontFamily; });
$effect(() => { if (settingsCtx) settingsCtx.fontSize = selectedFontSize; });
// ── Tab ──────────────────────────────────────────────────────────────────────
let activeTab = $state<'profile' | 'stats' | 'history'>('profile');
// ── Sessions ─────────────────────────────────────────────────────────────────
type Session = {
id: string;
user_agent: string;
ip: string;
created_at: string;
last_seen: string;
is_current: boolean;
};
let sessions = $state<Session[]>(untrack(() => data.sessions ?? []));
let revokingId = $state<string | null>(null);
let revokeError = $state('');
async function revokeSession(session: Session) {
revokingId = session.id;
revokeError = '';
try {
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
if (!res.ok) { revokeError = 'Failed to end session. Please try again.'; return; }
if (session.is_current) {
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
if (logoutForm) logoutForm.submit();
return;
}
sessions = sessions.filter((s) => s.id !== session.id);
} catch {
revokeError = 'Network error. Please try again.';
} finally {
revokingId = null;
}
}
// ── Danger zone ──────────────────────────────────────────────────────────────
let deleteConfirmOpen = $state(false);
let deleteConfirmText = $state('');
let deleting = $state(false);
let deleteError = $state('');
const DELETE_KEYWORD = untrack(() => data.user.username);
const deleteReady = $derived(deleteConfirmText.trim() === DELETE_KEYWORD);
async function deleteAccount() {
if (!deleteReady) return;
deleting = true;
deleteError = '';
try {
const res = await fetch('/api/profile', { method: 'DELETE' });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
deleteError = body.message ?? `Delete failed (${res.status}). Please try again.`;
return;
}
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
if (logoutForm) logoutForm.submit();
} catch {
deleteError = 'Network error. Please try again.';
} finally {
deleting = false;
}
}
// ── Utilities ────────────────────────────────────────────────────────────────
function formatDate(iso: string): string {
if (!iso) return '—';
try {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
} catch { return iso; }
}
// ── Push notifications ────────────────────────────────────────────────────────
type PushState = 'unsupported' | 'default' | 'subscribed' | 'denied' | 'loading';
let pushState = $state<PushState>('unsupported');
let pushError = $state('');
$effect(() => {
if (!browser) return;
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
pushState = 'unsupported';
return;
}
// Check current permission / subscription state
(async () => {
const perm = Notification.permission;
if (perm === 'denied') { pushState = 'denied'; return; }
const reg = await navigator.serviceWorker.getRegistration('/');
if (!reg) { pushState = 'default'; return; }
const sub = await reg.pushManager.getSubscription();
pushState = sub ? 'subscribed' : 'default';
})();
});
async function subscribePush() {
pushError = '';
pushState = 'loading';
try {
// Fetch VAPID public key from backend
const keyRes = await fetch('/api/push-subscriptions/vapid-public-key');
if (!keyRes.ok) { pushError = 'Push notifications are not configured on this server.'; pushState = 'default'; return; }
const { public_key } = await keyRes.json() as { public_key: string };
// Register / get existing service worker
const reg = await navigator.serviceWorker.ready;
// Subscribe with VAPID
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
});
const json = sub.toJSON() as {
endpoint: string;
keys: { p256dh: string; auth: string };
};
const saveRes = await fetch('/api/push-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json),
});
if (!saveRes.ok) { pushError = 'Failed to save subscription. Please try again.'; pushState = 'default'; return; }
pushState = 'subscribed';
} catch (e) {
pushError = e instanceof Error ? e.message : 'Failed to enable notifications.';
pushState = Notification.permission === 'denied' ? 'denied' : 'default';
}
}
async function unsubscribePush() {
pushError = '';
pushState = 'loading';
try {
const reg = await navigator.serviceWorker.getRegistration('/');
const sub = await reg?.pushManager.getSubscription();
if (sub) {
await fetch('/api/push-subscription', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
await sub.unsubscribe();
}
pushState = 'default';
} catch (e) {
pushError = e instanceof Error ? e.message : 'Failed to disable notifications.';
pushState = 'subscribed';
}
}
/** Convert a base64url VAPID public key to a Uint8Array for PushManager.subscribe(). */
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const arr = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr.buffer as ArrayBuffer;
}
function parseUA(ua: string): string {
if (!ua) return 'Unknown browser';
if (/Mobile/i.test(ua)) {
const match = ua.match(/\(([^)]+)\)/);
return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device';
}
if (/Chrome\/(\d+)/i.test(ua)) return `Chrome ${ua.match(/Chrome\/(\d+)/i)![1]}`;
if (/Firefox\/(\d+)/i.test(ua)) return `Firefox ${ua.match(/Firefox\/(\d+)/i)![1]}`;
if (/Safari\/(\d+)/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari';
if (/Edg\/(\d+)/i.test(ua)) return `Edge ${ua.match(/Edg\/(\d+)/i)![1]}`;
return ua.slice(0, 48) + (ua.length > 48 ? '…' : '');
}
</script>
<svelte:head>
<title>{m.profile_page_title()}</title>
</svelte:head>
{#if cropFile && browser}
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
<AvatarCropModal file={cropFile} onconfirm={handleCropConfirm} oncancel={handleCropCancel} />
{/await}
{/if}
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
<div class="max-w-2xl mx-auto space-y-6 pb-12">
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
{#if justSubscribed}
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4">
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
</div>
{/if}
<!-- ── Profile header ───────────────────────────────────────────────────── -->
<div class="flex items-center gap-5 pt-2">
<div class="relative shrink-0">
<button
onclick={() => fileInput?.click()}
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none"
title={m.profile_change_avatar()}
disabled={avatarUploading}
>
{#if avatarUrl}
<img src={avatarUrl} alt="Profile" class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<span class="text-3xl font-bold text-(--color-muted) select-none">
{data.user.username.slice(0, 1).toUpperCase()}
</span>
</div>
{/if}
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{#if avatarUploading}
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
</svg>
{:else}
<span class="text-xs font-semibold text-white tracking-wide">Edit</span>
{/if}
</div>
</button>
<input bind:this={fileInput} type="file" accept="image/jpeg,image/png,image/webp" class="hidden" onchange={handleAvatarChange} />
</div>
<div class="min-w-0">
<h1 class="text-2xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
{#if data.isPro}
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
{m.profile_plan_pro()}
</span>
{/if}
</div>
{#if avatarError}
<p class="text-(--color-danger) text-xs mt-1.5">{avatarError}</p>
{:else}
<p class="text-(--color-muted) text-xs mt-1.5">{m.profile_click_to_change()}</p>
{/if}
</div>
</div>
<!-- Tabs -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 border border-(--color-border)">
{#each (['profile', 'stats', 'history'] as const) as tab}
<button
type="button"
onclick={() => (activeTab = tab)}
class={cn(
'flex-1 py-2 rounded-lg text-sm font-medium transition-colors',
activeTab === tab
? 'bg-(--color-surface-3) text-(--color-text) shadow-sm'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
>
{tab === 'profile' ? 'Profile' : tab === 'stats' ? 'Stats' : 'History'}
</button>
{/each}
</div>
{#if activeTab === 'profile'}
<!-- ── Subscription ──────────────────────────────────────────────────────── -->
{#if !data.isPro}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
</div>
<span class="shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) uppercase tracking-wide">
{m.profile_plan_free()}
</span>
</div>
<div class="mt-5 pt-5 border-t border-(--color-border)">
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()} <a href="/subscribe" class="text-(--color-brand) hover:underline">See plans →</a></p>
{#if checkoutError}
<p class="text-sm text-(--color-danger) mb-3">{checkoutError}</p>
{/if}
<div class="flex flex-wrap gap-3">
<button
type="button"
onclick={() => startCheckout('monthly')}
disabled={checkoutLoading !== null}
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60 disabled:cursor-wait">
{#if checkoutLoading === 'monthly'}
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
{/if}
{m.profile_upgrade_monthly()}
</button>
<button
type="button"
onclick={() => startCheckout('annual')}
disabled={checkoutLoading !== null}
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors disabled:opacity-60 disabled:cursor-wait">
{#if checkoutLoading === 'annual'}
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
{:else}
{m.profile_upgrade_annual()}
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">33%</span>
{/if}
</button>
</div>
</div>
</section>
{:else}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
</div>
<a href={manageUrl} target="_blank" rel="noopener noreferrer"
class="shrink-0 text-sm font-medium text-(--color-brand) hover:underline">
{m.profile_manage_subscription()}
</a>
</section>
{/if}
<!-- ── Preferences ───────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4">
<h2 class="text-base font-semibold text-(--color-text)">Preferences</h2>
<span class={cn(
'text-xs transition-all duration-300',
saveStatus === 'saving' ? 'text-(--color-muted)' :
saveStatus === 'saved' ? 'text-green-400' :
'opacity-0 pointer-events-none'
)}>
{#if saveStatus === 'saving'}{m.profile_saving()}
{:else if saveStatus === 'saved'}{m.profile_saved()}
{:else}{m.profile_saved()}{/if}
</span>
</div>
<!-- Theme -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
<div class="flex gap-2 flex-wrap items-center">
{#each THEMES as t, i}
{#if i === 6}
<span class="w-px h-6 bg-(--color-border) mx-1 self-center"></span>
{/if}
<button
type="button"
onclick={() => (selectedTheme = t.id)}
class={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
selectedTheme === t.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
)}
aria-pressed={selectedTheme === t.id}
>
<span class="w-3 h-3 rounded-full shrink-0 {t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
{t.label()}
</button>
{/each}
</div>
</div>
<!-- Font family -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
<div class="flex gap-2 flex-wrap">
{#each FONTS as f}
<button
type="button"
onclick={() => (selectedFontFamily = f.id)}
class={cn(
'px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
selectedFontFamily === f.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
)}
aria-pressed={selectedFontFamily === f.id}
>
{f.label()}
</button>
{/each}
</div>
</div>
<!-- Text size -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
<div class="flex gap-2 flex-wrap">
{#each FONT_SIZES as s}
<button
type="button"
onclick={() => (selectedFontSize = s.value)}
class={cn(
'px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
selectedFontSize === s.value
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
)}
aria-pressed={selectedFontSize === s.value}
>
{s.label()}
</button>
{/each}
</div>
</div>
<!-- Playback speed -->
<div class="px-6 py-5 space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-(--color-text)" for="speed-range">{m.profile_playback_speed({ speed: '' })}</label>
<span class="text-sm font-mono text-(--color-brand)">{audioStore.speed.toFixed(1)}x</span>
</div>
<input id="speed-range" type="range" min="0.5" max="3.0" step="0.1"
bind:value={audioStore.speed}
style="accent-color: var(--color-brand);" class="w-full" />
<div class="flex justify-between text-xs text-(--color-muted)">
<span>0.5x</span><span>3.0x</span>
</div>
</div>
<!-- Playback toggles -->
<div class="px-6 py-5 space-y-5">
<p class="text-sm font-medium text-(--color-text)">Playback</p>
<!-- Auto-advance -->
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm text-(--color-text)">{m.profile_auto_advance()}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Load the next chapter automatically when audio ends</p>
</div>
<button
type="button"
role="switch"
aria-checked={audioStore.autoNext}
aria-label="Auto-advance to next chapter"
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface)',
audioStore.autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
)}
>
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', audioStore.autoNext ? 'translate-x-6' : 'translate-x-1')}></span>
</button>
</div>
<!-- Announce chapter -->
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm text-(--color-text)">Announce chapter</p>
<p class="text-xs text-(--color-muted) mt-0.5">Read the chapter title aloud before advancing</p>
</div>
<button
type="button"
role="switch"
aria-checked={audioStore.announceChapter}
aria-label="Announce chapter title before auto-advance"
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface)',
audioStore.announceChapter ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
)}
>
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', audioStore.announceChapter ? 'translate-x-6' : 'translate-x-1')}></span>
</button>
</div>
<!-- Audio mode -->
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm text-(--color-text)">Audio mode</p>
<p class="text-xs text-(--color-muted) mt-0.5">
{audioStore.audioMode === 'stream' ? 'Stream — starts within seconds' : 'Generate — waits for full audio'}
{#if audioStore.voice.startsWith('cfai:')} <span class="text-(--color-border)">(not available for CF AI)</span>{/if}
</p>
</div>
<button
type="button"
aria-label="Toggle audio mode"
onclick={() => { audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream'; }}
disabled={audioStore.voice.startsWith('cfai:')}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface)',
audioStore.voice.startsWith('cfai:')
? 'opacity-40 cursor-not-allowed bg-(--color-surface-3) border border-(--color-border)'
: audioStore.audioMode === 'stream'
? 'bg-(--color-brand)'
: 'bg-(--color-surface-3) border border-(--color-border)'
)}
>
<span class={cn(
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:') ? 'translate-x-6' : 'translate-x-1'
)}></span>
</button>
</div>
</div>
</section>
<!-- ── Active sessions ───────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<div>
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_session_unrecognised()}</p>
</div>
{#if revokeError}
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">{revokeError}</div>
{/if}
{#if sessions.length === 0}
<p class="text-sm text-(--color-muted) italic">{m.profile_no_sessions()}</p>
{:else}
<ul class="space-y-2">
{#each sessions as session (session.id)}
<li class={cn(
'flex items-start justify-between gap-3 rounded-lg px-4 py-3',
session.is_current
? 'bg-(--color-brand)/10 border border-(--color-brand)/30'
: 'bg-(--color-surface-3)/50 border border-(--color-border)/50'
)}>
<div class="min-w-0 space-y-0.5">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-(--color-text) truncate">{parseUA(session.user_agent)}</span>
{#if session.is_current}
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/20 text-(--color-brand-dim) border border-(--color-brand)/40">{m.profile_session_this()}</span>
{/if}
</div>
{#if session.ip}
<p class="text-xs text-(--color-muted) font-mono">{session.ip}</p>
{/if}
<p class="text-xs text-(--color-muted)">
{m.profile_session_signed_in({ date: formatDate(session.created_at) })}
{#if session.last_seen && session.last_seen !== session.created_at}
{m.profile_session_last_seen({ date: formatDate(session.last_seen) })}
{/if}
</p>
</div>
<button
onclick={() => revokeSession(session)}
disabled={revokingId === session.id}
class={cn(
'shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50',
session.is_current
? 'bg-(--color-danger)/10 text-(--color-danger) border border-(--color-danger)/60 hover:bg-(--color-danger)/20'
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-2)'
)}
>
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
</button>
</li>
{/each}
</ul>
{/if}
</section>
<!-- ── Push notifications ────────────────────────────────────────────────── -->
{#if pushState !== 'unsupported'}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="text-base font-semibold text-(--color-text)">Push notifications</h2>
<p class="text-sm text-(--color-muted) mt-0.5">
{#if pushState === 'subscribed'}
You'll receive a push notification when new chapters are added to books in your library.
{:else if pushState === 'denied'}
Notifications are blocked by your browser. Change the permission in your browser settings.
{:else}
Get notified when new chapters arrive for books in your library.
{/if}
</p>
{#if pushError}
<p class="text-sm text-(--color-danger) mt-1.5">{pushError}</p>
{/if}
</div>
<div class="shrink-0">
{#if pushState === 'subscribed'}
<button
type="button"
onclick={unsubscribePush}
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-sm font-medium transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg>
Turn off
</button>
{:else if pushState === 'denied'}
<span class="text-xs text-(--color-muted) italic">Blocked</span>
{:else}
<button
type="button"
onclick={subscribePush}
disabled={pushState === 'loading'}
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) disabled:opacity-60 text-sm font-semibold transition-colors"
>
{#if pushState === 'loading'}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-5-5.917V5a1 1 0 10-2 0v.083A6 6 0 006 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg>
{/if}
Turn on
</button>
{/if}
</div>
</div>
</section>
{/if}
<!-- ── Danger zone ───────────────────────────────────────────────────────── -->
<section class="rounded-xl border border-red-500/30 bg-red-500/5 overflow-hidden">
<button
type="button"
onclick={() => { deleteConfirmOpen = !deleteConfirmOpen; deleteConfirmText = ''; deleteError = ''; }}
class="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-red-500/5 transition-colors"
>
<div>
<p class="text-sm font-semibold text-red-400">Danger zone</p>
<p class="text-xs text-(--color-muted) mt-0.5">Irreversible actions — proceed with care</p>
</div>
<span class="text-xs text-(--color-muted)">{deleteConfirmOpen ? 'Close' : 'Open'}</span>
</button>
{#if deleteConfirmOpen}
<div class="px-6 pb-6 space-y-4 border-t border-red-500/20">
<div class="pt-4">
<p class="text-sm font-medium text-(--color-text)">Delete account</p>
<p class="text-xs text-(--color-muted) mt-1">
This permanently deletes your account, reading history, settings, and all associated data. This action cannot be undone.
</p>
</div>
<div class="space-y-2">
<label for="delete-confirm" class="text-xs text-(--color-muted)">
Type <strong class="text-(--color-text) font-mono">{DELETE_KEYWORD}</strong> to confirm
</label>
<input
id="delete-confirm"
type="text"
bind:value={deleteConfirmText}
placeholder={DELETE_KEYWORD}
autocomplete="off"
class="w-full bg-(--color-surface-3) border border-red-500/40 rounded-lg px-3 py-2 text-sm text-(--color-text) placeholder:text-(--color-border) focus:outline-none focus:ring-2 focus:ring-red-500/50"
/>
</div>
{#if deleteError}
<p class="text-sm text-red-400">{deleteError}</p>
{/if}
<button
type="button"
onclick={deleteAccount}
disabled={!deleteReady || deleting}
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/40 text-sm font-semibold transition-colors hover:bg-red-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
{#if deleting}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
Deleting…
{:else}
Delete my account
{/if}
</button>
</div>
{/if}
</section>
{/if} <!-- end profile tab -->
{#if activeTab === 'stats'}
<div class="space-y-4">
<!-- Reading overview -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Reading Overview</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
{#each [
{ label: 'Chapters Read', value: data.stats.totalChaptersRead },
{ label: 'Completed', value: data.stats.booksCompleted },
{ label: 'Reading', value: data.stats.booksReading },
{ label: 'Plan to Read', value: data.stats.booksPlanToRead },
] as stat}
<div class="bg-(--color-surface-3) rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{stat.value}</p>
<p class="text-xs text-(--color-muted) mt-0.5">{stat.label}</p>
</div>
{/each}
</div>
</section>
<!-- Streak + rating -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Activity</h2>
<div class="grid grid-cols-2 gap-3">
<div class="bg-(--color-surface-3) rounded-lg p-4">
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
<p class="text-xs text-(--color-muted) mt-1">day streak</p>
</div>
<div class="bg-(--color-surface-3) rounded-lg p-4">
<p class="text-2xl font-bold text-(--color-text) tabular-nums">
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
</p>
<p class="text-xs text-(--color-muted) mt-1">avg rating given</p>
</div>
</div>
</section>
<!-- Top genres -->
{#if data.stats.topGenres.length > 0}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-3">Favourite Genres</h2>
<div class="flex flex-wrap gap-2">
{#each data.stats.topGenres as genre, i}
<span class={cn(
'px-3 py-1.5 rounded-full text-sm font-medium',
i === 0
? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30'
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'
)}>
{genre}
</span>
{/each}
</div>
</section>
{/if}
{#if data.stats.booksDropped > 0}
<p class="text-xs text-(--color-muted) text-center">
{data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''}
<a href="/books" class="text-(--color-brand) hover:underline">revisit your library</a>
</p>
{/if}
</div>
{/if}
{#if activeTab === 'history'}
<div class="space-y-2">
{#if data.history.length === 0}
<div class="py-16 text-center text-(--color-muted)">
<p class="text-sm">No reading history yet.</p>
</div>
{:else}
{#each data.history as item}
<a
href="/books/{item.slug}/chapters/{item.chapter}"
class="flex items-center gap-3 px-4 py-3 bg-(--color-surface-2) rounded-xl border border-(--color-border) hover:border-zinc-500 transition-colors group"
>
<div class="w-8 h-11 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0">
{#if item.cover}
<img src={item.cover} alt={item.title} class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3)"></div>
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-(--color-text) truncate group-hover:text-(--color-brand) transition-colors">{item.title}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Chapter {item.chapter}</p>
</div>
<p class="text-xs text-(--color-muted) shrink-0 tabular-nums">
{#if item.updated}
{(() => {
const ms = Date.now() - new Date(item.updated).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 60) return mins <= 1 ? 'just now' : `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return new Date(item.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
})()}
{/if}
</p>
</a>
{/each}
{/if}
</div>
{/if}
</div>