@@ -1,6 +1,5 @@
< script lang = "ts" >
import { enhance } from '$app/forms' ;
import { invalidateAll } from '$app/navigation' ;
import { untrack , getContext } from 'svelte' ;
import type { PageData , ActionData } from './$types' ;
import { audioStore } from '$lib/audio.svelte' ;
@@ -16,14 +15,12 @@
let avatarError = $state ( '' );
let fileInput : HTMLInputElement | null = null ;
// Crop modal state
let cropFile = $state < File | null >( null );
function handleAvatarChange ( e : Event ) {
const input = e . target as HTMLInputElement ;
const file = input . files ? .[ 0 ];
if ( ! file ) return ;
// Reset input so the same file can be re-selected after cancel
if ( fileInput ) fileInput . value = '' ;
cropFile = file ;
}
@@ -33,7 +30,6 @@
avatarUploading = true ;
avatarError = '' ;
try {
// POST raw bytes to the SvelteKit server, which proxies to MinIO internally.
const res = await fetch ( '/api/profile/avatar' , {
method : 'POST' ,
headers : { 'Content-Type' : mimeType },
@@ -57,95 +53,104 @@
cropFile = null ;
}
// ── Settings ────────────────────────────────────────────────────────────────
// ── Voices ─── ────────────────────────────────────────────────────────────────
let voices = $state < Voice [] >([]);
let voicesLoaded = $state ( false );
// Derived: voices grouped by engine
const kokoroVoices = $derived ( voices . filter (( v ) => v . engine === 'kokoro' ));
const pocketVoices = $derived ( voices . filter (( v ) => v . engine === 'pocket-tts' ));
// Load voices on mount
$effect (() => {
fetch ( '/api/voices' )
. then (( r ) => r . json ())
. then (( d : { voices : Voice [] }) => {
voices = d . voices ?? [] ;
voicesLoaded = true ;
})
. catch (() => {
voicesLoaded = true ;
});
. then (( d : { voices : Voice [] }) => { voices = d . voices ?? []; voicesLoaded = true ; })
. catch (() => { voicesLoaded = true ; }) ;
});
// Mirror from audioStore so sliders feel live
// ── Settings state ───────────────────────────────────────────────────────────
let voice = $state ( audioStore . voice );
let speed = $state ( audioStore . speed );
let autoNext = $state ( audioStore . autoNext );
// Keep in sync when layout changes them externally
$effect (() => {
voice = audioStore . voice ;
speed = audioStore . speed ;
autoNext = audioStore . autoNext ;
});
// ── Theme + Font ─────────────────────────────────────────────────────────────
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 }[] = [
{ 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' },
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 : '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 () },
{ 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 : 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 () },
{ value : 1.3 , label : () => m . profile_text_size_xl () },
];
let settingsSaving = $state ( false );
let settingsSaved = $state ( false ) ;
// ── Auto-save ────────────────────────────────────────────────────────────────
type SaveStatus = 'idle' | 'saving' | 'saved' ;
let saveStatus = $state < SaveStatus >( 'idle' );
let saveTimer = 0 ;
let savedTimer = 0 ;
let initialized = false ;
async function saveSettings() {
settingsSaving = true ;
settingsSaved = fals e ;
try {
await fetch ( '/api/settings' , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON.stringify ({ autoNext , voice , speed , theme : selectedTheme , fontFamily : selectedFontFamily , fontSize : selectedFontSize })
});
// Sync to audioStore so the player picks up changes immediately
audioStore . autoNext = autoNext ;
audioStore . voice = voice ;
audioStore . speed = speed ;
// Apply theme + font live via context
if ( settingsCtx ) {
settingsCtx . current = selectedTheme ;
settingsCtx . fontFamily = selectedFontFamily ;
settingsCtx . fontSize = selectedFontSize ;
}
await invalidateAll ();
settingsSaved = true ;
setTimeout (() => ( settingsSaved = false ), 2500 );
} finally {
settingsSaving = false ;
$effect (() => {
// Read all settings deps to subscribe
const t = selectedThem e;
const ff = selectedFontFamily ;
const fs = selectedFontSize ;
const v = voice ;
const sp = speed ;
const an = autoNext ;
// Apply context immediately (font/theme previews live without waiting for save)
if ( settingsCtx ) {
settingsCtx . current = t ;
settingsCtx . fontFamily = ff ;
settingsCtx . fontSize = fs ;
}
}
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 = {
id : string ;
user_agent : string ;
@@ -164,19 +169,12 @@
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 ( ! res . ok ) { revokeError = 'Failed to end session. Please try again.' ; return ; }
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 ;
if ( logoutForm ) {
logoutForm . submit ();
}
if ( logoutForm ) logoutForm . submit ();
return ;
}
// Remove from local list
sessions = sessions . filter (( s ) => s . id !== session . id );
} catch {
revokeError = 'Network error. Please try again.' ;
@@ -188,18 +186,12 @@
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 ;
}
return new Intl . DateTimeFormat ( undefined , { dateStyle : 'medium' , timeStyle : 'short' }). format ( new Date ( iso ));
} catch { return iso ; }
}
function parseUA ( ua : string ) : string {
if ( ! ua ) return 'Unknown browser' ;
// Very lightweight UA display — just show the most meaningful part
if ( /Mobile/i . test ( ua )) {
const match = ua . match ( /\(([^)]+)\)/ );
return match ? `Mobile — ${ match [ 1 ]. split ( ';' )[ 0 ]. trim () } ` : 'Mobile device' ;
@@ -218,24 +210,20 @@
{ #if cropFile && browser }
{ #await import ( '$lib/components/AvatarCropModal.svelte' ) then { default : AvatarCropModal }}
< AvatarCropModal
file = { cropFile }
onconfirm= { handleCropConfirm }
oncancel = { handleCropCancel }
/ >
< AvatarCropModal file = { cropFile } onconfirm= { handleCropConfirm } oncancel = { handleCropCancel } / >
{ /await }
{ /if }
<!-- Hidden logout form used when user ends their own session -->
< form id = "logout-form" method = "POST" action = "/logout" class = "hidden" ></ form >
< div class = "max-w-xl mx-auto space-y-10 " >
< div class = "flex items-center gap-5" >
<!-- Avatar -->
< div class = "max-w-2 xl mx-auto space-y-6 pb-12 " >
<!-- ── 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 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 ()}
disabled= { avatarUploading }
>
@@ -248,7 +236,6 @@
</ svg >
</ div >
{ /if }
<!-- Hover overlay -->
< 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" >
@@ -263,97 +250,96 @@
{ /if }
</ div >
</ button >
< input
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 }
< input bind:this = { fileInput } type="file" accept = "image/jpeg,image/png,image/webp" class = "hidden" onchange = { handleAvatarChange } / >
</ div >
{ #if data . isPro }
< p class = "text-sm text-(--color-text)" > { m . profile_pro_activ e() }</ p >
< p class = "text-sm text-(--color-muted)" > { m . profile_pro_perks ()} </ p >
< a
href = "https://polar.sh/libnovel"
target = "_blank"
rel = "noopener noreferrer"
class = "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 >
{ : 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 class = "min-w-0" >
< h1 class = "text-2xl font-bold text-(--color-text) truncate " > { data . user . usernam e} </ 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 = "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" >
< 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 >
{ m . profile_plan_pro ()}
</ span >
{ /if }
</ 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 >
{ : 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 ────────────────────────────────────────────────────────── -->
< 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_appearance_heading ()} </ h2 >
<!-- ── Preferences ── ────────────────────────────────────────────────────────── -->
< section class = "bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border) " >
< 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 >
< div class = "flex gap-3 flex-wrap" >
{ #each THEMES as t }
< div class = "flex gap-2 flex-wrap items-center " >
{ #each THEMES as t , i }
{ #if i === 3 }
< span class = "w-px h-6 bg-(--color-border) mx-1 self-center" ></ span >
{ /if }
< button
type = "button"
onclick = {() => ( selectedTheme = t . id )}
@@ -363,26 +349,25 @@
: '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.5 h-3.5 rounded-full flex- shrink-0" style = "background: { t . swatch } ;" ></ span >
< span class = "w-3 h-3 rounded-full shrink-0 { t . light ? 'ring-1 ring-(--color-border)' : '' } " style = "background: { t . swatch } ;" ></ span >
{ 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 >
{ /each }
</ 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 >
< div class = "flex gap-2 flex-wrap" >
{ #each FONTS as f }
< button
type = "button"
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 }
>
{ f . label ()}
@@ -391,14 +376,18 @@
</ 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 >
< div class = "flex gap-2 flex-wrap" >
{ #each FONT_SIZES as s }
< button
type = "button"
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 }
>
{ s . label ()}
@@ -407,115 +396,73 @@
</ 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 >
<!-- ── 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" >
<!-- TTS voice -- >
< div class = "px-6 py-5 space-y-3" >
< label class = "block text-sm font-medium text-(--color-text)" for = "voice-select" > { m . profile_tts_voice ()} </ label >
{ #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 }
< 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 >
</ select >
{ : else }
< select
id = "voice-select"
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 )"
>
< select id = "voice-select" 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 }
< optgroup label = "Kokoro (GPU)" >
{ #each kokoroVoices as v }
< option value = { v . id } > { v . id } </option >
{ /each }
{ #each kokoroVoices as v } < option value = { v . id } > { v . id } </option > { /each }
</ optgroup >
{ /if }
{ #if pocketVoices . length > 0 }
< optgroup label = "Pocket TTS (CPU)" >
{ #each pocketVoices as v }
< option value = { v . id } > { v . id } </option >
{ /each }
{ #each pocketVoices as v } < option value = { v . id } > { v . id } </option > { /each }
</ optgroup >
{ /if }
</ select >
{ /if }
</ div >
<!-- S peed -->
< div class = "space-y-1.5 " >
< label class = "block text-sm font-medium text-(--color-text)" for = "speed-range " >
{ m . profile_playback_speed ({ speed : speed.toFixed ( 1 ) })}
</ label >
< input
id = "speed-range"
type = "range"
min = "0.5"
max = "3.0"
step = "0.1"
bind:value = { speed }
style="accent-color: var ( --color-brand );"
class = "w-full"
/>
<!-- Playback s peed -->
< 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)" > { speed . toFixed ( 1 )} x</ span >
</ div >
< input id = "speed-range" type = "range" 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)" >
< span > 0.5x</ span >
< span > 3.0x</ span >
< span > 0.5x</ span >< span > 3.0x</ span >
</ div >
</ div >
<!-- Auto-next toggl e -->
< div class = "flex items-center justify-between" >
< span class = "text-sm font-medium text-(--color-text)" > { m . profile_auto_advance ()} </ span >
<!-- Auto-advanc e -->
< div class = "px-6 py-5 flex items-center justify-between" >
< 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
type = "button"
role = "switch"
aria-checked = { 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 >
</ button >
</ 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 >
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
<!-- ── Active sessions ──────────────────────────────────────────────────────── -->
< 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 >
< p class = "text-sm text-(--color-muted )" > { m . profile_session_unrecognised ()} </ p >
< 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 >
< 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 }
@@ -547,7 +494,7 @@
class = "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-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 ()}
</ button >
@@ -556,4 +503,5 @@
</ ul >
{ /if }
</ section >
</ div >