feat(i18n): Deutsch/Englisch, Sprachknopf und GitHub-Link in der Kopfzeile

- i18n-Kern im gettext-Stil (t/msg, Wörterbuch en.ts); Startsprache aus localStorage, sonst Browsersprache;
  <html lang> setzt auch das Inline-Skript in index.html. Ein Test prüft t()/msg() und en.ts in beide Richtungen.
- Kopfzeile: GitHub-Link und Sprachknopf (Zielsprache, Beschriftung und lang in der Zielsprache), Fußzeile aus mountFooter,
  Banner und Seitentitel übersetzt, statisches index.html englisch.
- Sprachwechsel ohne Neuladen (main.ts): Kopfzeile, Banner, Konto-Menü und Fußzeile neu aufgebaut, rerender() des Routers baut
  die Seite neu auf, der Editor-Store bleibt; der Appwrite-Client bekommt die Sprache.
- Kopfzeile bleibt in einer Zeile: einzeilig ab 1000 px (vorher 900), GitHub-Text ab 1230 px, auf schmalen Telefonen steht
  „Anmelden“ neben der Suche.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 18:33:15 +00:00
1 parent 9455013833
commit e1fb632cdd
21 files changed
+1321 -66

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
// Sprache der Seite (Deutsch/Englisch) im gettext-Stil: Der deutsche Text ist zugleich der Schlüssel.
// `t('Deutscher Text', { name })` schlägt ihn bei Englisch in EN (en.ts) nach und setzt die Platzhalter `{name}` ein;
// fehlt der Eintrag, bleibt der deutsche Text. Übersetzt wird beim Aufruf, ein Sprachwechsel baut die Oberfläche neu auf (main.ts).
import { EN } from './en';
export type Lang = 'de' | 'en';
export const LANG_KEY = 'aircard.lang';
/**
* Startsprache: die gespeicherte Wahl (`de` oder `en`), sonst Deutsch, wenn die erste Browsersprache mit `de` beginnt,
* andernfalls Englisch. Dieselbe Regel steht im Skript in index.html, das <html lang> vor dem ersten Paint setzt.
*/
export function initialLang(stored: string | null, browserLanguages: readonly string[]): Lang {
if (stored === 'de' || stored === 'en') return stored;
return (browserLanguages[0] ?? '').toLowerCase().startsWith('de') ? 'de' : 'en';
}
function readStored(): string | null {
try {
return localStorage.getItem(LANG_KEY);
} catch {
return null; // Speicher gesperrt (z. B. privates Fenster): es gilt die Browsersprache
}
}
/** Die bevorzugten Browsersprachen: `navigator.languages`, ersatzweise `[navigator.language]`. */
function browserLanguages(): readonly string[] {
if (navigator.languages?.length) return navigator.languages;
return navigator.language ? [navigator.language] : [];
}
// Die Sprache der Seite; null, bis sie zum ersten Mal gebraucht wird.
let lang: Lang | null = null;
const listeners = new Set<(lang: Lang) => void>();
/** Die Sprache der Seite; beim ersten Aufruf aus der gespeicherten Wahl bzw. der Browsersprache bestimmt. */
export function currentLang(): Lang {
lang ??= initialLang(readStored(), browserLanguages());
return lang;
}
/**
* Stellt die Sprache um: setzt <html lang>, merkt sie sich (außer mit `persist: false`) und meldet einen echten Wechsel an
* die Hörer. Das Neuzeichnen von Kopfzeile und Seite ist Sache der Hörer.
*/
export function setLang(next: Lang, opts: { persist?: boolean } = {}): void {
const changed = next !== currentLang();
lang = next;
document.documentElement.lang = next;
if (opts.persist ?? true) {
try {
localStorage.setItem(LANG_KEY, next);
} catch {
// gilt dann nur für diese Sitzung
}
}
// Kopie, damit sich ein Hörer beim Melden abmelden darf.
if (changed) for (const fn of [...listeners]) fn(next);
}
/** Meldet jeden Sprachwechsel (nicht den bloßen Aufruf mit der aktuellen Sprache). Gibt die Abmeldung zurück. */
export function onLangChange(fn: (lang: Lang) => void): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
/**
* Übersetzt einen deutschen Text in die Sprache der Seite und setzt die Platzhalter `{name}` aus `params` ein;
* ein Platzhalter ohne Wert bleibt stehen. Ohne Eintrag in EN bleibt der deutsche Text.
*/
export function t(text: string, params?: Readonly<Record<string, string | number>>): string {
const base = currentLang() === 'en' && Object.hasOwn(EN, text) ? EN[text] : text;
if (!params) return base;
return base.replace(/\{(\w+)\}/g, (placeholder, name: string) => (Object.hasOwn(params, name) ? String(params[name]) : placeholder));
}
/** Markiert einen deutschen Text, der vorab in einer Konstante steht; übersetzt wird erst beim Anzeigen mit `t()`. */
export function msg<T extends string>(text: T): T {
return text;
}