feat(ui): language persistence, theme fix, font/size settings, header quick-access
Some checks failed
CI / UI (push) Failing after 54s
CI / Backend (push) Successful in 1m56s
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Successful in 54s
CI / Backend (pull_request) Successful in 45s
CI / UI (pull_request) Successful in 50s
Release / Docker / ui (push) Successful in 2m23s
Release / Docker / runner (push) Successful in 3m15s
Release / Docker / backend (push) Successful in 2m3s
Release / Gitea Release (push) Failing after 2s
Some checks failed
CI / UI (push) Failing after 54s
CI / Backend (push) Successful in 1m56s
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Successful in 54s
CI / Backend (pull_request) Successful in 45s
CI / UI (pull_request) Successful in 50s
Release / Docker / ui (push) Successful in 2m23s
Release / Docker / runner (push) Successful in 3m15s
Release / Docker / backend (push) Successful in 2m3s
Release / Gitea Release (push) Failing after 2s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -392,5 +392,15 @@
|
|||||||
"reader_generate_samples": "Generate missing samples",
|
"reader_generate_samples": "Generate missing samples",
|
||||||
"reader_voice_applies_next": "New voice applies on next \u201cPlay narration\u201d.",
|
"reader_voice_applies_next": "New voice applies on next \u201cPlay narration\u201d.",
|
||||||
"reader_choose_voice": "Choose Voice",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,5 +391,15 @@
|
|||||||
"reader_generate_samples": "Générer les échantillons manquants",
|
"reader_generate_samples": "Générer les échantillons manquants",
|
||||||
"reader_voice_applies_next": "La nouvelle voix s'appliquera au prochain « Lire la narration ».",
|
"reader_voice_applies_next": "La nouvelle voix s'appliquera au prochain « Lire la narration ».",
|
||||||
"reader_choose_voice": "Choisir une voix",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,5 +391,15 @@
|
|||||||
"reader_generate_samples": "Hasilkan sampel yang hilang",
|
"reader_generate_samples": "Hasilkan sampel yang hilang",
|
||||||
"reader_voice_applies_next": "Suara baru berlaku pada \"Putar narasi\" berikutnya.",
|
"reader_voice_applies_next": "Suara baru berlaku pada \"Putar narasi\" berikutnya.",
|
||||||
"reader_choose_voice": "Pilih Suara",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,5 +391,15 @@
|
|||||||
"reader_generate_samples": "Gerar amostras ausentes",
|
"reader_generate_samples": "Gerar amostras ausentes",
|
||||||
"reader_voice_applies_next": "A nova voz será aplicada no próximo \"Reproduzir narração\".",
|
"reader_voice_applies_next": "A nova voz será aplicada no próximo \"Reproduzir narração\".",
|
||||||
"reader_choose_voice": "Escolher Voz",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,5 +391,15 @@
|
|||||||
"reader_generate_samples": "Сгенерировать недостающие образцы",
|
"reader_generate_samples": "Сгенерировать недостающие образцы",
|
||||||
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
|
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
|
||||||
"reader_choose_voice": "Выбрать голос",
|
"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": "Очень большой"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,11 +56,18 @@ html {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Reading typography custom properties ──────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--reading-font: system-ui, -apple-system, sans-serif;
|
||||||
|
--reading-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Chapter prose ─────────────────────────────────────────────────── */
|
/* ── Chapter prose ─────────────────────────────────────────────────── */
|
||||||
.prose-chapter {
|
.prose-chapter {
|
||||||
max-width: 72ch;
|
max-width: 72ch;
|
||||||
line-height: 1.85;
|
line-height: 1.85;
|
||||||
font-size: 1.05rem;
|
font-family: var(--reading-font);
|
||||||
|
font-size: var(--reading-size);
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export interface PBUserSettings {
|
|||||||
voice: string;
|
voice: string;
|
||||||
speed: number;
|
speed: number;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
|
locale?: string;
|
||||||
|
font_family?: string;
|
||||||
|
font_size?: number;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,7 +806,7 @@ export async function getSettings(
|
|||||||
|
|
||||||
export async function saveSettings(
|
export async function saveSettings(
|
||||||
sessionId: string,
|
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
|
userId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existing = await listOne<PBUserSettings & { id: string }>(
|
const existing = await listOne<PBUserSettings & { id: string }>(
|
||||||
@@ -819,6 +822,9 @@ export async function saveSettings(
|
|||||||
updated: new Date().toISOString()
|
updated: new Date().toISOString()
|
||||||
};
|
};
|
||||||
if (settings.theme !== undefined) payload.theme = settings.theme;
|
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 (userId) payload.user_id = userId;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import { log } from '$lib/server/logger';
|
|||||||
// Routes that are accessible without being logged in
|
// Routes that are accessible without being logged in
|
||||||
const PUBLIC_ROUTES = new Set(['/login', '/disclaimer', '/privacy', '/dmca', '/terms']);
|
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
|
// Allow /auth/* (OAuth initiation + callbacks) without login
|
||||||
const isPublic = PUBLIC_ROUTES.has(url.pathname) || url.pathname.startsWith('/auth/');
|
const isPublic = PUBLIC_ROUTES.has(url.pathname) || url.pathname.startsWith('/auth/');
|
||||||
if (!isPublic && !locals.user) {
|
if (!isPublic && !locals.user) {
|
||||||
redirect(302, `/login`);
|
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 {
|
try {
|
||||||
const row = await getSettings(locals.sessionId, locals.user?.id);
|
const row = await getSettings(locals.sessionId, locals.user?.id);
|
||||||
if (row) {
|
if (row) {
|
||||||
@@ -21,13 +21,33 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
|
|||||||
autoNext: row.auto_next ?? false,
|
autoNext: row.auto_next ?? false,
|
||||||
voice: row.voice ?? 'af_bella',
|
voice: row.voice ?? 'af_bella',
|
||||||
speed: row.speed ?? 1.0,
|
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) {
|
} catch (e) {
|
||||||
log.warn('layout', 'failed to load settings', { err: String(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 {
|
return {
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
isPro: locals.isPro,
|
isPro: locals.isPro,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
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();
|
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||||
|
|
||||||
@@ -26,11 +26,17 @@
|
|||||||
|
|
||||||
// ── Theme ──────────────────────────────────────────────────────────────
|
// ── Theme ──────────────────────────────────────────────────────────────
|
||||||
let currentTheme = $state(data.settings?.theme ?? 'amber');
|
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', {
|
setContext('theme', {
|
||||||
get current() { return currentTheme; },
|
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(() => {
|
$effect(() => {
|
||||||
@@ -39,6 +45,17 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const fontMap: Record<string, string> = {
|
||||||
|
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).
|
// Apply persisted settings once on mount (server-loaded data).
|
||||||
// Use a derived to react to future invalidateAll() re-loads too.
|
// Use a derived to react to future invalidateAll() re-loads too.
|
||||||
let settingsApplied = false;
|
let settingsApplied = false;
|
||||||
@@ -50,19 +67,23 @@
|
|||||||
audioStore.voice = data.settings.voice;
|
audioStore.voice = data.settings.voice;
|
||||||
audioStore.speed = data.settings.speed;
|
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';
|
currentTheme = data.settings.theme ?? 'amber';
|
||||||
|
currentFontFamily = data.settings.fontFamily ?? 'system';
|
||||||
|
currentFontSize = data.settings.fontSize ?? 1.0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Persist settings changes (debounced 800ms) ──────────────────────────
|
// ── Persist settings changes (debounced 800ms) ──────────────────────────
|
||||||
let settingsSaveTimer = 0;
|
let settingsSaveTimer = 0;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Subscribe to the four settings fields
|
// Subscribe to settings fields
|
||||||
const autoNext = audioStore.autoNext;
|
const autoNext = audioStore.autoNext;
|
||||||
const voice = audioStore.voice;
|
const voice = audioStore.voice;
|
||||||
const speed = audioStore.speed;
|
const speed = audioStore.speed;
|
||||||
const theme = currentTheme;
|
const theme = currentTheme;
|
||||||
|
const fontFamily = currentFontFamily;
|
||||||
|
const fontSize = currentFontSize;
|
||||||
|
|
||||||
// Skip saving until settings have been applied from the server
|
// Skip saving until settings have been applied from the server
|
||||||
if (!settingsApplied) return;
|
if (!settingsApplied) return;
|
||||||
@@ -72,7 +93,7 @@
|
|||||||
fetch('/api/settings', {
|
fetch('/api/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ autoNext, voice, speed, theme })
|
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize })
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, 800) as unknown as number;
|
}, 800) as unknown as number;
|
||||||
});
|
});
|
||||||
@@ -168,7 +189,7 @@
|
|||||||
audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30);
|
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() {
|
function cycleSpeed() {
|
||||||
const idx = speedSteps.indexOf(audioStore.speed);
|
const idx = speedSteps.indexOf(audioStore.speed);
|
||||||
@@ -283,6 +304,40 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-4">
|
<div class="ml-auto flex items-center gap-4">
|
||||||
|
<!-- Theme quick picker -->
|
||||||
|
<div class="hidden sm:flex items-center gap-1">
|
||||||
|
{#each [{ id: 'amber', color: '#f59e0b' }, { id: 'slate', color: '#818cf8' }, { id: 'rose', color: '#fb7185' }] as t}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { currentTheme = t.id; }}
|
||||||
|
title={t.id}
|
||||||
|
class="w-4 h-4 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-60 hover:opacity-100'}"
|
||||||
|
style="background: {t.color};"
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Language quick picker -->
|
||||||
|
<div class="hidden sm:flex items-center gap-0.5">
|
||||||
|
{#each locales as locale}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={async () => {
|
||||||
|
await fetch('/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
|
||||||
|
}).catch(() => {});
|
||||||
|
const { setLocale } = await import('$lib/paraglide/runtime.js');
|
||||||
|
setLocale(locale as any, { reload: true });
|
||||||
|
}}
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||||
|
>
|
||||||
|
{locale.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Desktop: admin + profile + sign out (hidden on mobile) -->
|
<!-- Desktop: admin + profile + sign out (hidden on mobile) -->
|
||||||
{#if data.user?.role === 'admin'}
|
{#if data.user?.role === 'admin'}
|
||||||
<a
|
<a
|
||||||
@@ -432,19 +487,25 @@
|
|||||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<!-- Locale switcher (always visible) -->
|
<!-- Locale switcher (footer) -->
|
||||||
<div class="hidden sm:flex items-center gap-1 ml-2">
|
<div class="hidden sm:flex items-center gap-1 ml-2">
|
||||||
{#each locales as locale}
|
{#each locales as locale}
|
||||||
<a
|
<button
|
||||||
href={localizeHref(page.url.pathname, { locale })}
|
type="button"
|
||||||
data-sveltekit-reload
|
onclick={async () => {
|
||||||
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale
|
await fetch('/api/settings', {
|
||||||
? 'text-(--color-brand) bg-(--color-brand)/10'
|
method: 'PUT',
|
||||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
|
||||||
|
}).catch(() => {});
|
||||||
|
const { setLocale } = await import('$lib/paraglide/runtime.js');
|
||||||
|
setLocale(locale as any, { reload: true });
|
||||||
|
}}
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||||
aria-label="{m.locale_switcher_label()}: {locale}"
|
aria-label="{m.locale_switcher_label()}: {locale}"
|
||||||
>
|
>
|
||||||
{locale.toUpperCase()}
|
{locale.toUpperCase()}
|
||||||
</a>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/settings
|
* 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.
|
* Returns defaults if no settings record exists yet.
|
||||||
*/
|
*/
|
||||||
export const GET: RequestHandler = async ({ locals }) => {
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
@@ -15,7 +15,10 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
autoNext: settings?.auto_next ?? false,
|
autoNext: settings?.auto_next ?? false,
|
||||||
voice: settings?.voice ?? 'af_bella',
|
voice: settings?.voice ?? 'af_bella',
|
||||||
speed: settings?.speed ?? 1.0,
|
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) {
|
} catch (e) {
|
||||||
log.error('settings', 'GET failed', { err: String(e) });
|
log.error('settings', 'GET failed', { err: String(e) });
|
||||||
@@ -25,7 +28,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/settings
|
* 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.
|
* Saves user preferences.
|
||||||
*/
|
*/
|
||||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
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(', ')}`);
|
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 {
|
try {
|
||||||
await saveSettings(locals.sessionId, body, locals.user?.id);
|
await saveSettings(locals.sessionId, body, locals.user?.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
linkOAuthToUser
|
linkOAuthToUser
|
||||||
} from '$lib/server/pocketbase';
|
} from '$lib/server/pocketbase';
|
||||||
import { createAuthToken } from '../../../../hooks.server';
|
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';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
type Provider = 'google' | 'github';
|
type Provider = 'google' | 'github';
|
||||||
@@ -227,12 +227,21 @@ export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── Create session + auth cookie ──────────────────────────────────────────
|
// ── Create session + auth cookie ──────────────────────────────────────────
|
||||||
const authSessionId = randomBytes(16).toString('hex');
|
let authSessionId: string;
|
||||||
const userAgent = '' ; // not available in RequestHandler — omit
|
|
||||||
const ip = '';
|
// Reuse existing session if the user is already logged in as the same user
|
||||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
|
if (locals.user?.id === user.id && locals.user?.authSessionId) {
|
||||||
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
|
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);
|
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||||
cookies.set(AUTH_COOKIE, token, {
|
cookies.set(AUTH_COOKIE, token, {
|
||||||
|
|||||||
@@ -117,11 +117,15 @@
|
|||||||
{ value: 'rank', label: m.catalogue_sort_rank() }
|
{ value: 'rank', label: m.catalogue_sort_rank() }
|
||||||
]);
|
]);
|
||||||
const FALLBACK_STATUSES = ['ongoing', 'completed'];
|
const FALLBACK_STATUSES = ['ongoing', 'completed'];
|
||||||
|
const STATUS_LABELS: Record<string, () => string> = {
|
||||||
|
ongoing: () => m.catalogue_status_ongoing(),
|
||||||
|
completed: () => m.catalogue_status_completed(),
|
||||||
|
};
|
||||||
const statuses = $derived([
|
const statuses = $derived([
|
||||||
{ value: 'all', label: m.catalogue_status_all() },
|
{ value: 'all', label: m.catalogue_status_all() },
|
||||||
...((data.statuses?.length ? data.statuses : FALLBACK_STATUSES).map((s: string) => ({
|
...((data.statuses?.length ? data.statuses : FALLBACK_STATUSES).map((s: string) => ({
|
||||||
value: s,
|
value: s,
|
||||||
label: s.charAt(0).toUpperCase() + s.slice(1)
|
label: STATUS_LABELS[s]?.() ?? (s.charAt(0).toUpperCase() + s.slice(1))
|
||||||
})))
|
})))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
|
import { listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
|
||||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||||
import { log } from '$lib/server/logger';
|
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 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -90,9 +90,11 @@
|
|||||||
autoNext = audioStore.autoNext;
|
autoNext = audioStore.autoNext;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Theme ────────────────────────────────────────────────────────────────────
|
// ── Theme + Font ─────────────────────────────────────────────────────────────
|
||||||
const themeCtx = getContext<{ currentTheme: string; setTheme: (t: string) => void } | undefined>('theme');
|
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
|
||||||
let selectedTheme = $state(untrack(() => data.settings?.theme ?? themeCtx?.currentTheme ?? '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));
|
||||||
|
|
||||||
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' },
|
||||||
@@ -100,6 +102,19 @@
|
|||||||
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
|
{ 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 settingsSaving = $state(false);
|
||||||
let settingsSaved = $state(false);
|
let settingsSaved = $state(false);
|
||||||
|
|
||||||
@@ -110,14 +125,18 @@
|
|||||||
await fetch('/api/settings', {
|
await fetch('/api/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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
|
// Sync to audioStore so the player picks up changes immediately
|
||||||
audioStore.autoNext = autoNext;
|
audioStore.autoNext = autoNext;
|
||||||
audioStore.voice = voice;
|
audioStore.voice = voice;
|
||||||
audioStore.speed = speed;
|
audioStore.speed = speed;
|
||||||
// Apply theme live via context
|
// Apply theme + font live via context
|
||||||
themeCtx?.setTheme(selectedTheme);
|
if (settingsCtx) {
|
||||||
|
settingsCtx.current = selectedTheme;
|
||||||
|
settingsCtx.fontFamily = selectedFontFamily;
|
||||||
|
settingsCtx.fontSize = selectedFontSize;
|
||||||
|
}
|
||||||
await invalidateAll();
|
await invalidateAll();
|
||||||
settingsSaved = true;
|
settingsSaved = true;
|
||||||
setTimeout(() => (settingsSaved = false), 2500);
|
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 ────────────────────────────────────────────────────────────────
|
// ── Sessions ────────────────────────────────────────────────────────────────
|
||||||
type Session = {
|
type Session = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -366,6 +374,51 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<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)'}"
|
||||||
|
aria-pressed={selectedFontFamily === f.id}
|
||||||
|
>
|
||||||
|
{f.label()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<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)'}"
|
||||||
|
aria-pressed={selectedFontSize === s.value}
|
||||||
|
>
|
||||||
|
{s.label()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</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-green-400">{m.profile_saved()}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
|
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
|
||||||
@@ -426,16 +479,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auto-next -->
|
<!-- Auto-next toggle -->
|
||||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
<div class="flex items-center justify-between">
|
||||||
<input
|
<span class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</span>
|
||||||
type="checkbox"
|
<button
|
||||||
bind:checked={autoNext}
|
type="button"
|
||||||
style="accent-color: var(--color-brand);"
|
role="switch"
|
||||||
class="w-4 h-4 rounded"
|
aria-checked={autoNext}
|
||||||
/>
|
onclick={() => (autoNext = !autoNext)}
|
||||||
<span class="text-sm text-(--color-text)">{m.profile_auto_advance()}</span>
|
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)'}"
|
||||||
</label>
|
>
|
||||||
|
<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">
|
<div class="flex items-center gap-3 pt-1">
|
||||||
<button
|
<button
|
||||||
@@ -500,75 +556,4 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Change password ──────────────────────────────────────────────────── -->
|
|
||||||
<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_change_password_heading()}</h2>
|
|
||||||
|
|
||||||
{#if form?.error}
|
|
||||||
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
|
|
||||||
{form.error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if pwSuccess}
|
|
||||||
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-2.5 text-sm text-green-300">
|
|
||||||
{m.profile_password_changed_ok()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/changePassword"
|
|
||||||
use:enhance={() => {
|
|
||||||
pwSubmitting = true;
|
|
||||||
return async ({ update }) => {
|
|
||||||
pwSubmitting = false;
|
|
||||||
await update();
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<label class="block text-sm font-medium text-(--color-text)" for="current">{m.profile_current_password()}</label>
|
|
||||||
<input
|
|
||||||
id="current"
|
|
||||||
name="current"
|
|
||||||
type="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<label class="block text-sm font-medium text-(--color-text)" for="next">{m.profile_new_password()}</label>
|
|
||||||
<input
|
|
||||||
id="next"
|
|
||||||
name="next"
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
required
|
|
||||||
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<label class="block text-sm font-medium text-(--color-text)" for="confirm">{m.profile_confirm_password()}</label>
|
|
||||||
<input
|
|
||||||
id="confirm"
|
|
||||||
name="confirm"
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
required
|
|
||||||
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={pwSubmitting}
|
|
||||||
class="px-4 py-2 rounded-lg bg-(--color-surface-3) text-(--color-text) font-semibold text-sm hover:bg-(--color-surface-3) transition-colors disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{pwSubmitting ? m.profile_updating() : m.profile_update_password()}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user