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

@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import * as m from '$lib/paraglide/messages.js';
const internalLinks = [
{ href: '/admin/scrape', label: 'Scrape' },
@@ -27,7 +28,7 @@
<aside class="w-48 shrink-0 border-r border-(--color-border) px-3 py-6 flex flex-col gap-6">
<!-- Internal pages -->
<div>
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Pages</p>
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_pages_label()}</p>
<nav class="flex flex-col gap-0.5">
{#each internalLinks as link}
<a
@@ -45,7 +46,7 @@
<!-- External tools -->
<div>
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Tools</p>
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_tools_label()}</p>
<nav class="flex flex-col gap-0.5">
{#each externalLinks as link}
<a

View File

@@ -3,6 +3,7 @@
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import type { AudioJob, AudioCacheEntry } from '$lib/server/pocketbase';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
@@ -94,13 +95,13 @@
</script>
<svelte:head>
<title>Audio — libnovel admin</title>
<title>{m.admin_audio_page_title()}</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-(--color-text)">Audio</h1>
<h1 class="text-2xl font-bold text-(--color-text)">{m.admin_audio_heading()}</h1>
<p class="text-(--color-muted) text-sm mt-1">
{stats.total} job{stats.total !== 1 ? 's' : ''} &middot;
<span class="text-green-400">{stats.done} done</span>
@@ -142,13 +143,13 @@
<input
type="search"
bind:value={jobsQ}
placeholder="Filter by slug, voice or status…"
placeholder={m.admin_audio_filter_jobs()}
class="w-full max-w-sm bg-(--color-surface-2) 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)"
/>
{#if filteredJobs.length === 0}
<p class="text-(--color-muted) text-sm py-8 text-center">
{jobsQ.trim() ? 'No matching jobs.' : 'No audio jobs yet.'}
{jobsQ.trim() ? m.admin_audio_no_matching_jobs() : m.admin_audio_no_jobs()}
</p>
{:else}
<!-- Desktop table -->
@@ -218,13 +219,13 @@
<input
type="search"
bind:value={cacheQ}
placeholder="Filter by slug, chapter or voice…"
placeholder={m.admin_audio_filter_cache()}
class="w-full max-w-sm bg-(--color-surface-2) 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)"
/>
{#if filteredCache.length === 0}
<p class="text-(--color-muted) text-sm py-8 text-center">
{cacheQ.trim() ? 'No results.' : 'Audio cache is empty.'}
{cacheQ.trim() ? m.admin_audio_no_cache_results() : m.admin_audio_cache_empty()}
</p>
{:else}
<!-- Desktop table -->

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
@@ -11,19 +12,19 @@
</script>
<svelte:head>
<title>Changelog — libnovel admin</title>
<title>{m.admin_changelog_page_title()}</title>
</svelte:head>
<div class="space-y-6 max-w-2xl">
<div class="flex items-center gap-3">
<h1 class="text-xl font-semibold text-(--color-text) flex-1">Changelog</h1>
<h1 class="text-xl font-semibold text-(--color-text) flex-1">{m.admin_changelog_heading()}</h1>
<a
href="https://gitea.kalekber.cc/kamil/libnovel/releases"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors flex items-center gap-1"
>
Gitea releases
{m.admin_changelog_gitea()}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
@@ -32,9 +33,9 @@
</div>
{#if data.error}
<p class="text-sm text-(--color-danger)">Could not load releases: {data.error}</p>
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: data.error })}</p>
{:else if data.releases.length === 0}
<p class="text-sm text-(--color-muted) py-8 text-center">No releases found.</p>
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
{:else}
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
{#each data.releases as release}

View File

@@ -3,6 +3,7 @@
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import type { ScrapingTask } from '$lib/server/pocketbase';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
@@ -228,15 +229,15 @@
</script>
<svelte:head>
<title>Scrape tasks — libnovel admin</title>
<title>{m.admin_scrape_page_title()}</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-xl font-semibold text-(--color-text) flex-1">Scrape</h1>
<h1 class="text-xl font-semibold text-(--color-text) flex-1">{m.admin_scrape_heading()}</h1>
<span class="text-xs {running ? 'text-(--color-brand) animate-pulse' : 'text-green-500'}">
{running ? 'Running' : 'Idle'}
{running ? m.admin_scrape_status_running() : m.admin_scrape_status_idle()}
</span>
</div>
@@ -244,20 +245,20 @@
<div class="divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
<!-- Full catalogue -->
<div class="flex items-center gap-4 px-4 py-3 bg-(--color-surface)">
<span class="text-sm text-(--color-muted) w-36 shrink-0">Full catalogue</span>
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_full_catalogue()}</span>
<button
onclick={triggerCatalogueScrape}
disabled={running || cataloguing}
class="px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
>
{cataloguing ? 'Queuing…' : running ? 'Running…' : 'Start scrape'}
{cataloguing ? m.admin_scrape_queuing() : running ? m.admin_scrape_running() : m.admin_scrape_start()}
</button>
{#if catalogueError}<span class="text-xs text-(--color-danger)">{catalogueError}</span>{/if}
</div>
<!-- Single book -->
<div id="book-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface)">
<span class="text-sm text-(--color-muted) w-36 shrink-0">Single book</span>
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_single_book()}</span>
<input
type="url"
bind:value={scrapeUrl}
@@ -269,14 +270,14 @@
disabled={!scrapeUrl.trim() || running || scraping}
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
>
{scraping ? 'Queuing…' : 'Scrape'}
{scraping ? m.admin_scrape_queuing() : m.admin_scrape_submit()}
</button>
{#if scrapeError}<span class="text-xs text-(--color-danger)">{scrapeError}</span>{/if}
</div>
<!-- Range scrape -->
<div id="range-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
<span class="text-sm text-(--color-muted) w-36 shrink-0">Chapter range</span>
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_range()}</span>
<input
type="url"
bind:value={rangeUrl}
@@ -302,14 +303,14 @@
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
>
{ranging ? 'Queuing…' : 'Go'}
{ranging ? m.admin_scrape_queuing() : 'Go'}
</button>
{#if rangeError}<span class="text-xs text-(--color-danger) w-full pl-40">{rangeError}</span>{/if}
</div>
<!-- Quick genre chips -->
<div class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
<span class="text-sm text-(--color-muted) w-36 shrink-0">Quick genres</span>
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_quick_genres()}</span>
<div class="flex flex-wrap gap-1.5">
{#each quickScrapes as qs}
<button
@@ -334,18 +335,18 @@
<!-- Tasks table -->
<div class="space-y-3">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="text-sm font-semibold text-(--color-muted) flex-1 uppercase tracking-widest">Task history</h2>
<h2 class="text-sm font-semibold text-(--color-muted) flex-1 uppercase tracking-widest">{m.admin_scrape_task_history()}</h2>
<input
type="search"
bind:value={q}
placeholder="Filter by kind, status or URL…"
placeholder={m.admin_scrape_filter_placeholder()}
class="w-full max-w-xs bg-(--color-surface-2) 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>
{#if filtered.length === 0}
<p class="text-(--color-muted) text-sm py-8 text-center">
{q.trim() ? 'No matching tasks.' : 'No scrape tasks yet.'}
{q.trim() ? m.admin_scrape_no_matching() : m.admin_tasks_empty()}
</p>
{:else}
<!-- Desktop table -->
@@ -393,7 +394,7 @@
disabled={cancellingIds.has(task.id)}
class="px-2 py-1 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
>
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel'}
{cancellingIds.has(task.id) ? 'Cancelling…' : m.admin_scrape_cancel()}
</button>
{/if}
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
@@ -461,7 +462,7 @@
disabled={cancellingIds.has(task.id)}
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
>
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel task'}
{cancellingIds.has(task.id) ? 'Cancelling…' : m.admin_scrape_cancel()} task
</button>
{/if}
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}