Compare commits
No commits in common. "9040e5ac02d082e60f395ce3322c3f3c3f8d6db2" and "0fca9cbfa286d6a5e4e61d613027320ffc1f785e" have entirely different histories.
9040e5ac02
...
0fca9cbfa2
|
|
@ -37,7 +37,6 @@
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"marked": "^18.0.0",
|
"marked": "^18.0.0",
|
||||||
"nostr-tools": "^2.23.3",
|
"nostr-tools": "^2.23.3",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2"
|
||||||
"svelte-i18n": "^4.0.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NostrEvent, TranslationInfo } from '$lib/nostr/loaders';
|
import type { NostrEvent, TranslationInfo } from '$lib/nostr/loaders';
|
||||||
import { loadTranslations } from '$lib/nostr/loaders';
|
import { loadTranslations } from '$lib/nostr/loaders';
|
||||||
import { activeLocale } from '$lib/i18n';
|
import { displayLanguage } from '$lib/nostr/languageNames';
|
||||||
import type { SupportedLocale } from '$lib/i18n/activeLocale';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
|
|
@ -25,83 +24,28 @@
|
||||||
if (event.id === currentId) loading = false;
|
if (event.id === currentId) loading = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function currentLang(): string {
|
|
||||||
return event.tags.find((tag) => tag[0] === 'l')?.[1] ?? 'de';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Option {
|
|
||||||
code: string;
|
|
||||||
href: string | null; // null = aktueller post, kein klick-ziel
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = $derived.by<Option[]>(() => {
|
|
||||||
const self: Option = { code: currentLang(), href: null };
|
|
||||||
const others: Option[] = translations.map((t) => ({
|
|
||||||
code: t.lang,
|
|
||||||
href: `/${t.slug}/`
|
|
||||||
}));
|
|
||||||
// aktuelle sprache zuerst, dann rest sortiert nach code
|
|
||||||
return [self, ...others.sort((a, b) => a.code.localeCompare(b.code))];
|
|
||||||
});
|
|
||||||
|
|
||||||
function selectOther(code: string, href: string) {
|
|
||||||
activeLocale.set(code as SupportedLocale);
|
|
||||||
// hartes location-setzen, damit svelte-kit-router den post-load triggert
|
|
||||||
window.location.href = href;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !loading && translations.length > 0}
|
{#if !loading && translations.length > 0}
|
||||||
<p class="lang-switch" role="group" aria-label="Article language">
|
<p class="availability">
|
||||||
<span class="icon" aria-hidden="true">📖</span>
|
Auch verfügbar in:
|
||||||
{#each options as opt, i}
|
{#each translations as t, i}
|
||||||
{#if opt.href === null}
|
<a href="/{t.slug}/" title={t.title}>{displayLanguage(t.lang)}</a>{#if i < translations.length - 1}, {/if}
|
||||||
<span class="btn active" aria-current="true">{opt.code.toUpperCase()}</span>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn"
|
|
||||||
onclick={() => selectOther(opt.code, opt.href!)}
|
|
||||||
>{opt.code.toUpperCase()}</button>
|
|
||||||
{/if}
|
|
||||||
{#if i < options.length - 1}<span class="sep" aria-hidden="true">|</span>{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.lang-switch {
|
.availability {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin: 0.25rem 0 1rem;
|
margin: 0.25rem 0 1rem;
|
||||||
}
|
}
|
||||||
.icon {
|
.availability a {
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn:hover:not(.active) {
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.btn.active {
|
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border-color: var(--accent);
|
text-decoration: none;
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
.sep {
|
.availability a:hover {
|
||||||
opacity: 0.4;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { t, activeLocale, SUPPORTED_LOCALES } from '$lib/i18n';
|
|
||||||
import type { SupportedLocale } from '$lib/i18n/activeLocale';
|
|
||||||
|
|
||||||
let current = $state<SupportedLocale>('de');
|
|
||||||
activeLocale.subscribe((v) => (current = v));
|
|
||||||
|
|
||||||
function select(lang: SupportedLocale) {
|
|
||||||
activeLocale.set(lang);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="switcher" role="group" aria-label={$t('lang.switch_aria')}>
|
|
||||||
{#each SUPPORTED_LOCALES as code}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn"
|
|
||||||
class:active={current === code}
|
|
||||||
aria-pressed={current === code}
|
|
||||||
onclick={() => select(code)}
|
|
||||||
>{code.toUpperCase()}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.switcher {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.btn:hover {
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.btn.active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
import ReplyComposer from './ReplyComposer.svelte';
|
import ReplyComposer from './ReplyComposer.svelte';
|
||||||
import ExternalClientLinks from './ExternalClientLinks.svelte';
|
import ExternalClientLinks from './ExternalClientLinks.svelte';
|
||||||
import LanguageAvailability from './LanguageAvailability.svelte';
|
import LanguageAvailability from './LanguageAvailability.svelte';
|
||||||
import { t, activeLocale } from '$lib/i18n';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
|
|
@ -22,20 +21,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const dtag = $derived(tagValue(event, 'd'));
|
const dtag = $derived(tagValue(event, 'd'));
|
||||||
let currentLocale = $state('de');
|
const title = $derived(tagValue(event, 'title') || '(ohne Titel)');
|
||||||
activeLocale.subscribe((v) => (currentLocale = v));
|
|
||||||
|
|
||||||
const title = $derived(tagValue(event, 'title') || $t('post.untitled'));
|
|
||||||
const summary = $derived(tagValue(event, 'summary'));
|
const summary = $derived(tagValue(event, 'summary'));
|
||||||
const image = $derived(tagValue(event, 'image'));
|
const image = $derived(tagValue(event, 'image'));
|
||||||
const publishedAt = $derived(
|
const publishedAt = $derived(
|
||||||
parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10)
|
parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10)
|
||||||
);
|
);
|
||||||
const date = $derived(
|
const date = $derived(
|
||||||
new Date(publishedAt * 1000).toLocaleDateString(
|
new Date(publishedAt * 1000).toLocaleDateString('de-DE', {
|
||||||
currentLocale === 'en' ? 'en-US' : 'de-DE',
|
year: 'numeric',
|
||||||
{ year: 'numeric', month: 'long', day: 'numeric' }
|
month: 'long',
|
||||||
)
|
day: 'numeric'
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const tags = $derived(tagsAll(event, 't'));
|
const tags = $derived(tagsAll(event, 't'));
|
||||||
const bodyHtml = $derived(renderMarkdown(event.content));
|
const bodyHtml = $derived(renderMarkdown(event.content));
|
||||||
|
|
@ -54,7 +51,7 @@
|
||||||
|
|
||||||
<h1 class="post-title">{title}</h1>
|
<h1 class="post-title">{title}</h1>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
{$t('post.published_on', { values: { date } })}
|
Veröffentlicht am {date}
|
||||||
{#if tags.length > 0}
|
{#if tags.length > 0}
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
{#each tags as t}
|
{#each tags as t}
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { detectInitialLocale } from './activeLocale';
|
|
||||||
|
|
||||||
describe('detectInitialLocale', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
globalThis.localStorage?.clear?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('nimmt wert aus localStorage, wenn vorhanden und gültig', () => {
|
|
||||||
const storage = new Map<string, string>([['locale', 'en']]);
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: (k) => storage.get(k) ?? null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'de-DE',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fällt auf navigator.language zurück, wenn storage leer', () => {
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'en-US',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalisiert navigator.language (de-AT → de)', () => {
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'de-AT',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('de');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fällt auf ersten supported eintrag, wenn navigator unbekannt', () => {
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'fr-FR',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('de');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignoriert ungültige werte im storage', () => {
|
|
||||||
const storage = new Map<string, string>([['locale', 'fr']]);
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: (k) => storage.get(k) ?? null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'en-US',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('en');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import { writable, type Writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export type SupportedLocale = 'de' | 'en';
|
|
||||||
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = ['de', 'en'] as const;
|
|
||||||
const STORAGE_KEY = 'locale';
|
|
||||||
|
|
||||||
interface Storage {
|
|
||||||
getItem: (key: string) => string | null;
|
|
||||||
setItem: (key: string, value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetectArgs {
|
|
||||||
storage: Storage;
|
|
||||||
navigatorLanguage: string | undefined;
|
|
||||||
supported: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detectInitialLocale(args: DetectArgs): SupportedLocale {
|
|
||||||
const stored = args.storage.getItem(STORAGE_KEY);
|
|
||||||
if (stored && (args.supported as readonly string[]).includes(stored)) {
|
|
||||||
return stored as SupportedLocale;
|
|
||||||
}
|
|
||||||
const nav = (args.navigatorLanguage ?? '').slice(0, 2).toLowerCase();
|
|
||||||
if ((args.supported as readonly string[]).includes(nav)) {
|
|
||||||
return nav as SupportedLocale;
|
|
||||||
}
|
|
||||||
return args.supported[0] as SupportedLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createActiveLocale(): Writable<SupportedLocale> & { bootstrap: () => void } {
|
|
||||||
const store = writable<SupportedLocale>('de');
|
|
||||||
let bootstrapped = false;
|
|
||||||
|
|
||||||
function bootstrap() {
|
|
||||||
if (bootstrapped) return;
|
|
||||||
bootstrapped = true;
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const initial = detectInitialLocale({
|
|
||||||
storage: window.localStorage,
|
|
||||||
navigatorLanguage: window.navigator.language,
|
|
||||||
supported: SUPPORTED_LOCALES
|
|
||||||
});
|
|
||||||
store.set(initial);
|
|
||||||
store.subscribe((v) => {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, v);
|
|
||||||
} catch {
|
|
||||||
// private-mode / quota — ignorieren
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscribe: store.subscribe,
|
|
||||||
set: store.set,
|
|
||||||
update: store.update,
|
|
||||||
bootstrap
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const activeLocale = createActiveLocale();
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { addMessages, init, locale, _ } from 'svelte-i18n';
|
|
||||||
import de from './messages/de.json';
|
|
||||||
import en from './messages/en.json';
|
|
||||||
import { activeLocale, SUPPORTED_LOCALES } from './activeLocale';
|
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
export function initI18n(): void {
|
|
||||||
if (initialized) return;
|
|
||||||
initialized = true;
|
|
||||||
addMessages('de', de);
|
|
||||||
addMessages('en', en);
|
|
||||||
init({
|
|
||||||
fallbackLocale: 'de',
|
|
||||||
initialLocale: 'de'
|
|
||||||
});
|
|
||||||
activeLocale.bootstrap();
|
|
||||||
activeLocale.subscribe((l) => {
|
|
||||||
locale.set(l);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { _ as t, locale, activeLocale, SUPPORTED_LOCALES };
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
{
|
|
||||||
"nav": {
|
|
||||||
"home": "Home",
|
|
||||||
"archive": "Archiv",
|
|
||||||
"imprint": "Impressum",
|
|
||||||
"brand_aria": "Zur Startseite"
|
|
||||||
},
|
|
||||||
"home": {
|
|
||||||
"greeting": "Hi 🖖 Willkommen auf meinem Blog 🤗",
|
|
||||||
"latest": "Neueste Beiträge",
|
|
||||||
"more_archive": "Alle Beiträge im Archiv →",
|
|
||||||
"empty": "Keine Posts gefunden auf den abgefragten Relays."
|
|
||||||
},
|
|
||||||
"archive": {
|
|
||||||
"title": "Archiv",
|
|
||||||
"subtitle": "Alle Beiträge, nach Jahr gruppiert.",
|
|
||||||
"doc_title": "Archiv – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"back_to_overview": "← Zurück zur Übersicht",
|
|
||||||
"untitled": "(ohne Titel)",
|
|
||||||
"published_on": "Veröffentlicht am {date}",
|
|
||||||
"not_found": "Post \"{slug}\" nicht gefunden.",
|
|
||||||
"unknown_error": "Unbekannter Fehler"
|
|
||||||
},
|
|
||||||
"imprint": {
|
|
||||||
"doc_title": "Impressum – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"lang": {
|
|
||||||
"switch_aria": "Sprache wechseln"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
{
|
|
||||||
"nav": {
|
|
||||||
"home": "Home",
|
|
||||||
"archive": "Archive",
|
|
||||||
"imprint": "Imprint",
|
|
||||||
"brand_aria": "Go to homepage"
|
|
||||||
},
|
|
||||||
"home": {
|
|
||||||
"greeting": "Hi 🖖 Welcome to my blog 🤗",
|
|
||||||
"latest": "Latest posts",
|
|
||||||
"more_archive": "All posts in the archive →",
|
|
||||||
"empty": "No posts found on the queried relays."
|
|
||||||
},
|
|
||||||
"archive": {
|
|
||||||
"title": "Archive",
|
|
||||||
"subtitle": "All posts, grouped by year.",
|
|
||||||
"doc_title": "Archive – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"back_to_overview": "← Back to overview",
|
|
||||||
"untitled": "(untitled)",
|
|
||||||
"published_on": "Published on {date}",
|
|
||||||
"not_found": "Post \"{slug}\" not found.",
|
|
||||||
"unknown_error": "Unknown error"
|
|
||||||
},
|
|
||||||
"imprint": {
|
|
||||||
"doc_title": "Imprint – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"lang": {
|
|
||||||
"switch_aria": "Switch language"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { displayLanguage } from './languageNames';
|
||||||
|
|
||||||
|
describe('displayLanguage', () => {
|
||||||
|
it('kennt deutsch', () => {
|
||||||
|
expect(displayLanguage('de')).toBe('Deutsch');
|
||||||
|
});
|
||||||
|
it('kennt english', () => {
|
||||||
|
expect(displayLanguage('en')).toBe('English');
|
||||||
|
});
|
||||||
|
it('fällt bei unbekanntem code auf uppercase-code zurück', () => {
|
||||||
|
expect(displayLanguage('fr')).toBe('FR');
|
||||||
|
});
|
||||||
|
it('fällt bei leerer sprache auf ? zurück', () => {
|
||||||
|
expect(displayLanguage('')).toBe('?');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
const NAMES: Record<string, string> = {
|
||||||
|
de: 'Deutsch',
|
||||||
|
en: 'English'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function displayLanguage(code: string): string {
|
||||||
|
if (!code) return '?';
|
||||||
|
return NAMES[code] ?? code.toUpperCase();
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,8 @@
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { bootstrapReadRelays } from '$lib/stores/readRelays';
|
import { bootstrapReadRelays } from '$lib/stores/readRelays';
|
||||||
import { initI18n, t } from '$lib/i18n';
|
|
||||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
|
||||||
import CcZeroBadge from '$lib/components/CcZeroBadge.svelte';
|
import CcZeroBadge from '$lib/components/CcZeroBadge.svelte';
|
||||||
|
|
||||||
initI18n();
|
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
// Normalisierter pfad ohne trailing slash für aktiv-erkennung ("/" bleibt "/")
|
// Normalisierter pfad ohne trailing slash für aktiv-erkennung ("/" bleibt "/")
|
||||||
|
|
@ -27,12 +23,11 @@
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
<a href="/" class="brand" aria-label={$t('nav.brand_aria')}>Jörg Lohrer</a>
|
<a href="/" class="brand" aria-label="Zur Startseite">Jörg Lohrer</a>
|
||||||
<nav aria-label={$t('nav.brand_aria')}>
|
<nav aria-label="Hauptnavigation">
|
||||||
<a href="/" class:active={isActive('/')}>{$t('nav.home')}</a>
|
<a href="/" class:active={isActive('/')}>Home</a>
|
||||||
<a href="/archiv/" class:active={isActive('/archiv/')}>{$t('nav.archive')}</a>
|
<a href="/archiv/" class:active={isActive('/archiv/')}>Archiv</a>
|
||||||
<a href="/impressum/" class:active={isActive('/impressum/')}>{$t('nav.imprint')}</a>
|
<a href="/impressum/" class:active={isActive('/impressum/')}>Impressum</a>
|
||||||
<LanguageSwitcher />
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -57,7 +52,7 @@
|
||||||
Jörg Lohrer
|
Jörg Lohrer
|
||||||
</span>
|
</span>
|
||||||
<span class="footer-sep">·</span>
|
<span class="footer-sep">·</span>
|
||||||
<a href="/impressum/">{$t('nav.imprint')}</a>
|
<a href="/impressum/">Impressum</a>
|
||||||
<span class="footer-sep">·</span>
|
<span class="footer-sep">·</span>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/joerglohrer/joerglohrerde"
|
href="https://github.com/joerglohrer/joerglohrerde"
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@
|
||||||
import PostCard from '$lib/components/PostCard.svelte';
|
import PostCard from '$lib/components/PostCard.svelte';
|
||||||
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
||||||
import SocialIcons from '$lib/components/SocialIcons.svelte';
|
import SocialIcons from '$lib/components/SocialIcons.svelte';
|
||||||
import { t, activeLocale } from '$lib/i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
// Lokales Profilbild aus static/ — schneller als der Nostr-kind:0-Roundtrip
|
// Lokales Profilbild aus static/ — schneller als der Nostr-kind:0-Roundtrip
|
||||||
// fürs kind:0 -> picture-Feld (URL wäre identisch, aber Netzwerk-Latenz).
|
// fürs kind:0 -> picture-Feld (URL wäre identisch, aber Netzwerk-Latenz).
|
||||||
|
|
@ -27,11 +25,11 @@
|
||||||
posts = list;
|
posts = list;
|
||||||
loading = false;
|
loading = false;
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
error = get(t)('home.empty');
|
error = 'Keine Posts gefunden auf den abgefragten Relays.';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loading = false;
|
loading = false;
|
||||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -48,17 +46,8 @@
|
||||||
const avatarSrc = HERO_AVATAR;
|
const avatarSrc = HERO_AVATAR;
|
||||||
const about = $derived.by(() => profile?.about ?? '');
|
const about = $derived.by(() => profile?.about ?? '');
|
||||||
const website = $derived.by(() => profile?.website ?? '');
|
const website = $derived.by(() => profile?.website ?? '');
|
||||||
let currentLocale = $state('de');
|
const latest = $derived(posts.slice(0, LATEST_COUNT));
|
||||||
activeLocale.subscribe((v) => (currentLocale = v));
|
const hasMore = $derived(posts.length > LATEST_COUNT);
|
||||||
|
|
||||||
const filtered = $derived.by(() =>
|
|
||||||
posts.filter((p) => {
|
|
||||||
const l = p.tags.find((tag) => tag[0] === 'l')?.[1];
|
|
||||||
return (l ?? 'de') === currentLocale;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const latest = $derived(filtered.slice(0, LATEST_COUNT));
|
|
||||||
const hasMore = $derived(filtered.length > LATEST_COUNT);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
|
|
@ -68,7 +57,10 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-text">
|
<div class="hero-text">
|
||||||
<h1 class="hero-name">{displayName}</h1>
|
<h1 class="hero-name">{displayName}</h1>
|
||||||
<p class="hero-greeting">{$t('home.greeting')}</p>
|
<p class="hero-greeting">
|
||||||
|
Hi <span aria-hidden="true">🖖</span> Willkommen auf meinem Blog
|
||||||
|
<span aria-hidden="true">🤗</span>
|
||||||
|
</p>
|
||||||
{#if about}
|
{#if about}
|
||||||
<p class="hero-about">{about}</p>
|
<p class="hero-about">{about}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -83,14 +75,14 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="latest">
|
<section class="latest">
|
||||||
<h2 class="section-title">{$t('home.latest')}</h2>
|
<h2 class="section-title">Neueste Beiträge</h2>
|
||||||
<LoadingOrError {loading} {error} />
|
<LoadingOrError {loading} {error} />
|
||||||
{#each latest as post (post.id)}
|
{#each latest as post (post.id)}
|
||||||
<PostCard event={post} />
|
<PostCard event={post} />
|
||||||
{/each}
|
{/each}
|
||||||
{#if hasMore}
|
{#if hasMore}
|
||||||
<div class="more">
|
<div class="more">
|
||||||
<a href="/archiv/" class="more-link">{$t('home.more_archive')}</a>
|
<a href="/archiv/" class="more-link">Alle Beiträge im Archiv →</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@
|
||||||
import { buildHablaLink } from '$lib/nostr/naddr';
|
import { buildHablaLink } from '$lib/nostr/naddr';
|
||||||
import PostView from '$lib/components/PostView.svelte';
|
import PostView from '$lib/components/PostView.svelte';
|
||||||
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const dtag = $derived(data.dtag);
|
const dtag = $derived(data.dtag);
|
||||||
|
|
@ -32,14 +30,14 @@
|
||||||
.then((p) => {
|
.then((p) => {
|
||||||
if (currentDtag !== dtag) return;
|
if (currentDtag !== dtag) return;
|
||||||
if (!p) {
|
if (!p) {
|
||||||
error = get(t)('post.not_found', { values: { slug: currentDtag } });
|
error = `Post "${currentDtag}" nicht gefunden.`;
|
||||||
} else {
|
} else {
|
||||||
post = p;
|
post = p;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (currentDtag !== dtag) return;
|
if (currentDtag !== dtag) return;
|
||||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (currentDtag === dtag) loading = false;
|
if (currentDtag === dtag) loading = false;
|
||||||
|
|
@ -47,7 +45,7 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="breadcrumb"><a href="/">{$t('post.back_to_overview')}</a></nav>
|
<nav class="breadcrumb"><a href="/">← Zurück zur Übersicht</a></nav>
|
||||||
|
|
||||||
<LoadingOrError {loading} {error} {hablaLink} />
|
<LoadingOrError {loading} {error} {hablaLink} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@
|
||||||
import { loadPostList } from '$lib/nostr/loaders';
|
import { loadPostList } from '$lib/nostr/loaders';
|
||||||
import PostCard from '$lib/components/PostCard.svelte';
|
import PostCard from '$lib/components/PostCard.svelte';
|
||||||
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
||||||
import { t, activeLocale } from '$lib/i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
let posts: NostrEvent[] = $state([]);
|
let posts: NostrEvent[] = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
@ -16,29 +14,19 @@
|
||||||
posts = await loadPostList();
|
posts = await loadPostList();
|
||||||
loading = false;
|
loading = false;
|
||||||
if (posts.length === 0) {
|
if (posts.length === 0) {
|
||||||
error = get(t)('home.empty');
|
error = 'Keine Posts gefunden auf den abgefragten Relays.';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loading = false;
|
loading = false;
|
||||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let currentLocale = $state('de');
|
|
||||||
activeLocale.subscribe((v) => (currentLocale = v));
|
|
||||||
|
|
||||||
const filtered = $derived.by(() =>
|
|
||||||
posts.filter((p) => {
|
|
||||||
const l = p.tags.find((tag) => tag[0] === 'l')?.[1];
|
|
||||||
return (l ?? 'de') === currentLocale;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Posts nach Jahr gruppieren (neueste zuerst)
|
// Posts nach Jahr gruppieren (neueste zuerst)
|
||||||
type YearGroup = { year: number; posts: NostrEvent[] };
|
type YearGroup = { year: number; posts: NostrEvent[] };
|
||||||
const groupsByYear = $derived.by<YearGroup[]>(() => {
|
const groupsByYear = $derived.by<YearGroup[]>(() => {
|
||||||
const byYear = new Map<number, NostrEvent[]>();
|
const byYear = new Map<number, NostrEvent[]>();
|
||||||
for (const p of filtered) {
|
for (const p of posts) {
|
||||||
const ts = Number(p.tags.find((t) => t[0] === 'published_at')?.[1] ?? p.created_at);
|
const ts = Number(p.tags.find((t) => t[0] === 'published_at')?.[1] ?? p.created_at);
|
||||||
const year = new Date(ts * 1000).getUTCFullYear();
|
const year = new Date(ts * 1000).getUTCFullYear();
|
||||||
if (!byYear.has(year)) byYear.set(year, []);
|
if (!byYear.has(year)) byYear.set(year, []);
|
||||||
|
|
@ -51,11 +39,11 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$t('archive.doc_title')}</title>
|
<title>Archiv – Jörg Lohrer</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<h1 class="title">{$t('archive.title')}</h1>
|
<h1 class="title">Archiv</h1>
|
||||||
<p class="meta">{$t('archive.subtitle')}</p>
|
<p class="meta">Alle Beiträge, nach Jahr gruppiert.</p>
|
||||||
|
|
||||||
<LoadingOrError {loading} {error} />
|
<LoadingOrError {loading} {error} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { renderMarkdown } from '$lib/render/markdown';
|
import { renderMarkdown } from '$lib/render/markdown';
|
||||||
import impressumRaw from '../../../../content/impressum.md?raw';
|
import impressumRaw from '../../../../content/impressum.md?raw';
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
|
|
||||||
// Frontmatter abtrennen, nur Body rendern.
|
// Frontmatter abtrennen, nur Body rendern.
|
||||||
// Toleriert trailing spaces auf den ---/--- trenner-zeilen.
|
// Toleriert trailing spaces auf den ---/--- trenner-zeilen.
|
||||||
|
|
@ -11,7 +10,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$t('imprint.doc_title')}</title>
|
<title>Impressum – Jörg Lohrer</title>
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index, follow" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,992 +0,0 @@
|
||||||
# Multilinguale SPA — UI-Lokalisierung + Listen-Filter (Plan 3/3)
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** UI-Chrome-Strings (Menü, Footer, Buttons, Seitentitel, Meldungen) werden über `svelte-i18n` lokalisiert — `de` als Default, `en` als zweite Sprache, Browser-Locale als Initial-Auswahl. Ein Locale-Store steuert zusätzlich die Listen-Seiten (Startseite + Archiv), sodass nur Posts in der aktiven Sprache erscheinen. Ein dezenter Umschalter im Header wechselt die Sprache.
|
|
||||||
|
|
||||||
**Architecture:** Locale-Store (`writable<'de'|'en'>`) bootstrapt aus `navigator.language`, persistiert in `localStorage`, speist `svelte-i18n` und die Listen-Filter. UI-Strings liegen in `app/src/lib/i18n/messages/{de,en}.json`, werden via `$t(...)` in Templates genutzt. Listen-Seiten (`+page.svelte`, `archiv/+page.svelte`) filtern `posts` client-seitig nach `l`-Tag gegen den aktiven Locale.
|
|
||||||
|
|
||||||
**Tech Stack:** SvelteKit (Svelte 5 Runes), TypeScript, `svelte-i18n` (runtime, ~10 KB), Vitest.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Spec-Referenz
|
|
||||||
|
|
||||||
Umsetzt den Abschnitt **UI-Lokalisierung (Chrome)** sowie die noch offene „Nur Posts der aktiven Sprache in Listen zeigen"-Forderung aus `docs/superpowers/specs/2026-04-21-multilingual-posts-design.md`. Damit sind alle Spec-Anforderungen nach Plan 1/2/3 umgesetzt.
|
|
||||||
|
|
||||||
## Datei-Struktur
|
|
||||||
|
|
||||||
**Zu erstellen:**
|
|
||||||
- `app/src/lib/i18n/index.ts` — initialisiert `svelte-i18n`, registriert Locale-Bundles, exportiert `t`, `locale` und den projekteigenen `activeLocale`-Store.
|
|
||||||
- `app/src/lib/i18n/messages/de.json` — DE-Strings für UI-Chrome.
|
|
||||||
- `app/src/lib/i18n/messages/en.json` — EN-Strings, identische Keys.
|
|
||||||
- `app/src/lib/i18n/activeLocale.ts` — Custom-Writable-Store, der Locale persistiert (localStorage) und mit `svelte-i18n` syncronisiert.
|
|
||||||
- `app/src/lib/i18n/activeLocale.test.ts` — Unit-Tests für Bootstrap, Persistence, Fallback.
|
|
||||||
- `app/src/lib/components/LanguageSwitcher.svelte` — Umschalter im Header, zwei Buttons „DE/EN".
|
|
||||||
|
|
||||||
**Zu ändern:**
|
|
||||||
- `app/src/routes/+layout.svelte` — Menü-/Footer-Strings via `$t`, `LanguageSwitcher` einbinden, i18n-Init im Script.
|
|
||||||
- `app/src/routes/+page.svelte` — Hero-Texte via `$t`, Liste nach `activeLocale` filtern.
|
|
||||||
- `app/src/routes/archiv/+page.svelte` — Seitenüberschrift via `$t`, Liste nach `activeLocale` filtern.
|
|
||||||
- `app/src/routes/impressum/+page.svelte` — Alle statischen Strings via `$t`.
|
|
||||||
- `app/src/routes/[...slug]/+page.svelte` — Breadcrumb („← Zurück zur Übersicht") via `$t`; Fehlermeldungen via `$t`.
|
|
||||||
- `app/src/lib/components/LoadingOrError.svelte` — falls es hartkodierte Strings enthält, auf `$t` umstellen.
|
|
||||||
- `app/src/lib/components/LanguageAvailability.svelte` — „Auch verfügbar in:" via `$t` statt hartkodiert.
|
|
||||||
- `app/src/lib/components/PostView.svelte` — „(ohne Titel)" + Datumsformat-Locale via `$t` bzw. `activeLocale`.
|
|
||||||
- `app/package.json` — `svelte-i18n` als Dependency.
|
|
||||||
|
|
||||||
**Nicht angefasst:**
|
|
||||||
- Post-Content (`event.content`) — Markdown-Body bleibt in Autorensprache, wird nicht übersetzt.
|
|
||||||
- `app/src/lib/nostr/*` — Relay-Loader sind sprach-agnostisch.
|
|
||||||
- URL-Schema — weiterhin `/<slug>/`, kein Sprach-Präfix.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: svelte-i18n installieren und Messages-Files anlegen
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `app/src/lib/i18n/messages/de.json`, `app/src/lib/i18n/messages/en.json`
|
|
||||||
- Modify: `app/package.json`, `app/package-lock.json`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Dependency installieren**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm install svelte-i18n
|
|
||||||
```
|
|
||||||
|
|
||||||
Prüfen:
|
|
||||||
```bash
|
|
||||||
grep "svelte-i18n" /Users/joerglohrer/repositories/joerglohrerde/app/package.json
|
|
||||||
```
|
|
||||||
Expected: eine Zeile wie `"svelte-i18n": "^4.x.x"`.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Messages-Dateien anlegen**
|
|
||||||
|
|
||||||
Erstelle `app/src/lib/i18n/messages/de.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nav": {
|
|
||||||
"home": "Home",
|
|
||||||
"archive": "Archiv",
|
|
||||||
"imprint": "Impressum",
|
|
||||||
"brand_aria": "Zur Startseite"
|
|
||||||
},
|
|
||||||
"home": {
|
|
||||||
"greeting": "Hi 🖖 Willkommen auf meinem Blog 🤗",
|
|
||||||
"latest": "Neueste Beiträge",
|
|
||||||
"more_archive": "Alle Beiträge im Archiv →",
|
|
||||||
"empty": "Keine Posts gefunden auf den abgefragten Relays."
|
|
||||||
},
|
|
||||||
"archive": {
|
|
||||||
"title": "Archiv",
|
|
||||||
"subtitle": "Alle Beiträge, nach Jahr gruppiert.",
|
|
||||||
"doc_title": "Archiv – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"back_to_overview": "← Zurück zur Übersicht",
|
|
||||||
"untitled": "(ohne Titel)",
|
|
||||||
"published_on": "Veröffentlicht am {date}",
|
|
||||||
"also_available_in": "Auch verfügbar in:",
|
|
||||||
"not_found": "Post \"{slug}\" nicht gefunden.",
|
|
||||||
"unknown_error": "Unbekannter Fehler"
|
|
||||||
},
|
|
||||||
"imprint": {
|
|
||||||
"doc_title": "Impressum – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"lang": {
|
|
||||||
"de": "Deutsch",
|
|
||||||
"en": "English",
|
|
||||||
"switch_aria": "Sprache wechseln"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Erstelle `app/src/lib/i18n/messages/en.json` mit denselben Keys:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nav": {
|
|
||||||
"home": "Home",
|
|
||||||
"archive": "Archive",
|
|
||||||
"imprint": "Imprint",
|
|
||||||
"brand_aria": "Go to homepage"
|
|
||||||
},
|
|
||||||
"home": {
|
|
||||||
"greeting": "Hi 🖖 Welcome to my blog 🤗",
|
|
||||||
"latest": "Latest posts",
|
|
||||||
"more_archive": "All posts in the archive →",
|
|
||||||
"empty": "No posts found on the queried relays."
|
|
||||||
},
|
|
||||||
"archive": {
|
|
||||||
"title": "Archive",
|
|
||||||
"subtitle": "All posts, grouped by year.",
|
|
||||||
"doc_title": "Archive – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"back_to_overview": "← Back to overview",
|
|
||||||
"untitled": "(untitled)",
|
|
||||||
"published_on": "Published on {date}",
|
|
||||||
"also_available_in": "Also available in:",
|
|
||||||
"not_found": "Post \"{slug}\" not found.",
|
|
||||||
"unknown_error": "Unknown error"
|
|
||||||
},
|
|
||||||
"imprint": {
|
|
||||||
"doc_title": "Imprint – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"lang": {
|
|
||||||
"de": "German",
|
|
||||||
"en": "English",
|
|
||||||
"switch_aria": "Switch language"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde && git add app/package.json app/package-lock.json app/src/lib/i18n/messages/de.json app/src/lib/i18n/messages/en.json && git commit -m "chore(app): svelte-i18n + ui-messages-files (de/en)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: `activeLocale`-Store mit Persistence
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `app/src/lib/i18n/activeLocale.ts`
|
|
||||||
- Create: `app/src/lib/i18n/activeLocale.test.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Test schreiben**
|
|
||||||
|
|
||||||
Erstelle `app/src/lib/i18n/activeLocale.test.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
||||||
import { detectInitialLocale } from './activeLocale';
|
|
||||||
|
|
||||||
describe('detectInitialLocale', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Kein localStorage zwischen Tests
|
|
||||||
globalThis.localStorage?.clear?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('nimmt wert aus localStorage, wenn vorhanden und gültig', () => {
|
|
||||||
const storage = new Map<string, string>([['locale', 'en']]);
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: (k) => storage.get(k) ?? null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'de-DE',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fällt auf navigator.language zurück, wenn storage leer', () => {
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'en-US',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalisiert navigator.language (de-DE → de)', () => {
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'de-AT',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('de');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fällt auf ersten supported eintrag, wenn navigator unbekannt', () => {
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'fr-FR',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('de');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignoriert ungültige werte im storage', () => {
|
|
||||||
const storage = new Map<string, string>([['locale', 'fr']]);
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: (k) => storage.get(k) ?? null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'en-US',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('en');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Test laufen, Erwartung FAIL**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: FAIL — Modul existiert nicht.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Store und Detect-Funktion implementieren**
|
|
||||||
|
|
||||||
Erstelle `app/src/lib/i18n/activeLocale.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { writable, type Writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export type SupportedLocale = 'de' | 'en';
|
|
||||||
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = ['de', 'en'] as const;
|
|
||||||
const STORAGE_KEY = 'locale';
|
|
||||||
|
|
||||||
interface Storage {
|
|
||||||
getItem: (key: string) => string | null;
|
|
||||||
setItem: (key: string, value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetectArgs {
|
|
||||||
storage: Storage;
|
|
||||||
navigatorLanguage: string | undefined;
|
|
||||||
supported: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detectInitialLocale(args: DetectArgs): SupportedLocale {
|
|
||||||
const stored = args.storage.getItem(STORAGE_KEY);
|
|
||||||
if (stored && (args.supported as readonly string[]).includes(stored)) {
|
|
||||||
return stored as SupportedLocale;
|
|
||||||
}
|
|
||||||
const nav = (args.navigatorLanguage ?? '').slice(0, 2).toLowerCase();
|
|
||||||
if ((args.supported as readonly string[]).includes(nav)) {
|
|
||||||
return nav as SupportedLocale;
|
|
||||||
}
|
|
||||||
return args.supported[0] as SupportedLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createActiveLocale(): Writable<SupportedLocale> & { bootstrap: () => void } {
|
|
||||||
const store = writable<SupportedLocale>('de');
|
|
||||||
let bootstrapped = false;
|
|
||||||
|
|
||||||
function bootstrap() {
|
|
||||||
if (bootstrapped) return;
|
|
||||||
bootstrapped = true;
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const initial = detectInitialLocale({
|
|
||||||
storage: window.localStorage,
|
|
||||||
navigatorLanguage: window.navigator.language,
|
|
||||||
supported: SUPPORTED_LOCALES
|
|
||||||
});
|
|
||||||
store.set(initial);
|
|
||||||
store.subscribe((v) => {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, v);
|
|
||||||
} catch {
|
|
||||||
// private-mode / quota — ignorieren
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscribe: store.subscribe,
|
|
||||||
set: store.set,
|
|
||||||
update: store.update,
|
|
||||||
bootstrap
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const activeLocale = createActiveLocale();
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Test laufen, Erwartung PASS**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 46 passed (41 pre-existing + 5 neue).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/lib/i18n/activeLocale.ts app/src/lib/i18n/activeLocale.test.ts && git commit -m "feat(app): activeLocale-store mit persistence + initial-detection"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: `i18n/index.ts` — svelte-i18n-Registrierung und Sync
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `app/src/lib/i18n/index.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Datei erstellen**
|
|
||||||
|
|
||||||
Erstelle `app/src/lib/i18n/index.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { addMessages, init, locale, _ } from 'svelte-i18n';
|
|
||||||
import de from './messages/de.json';
|
|
||||||
import en from './messages/en.json';
|
|
||||||
import { activeLocale, SUPPORTED_LOCALES } from './activeLocale';
|
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
export function initI18n(): void {
|
|
||||||
if (initialized) return;
|
|
||||||
initialized = true;
|
|
||||||
addMessages('de', de);
|
|
||||||
addMessages('en', en);
|
|
||||||
init({
|
|
||||||
fallbackLocale: 'de',
|
|
||||||
initialLocale: 'de'
|
|
||||||
});
|
|
||||||
activeLocale.bootstrap();
|
|
||||||
activeLocale.subscribe((l) => {
|
|
||||||
locale.set(l);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { _ as t, locale, activeLocale, SUPPORTED_LOCALES };
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Typecheck**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -5
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 0 errors, 0 warnings.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/lib/i18n/index.ts && git commit -m "feat(app): i18n-init registriert messages und syncs mit activeLocale"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: `LanguageSwitcher`-Komponente
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `app/src/lib/components/LanguageSwitcher.svelte`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Komponente erstellen**
|
|
||||||
|
|
||||||
Erstelle `app/src/lib/components/LanguageSwitcher.svelte`:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import { t, activeLocale, SUPPORTED_LOCALES } from '$lib/i18n';
|
|
||||||
import type { SupportedLocale } from '$lib/i18n/activeLocale';
|
|
||||||
|
|
||||||
let current = $state<SupportedLocale>('de');
|
|
||||||
activeLocale.subscribe((v) => (current = v));
|
|
||||||
|
|
||||||
function select(lang: SupportedLocale) {
|
|
||||||
activeLocale.set(lang);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="switcher" role="group" aria-label={$t('lang.switch_aria')}>
|
|
||||||
{#each SUPPORTED_LOCALES as code}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn"
|
|
||||||
class:active={current === code}
|
|
||||||
aria-pressed={current === code}
|
|
||||||
onclick={() => select(code)}
|
|
||||||
>{code.toUpperCase()}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.switcher {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.btn:hover {
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.btn.active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Typecheck**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -5
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 0 errors.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/lib/components/LanguageSwitcher.svelte && git commit -m "feat(app): LanguageSwitcher-komponente mit de/en-buttons"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Layout lokalisieren + Switcher einbinden
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `app/src/routes/+layout.svelte`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Imports und i18n-Init ergänzen**
|
|
||||||
|
|
||||||
Öffne `app/src/routes/+layout.svelte`. Im `<script>`-Block, nach `import { bootstrapReadRelays } ...`, ergänze:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { initI18n, t } from '$lib/i18n';
|
|
||||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
|
||||||
|
|
||||||
initI18n();
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Brand-ARIA und Nav-Labels umstellen**
|
|
||||||
|
|
||||||
Ersetze den Header-Block:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<header class="site-header">
|
|
||||||
<div class="header-inner">
|
|
||||||
<a href="/" class="brand" aria-label="Zur Startseite">Jörg Lohrer</a>
|
|
||||||
<nav aria-label="Hauptnavigation">
|
|
||||||
<a href="/" class:active={isActive('/')}>Home</a>
|
|
||||||
<a href="/archiv/" class:active={isActive('/archiv/')}>Archiv</a>
|
|
||||||
<a href="/impressum/" class:active={isActive('/impressum/')}>Impressum</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
```
|
|
||||||
|
|
||||||
durch:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<header class="site-header">
|
|
||||||
<div class="header-inner">
|
|
||||||
<a href="/" class="brand" aria-label={$t('nav.brand_aria')}>Jörg Lohrer</a>
|
|
||||||
<nav aria-label={$t('nav.brand_aria')}>
|
|
||||||
<a href="/" class:active={isActive('/')}>{$t('nav.home')}</a>
|
|
||||||
<a href="/archiv/" class:active={isActive('/archiv/')}>{$t('nav.archive')}</a>
|
|
||||||
<a href="/impressum/" class:active={isActive('/impressum/')}>{$t('nav.imprint')}</a>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Impressum-Footer-Link-Label per `$t`**
|
|
||||||
|
|
||||||
Im Footer-Block, ersetze `<a href="/impressum/">Impressum</a>` durch:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<a href="/impressum/">{$t('nav.imprint')}</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Dev-Server starten und im Browser prüfen**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && timeout 15 npm run dev 2>&1 | head -5
|
|
||||||
```
|
|
||||||
|
|
||||||
Falls Vite einen Server ohne Fehler startet, ist die Basis OK.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Typecheck + Tests**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -3
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit 2>&1 | tail -3
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 0 Errors, 46 Tests passed.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/routes/+layout.svelte && git commit -m "feat(app): layout-header lokalisiert + sprach-switcher eingebunden"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Startseite (`+page.svelte`) lokalisieren + Listen-Filter
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `app/src/routes/+page.svelte`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Imports ergänzen**
|
|
||||||
|
|
||||||
Öffne `app/src/routes/+page.svelte`. Im `<script>`-Block, ergänze nach den bestehenden Imports:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { t, activeLocale } from '$lib/i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Filter-Hilfsfunktion und `latest` anpassen**
|
|
||||||
|
|
||||||
Direkt vor `const latest = $derived(posts.slice(0, LATEST_COUNT));` die bestehende Zeile ersetzen durch:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
let currentLocale = $state('de');
|
|
||||||
activeLocale.subscribe((v) => (currentLocale = v));
|
|
||||||
|
|
||||||
const filtered = $derived.by(() =>
|
|
||||||
posts.filter((p) => {
|
|
||||||
const l = p.tags.find((t) => t[0] === 'l')?.[1];
|
|
||||||
// Ohne l-tag: als 'de' behandeln, damit alte Posts sichtbar bleiben
|
|
||||||
return (l ?? 'de') === currentLocale;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const latest = $derived(filtered.slice(0, LATEST_COUNT));
|
|
||||||
const hasMore = $derived(filtered.length > LATEST_COUNT);
|
|
||||||
```
|
|
||||||
|
|
||||||
(Die bestehende `hasMore`-Zeile weiter unten entfernen, sonst doppelt.)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Fehler- und Titel-Strings per `$t`**
|
|
||||||
|
|
||||||
Ersetze in `onMount`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (list.length === 0) {
|
|
||||||
error = 'Keine Posts gefunden auf den abgefragten Relays.';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
durch:
|
|
||||||
```typescript
|
|
||||||
if (list.length === 0) {
|
|
||||||
error = get(t)('home.empty');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
und:
|
|
||||||
```typescript
|
|
||||||
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
|
||||||
```
|
|
||||||
durch:
|
|
||||||
```typescript
|
|
||||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Template-Strings umstellen**
|
|
||||||
|
|
||||||
Ersetze im Template:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<p class="hero-greeting">
|
|
||||||
Hi <span aria-hidden="true">🖖</span> Willkommen auf meinem Blog
|
|
||||||
<span aria-hidden="true">🤗</span>
|
|
||||||
</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
durch:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<p class="hero-greeting">{$t('home.greeting')}</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
Ersetze `<h2 class="section-title">Neueste Beiträge</h2>` durch:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<h2 class="section-title">{$t('home.latest')}</h2>
|
|
||||||
```
|
|
||||||
|
|
||||||
Ersetze den „Alle Beiträge im Archiv →"-Link durch:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<a href="/archiv/" class="more-link">{$t('home.more_archive')}</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Typecheck + Tests**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -3
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit 2>&1 | tail -3
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 0 Errors, 46 passed.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/routes/+page.svelte && git commit -m "feat(app): startseite lokalisiert + liste nach aktivem locale gefiltert"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: Archiv-Seite lokalisieren + Listen-Filter
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `app/src/routes/archiv/+page.svelte`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Imports ergänzen**
|
|
||||||
|
|
||||||
Im `<script>`-Block nach den existierenden Imports:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { t, activeLocale } from '$lib/i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Filter einbauen und `groupsByYear` daran knüpfen**
|
|
||||||
|
|
||||||
Direkt vor `const groupsByYear = ...`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
let currentLocale = $state('de');
|
|
||||||
activeLocale.subscribe((v) => (currentLocale = v));
|
|
||||||
|
|
||||||
const filtered = $derived.by(() =>
|
|
||||||
posts.filter((p) => {
|
|
||||||
const l = p.tags.find((t) => t[0] === 'l')?.[1];
|
|
||||||
return (l ?? 'de') === currentLocale;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Ersetze in `groupsByYear` das `for (const p of posts)` durch `for (const p of filtered)`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Fehler-Strings per `$t`**
|
|
||||||
|
|
||||||
Ersetze in `onMount`:
|
|
||||||
```typescript
|
|
||||||
error = 'Keine Posts gefunden auf den abgefragten Relays.';
|
|
||||||
```
|
|
||||||
→
|
|
||||||
```typescript
|
|
||||||
error = get(t)('home.empty');
|
|
||||||
```
|
|
||||||
|
|
||||||
und:
|
|
||||||
```typescript
|
|
||||||
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
|
||||||
```
|
|
||||||
→
|
|
||||||
```typescript
|
|
||||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Template-Strings umstellen**
|
|
||||||
|
|
||||||
Ersetze:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<svelte:head>
|
|
||||||
<title>Archiv – Jörg Lohrer</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1 class="title">Archiv</h1>
|
|
||||||
<p class="meta">Alle Beiträge, nach Jahr gruppiert.</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
durch:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('archive.doc_title')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1 class="title">{$t('archive.title')}</h1>
|
|
||||||
<p class="meta">{$t('archive.subtitle')}</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Typecheck + Tests**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -3
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit 2>&1 | tail -3
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 0 Errors, 46 passed.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/routes/archiv/+page.svelte && git commit -m "feat(app): archiv-seite lokalisiert + nach locale gefiltert"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 8: Post-Route + Komponenten lokalisieren
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `app/src/routes/[...slug]/+page.svelte`
|
|
||||||
- Modify: `app/src/lib/components/LanguageAvailability.svelte`
|
|
||||||
- Modify: `app/src/lib/components/PostView.svelte`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Post-Route**
|
|
||||||
|
|
||||||
Öffne `app/src/routes/[...slug]/+page.svelte`. Im `<script>`-Block, nach den bestehenden Imports:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
```
|
|
||||||
|
|
||||||
Ersetze im `$effect`-Block:
|
|
||||||
```typescript
|
|
||||||
error = `Post "${currentDtag}" nicht gefunden.`;
|
|
||||||
```
|
|
||||||
→
|
|
||||||
```typescript
|
|
||||||
error = get(t)('post.not_found', { values: { slug: currentDtag } });
|
|
||||||
```
|
|
||||||
|
|
||||||
Ersetze:
|
|
||||||
```typescript
|
|
||||||
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
|
||||||
```
|
|
||||||
→
|
|
||||||
```typescript
|
|
||||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
|
||||||
```
|
|
||||||
|
|
||||||
Ersetze im Template:
|
|
||||||
```svelte
|
|
||||||
<nav class="breadcrumb"><a href="/">← Zurück zur Übersicht</a></nav>
|
|
||||||
```
|
|
||||||
→
|
|
||||||
```svelte
|
|
||||||
<nav class="breadcrumb"><a href="/">{$t('post.back_to_overview')}</a></nav>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: LanguageAvailability**
|
|
||||||
|
|
||||||
Öffne `app/src/lib/components/LanguageAvailability.svelte`. Import ergänzen:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
```
|
|
||||||
|
|
||||||
Ersetze im Template:
|
|
||||||
```svelte
|
|
||||||
Auch verfügbar in:
|
|
||||||
```
|
|
||||||
→
|
|
||||||
```svelte
|
|
||||||
{$t('post.also_available_in')}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: PostView — „ohne Titel" + Datums-Locale**
|
|
||||||
|
|
||||||
Öffne `app/src/lib/components/PostView.svelte`. Import ergänzen:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { t, activeLocale } from '$lib/i18n';
|
|
||||||
```
|
|
||||||
|
|
||||||
Ersetze:
|
|
||||||
```typescript
|
|
||||||
const title = $derived(tagValue(event, 'title') || '(ohne Titel)');
|
|
||||||
```
|
|
||||||
→
|
|
||||||
```typescript
|
|
||||||
let currentLocale = $state('de');
|
|
||||||
activeLocale.subscribe((v) => (currentLocale = v));
|
|
||||||
|
|
||||||
const title = $derived(tagValue(event, 'title') || $t('post.untitled'));
|
|
||||||
```
|
|
||||||
|
|
||||||
Und den Datums-Block:
|
|
||||||
```typescript
|
|
||||||
const date = $derived(
|
|
||||||
new Date(publishedAt * 1000).toLocaleDateString('de-DE', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
```
|
|
||||||
→
|
|
||||||
```typescript
|
|
||||||
const date = $derived(
|
|
||||||
new Date(publishedAt * 1000).toLocaleDateString(
|
|
||||||
currentLocale === 'en' ? 'en-US' : 'de-DE',
|
|
||||||
{ year: 'numeric', month: 'long', day: 'numeric' }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Und im Template:
|
|
||||||
```svelte
|
|
||||||
Veröffentlicht am {date}
|
|
||||||
```
|
|
||||||
→
|
|
||||||
```svelte
|
|
||||||
{$t('post.published_on', { values: { date } })}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Typecheck + Tests**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -3
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit 2>&1 | tail -3
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 0 Errors, 46 passed.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde && git add 'app/src/routes/[...slug]/+page.svelte' app/src/lib/components/LanguageAvailability.svelte app/src/lib/components/PostView.svelte && git commit -m "feat(app): post-route + komponenten lokalisiert (titel, datum, hinweise)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 9: Impressum lokalisieren
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `app/src/routes/impressum/+page.svelte`
|
|
||||||
|
|
||||||
Impressum hat einen Mix aus Template-Text und Rechts-Text (Postanschrift), der nicht übersetzt werden sollte. Wir lokalisieren den Seitentitel und ggf. Überschriften — der juristische Kern (Anbieterkennzeichnung, Haftungshinweise) bleibt auf Deutsch.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Datei öffnen und sichten**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat /Users/joerglohrer/repositories/joerglohrerde/app/src/routes/impressum/+page.svelte
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Minimal-Anpassung**
|
|
||||||
|
|
||||||
Ergänze im `<script>`-Block (oder erstelle ihn, falls er fehlt):
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
Ersetze `<svelte:head><title>Impressum – Jörg Lohrer</title></svelte:head>` durch:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('imprint.doc_title')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
```
|
|
||||||
|
|
||||||
Der restliche Inhalt bleibt auf Deutsch — juristische Anbieterkennzeichnung nach deutschem Recht; eine englische Version würde neue rechtliche Prüfung erfordern (Out of Scope).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Typecheck**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -3
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 0 Errors.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/routes/impressum/+page.svelte && git commit -m "feat(app): impressum-seitentitel lokalisiert (inhalt bleibt DE)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 10: Ende-zu-Ende-Test im Browser
|
|
||||||
|
|
||||||
**Files:** — (reine Verifikation)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Dev-Server**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Browser-Tests**
|
|
||||||
|
|
||||||
Öffne `http://localhost:5173/`. Prüfe:
|
|
||||||
|
|
||||||
1. **Standard-Locale:** Erstes Öffnen — Sprache ist `de` (wenn Browser deutsch) oder `en` (wenn englisch). Titel, Menü, Greeting in dieser Sprache.
|
|
||||||
2. **Switcher klicken:** Klick auf „EN" im Header-Switcher — Menü, Greeting, „Latest posts", Archiv-Link-Text wechseln. URL ändert sich **nicht**.
|
|
||||||
3. **Liste gefiltert:** Startseite zeigt nur englische Posts (Bible Selfies), Archiv nur englische. Klick auf „DE" — deutsche Posts erscheinen.
|
|
||||||
4. **Post-Detail:** Klick auf einen deutschen Post (Liste auf DE) — Datumsformat deutsch („17. April 2025"). Klick auf „Auch verfügbar in: English" auf `bibel-selfies` — englische Version erscheint, Datumsformat englisch („April 17, 2025"), Breadcrumb „← Back to overview".
|
|
||||||
5. **Persistence:** Browser-Seite reload — aktive Sprache bleibt (aus localStorage).
|
|
||||||
6. **404-Case:** Öffne `/nicht-da/` — Fehlermeldung im aktiven Locale.
|
|
||||||
|
|
||||||
Stoppe Dev-Server.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Kein Commit nötig.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 11: Gesamt-Testlauf + Deploy
|
|
||||||
|
|
||||||
**Files:** — (Verifikation + Deploy)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Vitest**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 46 passed.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Svelte-check**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -3
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 0 errors, 0 warnings.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Build**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run build 2>&1 | tail -10
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: Build erfolgreich.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Push und Deploy auf prod**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/joerglohrer/repositories/joerglohrerde && git push
|
|
||||||
DEPLOY_TARGET=prod /Users/joerglohrer/repositories/joerglohrerde/scripts/deploy-svelte.sh 2>&1 | tail -10
|
|
||||||
```
|
|
||||||
|
|
||||||
**WICHTIG:** `DEPLOY_TARGET=prod` explizit setzen. Der Skript-Default zielt auf `svelte.joerg-lohrer.de` (historischer Cutover-Stand). Prod-Cutover läuft über STAGING_FTP_*-Webroot mit SITE_URL `https://joerg-lohrer.de` — das ist das `prod`-Target.
|
|
||||||
|
|
||||||
Expected: Upload fertig, Live-Check HTTP 200 mit aktuellem `last-modified`.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Live-Verifikation**
|
|
||||||
|
|
||||||
Öffne `https://joerg-lohrer.de/` und wiederhole Task 10 Step 2 auf der Live-Seite.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Kein Commit — Abschluss.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fertig
|
|
||||||
|
|
||||||
Nach Task 11:
|
|
||||||
- `svelte-i18n` aktiv, UI-Chrome in `de`/`en`
|
|
||||||
- Locale-Store mit Persistence + Browser-Locale-Default
|
|
||||||
- `LanguageSwitcher` im Header, zwei Buttons
|
|
||||||
- Listen-Seiten (Startseite + Archiv) nur Posts des aktiven Locales
|
|
||||||
- PostView zeigt Datum im aktiven Locale, Hinweise übersetzt
|
|
||||||
- Impressum-Titel übersetzt, juristischer Inhalt bewusst DE
|
|
||||||
- Live-Deploy auf prod
|
|
||||||
|
|
||||||
**Damit ist die Spec `2026-04-21-multilingual-posts-design.md` vollständig umgesetzt — über Plan 1 (Publish-Pipeline), Plan 2 (SPA-Translation-Links) und Plan 3 (UI-i18n + Listen-Filter).**
|
|
||||||
Loading…
Reference in New Issue