From 4b8104f087193759be34bde642fc2d4ee1839e57 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 29 Mar 2026 21:43:00 +0500 Subject: [PATCH] feat(ui): language persistence, theme fix, font/size settings, header quick-access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix language not persisting after refresh: save locale in user_settings, set PARAGLIDE_LOCALE cookie from DB preference on server load - Fix theme change not applying: context setter was mismatched (setTheme vs current) - Add font family (system/serif/mono) and text size (sm/md/lg/xl) user settings stored in DB and applied via CSS custom properties - Add theme color dots and language picker to desktop header for quick access - Footer locale switcher now saves preference to DB before switching - Remove change password section (OAuth-only, no password login) - Fix active sessions piling up: reuse existing session on re-login via OAuth - Extend speed step cycle to include 2.5× and 3.0× (matching profile slider) - Replace plain checkbox with modern toggle switch for auto-next setting - Fix catalogue status labels (ongoing/completed) to use translation keys - Add font family and text size translation keys to all 5 locale files Co-Authored-By: Claude Sonnet 4.6 --- ui/messages/en.json | 12 +- ui/messages/fr.json | 12 +- ui/messages/id.json | 12 +- ui/messages/pt-BR.json | 12 +- ui/messages/ru.json | 12 +- ui/src/app.css | 9 +- ui/src/lib/server/pocketbase.ts | 8 +- ui/src/routes/+layout.server.ts | 26 ++- ui/src/routes/+layout.svelte | 91 +++++++-- ui/src/routes/api/settings/+server.ts | 27 ++- .../auth/[provider]/callback/+server.ts | 23 ++- ui/src/routes/catalogue/+page.svelte | 6 +- ui/src/routes/profile/+page.server.ts | 43 +---- ui/src/routes/profile/+page.svelte | 181 ++++++++---------- 14 files changed, 300 insertions(+), 174 deletions(-) diff --git a/ui/messages/en.json b/ui/messages/en.json index 2d99711..ed70450 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -392,5 +392,15 @@ "reader_generate_samples": "Generate missing samples", "reader_voice_applies_next": "New voice applies on next \u201cPlay narration\u201d.", "reader_choose_voice": "Choose Voice", - "reader_generating_narration": "Generating narration\u2026" + "reader_generating_narration": "Generating narration\u2026", + + "profile_font_family": "Font Family", + "profile_font_system": "System", + "profile_font_serif": "Serif", + "profile_font_mono": "Monospace", + "profile_text_size": "Text Size", + "profile_text_size_sm": "Small", + "profile_text_size_md": "Normal", + "profile_text_size_lg": "Large", + "profile_text_size_xl": "X-Large" } diff --git a/ui/messages/fr.json b/ui/messages/fr.json index 79d35c0..ff297d5 100644 --- a/ui/messages/fr.json +++ b/ui/messages/fr.json @@ -391,5 +391,15 @@ "reader_generate_samples": "Générer les échantillons manquants", "reader_voice_applies_next": "La nouvelle voix s'appliquera au prochain « Lire la narration ».", "reader_choose_voice": "Choisir une voix", - "reader_generating_narration": "Génération de la narration…" + "reader_generating_narration": "Génération de la narration…", + + "profile_font_family": "Police", + "profile_font_system": "Système", + "profile_font_serif": "Serif", + "profile_font_mono": "Mono", + "profile_text_size": "Taille du texte", + "profile_text_size_sm": "Petit", + "profile_text_size_md": "Normal", + "profile_text_size_lg": "Grand", + "profile_text_size_xl": "Très grand" } diff --git a/ui/messages/id.json b/ui/messages/id.json index 8c06381..a2eb1cd 100644 --- a/ui/messages/id.json +++ b/ui/messages/id.json @@ -391,5 +391,15 @@ "reader_generate_samples": "Hasilkan sampel yang hilang", "reader_voice_applies_next": "Suara baru berlaku pada \"Putar narasi\" berikutnya.", "reader_choose_voice": "Pilih Suara", - "reader_generating_narration": "Membuat narasi…" + "reader_generating_narration": "Membuat narasi…", + + "profile_font_family": "Jenis Font", + "profile_font_system": "Sistem", + "profile_font_serif": "Serif", + "profile_font_mono": "Mono", + "profile_text_size": "Ukuran Teks", + "profile_text_size_sm": "Kecil", + "profile_text_size_md": "Normal", + "profile_text_size_lg": "Besar", + "profile_text_size_xl": "Sangat Besar" } diff --git a/ui/messages/pt-BR.json b/ui/messages/pt-BR.json index 9eb071d..e049b66 100644 --- a/ui/messages/pt-BR.json +++ b/ui/messages/pt-BR.json @@ -391,5 +391,15 @@ "reader_generate_samples": "Gerar amostras ausentes", "reader_voice_applies_next": "A nova voz será aplicada no próximo \"Reproduzir narração\".", "reader_choose_voice": "Escolher Voz", - "reader_generating_narration": "Gerando narração…" + "reader_generating_narration": "Gerando narração…", + + "profile_font_family": "Fonte", + "profile_font_system": "Sistema", + "profile_font_serif": "Serif", + "profile_font_mono": "Mono", + "profile_text_size": "Tamanho do texto", + "profile_text_size_sm": "Pequeno", + "profile_text_size_md": "Normal", + "profile_text_size_lg": "Grande", + "profile_text_size_xl": "Muito grande" } diff --git a/ui/messages/ru.json b/ui/messages/ru.json index ea6763f..983cb80 100644 --- a/ui/messages/ru.json +++ b/ui/messages/ru.json @@ -391,5 +391,15 @@ "reader_generate_samples": "Сгенерировать недостающие образцы", "reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».", "reader_choose_voice": "Выбрать голос", - "reader_generating_narration": "Генерация озвучки…" + "reader_generating_narration": "Генерация озвучки…", + + "profile_font_family": "Шрифт", + "profile_font_system": "Системный", + "profile_font_serif": "Serif", + "profile_font_mono": "Моноширинный", + "profile_text_size": "Размер текста", + "profile_text_size_sm": "Маленький", + "profile_text_size_md": "Нормальный", + "profile_text_size_lg": "Большой", + "profile_text_size_xl": "Очень большой" } diff --git a/ui/src/app.css b/ui/src/app.css index 0cf8a36..95dfd0c 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -56,11 +56,18 @@ html { color: var(--color-text); } +/* ── Reading typography custom properties ──────────────────────────── */ +:root { + --reading-font: system-ui, -apple-system, sans-serif; + --reading-size: 1.05rem; +} + /* ── Chapter prose ─────────────────────────────────────────────────── */ .prose-chapter { max-width: 72ch; line-height: 1.85; - font-size: 1.05rem; + font-family: var(--reading-font); + font-size: var(--reading-size); color: var(--color-muted); } diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index bde49f3..5bd496c 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -55,6 +55,9 @@ export interface PBUserSettings { voice: string; speed: number; theme?: string; + locale?: string; + font_family?: string; + font_size?: number; updated?: string; } @@ -803,7 +806,7 @@ export async function getSettings( export async function saveSettings( sessionId: string, - settings: { autoNext: boolean; voice: string; speed: number; theme?: string }, + settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number }, userId?: string ): Promise { const existing = await listOne( @@ -819,6 +822,9 @@ export async function saveSettings( updated: new Date().toISOString() }; if (settings.theme !== undefined) payload.theme = settings.theme; + if (settings.locale !== undefined) payload.locale = settings.locale; + if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily; + if (settings.fontSize !== undefined) payload.font_size = settings.fontSize; if (userId) payload.user_id = userId; if (existing) { diff --git a/ui/src/routes/+layout.server.ts b/ui/src/routes/+layout.server.ts index 575a5a9..7973002 100644 --- a/ui/src/routes/+layout.server.ts +++ b/ui/src/routes/+layout.server.ts @@ -6,14 +6,14 @@ import { log } from '$lib/server/logger'; // Routes that are accessible without being logged in const PUBLIC_ROUTES = new Set(['/login', '/disclaimer', '/privacy', '/dmca', '/terms']); -export const load: LayoutServerLoad = async ({ locals, url }) => { +export const load: LayoutServerLoad = async ({ locals, url, cookies }) => { // Allow /auth/* (OAuth initiation + callbacks) without login const isPublic = PUBLIC_ROUTES.has(url.pathname) || url.pathname.startsWith('/auth/'); if (!isPublic && !locals.user) { redirect(302, `/login`); } - let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber' }; + let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0 }; try { const row = await getSettings(locals.sessionId, locals.user?.id); if (row) { @@ -21,13 +21,33 @@ export const load: LayoutServerLoad = async ({ locals, url }) => { autoNext: row.auto_next ?? false, voice: row.voice ?? 'af_bella', speed: row.speed ?? 1.0, - theme: row.theme ?? 'amber' + theme: row.theme ?? 'amber', + locale: row.locale ?? 'en', + fontFamily: row.font_family ?? 'system', + fontSize: row.font_size ?? 1.0 }; } } catch (e) { log.warn('layout', 'failed to load settings', { err: String(e) }); } + // If user is logged in and has a non-English locale saved, ensure the + // PARAGLIDE_LOCALE cookie is set so the locale persists after refresh. + if (locals.user) { + const savedLocale = settings.locale ?? 'en'; + if (savedLocale !== 'en') { + const currentCookieLocale = cookies.get('PARAGLIDE_LOCALE'); + if (currentCookieLocale !== savedLocale) { + cookies.set('PARAGLIDE_LOCALE', savedLocale, { + path: '/', + maxAge: 34560000, + sameSite: 'lax', + httpOnly: false + }); + } + } + } + return { user: locals.user, isPro: locals.isPro, diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index 6d38f4f..a14563a 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -10,7 +10,7 @@ import { Button } from '$lib/components/ui/button'; import { cn } from '$lib/utils'; import * as m from '$lib/paraglide/messages.js'; - import { locales, getLocale, localizeHref } from '$lib/paraglide/runtime.js'; + import { locales, getLocale } from '$lib/paraglide/runtime.js'; let { children, data }: { children: Snippet; data: LayoutData } = $props(); @@ -26,11 +26,17 @@ // ── Theme ────────────────────────────────────────────────────────────── let currentTheme = $state(data.settings?.theme ?? 'amber'); + let currentFontFamily = $state(data.settings?.fontFamily ?? 'system'); + let currentFontSize = $state(data.settings?.fontSize ?? 1.0); - // Expose theme state to child pages (e.g. profile theme picker) + // Expose theme + font state to child pages (e.g. profile picker) setContext('theme', { get current() { return currentTheme; }, - set current(v: string) { currentTheme = v; } + set current(v: string) { currentTheme = v; }, + get fontFamily() { return currentFontFamily; }, + set fontFamily(v: string) { currentFontFamily = v; }, + get fontSize() { return currentFontSize; }, + set fontSize(v: number) { currentFontSize = v; } }); $effect(() => { @@ -39,6 +45,17 @@ } }); + $effect(() => { + if (typeof document === 'undefined') return; + const fontMap: Record = { + system: 'system-ui, -apple-system, sans-serif', + serif: "Georgia, 'Times New Roman', serif", + mono: "'Courier New', monospace", + }; + document.documentElement.style.setProperty('--reading-font', fontMap[currentFontFamily] ?? fontMap.system); + document.documentElement.style.setProperty('--reading-size', `${currentFontSize}rem`); + }); + // Apply persisted settings once on mount (server-loaded data). // Use a derived to react to future invalidateAll() re-loads too. let settingsApplied = false; @@ -50,19 +67,23 @@ audioStore.voice = data.settings.voice; audioStore.speed = data.settings.speed; } - // Always sync theme (profile page calls invalidateAll after saving) + // Always sync theme + font (profile page calls invalidateAll after saving) currentTheme = data.settings.theme ?? 'amber'; + currentFontFamily = data.settings.fontFamily ?? 'system'; + currentFontSize = data.settings.fontSize ?? 1.0; } }); // ── Persist settings changes (debounced 800ms) ────────────────────────── let settingsSaveTimer = 0; $effect(() => { - // Subscribe to the four settings fields + // Subscribe to settings fields const autoNext = audioStore.autoNext; const voice = audioStore.voice; const speed = audioStore.speed; const theme = currentTheme; + const fontFamily = currentFontFamily; + const fontSize = currentFontSize; // Skip saving until settings have been applied from the server if (!settingsApplied) return; @@ -72,7 +93,7 @@ fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ autoNext, voice, speed, theme }) + body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize }) }).catch(() => {}); }, 800) as unknown as number; }); @@ -168,7 +189,7 @@ audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30); } - const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]; + const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]; function cycleSpeed() { const idx = speedSteps.indexOf(audioStore.speed); @@ -283,6 +304,40 @@
+ + + + + + {#if data.user?.role === 'admin'} - + diff --git a/ui/src/routes/api/settings/+server.ts b/ui/src/routes/api/settings/+server.ts index 9543e1a..6f9e747 100644 --- a/ui/src/routes/api/settings/+server.ts +++ b/ui/src/routes/api/settings/+server.ts @@ -5,7 +5,7 @@ import { log } from '$lib/server/logger'; /** * GET /api/settings - * Returns the current user's settings (auto_next, voice, speed, theme). + * Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize). * Returns defaults if no settings record exists yet. */ export const GET: RequestHandler = async ({ locals }) => { @@ -15,7 +15,10 @@ export const GET: RequestHandler = async ({ locals }) => { autoNext: settings?.auto_next ?? false, voice: settings?.voice ?? 'af_bella', speed: settings?.speed ?? 1.0, - theme: settings?.theme ?? 'amber' + theme: settings?.theme ?? 'amber', + locale: settings?.locale ?? 'en', + fontFamily: settings?.font_family ?? 'system', + fontSize: settings?.font_size ?? 1.0 }); } catch (e) { log.error('settings', 'GET failed', { err: String(e) }); @@ -25,7 +28,7 @@ export const GET: RequestHandler = async ({ locals }) => { /** * PUT /api/settings - * Body: { autoNext: boolean, voice: string, speed: number, theme?: string } + * Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number } * Saves user preferences. */ export const PUT: RequestHandler = async ({ request, locals }) => { @@ -46,6 +49,24 @@ export const PUT: RequestHandler = async ({ request, locals }) => { error(400, `Invalid theme — must be one of: ${validThemes.join(', ')}`); } + // locale is optional — if provided it must be a known value + const validLocales = ['en', 'ru', 'id', 'pt-BR', 'fr']; + if (body.locale !== undefined && !validLocales.includes(body.locale)) { + error(400, `Invalid locale — must be one of: ${validLocales.join(', ')}`); + } + + // fontFamily is optional — if provided it must be a known value + const validFontFamilies = ['system', 'serif', 'mono']; + if (body.fontFamily !== undefined && !validFontFamilies.includes(body.fontFamily)) { + error(400, `Invalid fontFamily — must be one of: ${validFontFamilies.join(', ')}`); + } + + // fontSize is optional — if provided it must be one of the valid steps + const validFontSizes = [0.9, 1.0, 1.15, 1.3]; + if (body.fontSize !== undefined && !validFontSizes.includes(body.fontSize)) { + error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`); + } + try { await saveSettings(locals.sessionId, body, locals.user?.id); } catch (e) { diff --git a/ui/src/routes/auth/[provider]/callback/+server.ts b/ui/src/routes/auth/[provider]/callback/+server.ts index e96615c..7deb1d7 100644 --- a/ui/src/routes/auth/[provider]/callback/+server.ts +++ b/ui/src/routes/auth/[provider]/callback/+server.ts @@ -25,7 +25,7 @@ import { linkOAuthToUser } from '$lib/server/pocketbase'; import { createAuthToken } from '../../../../hooks.server'; -import { createUserSession, mergeSessionProgress } from '$lib/server/pocketbase'; +import { createUserSession, touchUserSession, mergeSessionProgress } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; type Provider = 'google' | 'github'; @@ -227,12 +227,21 @@ export const GET: RequestHandler = async ({ params, url, cookies, locals }) => { ); // ── Create session + auth cookie ────────────────────────────────────────── - const authSessionId = randomBytes(16).toString('hex'); - const userAgent = '' ; // not available in RequestHandler — omit - const ip = ''; - createUserSession(user.id, authSessionId, userAgent, ip).catch((err) => - log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) }) - ); + let authSessionId: string; + + // Reuse existing session if the user is already logged in as the same user + if (locals.user?.id === user.id && locals.user?.authSessionId) { + authSessionId = locals.user.authSessionId; + // Just touch the existing session to update last_seen + touchUserSession(authSessionId).catch(() => {}); + } else { + authSessionId = randomBytes(16).toString('hex'); + const userAgent = ''; // not available in RequestHandler — omit + const ip = ''; + createUserSession(user.id, authSessionId, userAgent, ip).catch((err) => + log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) }) + ); + } const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); cookies.set(AUTH_COOKIE, token, { diff --git a/ui/src/routes/catalogue/+page.svelte b/ui/src/routes/catalogue/+page.svelte index bdbad1c..8cd9ae2 100644 --- a/ui/src/routes/catalogue/+page.svelte +++ b/ui/src/routes/catalogue/+page.svelte @@ -117,11 +117,15 @@ { value: 'rank', label: m.catalogue_sort_rank() } ]); const FALLBACK_STATUSES = ['ongoing', 'completed']; + const STATUS_LABELS: Record string> = { + ongoing: () => m.catalogue_status_ongoing(), + completed: () => m.catalogue_status_completed(), + }; const statuses = $derived([ { value: 'all', label: m.catalogue_status_all() }, ...((data.statuses?.length ? data.statuses : FALLBACK_STATUSES).map((s: string) => ({ value: s, - label: s.charAt(0).toUpperCase() + s.slice(1) + label: STATUS_LABELS[s]?.() ?? (s.charAt(0).toUpperCase() + s.slice(1)) }))) ]); diff --git a/ui/src/routes/profile/+page.server.ts b/ui/src/routes/profile/+page.server.ts index 76ebca5..a144113 100644 --- a/ui/src/routes/profile/+page.server.ts +++ b/ui/src/routes/profile/+page.server.ts @@ -1,6 +1,6 @@ -import { fail, redirect } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; -import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { listUserSessions, getUserByUsername } from '$lib/server/pocketbase'; import { resolveAvatarUrl } from '$lib/server/minio'; import { log } from '$lib/server/logger'; @@ -38,40 +38,3 @@ export const load: PageServerLoad = async ({ locals }) => { })) }; }; - -export const actions: Actions = { - changePassword: async ({ request, locals }) => { - if (!locals.user) { - return fail(401, { error: 'Not logged in.' }); - } - - const data = await request.formData(); - const current = (data.get('current') as string | null) ?? ''; - const next = (data.get('next') as string | null) ?? ''; - const confirm = (data.get('confirm') as string | null) ?? ''; - - if (!current || !next || !confirm) { - return fail(400, { error: 'All fields are required.' }); - } - if (next.length < 8) { - return fail(400, { error: 'New password must be at least 8 characters.' }); - } - if (next !== confirm) { - return fail(400, { error: 'New passwords do not match.' }); - } - - let ok: boolean; - try { - ok = await changePassword(locals.user.id, current, next); - } catch (e) { - log.error('profile', 'changePassword failed', { err: String(e) }); - return fail(500, { error: 'An error occurred. Please try again.' }); - } - - if (!ok) { - return fail(401, { error: 'Current password is incorrect.' }); - } - - return { success: true }; - } -}; diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index edecc89..b2febfe 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -90,9 +90,11 @@ autoNext = audioStore.autoNext; }); - // ── Theme ──────────────────────────────────────────────────────────────────── - const themeCtx = getContext<{ currentTheme: string; setTheme: (t: string) => void } | undefined>('theme'); - let selectedTheme = $state(untrack(() => data.settings?.theme ?? themeCtx?.currentTheme ?? 'amber')); + // ── 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' }, @@ -100,6 +102,19 @@ { id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' }, ]; + 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() }, + ]; + let settingsSaving = $state(false); let settingsSaved = $state(false); @@ -110,14 +125,18 @@ await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ autoNext, voice, speed, theme: selectedTheme }) + 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 live via context - themeCtx?.setTheme(selectedTheme); + // 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); @@ -126,17 +145,6 @@ } } - // ── Password change ───────────────────────────────────────────────────────── - let pwSubmitting = $state(false); - let pwSuccess = $state(false); - - $effect(() => { - if (form?.success) { - pwSuccess = true; - setTimeout(() => (pwSuccess = false), 3000); - } - }); - // ── Sessions ──────────────────────────────────────────────────────────────── type Session = { id: string; @@ -366,6 +374,51 @@ {/each}
+ +
+

{m.profile_font_family()}

+
+ {#each FONTS as f} + + {/each} +
+
+ +
+

{m.profile_text_size()}

+
+ {#each FONT_SIZES as s} + + {/each} +
+
+ +
+ + {#if settingsSaved} + {m.profile_saved()} + {/if} +
@@ -426,16 +479,19 @@ - - + +
+ {m.profile_auto_advance()} + +
- -