diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 8d4fbe0..9b9230d 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -1415,6 +1415,56 @@ export async function revokeAllUserSessions(userId: string): Promise { ); } +/** + * Delete all data associated with a user account: + * - user_settings, user_library, progress, comment_votes, book_ratings, + * user_subscriptions, user_sessions, notifications rows owned by the user + * - the app_users record itself + * + * Does NOT delete audio files from MinIO (shared cache) or book comments + * (anonymised to preserve discussion threads). + */ +export async function deleteUserAccount(userId: string, sessionId: string): Promise { + const collections = [ + { name: 'user_settings', filter: `(user_id="${userId}" || session_id="${sessionId}")` }, + { name: 'user_library', filter: `(user_id="${userId}" || session_id="${sessionId}")` }, + { name: 'progress', filter: `(user_id="${userId}" || session_id="${sessionId}")` }, + { name: 'comment_votes', filter: `user_id="${userId}"` }, + { name: 'book_ratings', filter: `user_id="${userId}"` }, + { name: 'user_subscriptions', filter: `(follower_id="${userId}" || followee_id="${userId}")` }, + { name: 'notifications', filter: `user_id="${userId}"` }, + { name: 'user_sessions', filter: `user_id="${userId}"` }, + ]; + + const token = await getToken(); + + for (const { name, filter } of collections) { + try { + const rows = await listAll<{ id: string }>(name, filter); + await Promise.all( + rows.map((r) => + fetch(`${PB_URL}/api/collections/${name}/records/${r.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }).catch(() => {}) + ) + ); + } catch { + // Best-effort: log and continue so one failure doesn't abort the rest + log.warn('pocketbase', `deleteUserAccount: failed to purge ${name}`, { userId }); + } + } + + // Delete the user record last + const res = await pbDelete(`/api/collections/app_users/records/${userId}`); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'deleteUserAccount: failed to delete app_users record', { userId, status: res.status, body }); + throw new Error(`Failed to delete user record (${res.status})`); + } + log.info('pocketbase', 'deleteUserAccount: account deleted', { userId }); +} + /** * Update the avatar_url field for a user record. */ diff --git a/ui/src/routes/api/profile/+server.ts b/ui/src/routes/api/profile/+server.ts new file mode 100644 index 0000000..091ec05 --- /dev/null +++ b/ui/src/routes/api/profile/+server.ts @@ -0,0 +1,26 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { deleteUserAccount } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * DELETE /api/profile + * + * Permanently deletes the authenticated user's account and all associated data: + * settings, library, progress, votes, ratings, sessions, notifications. + * + * The app_users record is removed last. The caller should immediately log the + * user out (submit the logout form) to clear the session cookie. + */ +export const DELETE: RequestHandler = async ({ locals }) => { + if (!locals.user) error(401, 'Not authenticated'); + + try { + await deleteUserAccount(locals.user.id, locals.sessionId); + } catch (e) { + log.error('profile', 'DELETE /api/profile failed', { userId: locals.user.id, err: String(e) }); + error(500, { message: 'Failed to delete account. Please try again or contact support.' }); + } + + return json({ ok: true }); +}; diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index b15d8ba..8fc6add 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -3,15 +3,16 @@ 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 type { Voice } from '$lib/types'; + import { cn } from '$lib/utils'; import * as m from '$lib/paraglide/messages.js'; let { data, form }: { data: PageData; form: ActionData } = $props(); // ── Polar checkout ─────────────────────────────────────────────────────────── - // Customer portal: always link to the org portal const manageUrl = `https://polar.sh/libnovel/portal`; let checkoutLoading = $state<'monthly' | 'annual' | null>(null); @@ -41,14 +42,12 @@ } // ── Avatar ─────────────────────────────────────────────────────────────────── - // Show a welcome banner when Polar redirects back with ?subscribed=1 const justSubscribed = $derived(browser && page.url.searchParams.get('subscribed') === '1'); let avatarUrl = $state(untrack(() => data.avatarUrl ?? null)); let avatarUploading = $state(false); let avatarError = $state(''); let fileInput: HTMLInputElement | null = null; - let cropFile = $state(null); function handleAvatarChange(e: Event) { @@ -83,9 +82,7 @@ } } - function handleCropCancel() { - cropFile = null; - } + function handleCropCancel() { cropFile = null; } // ── Voices ─────────────────────────────────────────────────────────────────── let voices = $state([]); @@ -93,7 +90,7 @@ const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro')); const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts')); - const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai')); + const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai')); function voiceLabel(v: Voice): string { if (v.engine === 'cfai') { @@ -102,14 +99,14 @@ } if (v.engine === 'pocket-tts') { const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); - return name + (v.gender ? ` (EN ${v.gender.toUpperCase()})` : ''); + return name + (v.gender ? ` (${v.lang?.toUpperCase().replace('-','')} ${v.gender.toUpperCase()})` : ''); } // Kokoro: "af_bella" → "Bella (US F)" const langMap: Record = { - af: 'US', am: 'US', bf: 'UK', bm: 'UK', - ef: 'ES', em: 'ES', ff: 'FR', - hf: 'IN', hm: 'IN', 'if': 'IT', im: 'IT', - jf: 'JP', jm: 'JP', pf: 'PT', pm: 'PT', zf: 'ZH', zm: 'ZH', + af:'US', am:'US', bf:'UK', bm:'UK', + ef:'ES', em:'ES', ff:'FR', + hf:'IN', hm:'IN', 'if':'IT', im:'IT', + jf:'JP', jm:'JP', pf:'PT', pm:'PT', zf:'ZH', zm:'ZH', }; const prefix = v.id.slice(0, 2); const name = v.id.slice(3).replace(/^v0/, '').replace(/^([a-z])/, (c) => c.toUpperCase()); @@ -125,21 +122,41 @@ .catch(() => { voicesLoaded = true; }); }); - // ── Settings state ─────────────────────────────────────────────────────────── - let voice = $state(audioStore.voice); - let speed = $state(audioStore.speed); - let autoNext = $state(audioStore.autoNext); + // Voice sample playback + let samplePlayingVoice = $state(null); + let sampleAudio = $state(null); - $effect(() => { - voice = audioStore.voice; - speed = audioStore.speed; - autoNext = audioStore.autoNext; - }); + function stopSample() { + if (sampleAudio) { sampleAudio.pause(); sampleAudio.src = ''; sampleAudio = null; } + samplePlayingVoice = null; + } + + async function toggleSample(voiceId: string) { + if (samplePlayingVoice === voiceId) { stopSample(); return; } + stopSample(); + samplePlayingVoice = voiceId; + try { + const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voiceId)}`); + if (res.status === 404) { samplePlayingVoice = null; return; } + if (!res.ok) throw new Error(); + const { url } = await res.json() as { url: string }; + const audio = new Audio(url); + sampleAudio = audio; + audio.onended = () => { if (samplePlayingVoice === voiceId) stopSample(); }; + audio.onerror = () => { if (samplePlayingVoice === voiceId) stopSample(); }; + await audio.play(); + } catch { samplePlayingVoice = 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 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)); + 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' }, @@ -166,51 +183,50 @@ { value: 1.3, label: () => m.profile_text_size_xl() }, ]; - // ── Auto-save ──────────────────────────────────────────────────────────────── + // Local save-status indicator — layout's effect does the actual debounced save. type SaveStatus = 'idle' | 'saving' | 'saved'; let saveStatus = $state('idle'); - let saveTimer = 0; let savedTimer = 0; let initialized = false; + function markSaved() { + saveStatus = 'saving'; + clearTimeout(savedTimer); + // Give a tick for layout's effect to fire, then show ✓ Saved + 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(() => { - // Read all settings deps to subscribe - const t = selectedTheme; + const t = selectedTheme; const ff = selectedFontFamily; const fs = selectedFontSize; - const v = voice; - const sp = speed; - const an = autoNext; + const v = audioStore.voice; + const sp = audioStore.speed; + const an = audioStore.autoNext; + const ac = audioStore.announceChapter; + const am = audioStore.audioMode; - // Apply context immediately (font/theme previews live without waiting for save) if (settingsCtx) { - settingsCtx.current = t; + settingsCtx.current = t; settingsCtx.fontFamily = ff; - settingsCtx.fontSize = fs; + settingsCtx.fontSize = fs; } - audioStore.voice = v; - audioStore.autoNext = an; if (!initialized) { initialized = true; return; } - - clearTimeout(saveTimer); - saveTimer = setTimeout(async () => { - saveStatus = 'saving'; - try { - await fetch('/api/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ autoNext: an, voice: v, speed: sp, theme: t, fontFamily: ff, fontSize: fs }) - }); - saveStatus = 'saved'; - clearTimeout(savedTimer); - savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number; - } catch { - saveStatus = 'idle'; - } - }, 800) as unknown as number; + void v; void sp; void an; void ac; void am; // keep subscriptions live + markSaved(); }); + // Keep theme/font writes flowing into layout context when changed from selectors + $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'); @@ -224,12 +240,12 @@ is_current: boolean; }; - let sessions = $state(untrack(() => data.sessions ?? [])); - let revokingId = $state(null); + let sessions = $state(untrack(() => data.sessions ?? [])); + let revokingId = $state(null); let revokeError = $state(''); async function revokeSession(session: Session) { - revokingId = session.id; + revokingId = session.id; revokeError = ''; try { const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' }); @@ -247,6 +263,37 @@ } } + // ── 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; + } + // Server deleted account — submit logout form to clear session cookie + 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 { @@ -293,7 +340,7 @@ {/if} - +
@@ -364,7 +413,8 @@
{#if activeTab === 'profile'} - + + {#if !data.isPro}
@@ -424,20 +474,21 @@
{/if} - +
- +

Preferences

- - {#if saveStatus === 'saving'} - {m.profile_saving()}… - {:else if saveStatus === 'saved'} - ✓ {m.profile_saved()} - {:else} - {m.profile_saved()} - {/if} + + {#if saveStatus === 'saving'}{m.profile_saving()}… + {:else if saveStatus === 'saved'}✓ {m.profile_saved()} + {:else}{m.profile_saved()}{/if}
@@ -446,16 +497,18 @@

{m.profile_theme_label()}

{#each THEMES as t, i} - {#if i === 3} + {#if i === 6} {/if}
- +
- +

{m.profile_tts_voice()}

{#if !voicesLoaded} -
+
+ {#each [1,2,3] as _} +
+ {/each} +
{:else if voices.length === 0} - +

No voices available.

{:else} - + + {#each [ + { label: 'Kokoro (GPU)', voices: kokoroVoices }, + { label: 'Pocket TTS (CPU)', voices: pocketVoices }, + { label: 'Cloudflare AI', voices: cfaiVoices }, + ].filter(g => g.voices.length > 0) as group} +
+

{group.label}

+
+ {#each group.voices as v (v.id)} + {@const isSelected = audioStore.voice === v.id} + {@const isPlaying = samplePlayingVoice === v.id} + +
{ audioStore.voice = v.id; }} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); audioStore.voice = v.id; } }} + class={cn( + 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border text-sm transition-colors cursor-pointer select-none', + isSelected + ? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)' + : 'border-(--color-border) bg-(--color-surface-3) text-(--color-text) hover:border-(--color-brand)/40' + )} + > +
+ {#if isSelected} + + + + {:else} + + {/if} + {voiceLabel(v)} +
+ + +
+ {/each} +
+
+ {/each} {/if}
@@ -540,36 +644,101 @@
- {speed.toFixed(1)}x + {audioStore.speed.toFixed(1)}x
-
0.5x3.0x
- -
-
-

{m.profile_auto_advance()}

-

Automatically load the next chapter when audio finishes

+ +
+

Playback

+ + +
+
+

{m.profile_auto_advance()}

+

Automatically load the next chapter when audio finishes

+
+ +
+ + +
+
+

Announce chapter

+

Read the chapter title aloud before auto-advancing

+
+ +
+ + +
+
+

Audio mode

+

+ {#if audioStore.audioMode === 'stream'} + Stream — audio starts within seconds, saved in background + {:else} + Generate — wait for full audio before playing + {/if} +

+
+
-
- +

{m.profile_sessions_heading()}

@@ -585,7 +754,12 @@ {:else}
    {#each sessions as session (session.id)} -
  • +
  • {parseUA(session.user_agent)} @@ -606,10 +780,12 @@ @@ -618,7 +794,74 @@
{/if}
- {/if} + + +
+ + + {#if deleteConfirmOpen} +
+
+

Delete account

+

+ This permanently deletes your account, reading history, settings, and all associated data. This action cannot be undone. +

+
+ +
+ + +
+ + {#if deleteError} +

{deleteError}

+ {/if} + + +
+ {/if} +
+ + {/if} {#if activeTab === 'stats'}
@@ -629,9 +872,9 @@
{#each [ { label: 'Chapters Read', value: data.stats.totalChaptersRead, icon: '📖' }, - { label: 'Completed', value: data.stats.booksCompleted, icon: '✅' }, - { label: 'Reading', value: data.stats.booksReading, icon: '📚' }, - { label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' }, + { label: 'Completed', value: data.stats.booksCompleted, icon: '✅' }, + { label: 'Reading', value: data.stats.booksReading, icon: '📚' }, + { label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' }, ] as stat}

{stat.value}

@@ -670,8 +913,12 @@

Favourite Genres

{#each data.stats.topGenres as genre, i} - + {#if i === 0}🏆{/if} {genre} @@ -680,7 +927,6 @@ {/if} - {#if data.stats.booksDropped > 0}

{data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''} — @@ -706,7 +952,6 @@ 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" > -

{#if item.cover} {item.title} @@ -718,14 +963,10 @@
{/if}
- -

{item.title}

Chapter {item.chapter}

- -

{#if item.updated} {(() => {