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

+181
View File
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Der Einstieg läuft mit echtem Router, echter Kopfzeile, Banner, Fußzeile und Konto-Menü; Seiten, Anmeldung und Appwrite sind Attrappen.
const mocks = vi.hoisted(() => {
const page = () => vi.fn((..._args: unknown[]) => vi.fn()); // baut eine Seite auf und gibt deren Aufräum-Funktion zurück
return {
setLocale: vi.fn(),
getCurrentUser: vi.fn(),
authListeners: new Set<(user: null) => void>(),
pages: { editor: page(), cards: page(), community: page(), reset: page(), home: page() },
};
});
vi.mock('./account/appwrite', () => ({ client: { setLocale: mocks.setLocale } }));
vi.mock('./account/auth', () => ({
getCurrentUser: mocks.getCurrentUser,
currentUser: () => null,
logout: vi.fn(),
onAuthChange: (fn: (user: null) => void) => {
mocks.authListeners.add(fn);
return () => mocks.authListeners.delete(fn);
},
}));
vi.mock('./account/login-dialog', () => ({ requireLogin: vi.fn() }));
vi.mock('./editor/auth-reset', () => ({ bindEditorToAuth: vi.fn() }));
vi.mock('./editor/editor-page', () => ({ mountEditor: mocks.pages.editor }));
vi.mock('./pages/cards-page', () => ({ mountCardsPage: mocks.pages.cards }));
vi.mock('./pages/community-page', () => ({ mountCommunityPage: mocks.pages.community }));
vi.mock('./pages/reset-page', () => ({ mountResetPage: mocks.pages.reset }));
vi.mock('./pages/home-page', () => ({ mountHomePage: mocks.pages.home }));
const DE_FOOTER = 'Inoffizielle Community-Seite für die App AirCard-iOS';
const EN_FOOTER = 'Unofficial community site for the AirCard-iOS app';
const slot = () => document.getElementById('account-slot') as HTMLElement;
const langButton = () => document.querySelector<HTMLButtonElement>('button.lang-btn')!;
const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent);
const signIn = () => [...slot().querySelectorAll('button')].find((b) => b.textContent === 'Anmelden');
/** Startet main.ts frisch (es läuft beim Import) auf der Adresse `path`; `storedLang` ist die gespeicherte Sprachwahl (null: keine). */
async function start(path = '/', storedLang: string | null = 'de') {
vi.resetModules();
localStorage.clear();
if (storedLang) localStorage.setItem('aircard.lang', storedLang);
history.replaceState(null, '', path);
document.body.innerHTML =
'<div id="donate-banner"></div>' +
'<header id="app-header"><a class="brand" href="/" data-link><span>AirCard-IOS</span></a><div id="account-slot"></div></header>' +
'<main id="app-root"></main>' +
'<footer id="app-footer"></footer>';
await import('./main');
}
beforeEach(() => {
mocks.setLocale.mockReset();
mocks.getCurrentUser.mockReset().mockResolvedValue(null); // Gast: der Anmeldestatus ist „abgemeldet“
mocks.authListeners.clear();
for (const page of Object.values(mocks.pages)) page.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
history.replaceState(null, '', '/');
});
describe('Start', () => {
it('Rahmen in der gespeicherten Sprache; der Appwrite-Client bekommt sie; die Seite der Adresse wird aufgebaut', async () => {
await start('/gestalten');
await vi.waitFor(() => expect(signIn()).toBeDefined());
expect(document.documentElement.lang).toBe('de');
expect(mocks.setLocale).toHaveBeenCalledExactlyOnceWith('de');
expect(navTexts()).toEqual(['Community', 'Gestalten', 'Meine Karten']);
expect(document.querySelector('.donate-banner-link')?.textContent).toBe('Unterstützen →');
expect(document.getElementById('app-footer')?.textContent).toContain(DE_FOOTER);
expect(langButton().textContent).toBe('EN');
expect(document.title).toBe('Gestalten – AirCard-IOS');
expect(mocks.pages.editor).toHaveBeenCalledTimes(1);
});
it('ohne gespeicherte Wahl entscheidet die Browsersprache; der Start speichert nichts', async () => {
vi.stubGlobal('navigator', { languages: ['en-US'], language: 'en-US' });
await start('/karten', null);
expect(document.documentElement.lang).toBe('en');
expect(mocks.setLocale).toHaveBeenCalledExactlyOnceWith('en');
expect(navTexts()).toEqual(['Community', 'Create', 'My cards']);
expect(document.getElementById('app-footer')?.textContent).toContain(EN_FOOTER);
expect(document.title).toBe('My cards – AirCard-IOS');
expect(langButton().textContent).toBe('DE');
expect(localStorage.getItem('aircard.lang')).toBeNull();
});
});
describe('Sprachwechsel', () => {
it('Kopfzeile, Banner, Fußzeile und Titel wechseln ohne Neuladen; der Client bekommt die Sprache', async () => {
await start('/gestalten');
const root = document.getElementById('app-root');
langButton().click();
expect(document.documentElement.lang).toBe('en');
expect(localStorage.getItem('aircard.lang')).toBe('en');
expect(mocks.setLocale).toHaveBeenLastCalledWith('en');
expect(navTexts()).toEqual(['Community', 'Create', 'My cards']);
expect(document.querySelector('.donate-banner-link')?.textContent).toBe('Support us →');
expect(document.getElementById('app-footer')?.textContent).toContain(EN_FOOTER);
expect(langButton().textContent).toBe('DE');
expect(document.title).toBe('Create – AirCard-IOS');
expect(document.getElementById('app-root')).toBe(root); // dasselbe Dokument, die Seite wurde nicht neu geladen
expect(location.pathname).toBe('/gestalten'); // die Adresse bleibt deutsch und unverändert
langButton().click(); // und zurück
expect(document.documentElement.lang).toBe('de');
expect(mocks.setLocale).toHaveBeenLastCalledWith('de');
expect(navTexts()).toEqual(['Community', 'Gestalten', 'Meine Karten']);
expect(document.getElementById('app-footer')?.textContent).toContain(DE_FOOTER);
});
it('die aktuelle Seite wird aufgeräumt und im selben Container neu gebaut; der Editor-Store bleibt dasselbe Objekt', async () => {
const cleanup = vi.fn();
mocks.pages.editor.mockImplementation(() => cleanup);
await start('/gestalten');
const [root, store] = mocks.pages.editor.mock.calls[0] as [HTMLElement, { get(): { cardName: string }; set(patch: object): void }];
store.set({ cardName: 'Mein Entwurf' }); // ein ungespeicherter Entwurf
langButton().click();
expect(cleanup).toHaveBeenCalledTimes(1);
expect(mocks.pages.editor).toHaveBeenCalledTimes(2);
const [rootAfter, storeAfter] = mocks.pages.editor.mock.calls[1] as [HTMLElement, typeof store];
expect(rootAfter).toBe(root);
expect(storeAfter).toBe(store);
expect(storeAfter.get().cardName).toBe('Mein Entwurf');
});
it('das Konto-Menü wird neu gebaut und zeigt sich sofort, wenn der Anmeldestatus schon bekannt ist', async () => {
await start('/');
await vi.waitFor(() => expect(signIn()).toBeDefined());
expect(mocks.authListeners.size).toBe(1);
langButton().click();
expect(signIn()).toBeDefined(); // kein leerer Platz bis zur nächsten Änderung
expect(mocks.authListeners.size).toBe(1); // das alte Menü hat sich abgemeldet
expect(mocks.getCurrentUser).toHaveBeenCalledTimes(1); // der Status wird nicht noch einmal geholt
});
it('kommt der Wechsel vor dem Anmeldestatus, bleibt das neue Konto-Menü leer, bis er da ist', async () => {
let answer!: (user: null) => void;
mocks.getCurrentUser.mockReturnValue(new Promise<null>((resolve) => (answer = resolve)));
await start('/');
expect(slot().childElementCount).toBe(0);
langButton().click();
expect(slot().childElementCount).toBe(0);
expect(mocks.authListeners.size).toBe(1);
answer(null);
await vi.waitFor(() => expect(signIn()).toBeDefined());
});
it('der Fokus bleibt auf dem Sprachknopf, auch wenn die Kopfzeile neu gebaut wird', async () => {
await start('/');
const old = langButton();
old.focus();
expect(document.activeElement).toBe(old);
old.click();
expect(langButton()).not.toBe(old);
expect(document.activeElement).toBe(langButton());
});
it('ohne Fokus auf dem Sprachknopf wird kein Fokus gesetzt', async () => {
await start('/');
(document.activeElement as HTMLElement | null)?.blur();
langButton().click();
expect(document.activeElement).toBe(document.body);
});
});