feat(i18n): add Paraglide i18n with 5 locales (v2.3.22)
Some checks failed
CI / Backend (pull_request) Successful in 41s
CI / UI (pull_request) Failing after 27s
CI / UI (push) Failing after 27s
CI / Backend (push) Successful in 48s
Release / Test backend (push) Successful in 23s
Release / Check ui (push) Failing after 33s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Failing after 52s
Release / Docker / backend (push) Failing after 11s
Release / Docker / runner (push) Successful in 1m51s
Release / Gitea Release (push) Has been skipped
Some checks failed
CI / Backend (pull_request) Successful in 41s
CI / UI (pull_request) Failing after 27s
CI / UI (push) Failing after 27s
CI / Backend (push) Successful in 48s
Release / Test backend (push) Successful in 23s
Release / Check ui (push) Failing after 33s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Failing after 52s
Release / Docker / backend (push) Failing after 11s
Release / Docker / runner (push) Successful in 1m51s
Release / Gitea Release (push) Has been skipped
- Install @inlang/paraglide-js v2.15.1; configure project.inlang settings - Add en/ru/id/pt-BR/fr message catalogues (~140 keys each) - Wire paraglideVitePlugin in vite.config.ts, reroute hook in hooks.ts, and paraglideHandle middleware in hooks.server.ts - Migrate all routes and shared components to use m.*() message calls - Fix duplicate onMount body in chapters/[n]/+page.svelte - Build passes; svelte-check: 0 errors, 3 pre-existing warnings
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import type { Voice } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
@@ -93,10 +94,10 @@
|
||||
const themeCtx = getContext<{ currentTheme: string; setTheme: (t: string) => void } | undefined>('theme');
|
||||
let selectedTheme = $state(untrack(() => data.settings?.theme ?? themeCtx?.currentTheme ?? 'amber'));
|
||||
|
||||
const THEMES: { id: string; label: string; swatch: string }[] = [
|
||||
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
|
||||
{ id: 'slate', label: 'Slate', swatch: '#818cf8' },
|
||||
{ id: 'rose', label: 'Rose', swatch: '#fb7185' },
|
||||
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' },
|
||||
];
|
||||
|
||||
let settingsSaving = $state(false);
|
||||
@@ -204,7 +205,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profile — libnovel</title>
|
||||
<title>{m.profile_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if cropFile && browser}
|
||||
@@ -227,7 +228,7 @@
|
||||
<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)"
|
||||
title="Change profile picture"
|
||||
title={m.profile_change_avatar()}
|
||||
disabled={avatarUploading}
|
||||
>
|
||||
{#if avatarUrl}
|
||||
@@ -269,17 +270,17 @@
|
||||
{#if avatarError}
|
||||
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
|
||||
{:else}
|
||||
<p class="text-(--color-muted) text-xs mt-1">Click avatar to change photo</p>
|
||||
<p class="text-(--color-muted) text-xs mt-1">{m.profile_click_to_change()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 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)">Appearance</h2>
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_appearance_heading()}</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-(--color-text)">Theme</p>
|
||||
<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}
|
||||
<button
|
||||
@@ -292,7 +293,7 @@
|
||||
aria-pressed={selectedTheme === t.id}
|
||||
>
|
||||
<span class="w-3.5 h-3.5 rounded-full flex-shrink-0" style="background: {t.swatch};"></span>
|
||||
{t.label}
|
||||
{t.label()}
|
||||
{#if selectedTheme === t.id}
|
||||
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
@@ -306,16 +307,16 @@
|
||||
|
||||
<!-- ── 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)">Reading settings</h2>
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_reading_heading()}</h2>
|
||||
|
||||
<!-- Voice -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">TTS voice</label>
|
||||
<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>
|
||||
{: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>No voices available</option>
|
||||
<option>{m.common_loading()}</option>
|
||||
</select>
|
||||
{:else}
|
||||
<select
|
||||
@@ -344,7 +345,7 @@
|
||||
<!-- Speed -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="speed-range">
|
||||
Playback speed — <span class="text-(--color-brand) font-mono">{speed.toFixed(1)}x</span>
|
||||
{m.profile_playback_speed({ speed: speed.toFixed(1) })}
|
||||
</label>
|
||||
<input
|
||||
id="speed-range"
|
||||
@@ -370,7 +371,7 @@
|
||||
style="accent-color: var(--color-brand);"
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-(--color-text)">Auto-advance to next chapter</span>
|
||||
<span class="text-sm text-(--color-text)">{m.profile_auto_advance()}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
@@ -379,18 +380,18 @@
|
||||
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 ? 'Saving…' : 'Save settings'}
|
||||
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
|
||||
</button>
|
||||
{#if settingsSaved}
|
||||
<span class="text-sm text-green-400">Saved!</span>
|
||||
<span class="text-sm text-green-400">{m.profile_saved()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 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)">Active sessions</h2>
|
||||
<p class="text-sm text-(--color-muted)">These are all devices currently signed into your account. End any session you don't recognise.</p>
|
||||
<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>
|
||||
|
||||
{#if revokeError}
|
||||
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
|
||||
@@ -399,7 +400,7 @@
|
||||
{/if}
|
||||
|
||||
{#if sessions.length === 0}
|
||||
<p class="text-sm text-(--color-muted) italic">No session records found. Sessions are tracked from the next login.</p>
|
||||
<p class="text-sm text-(--color-muted) italic">{m.profile_no_sessions()}</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each sessions as session (session.id)}
|
||||
@@ -408,16 +409,16 @@
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-(--color-text) truncate">{parseUA(session.user_agent)}</span>
|
||||
{#if session.is_current}
|
||||
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/20 text-(--color-brand-dim) border border-(--color-brand)/40">This session</span>
|
||||
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/20 text-(--color-brand-dim) border border-(--color-brand)/40">{m.profile_session_this()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if session.ip}
|
||||
<p class="text-xs text-(--color-muted) font-mono">{session.ip}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Signed in {formatDate(session.created_at)}
|
||||
{m.profile_session_signed_in({ date: formatDate(session.created_at) })}
|
||||
{#if session.last_seen && session.last_seen !== session.created_at}
|
||||
· Last seen {formatDate(session.last_seen)}
|
||||
{m.profile_session_last_seen({ date: formatDate(session.last_seen) })}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -429,7 +430,7 @@
|
||||
? '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)'}"
|
||||
>
|
||||
{revokingId === session.id ? '…' : session.is_current ? 'Sign out' : 'End'}
|
||||
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -439,7 +440,7 @@
|
||||
|
||||
<!-- ── 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)">Change password</h2>
|
||||
<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)">
|
||||
@@ -449,7 +450,7 @@
|
||||
|
||||
{#if pwSuccess}
|
||||
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-2.5 text-sm text-green-300">
|
||||
Password changed successfully.
|
||||
{m.profile_password_changed_ok()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -466,7 +467,7 @@
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="current">Current password</label>
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="current">{m.profile_current_password()}</label>
|
||||
<input
|
||||
id="current"
|
||||
name="current"
|
||||
@@ -477,7 +478,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="next">New password</label>
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="next">{m.profile_new_password()}</label>
|
||||
<input
|
||||
id="next"
|
||||
name="next"
|
||||
@@ -488,7 +489,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="confirm">Confirm new password</label>
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="confirm">{m.profile_confirm_password()}</label>
|
||||
<input
|
||||
id="confirm"
|
||||
name="confirm"
|
||||
@@ -503,7 +504,7 @@
|
||||
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 ? 'Updating…' : 'Update password'}
|
||||
{pwSubmitting ? m.profile_updating() : m.profile_update_password()}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user