feat(app): sprach-switcher direkt im post (📖 DE | EN)

statt text-hinweis "auch verfügbar in: ..." zeigt der post jetzt einen
kompakten switcher (📖 aktiver-code | anderer-code). klick auf den
anderen code setzt die ui-sprache global und navigiert zur sprach-
variante — alles konsistent.

language names raus (unused): displayLanguage + tests entfernt, da die
darstellung nun nur noch sprachcodes (DE/EN) zeigt. auch i18n-keys
lang.de/lang.en und post.also_available_in aufgeräumt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-21 16:09:33 +02:00
parent 238b2a0938
commit 9040e5ac02
5 changed files with 67 additions and 44 deletions

View File

@ -1,8 +1,8 @@
<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 { displayLanguage } from '$lib/nostr/languageNames'; import { activeLocale } from '$lib/i18n';
import { t } from '$lib/i18n'; import type { SupportedLocale } from '$lib/i18n/activeLocale';
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
@ -25,28 +25,83 @@
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="availability"> <p class="lang-switch" role="group" aria-label="Article language">
{$t('post.also_available_in')} <span class="icon" aria-hidden="true">📖</span>
{#each translations as t, i} {#each options as opt, i}
<a href="/{t.slug}/" title={t.title}>{displayLanguage(t.lang)}</a>{#if i < translations.length - 1}, {/if} {#if opt.href === null}
<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>
.availability { .lang-switch {
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;
} }
.availability a { .icon {
color: var(--accent); font-size: 1rem;
text-decoration: none; line-height: 1;
} }
.availability a:hover { .btn {
text-decoration: underline; 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);
border-color: var(--accent);
cursor: default;
}
.sep {
opacity: 0.4;
} }
</style> </style>

View File

@ -20,7 +20,6 @@
"back_to_overview": "← Zurück zur Übersicht", "back_to_overview": "← Zurück zur Übersicht",
"untitled": "(ohne Titel)", "untitled": "(ohne Titel)",
"published_on": "Veröffentlicht am {date}", "published_on": "Veröffentlicht am {date}",
"also_available_in": "Auch verfügbar in:",
"not_found": "Post \"{slug}\" nicht gefunden.", "not_found": "Post \"{slug}\" nicht gefunden.",
"unknown_error": "Unbekannter Fehler" "unknown_error": "Unbekannter Fehler"
}, },
@ -28,8 +27,6 @@
"doc_title": "Impressum Jörg Lohrer" "doc_title": "Impressum Jörg Lohrer"
}, },
"lang": { "lang": {
"de": "Deutsch",
"en": "English",
"switch_aria": "Sprache wechseln" "switch_aria": "Sprache wechseln"
} }
} }

View File

@ -20,7 +20,6 @@
"back_to_overview": "← Back to overview", "back_to_overview": "← Back to overview",
"untitled": "(untitled)", "untitled": "(untitled)",
"published_on": "Published on {date}", "published_on": "Published on {date}",
"also_available_in": "Also available in:",
"not_found": "Post \"{slug}\" not found.", "not_found": "Post \"{slug}\" not found.",
"unknown_error": "Unknown error" "unknown_error": "Unknown error"
}, },
@ -28,8 +27,6 @@
"doc_title": "Imprint Jörg Lohrer" "doc_title": "Imprint Jörg Lohrer"
}, },
"lang": { "lang": {
"de": "German",
"en": "English",
"switch_aria": "Switch language" "switch_aria": "Switch language"
} }
} }

View File

@ -1,17 +0,0 @@
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('?');
});
});

View File

@ -1,9 +0,0 @@
const NAMES: Record<string, string> = {
de: 'Deutsch',
en: 'English'
};
export function displayLanguage(code: string): string {
if (!code) return '?';
return NAMES[code] ?? code.toUpperCase();
}