i18n(common): bootstrap shared namespace + migrate top-level UI

Add a per-locale common dictionary at src/lib/i18n/common/{de,en}.ts and
the shim src/lib/js/commonI18n.ts. Migrate inline lang ternaries on the
homepage (welcome/sections/links), OfflineSyncButton (all label
ternaries), DatePicker (today/select date), ErrorView (Error/Fehler
eyebrow), and UserHeader (login aria/title) to use the shared dict.

The long marketing intro paragraphs on the homepage stay inline since
they're one-shot content with no drift risk and don't benefit from
per-key extraction.

Bump site version to 1.57.0 (new namespace).
This commit is contained in:
2026-05-01 14:03:52 +02:00
parent 71f7322624
commit 79f4dbb101
9 changed files with 131 additions and 27 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.56.2", "version": "1.57.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+6 -3
View File
@@ -2,7 +2,10 @@
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Calendar from '@lucide/svelte/icons/calendar'; import Calendar from '@lucide/svelte/icons/calendar';
import { m } from '$lib/js/commonI18n';
/** @typedef {import('$lib/js/commonI18n').CommonLang} CommonLang */
let { value = $bindable(''), lang = 'en', min = '', max = '' } = $props(); let { value = $bindable(''), lang = 'en', min = '', max = '' } = $props();
const t = $derived(m[/** @type {CommonLang} */ (lang)]);
let open = $state(false); let open = $state(false);
/** @type {HTMLDivElement | null} */ /** @type {HTMLDivElement | null} */
@@ -35,8 +38,8 @@
const todayStr = new Date().toISOString().slice(0, 10); const todayStr = new Date().toISOString().slice(0, 10);
const displayDate = $derived.by(() => { const displayDate = $derived.by(() => {
if (!value) return lang === 'en' ? 'Select date' : 'Datum wählen'; if (!value) return t.select_date;
if (value === todayStr) return lang === 'en' ? 'Today' : 'Heute'; if (value === todayStr) return t.today;
const d = new Date(value + 'T12:00:00'); const d = new Date(value + 'T12:00:00');
return d.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', { weekday: 'short', month: 'short', day: 'numeric' }); return d.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', { weekday: 'short', month: 'short', day: 'numeric' });
}); });
@@ -182,7 +185,7 @@
{#if value !== todayStr} {#if value !== todayStr}
<button type="button" class="dp-today-btn" onclick={goToday}> <button type="button" class="dp-today-btn" onclick={goToday}>
{lang === 'en' ? 'Today' : 'Heute'} {t.today}
</button> </button>
{/if} {/if}
</div> </div>
+3 -1
View File
@@ -5,6 +5,7 @@
import SearchX from '@lucide/svelte/icons/search-x'; import SearchX from '@lucide/svelte/icons/search-x';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert'; import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
import CircleAlert from '@lucide/svelte/icons/circle-alert'; import CircleAlert from '@lucide/svelte/icons/circle-alert';
import { m } from '$lib/js/commonI18n';
interface BibleQuote { interface BibleQuote {
text: string; text: string;
reference: string; reference: string;
@@ -43,6 +44,7 @@
} }
let Icon = $derived(icon ?? defaultIcon(status)); let Icon = $derived(icon ?? defaultIcon(status));
const t = $derived(m[isEnglish ? 'en' : 'de']);
let openQuote = $derived(isEnglish ? '\u201C' : '\u201E'); let openQuote = $derived(isEnglish ? '\u201C' : '\u201E');
let closeQuote = $derived(isEnglish ? '\u201D' : '\u201C'); let closeQuote = $derived(isEnglish ? '\u201D' : '\u201C');
</script> </script>
@@ -52,7 +54,7 @@
<header class="eyebrow"> <header class="eyebrow">
<Icon size={14} strokeWidth={1.5} aria-hidden="true" /> <Icon size={14} strokeWidth={1.5} aria-hidden="true" />
<span class="eyebrow-label"> <span class="eyebrow-label">
{isEnglish ? 'Error' : 'Fehler'} {t.error_label}
</span> </span>
</header> </header>
+9 -7
View File
@@ -1,20 +1,22 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pwaStore } from '$lib/stores/pwa.svelte'; import { pwaStore } from '$lib/stores/pwa.svelte';
import { m, type CommonLang } from '$lib/js/commonI18n';
let { lang = 'de' }: { lang?: string } = $props(); let { lang = 'de' }: { lang?: string } = $props();
let showTooltip = $state(false); let showTooltip = $state(false);
let mounted = $state(false); let mounted = $state(false);
const t = $derived(m[lang as CommonLang]);
const labels = $derived({ const labels = $derived({
syncForOffline: lang === 'en' ? 'Save for offline' : 'Offline speichern', syncForOffline: t.sync_for_offline,
syncing: lang === 'en' ? 'Syncing...' : 'Synchronisiere...', syncing: t.syncing,
offlineReady: lang === 'en' ? 'Offline ready' : 'Offline bereit', offlineReady: t.offline_ready,
lastSync: lang === 'en' ? 'Last sync' : 'Letzte Sync', lastSync: t.last_sync,
recipes: lang === 'en' ? 'recipes' : 'Rezepte', recipes: t.recipes_word,
syncNow: lang === 'en' ? 'Sync now' : 'Jetzt synchronisieren', syncNow: t.sync_now,
clearData: lang === 'en' ? 'Clear offline data' : 'Offline-Daten löschen' clearData: t.clear_offline_data
}); });
onMount(async () => { onMount(async () => {
+4 -2
View File
@@ -4,8 +4,10 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import LogIn from '@lucide/svelte/icons/log-in'; import LogIn from '@lucide/svelte/icons/log-in';
import { m, type CommonLang } from '$lib/js/commonI18n';
let { user, recipeLang = 'rezepte', lang = 'de' } = $props(); let { user, recipeLang = 'rezepte', lang = 'de' } = $props();
const t = $derived(m[lang as CommonLang]);
function toggle_options(){ function toggle_options(){
const el = document.querySelector("#options-wrap") as HTMLElement | null; const el = document.querySelector("#options-wrap") as HTMLElement | null;
@@ -167,8 +169,8 @@
<a <a
class="entry login-link" class="entry login-link"
href={`${resolve('/login')}?callbackUrl=${encodeURIComponent(page.url.pathname + (browser ? page.url.search : ''))}`} href={`${resolve('/login')}?callbackUrl=${encodeURIComponent(page.url.pathname + (browser ? page.url.search : ''))}`}
aria-label={lang === 'de' ? 'Anmelden' : 'Login'} aria-label={t.login}
title={lang === 'de' ? 'Anmelden' : 'Login'} title={t.login}
> >
<LogIn size={18} /> <LogIn size={18} />
</a> </a>
+37
View File
@@ -0,0 +1,37 @@
/** German common UI strings — source of truth for the key set. */
export const de = {
// Auth / user header
login: 'Anmelden',
// (main) homepage
welcome: 'Willkommen auf bocken.org',
pages: 'Seiten',
recipes: 'Rezepte',
family_photos: 'Familienbilder',
video_conferences: 'Videokonferenzen',
search_engine: 'Suchmaschine',
shopping: 'Einkauf',
family_tree: 'Stammbaum',
faith: 'Glaube',
documents: 'Dokumente',
audiobooks_podcasts: 'Hörbücher & Podcasts',
nutrition: 'Ernährung',
tasks: 'Aufgaben',
// Offline sync button
sync_for_offline: 'Offline speichern',
syncing: 'Synchronisiere…',
offline_ready: 'Offline bereit',
last_sync: 'Letzte Sync',
recipes_word: 'Rezepte',
sync_now: 'Jetzt synchronisieren',
clear_offline_data: 'Offline-Daten löschen',
// Date picker
select_date: 'Datum wählen',
today: 'Heute',
// Error view
error_label: 'Fehler'
} as const;
+37
View File
@@ -0,0 +1,37 @@
import type { de } from './de';
export const en = {
// Auth / user header
login: 'Login',
// (main) homepage
welcome: 'Welcome to bocken.org',
pages: 'Pages',
recipes: 'Recipes',
family_photos: 'Family Photos',
video_conferences: 'Video Conferences',
search_engine: 'Search Engine',
shopping: 'Shopping',
family_tree: 'Family Tree',
faith: 'Faith',
documents: 'Documents',
audiobooks_podcasts: 'Audiobooks & Podcasts',
nutrition: 'Nutrition',
tasks: 'Tasks',
// Offline sync button
sync_for_offline: 'Save for offline',
syncing: 'Syncing…',
offline_ready: 'Offline ready',
last_sync: 'Last sync',
recipes_word: 'recipes',
sync_now: 'Sync now',
clear_offline_data: 'Clear offline data',
// Date picker
select_date: 'Select date',
today: 'Today',
// Error view
error_label: 'Error'
} as const satisfies Record<keyof typeof de, string>;
+19
View File
@@ -0,0 +1,19 @@
/**
* Shared/top-level UI strings used across the site (homepage, auth header,
* offline sync button, date picker, error view).
*
* Per-locale tables live in `$lib/i18n/common/{de,en}.ts`. Use
* `m[lang].key` (or `m[lang][expr]` for dynamic keys) directly:
*
* import { m } from '$lib/js/commonI18n';
* const t = $derived(m[lang]);
* ... t.login ...
*/
import { de } from '$lib/i18n/common/de';
import { en } from '$lib/i18n/common/en';
export const m = { de, en } as const;
export type CommonLang = keyof typeof m;
export type CommonKey = keyof typeof de;
+15 -13
View File
@@ -34,33 +34,35 @@
}; };
}); });
import { m } from '$lib/js/commonI18n';
const t = $derived(m[lang]);
const isEnglish = $derived(lang === 'en'); const isEnglish = $derived(lang === 'en');
const labels = $derived({ const labels = $derived({
welcome: isEnglish ? 'Welcome to bocken.org' : 'Willkommen auf bocken.org', welcome: t.welcome,
intro1: isEnglish intro1: isEnglish
? 'Hello, I\'m Alexander Bocken. On this website you\'ll find some software projects for friends, family, and myself. Everything is self-hosted at my home on a small mini-server (Arch, btw).' ? 'Hello, I\'m Alexander Bocken. On this website you\'ll find some software projects for friends, family, and myself. Everything is self-hosted at my home on a small mini-server (Arch, btw).'
: 'Hallo, ich bin Alexander Bocken. Auf dieser Seite findest du einige Softwareprojekte für Freunde, Familie und mich. Alles ist selbst gehostet bei mir daheim auf einem kleinen Mini-Server (Arch, btw).', : 'Hallo, ich bin Alexander Bocken. Auf dieser Seite findest du einige Softwareprojekte für Freunde, Familie und mich. Alles ist selbst gehostet bei mir daheim auf einem kleinen Mini-Server (Arch, btw).',
intro2: isEnglish intro2: isEnglish
? 'I recommend my continuously growing recipe collection. There you\'ll find many delicious recipes that I\'ve tried myself and constantly refine. You\'re also welcome to use my search engine or Jitsi instance for video conferences. Some things are hidden behind a login, others are publicly accessible. If you know a bit about programming, feel free to browse my Git repositories.' ? 'I recommend my continuously growing recipe collection. There you\'ll find many delicious recipes that I\'ve tried myself and constantly refine. You\'re also welcome to use my search engine or Jitsi instance for video conferences. Some things are hidden behind a login, others are publicly accessible. If you know a bit about programming, feel free to browse my Git repositories.'
: 'Zu empfehlen ist meine stetig wachsende Rezeptsammlung. Dort findest du viele leckere Rezepte, die ich selbst ausprobiert habe und ständig weiterfeilsche. Zudem kannst du gerne meine Suchmaschine oder auch Jitsi-instanz für Videokonferenzen nutzen. Einiges ist hinter einem Login versteckt, anderes ist öffentlich zugänglich. Wer sich ein bisschen mit Programmieren auskennt, kann auch gerne in meinen Git-Repositories stöbern.', : 'Zu empfehlen ist meine stetig wachsende Rezeptsammlung. Dort findest du viele leckere Rezepte, die ich selbst ausprobiert habe und ständig weiterfeilsche. Zudem kannst du gerne meine Suchmaschine oder auch Jitsi-instanz für Videokonferenzen nutzen. Einiges ist hinter einem Login versteckt, anderes ist öffentlich zugänglich. Wer sich ein bisschen mit Programmieren auskennt, kann auch gerne in meinen Git-Repositories stöbern.',
pages: isEnglish ? 'Pages' : 'Seiten', pages: t.pages,
recipes: isEnglish ? 'Recipes' : 'Rezepte', recipes: t.recipes,
git: 'Git', git: 'Git',
streaming: 'Streaming', streaming: 'Streaming',
familyPhotos: isEnglish ? 'Family Photos' : 'Familienbilder', familyPhotos: t.family_photos,
cloud: 'Cloud', cloud: 'Cloud',
videoConferences: isEnglish ? 'Video Conferences' : 'Videokonferenzen', videoConferences: t.video_conferences,
searchEngine: isEnglish ? 'Search Engine' : 'Suchmaschine', searchEngine: t.search_engine,
shopping: isEnglish ? 'Shopping' : 'Einkauf', shopping: t.shopping,
familyTree: isEnglish ? 'Family Tree' : 'Stammbaum', familyTree: t.family_tree,
faith: isEnglish ? 'Faith' : 'Glaube', faith: t.faith,
chat: 'Chat', chat: 'Chat',
transmission: 'Transmission', transmission: 'Transmission',
documents: isEnglish ? 'Documents' : 'Dokumente', documents: t.documents,
audiobooksPodcasts: isEnglish ? 'Audiobooks & Podcasts' : 'Hörbücher & Podcasts', audiobooksPodcasts: t.audiobooks_podcasts,
fitness: 'Fitness', fitness: 'Fitness',
nutrition: isEnglish ? 'Nutrition' : 'Ernährung', nutrition: t.nutrition,
tasks: isEnglish ? 'Tasks' : 'Aufgaben' tasks: t.tasks
}); });
</script> </script>
<style> <style>