feat(profile): auto-save settings, merge sections, fix font/size preview
Some checks failed
CI / Backend (push) Successful in 30s
Release / Test backend (push) Successful in 33s
Release / Docker / caddy (push) Failing after 10s
CI / UI (push) Successful in 52s
Release / Check ui (push) Successful in 1m7s
CI / Backend (pull_request) Successful in 40s
CI / UI (pull_request) Successful in 53s
Release / Docker / runner (push) Failing after 46s
Release / Docker / backend (push) Successful in 3m16s
Release / Docker / ui (push) Successful in 5m2s
Release / Gitea Release (push) Has been skipped

- Remove both "Save settings" buttons; all settings now auto-save with
  800ms debounce and show a transient "✓ Saved" indicator
- Apply theme, font family, and font size to context immediately on
  change so the preview is live without waiting for the save
- Merge Appearance + Reading settings into a single Preferences card
  with dividers — fewer sections, less visual noise
- Pro users see a compact subscription row; free users see upgrade CTAs
- Speed label splits value and units (shows "1.5x" separately in brand
  color) for cleaner readout
- Auto-advance toggle gains a subtitle description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Admin
2026-03-30 19:07:51 +05:00
parent 2deb306419
commit 90dbecfa17

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { untrack, getContext } from 'svelte'; import { untrack, getContext } from 'svelte';
import type { PageData, ActionData } from './$types'; import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte'; import { audioStore } from '$lib/audio.svelte';
@@ -16,14 +15,12 @@
let avatarError = $state(''); let avatarError = $state('');
let fileInput: HTMLInputElement | null = null; let fileInput: HTMLInputElement | null = null;
// Crop modal state
let cropFile = $state<File | null>(null); let cropFile = $state<File | null>(null);
function handleAvatarChange(e: Event) { function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
if (!file) return; if (!file) return;
// Reset input so the same file can be re-selected after cancel
if (fileInput) fileInput.value = ''; if (fileInput) fileInput.value = '';
cropFile = file; cropFile = file;
} }
@@ -33,7 +30,6 @@
avatarUploading = true; avatarUploading = true;
avatarError = ''; avatarError = '';
try { try {
// POST raw bytes to the SvelteKit server, which proxies to MinIO internally.
const res = await fetch('/api/profile/avatar', { const res = await fetch('/api/profile/avatar', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': mimeType }, headers: { 'Content-Type': mimeType },
@@ -57,95 +53,101 @@
cropFile = null; cropFile = null;
} }
// ── Settings ──────────────────────────────────────────────────────────────── // ── Voices ───────────────────────────────────────────────────────────────────
let voices = $state<Voice[]>([]); let voices = $state<Voice[]>([]);
let voicesLoaded = $state(false); let voicesLoaded = $state(false);
// Derived: voices grouped by engine
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro')); const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts')); const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
// Load voices on mount
$effect(() => { $effect(() => {
fetch('/api/voices') fetch('/api/voices')
.then((r) => r.json()) .then((r) => r.json())
.then((d: { voices: Voice[] }) => { .then((d: { voices: Voice[] }) => { voices = d.voices ?? []; voicesLoaded = true; })
voices = d.voices ?? []; .catch(() => { voicesLoaded = true; });
voicesLoaded = true;
})
.catch(() => {
voicesLoaded = true;
});
}); });
// Mirror from audioStore so sliders feel live // ── Settings state ───────────────────────────────────────────────────────────
let voice = $state(audioStore.voice); let voice = $state(audioStore.voice);
let speed = $state(audioStore.speed); let speed = $state(audioStore.speed);
let autoNext = $state(audioStore.autoNext); let autoNext = $state(audioStore.autoNext);
// Keep in sync when layout changes them externally
$effect(() => { $effect(() => {
voice = audioStore.voice; voice = audioStore.voice;
speed = audioStore.speed; speed = audioStore.speed;
autoNext = audioStore.autoNext; autoNext = audioStore.autoNext;
}); });
// ── Theme + Font ─────────────────────────────────────────────────────────────
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme'); 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 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 }[] = [ const THEMES: { id: string; label: () => string; swatch: string }[] = [
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' }, { id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' }, { id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' }, { id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
]; ];
const FONTS = [ const FONTS = [
{ id: 'system', label: () => m.profile_font_system() }, { id: 'system', label: () => m.profile_font_system() },
{ id: 'serif', label: () => m.profile_font_serif() }, { id: 'serif', label: () => m.profile_font_serif() },
{ id: 'mono', label: () => m.profile_font_mono() }, { id: 'mono', label: () => m.profile_font_mono() },
]; ];
const FONT_SIZES = [ const FONT_SIZES = [
{ value: 0.9, label: () => m.profile_text_size_sm() }, { value: 0.9, label: () => m.profile_text_size_sm() },
{ value: 1.0, label: () => m.profile_text_size_md() }, { value: 1.0, label: () => m.profile_text_size_md() },
{ value: 1.15, label: () => m.profile_text_size_lg() }, { value: 1.15, label: () => m.profile_text_size_lg() },
{ value: 1.3, label: () => m.profile_text_size_xl() }, { value: 1.3, label: () => m.profile_text_size_xl() },
]; ];
let settingsSaving = $state(false); // ── Auto-save ────────────────────────────────────────────────────────────────
let settingsSaved = $state(false); type SaveStatus = 'idle' | 'saving' | 'saved';
let saveStatus = $state<SaveStatus>('idle');
let saveTimer = 0;
let savedTimer = 0;
let initialized = false;
async function saveSettings() { $effect(() => {
settingsSaving = true; // Read all settings deps to subscribe
settingsSaved = false; const t = selectedTheme;
try { const ff = selectedFontFamily;
await fetch('/api/settings', { const fs = selectedFontSize;
method: 'PUT', const v = voice;
headers: { 'Content-Type': 'application/json' }, const sp = speed;
body: JSON.stringify({ autoNext, voice, speed, theme: selectedTheme, fontFamily: selectedFontFamily, fontSize: selectedFontSize }) const an = autoNext;
});
// Sync to audioStore so the player picks up changes immediately // Apply context immediately (font/theme previews live without waiting for save)
audioStore.autoNext = autoNext; if (settingsCtx) {
audioStore.voice = voice; settingsCtx.current = t;
audioStore.speed = speed; settingsCtx.fontFamily = ff;
// Apply theme + font live via context settingsCtx.fontSize = fs;
if (settingsCtx) {
settingsCtx.current = selectedTheme;
settingsCtx.fontFamily = selectedFontFamily;
settingsCtx.fontSize = selectedFontSize;
}
await invalidateAll();
settingsSaved = true;
setTimeout(() => (settingsSaved = false), 2500);
} finally {
settingsSaving = false;
} }
} audioStore.voice = v;
audioStore.autoNext = an;
// ── Sessions ──────────────────────────────────────────────────────────────── 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;
});
// ── Sessions ─────────────────────────────────────────────────────────────────
type Session = { type Session = {
id: string; id: string;
user_agent: string; user_agent: string;
@@ -164,19 +166,12 @@
revokeError = ''; revokeError = '';
try { try {
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' }); const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
if (!res.ok) { if (!res.ok) { revokeError = 'Failed to end session. Please try again.'; return; }
revokeError = 'Failed to end session. Please try again.';
return;
}
if (session.is_current) { if (session.is_current) {
// Ended our own session — submit the logout form to clear the cookie
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null; const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
if (logoutForm) { if (logoutForm) logoutForm.submit();
logoutForm.submit();
}
return; return;
} }
// Remove from local list
sessions = sessions.filter((s) => s.id !== session.id); sessions = sessions.filter((s) => s.id !== session.id);
} catch { } catch {
revokeError = 'Network error. Please try again.'; revokeError = 'Network error. Please try again.';
@@ -188,18 +183,12 @@
function formatDate(iso: string): string { function formatDate(iso: string): string {
if (!iso) return '—'; if (!iso) return '—';
try { try {
return new Intl.DateTimeFormat(undefined, { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
dateStyle: 'medium', } catch { return iso; }
timeStyle: 'short'
}).format(new Date(iso));
} catch {
return iso;
}
} }
function parseUA(ua: string): string { function parseUA(ua: string): string {
if (!ua) return 'Unknown browser'; if (!ua) return 'Unknown browser';
// Very lightweight UA display — just show the most meaningful part
if (/Mobile/i.test(ua)) { if (/Mobile/i.test(ua)) {
const match = ua.match(/\(([^)]+)\)/); const match = ua.match(/\(([^)]+)\)/);
return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device'; return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device';
@@ -218,24 +207,20 @@
{#if cropFile && browser} {#if cropFile && browser}
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }} {#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
<AvatarCropModal <AvatarCropModal file={cropFile} onconfirm={handleCropConfirm} oncancel={handleCropCancel} />
file={cropFile}
onconfirm={handleCropConfirm}
oncancel={handleCropCancel}
/>
{/await} {/await}
{/if} {/if}
<!-- Hidden logout form used when user ends their own session -->
<form id="logout-form" method="POST" action="/logout" class="hidden"></form> <form id="logout-form" method="POST" action="/logout" class="hidden"></form>
<div class="max-w-xl mx-auto space-y-10"> <div class="max-w-2xl mx-auto space-y-6 pb-12">
<div class="flex items-center gap-5">
<!-- Avatar --> <!-- ── Profile header ──────────────────────────────────────────────────────── -->
<div class="flex items-center gap-5 pt-2">
<div class="relative shrink-0"> <div class="relative shrink-0">
<button <button
onclick={() => fileInput?.click()} 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 focus:ring-(--color-brand)" 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()} title={m.profile_change_avatar()}
disabled={avatarUploading} disabled={avatarUploading}
> >
@@ -248,7 +233,6 @@
</svg> </svg>
</div> </div>
{/if} {/if}
<!-- Hover overlay -->
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> <div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{#if avatarUploading} {#if avatarUploading}
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
@@ -263,96 +247,92 @@
{/if} {/if}
</div> </div>
</button> </button>
<input <input bind:this={fileInput} type="file" accept="image/jpeg,image/png,image/webp" class="hidden" onchange={handleAvatarChange} />
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
onchange={handleAvatarChange}
/>
</div>
<div>
<h1 class="text-2xl font-bold text-(--color-text)">{data.user.username}</h1>
<p class="text-(--color-muted) text-sm mt-0.5 capitalize">{data.user.role}</p>
{#if avatarError}
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
{:else}
<p class="text-(--color-muted) text-xs mt-1">{m.profile_click_to_change()}</p>
{/if}
</div>
</div>
<!-- ── Subscription ─────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<div class="flex items-center justify-between gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
{#if data.isPro}
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 tracking-wide uppercase">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
</svg>
{m.profile_plan_pro()}
</span>
{:else}
<span class="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>
{/if}
</div> </div>
{#if data.isPro} <div class="min-w-0">
<p class="text-sm text-(--color-text)">{m.profile_pro_active()}</p> <h1 class="text-2xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
<p class="text-sm text-(--color-muted)">{m.profile_pro_perks()}</p> <div class="flex items-center gap-2 mt-1 flex-wrap">
<a <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>
href="https://polar.sh/libnovel" {#if data.isPro}
target="_blank" <span class="inline-flex items-center gap-1 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">
rel="noopener noreferrer" <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
class="inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline" {m.profile_plan_pro()}
> </span>
{m.profile_manage_subscription()} {/if}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
{:else}
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
<div>
<p class="text-sm font-semibold text-(--color-text) mb-3">{m.profile_upgrade_heading()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
<div class="flex flex-wrap gap-3">
<a
href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
>
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
</svg>
{m.profile_upgrade_monthly()}
</a>
<a
href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors"
>
{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>
</a>
</div>
</div> </div>
{/if} {#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>
<!-- ── 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()}</p>
<div class="flex flex-wrap gap-3">
<a href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9" target="_blank" rel="noopener noreferrer"
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">
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
{m.profile_upgrade_monthly()}
</a>
<a href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21" target="_blank" rel="noopener noreferrer"
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">
{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>
</a>
</div>
</div>
</section> </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="https://polar.sh/libnovel" target="_blank" rel="noopener noreferrer"
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
{m.profile_manage_subscription()}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</section>
{/if}
<!-- ── Appearance ────────────────────────────────────────────────────────── --> <!-- ── Preferences ──────────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5"> <section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_appearance_heading()}</h2>
<div class="space-y-2"> <!-- Section header with auto-save indicator -->
<div class="flex items-center justify-between px-6 py-4">
<h2 class="text-base font-semibold text-(--color-text)">Preferences</h2>
<span class="text-xs transition-all duration-300 {saveStatus === 'saving' ? 'text-(--color-muted)' : saveStatus === 'saved' ? 'text-(--color-success)' : '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> <p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
<div class="flex gap-3 flex-wrap"> <div class="flex gap-2 flex-wrap">
{#each THEMES as t} {#each THEMES as t}
<button <button
type="button" type="button"
@@ -363,26 +343,25 @@
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}" : 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={selectedTheme === t.id} aria-pressed={selectedTheme === t.id}
> >
<span class="w-3.5 h-3.5 rounded-full flex-shrink-0" style="background: {t.swatch};"></span> <span class="w-3 h-3 rounded-full shrink-0" style="background: {t.swatch};"></span>
{t.label()} {t.label()}
{#if selectedTheme === t.id}
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
{/if}
</button> </button>
{/each} {/each}
</div> </div>
</div> </div>
<div class="space-y-2"> <!-- 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> <p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
{#each FONTS as f} {#each FONTS as f}
<button <button
type="button" type="button"
onclick={() => (selectedFontFamily = f.id)} onclick={() => (selectedFontFamily = f.id)}
class="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)'}" class="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} aria-pressed={selectedFontFamily === f.id}
> >
{f.label()} {f.label()}
@@ -391,14 +370,18 @@
</div> </div>
</div> </div>
<div class="space-y-2"> <!-- 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> <p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
{#each FONT_SIZES as s} {#each FONT_SIZES as s}
<button <button
type="button" type="button"
onclick={() => (selectedFontSize = s.value)} onclick={() => (selectedFontSize = s.value)}
class="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)'}" class="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} aria-pressed={selectedFontSize === s.value}
> >
{s.label()} {s.label()}
@@ -407,115 +390,73 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-3 pt-1"> <!-- TTS voice -->
<button <div class="px-6 py-5 space-y-3">
onclick={saveSettings}
disabled={settingsSaving}
class="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"
>
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
</button>
{#if settingsSaved}
<span class="text-sm text-(--color-success)">{m.profile_saved()}</span>
{/if}
</div>
</section>
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_reading_heading()}</h2>
<!-- Voice -->
<div class="space-y-1.5">
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">{m.profile_tts_voice()}</label> <label class="block text-sm font-medium text-(--color-text)" for="voice-select">{m.profile_tts_voice()}</label>
{#if !voicesLoaded} {#if !voicesLoaded}
<div class="h-9 bg-(--color-surface-3) rounded animate-pulse"></div> <div class="h-9 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
{:else if voices.length === 0} {:else if voices.length === 0}
<select id="voice-select" disabled class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-muted) text-sm cursor-not-allowed"> <select id="voice-select" disabled class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-muted) text-sm cursor-not-allowed">
<option>{m.common_loading()}</option> <option>{m.common_loading()}</option>
</select> </select>
{:else} {:else}
<select <select id="voice-select" bind:value={voice}
id="voice-select" class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)">
bind:value={voice}
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
>
{#if kokoroVoices.length > 0} {#if kokoroVoices.length > 0}
<optgroup label="Kokoro (GPU)"> <optgroup label="Kokoro (GPU)">
{#each kokoroVoices as v} {#each kokoroVoices as v}<option value={v.id}>{v.id}</option>{/each}
<option value={v.id}>{v.id}</option>
{/each}
</optgroup> </optgroup>
{/if} {/if}
{#if pocketVoices.length > 0} {#if pocketVoices.length > 0}
<optgroup label="Pocket TTS (CPU)"> <optgroup label="Pocket TTS (CPU)">
{#each pocketVoices as v} {#each pocketVoices as v}<option value={v.id}>{v.id}</option>{/each}
<option value={v.id}>{v.id}</option>
{/each}
</optgroup> </optgroup>
{/if} {/if}
</select> </select>
{/if} {/if}
</div> </div>
<!-- Speed --> <!-- Playback speed -->
<div class="space-y-1.5"> <div class="px-6 py-5 space-y-3">
<label class="block text-sm font-medium text-(--color-text)" for="speed-range"> <div class="flex items-center justify-between">
{m.profile_playback_speed({ speed: speed.toFixed(1) })} <label class="text-sm font-medium text-(--color-text)" for="speed-range">{m.profile_playback_speed({ speed: '' })}</label>
</label> <span class="text-sm font-mono text-(--color-brand)">{speed.toFixed(1)}x</span>
<input </div>
id="speed-range" <input id="speed-range" type="range" min="0.5" max="3.0" step="0.1" bind:value={speed}
type="range" style="accent-color: var(--color-brand);" class="w-full" />
min="0.5"
max="3.0"
step="0.1"
bind:value={speed}
style="accent-color: var(--color-brand);"
class="w-full"
/>
<div class="flex justify-between text-xs text-(--color-muted)"> <div class="flex justify-between text-xs text-(--color-muted)">
<span>0.5x</span> <span>0.5x</span><span>3.0x</span>
<span>3.0x</span>
</div> </div>
</div> </div>
<!-- Auto-next toggle --> <!-- Auto-advance -->
<div class="flex items-center justify-between"> <div class="px-6 py-5 flex items-center justify-between">
<span class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</span> <div>
<p class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Automatically load the next chapter when audio finishes</p>
</div>
<button <button
type="button" type="button"
role="switch" role="switch"
aria-checked={autoNext} aria-checked={autoNext}
onclick={() => (autoNext = !autoNext)} onclick={() => (autoNext = !autoNext)}
class="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) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'}" class="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) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'}"
> >
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {autoNext ? 'translate-x-6' : 'translate-x-1'}"></span> <span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {autoNext ? 'translate-x-6' : 'translate-x-1'}"></span>
</button> </button>
</div> </div>
<div class="flex items-center gap-3 pt-1">
<button
onclick={saveSettings}
disabled={settingsSaving}
class="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"
>
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
</button>
{#if settingsSaved}
<span class="text-sm text-(--color-success)">{m.profile_saved()}</span>
{/if}
</div>
</section> </section>
<!-- ── Active sessions ──────────────────────────────────────────────────── --> <!-- ── Active sessions ──────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4"> <section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2> <div>
<p class="text-sm text-(--color-muted)">{m.profile_session_unrecognised()}</p> <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} {#if revokeError}
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)"> <div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">{revokeError}</div>
{revokeError}
</div>
{/if} {/if}
{#if sessions.length === 0} {#if sessions.length === 0}
@@ -547,7 +488,7 @@
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
{session.is_current {session.is_current
? 'bg-(--color-danger)/10 text-(--color-danger) border border-(--color-danger)/60 hover:bg-(--color-danger)/20' ? '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-3)'}" : '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()} {revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
</button> </button>
@@ -556,4 +497,5 @@
</ul> </ul>
{/if} {/if}
</section> </section>
</div> </div>