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

@@ -52,6 +52,7 @@
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import type { Voice } from '$lib/types';
import * as m from '$lib/paraglide/messages.js';
interface Props {
slug: string;
@@ -702,7 +703,7 @@
size="icon"
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v.id ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
title={samplePlayingVoice === v.id ? m.reader_voice_stop_sample() : m.reader_voice_play_sample()}
aria-label={samplePlayingVoice === v.id ? `Stop ${v.id} sample` : `Play ${v.id} sample`}
>
{#if samplePlayingVoice === v.id}
@@ -724,7 +725,7 @@
<svg class="w-4 h-4 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
</svg>
<span class="text-sm text-(--color-text) font-medium">Audio Narration</span>
<span class="text-sm text-(--color-text) font-medium">{m.reader_audio_narration()}</span>
</div>
<!-- Voice selector button -->
@@ -734,7 +735,7 @@
size="sm"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
title="Change voice"
title={m.reader_change_voice()}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
@@ -751,13 +752,13 @@
{#if showVoicePanel && voices.length > 0}
<div class="mb-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
<div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Choose Voice</span>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.reader_choose_voice()}</span>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
onclick={() => { stopSample(); showVoicePanel = false; }}
aria-label="Close voice selector"
aria-label={m.reader_close_voice_panel()}
>
<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="M6 18L18 6M6 6l12 12"/>
@@ -787,7 +788,7 @@
</div>
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
<p class="text-xs text-(--color-muted)">
New voice applies on next "Play narration".
{m.reader_voice_applies_next()}
{#if voices.length > 0}
<a
href="/api/audio/voice-samples"
@@ -796,7 +797,7 @@
e.preventDefault();
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
}}
>Generate missing samples</a>
>{m.reader_generate_samples()}</a>
{/if}
</p>
</div>
@@ -810,12 +811,12 @@
{#if audioStore.status === 'error'}
<p class="text-(--color-danger) text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
{/if}
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play narration
</Button>
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{m.reader_play_narration()}
</Button>
{:else if audioStore.status === 'loading'}
<Button variant="default" size="sm" disabled>
@@ -823,12 +824,12 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Loading…
{m.player_loading()}
</Button>
{:else if audioStore.status === 'generating'}
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Generating narration</p>
<p class="text-xs text-(--color-muted)">{m.reader_generating_narration()}</p>
<div class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden">
<div
class="h-full bg-(--color-brand) rounded-full transition-none"
@@ -842,17 +843,17 @@
<!-- Mini-bar is the canonical control surface — show a compact indicator here -->
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
{#if audioStore.isPlaying}
<svg class="w-3.5 h-3.5 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
<span>Playing — controls below</span>
{:else}
<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<span>Paused — controls below</span>
{/if}
{#if audioStore.isPlaying}
<svg class="w-3.5 h-3.5 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
<span>{m.reader_playing()}</span>
{:else}
<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<span>{m.reader_paused()}</span>
{/if}
<span class="tabular-nums text-(--color-muted) opacity-60">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</span>
@@ -864,39 +865,39 @@
variant="ghost"
size="sm"
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
title={audioStore.autoNext ? `Auto-next on — will play Ch.${nextChapter} automatically` : 'Auto-next off'}
aria-pressed={audioStore.autoNext}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
Auto
</Button>
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
title={audioStore.autoNext ? m.player_auto_next_on() : m.player_auto_next_off()}
aria-pressed={audioStore.autoNext}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
{m.reader_auto_next()}
</Button>
{/if}
</div>
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
<div class="mt-2">
{#if audioStore.nextStatus === 'prefetching'}
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>Preparing Ch.{nextChapter}{Math.round(audioStore.nextProgress)}%</span>
</div>
{:else if audioStore.nextStatus === 'prefetched'}
<p class="text-xs text-(--color-muted) flex items-center gap-1">
<svg class="w-3 h-3 text-(--color-brand) 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>
Ch.{nextChapter} ready
</p>
{:else if audioStore.nextStatus === 'failed'}
<p class="text-xs text-(--color-muted) opacity-60">Ch.{nextChapter} will generate on navigate</p>
{/if}
{#if audioStore.nextStatus === 'prefetching'}
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>{m.reader_ch_preparing({ n: String(nextChapter), percent: String(Math.round(audioStore.nextProgress)) })}</span>
</div>
{:else if audioStore.nextStatus === 'prefetched'}
<p class="text-xs text-(--color-muted) flex items-center gap-1">
<svg class="w-3 h-3 text-(--color-brand) 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>
{m.reader_ch_ready({ n: String(nextChapter) })}
</p>
{:else if audioStore.nextStatus === 'failed'}
<p class="text-xs text-(--color-muted) opacity-60">{m.reader_ch_generate_on_nav({ n: String(nextChapter) })}</p>
{/if}
</div>
{/if}
{/if}
@@ -905,10 +906,10 @@
<!-- ── A different chapter is currently playing ── -->
<div class="flex items-center justify-between gap-3">
<p class="text-xs text-(--color-muted)">
Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`}
{m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })}
</p>
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
Load this chapter
{m.reader_load_this_chapter()}
</Button>
</div>
@@ -918,7 +919,7 @@
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play narration
{m.reader_play_narration()}
</Button>
{/if}
</div>