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

- 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:
Admin
2026-03-29 10:43:53 +05:00
parent 7a2a4fc755
commit 0a3a61a3ef
33 changed files with 2595 additions and 412 deletions

View File

@@ -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>