feat(i18n): Konto, Editor und Karte auf Englisch

Task 10: alle sichtbaren Texte von account/, editor/, card/ und der Seite „Passwort neu setzen“ (Texte, aria-label, title, Meldungen,
Rückfragen) gehen durch t()/msg(); en.ts bekommt 87 Einträge.

- account/: Konto-Menü, Login-Dialog (Titel, Reiter, Felder, Meldungen) und errors.ts (errorMessage übersetzt beim Zurückgeben).
  validatePassword nutzt {min}/{max}. Der Login-Dialog wird nur einmal gebaut: die Beschriftung des Schließen-Knopfs wird bei jedem
  Öffnen in der aktuellen Sprache gesetzt. LOGIN_NEEDED und DEFAULT_CARD_NAME bleiben deutsche Konstanten (msg), angezeigt werden sie
  mit t(); der Standardname einer neuen Karte folgt damit der Sprache („My card“).
- editor/: Vorlagen-, Options- und Vorschauspalte, Bild-Upload, Speichern, Download, Rückfragen. Konstanten (upload.ts, download.ts,
  editor-page.ts) bleiben deutsche Strings per msg(); validateImageFile, pngSizeWarning und das Speichern („Speichern fehlgeschlagen –
  {reason}“) liefern den Text schon übersetzt, Fehler tragen weiter die deutsche Konstante (Vergleiche mit READ_MESSAGE/PIXELS_MESSAGE
  gehen weiter) und werden beim Anzeigen mit t(error.message) übersetzt. Zoom „100 %“ / „100%“ und „Bearbeitet: {name}“ mit Platzhaltern.
- card/: Bezeichnungen der Marken (msg) und die Meldungen des Renderers (msg, Anzeige im Editor über t()).
- pages/reset-page.ts, account/login-dialog.ts: Die Meldung „Die Passwörter stimmen nicht überein.“ ist nicht mehr der Schlüssel zum
  Fokus: es zählt die Bedingung selbst, nicht der Vergleich mit dem (nun übersetzten) Text.
- i18n/coverage.test.ts: Heuristik für deutsche Text-Literale außerhalb von t()/msg() (Umlaut/ß, mindestens zwei Wörter, einzelnes
  großgeschriebenes Wort; gelesen wird der Syntaxbaum) für account/, editor/, card/ und pages/reset-page.ts; Ausnahmen mit Grund, jede
  muss noch zutreffen; Scanner mit eigenen Tests. Die Liste der Bereiche ist eine Konstante für Task 11.
- i18n/english-ui.test.ts (neu): Stichproben auf Englisch (Konto-Menü, Login-Dialog, Passwort-Seite, Editor inkl. Fehlerwege) und
  „kein deutscher Wörterbuchtext auf der englischen Seite“.
- test-setup.ts: Wer Module mit vi.resetModules() frisch lädt, bekam eine neue Kopie von i18n.ts mit der Browsersprache (happy-dom: en-US);
  der Browser meldet im Test jetzt Deutsch, damit auch diese Tests deutsch beginnen. main.test.ts: nach dem Wechsel auf Englisch heißt
  der Knopf des Konto-Menüs „Sign in“.
- Tests: Vitest 53 Dateien, 1779 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 23:58:49 +00:00
1 parent a75be2d44c
commit 1ff1873b27
23 files changed
+857 -169

No files matched your search

+8 -6
View File
@@ -1,7 +1,9 @@
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale
// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“).
// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel
// baut main.ts das Menü neu auf.
import './account.css';
import { el } from '../editor/dom';
import { t } from '../i18n/i18n';
import { currentUser, logout, onAuthChange } from './auth';
import { errorMessage } from './errors';
import { requireLogin } from './login-dialog';
@@ -26,7 +28,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
}
const user = currentUser();
if (!user) {
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Anmelden');
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, t('Anmelden'));
signIn.addEventListener('click', () => void requireLogin('login'));
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
return;
@@ -38,18 +40,18 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
{
type: 'button',
class: 'account-avatar',
'aria-label': 'Konto-Menü',
'aria-label': t('Konto-Menü'),
'aria-expanded': 'false',
'aria-controls': 'account-dropdown',
},
initial,
);
const email = el('span', { class: 'account-email', title: user.email }, user.email);
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, 'Meine Karten');
const signOut = el('button', { type: 'button', class: 'account-item' }, 'Abmelden');
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, t('Meine Karten'));
const signOut = el('button', { type: 'button', class: 'account-item' }, t('Abmelden'));
const error = el('span', { class: 'account-error', role: 'alert' });
const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, email, myCards, signOut, error);
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': 'Konto' }, avatar, dropdown);
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': t('Konto') }, avatar, dropdown);
let isOpen = false;
function setOpen(open: boolean): void {
+3 -2
View File
@@ -1,6 +1,7 @@
// Anmelden, Registrieren, Abmelden, Passwort zurücksetzen. Hält den angemeldeten Nutzer im Speicher
// und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite).
import { ID } from 'appwrite';
import { t } from '../i18n/i18n';
import { account } from './appwrite';
import { errorType, isUnauthorized } from './errors';
@@ -132,9 +133,9 @@ export function onLogout(fn: () => void): () => void {
};
}
/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer. */
/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer (in der Sprache der Seite). */
export function validatePassword(pw: string): string | null {
return pw.length >= PASSWORD_MIN && pw.length <= PASSWORD_MAX
? null
: `Das Passwort muss ${PASSWORD_MIN} bis ${PASSWORD_MAX} Zeichen lang sein.`;
: t('Das Passwort muss {min} bis {max} Zeichen lang sein.', { min: PASSWORD_MIN, max: PASSWORD_MAX });
}
+4 -3
View File
@@ -3,6 +3,7 @@
import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite';
import { ID_PATTERN, parseConfig, serializeConfig, type CardConfig } from '../card/config';
import type { SavedCardRef } from '../editor/state';
import { msg, t } from '../i18n/i18n';
import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite';
import { currentUserId } from './auth';
import { errorType } from './errors';
@@ -44,16 +45,16 @@ export interface SaveInput {
previous: SavedCardRef | null;
}
export const DEFAULT_CARD_NAME = 'Meine Karte';
export const DEFAULT_CARD_NAME = msg('Meine Karte');
const NAME_MAX = 80;
/** Zeilen- und Datei-IDs von Appwrite (definiert in card/config.ts, dort braucht sie auch die Logo-ID der Konfiguration). */
export { ID_PATTERN };
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“ (in der Sprache der Seite). */
export function normalizeName(name: string): string {
const collapsed = name.replace(/\s+/g, ' ').trim();
const cut = Array.from(collapsed).slice(0, NAME_MAX).join('').trim();
return cut || DEFAULT_CARD_NAME;
return cut || t(DEFAULT_CARD_NAME);
}
/** Nur der Besitzer darf die Zeile bzw. Datei lesen, ändern und löschen. */
+25 -23
View File
@@ -1,11 +1,13 @@
// Deutsche Fehlermeldungen für Appwrite-Fehler. Rein und ohne Appwrite-Import, damit gut testbar.
// Fehlermeldungen für Appwrite-Fehler in der Sprache der Seite: Die deutschen Texte sind die Schlüssel für t() (i18n/en.ts).
// Rein und ohne Appwrite-Import, damit gut testbar.
// Ein AppwriteException trägt `code` (HTTP-Status), `type` (z. B. user_invalid_credentials) und `message`.
import { msg, t } from '../i18n/i18n';
export type ErrorContext = 'login' | 'register' | 'recover' | 'reset' | 'save' | 'load';
const LINK_INVALID = 'Der Link ist ungültig oder abgelaufen.';
const WRONG_CREDENTIALS = 'E-Mail oder Passwort ist falsch.';
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.';
const LINK_INVALID = msg('Der Link ist ungültig oder abgelaufen.');
const WRONG_CREDENTIALS = msg('E-Mail oder Passwort ist falsch.');
const GENERIC = msg('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
function field(err: unknown, key: 'code' | 'type' | 'message'): unknown {
return typeof err === 'object' && err !== null ? (err as Record<string, unknown>)[key] : undefined;
@@ -40,46 +42,46 @@ export function isInvalidLinkError(err: unknown): boolean {
}
function passwordMessage(err: unknown, ctx: ErrorContext): string {
if (ctx === 'login') return WRONG_CREDENTIALS;
if (ctx === 'login') return t(WRONG_CREDENTIALS);
const message = field(err, 'message');
if (typeof message === 'string' && message.includes('commonly used')) {
return 'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.';
return t('Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.');
}
return 'Das Passwort muss 8 bis 256 Zeichen lang sein.';
return t('Das Passwort muss 8 bis 256 Zeichen lang sein.');
}
/** Meldung für Nutzer: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */
/** Meldung für Nutzer in der Sprache der Seite: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */
export function errorMessage(err: unknown, ctx: ErrorContext): string {
switch (errorType(err)) {
case 'user_invalid_credentials':
return WRONG_CREDENTIALS;
return t(WRONG_CREDENTIALS);
case 'user_already_exists':
return 'Diese E-Mail ist schon registriert. Melde dich an.';
return t('Diese E-Mail ist schon registriert. Melde dich an.');
case 'user_invalid_token':
return LINK_INVALID;
return t(LINK_INVALID);
case 'user_not_found':
if (ctx === 'recover') return 'Zu dieser E-Mail gibt es kein Konto.';
if (ctx === 'reset') return LINK_INVALID;
if (ctx === 'recover') return t('Zu dieser E-Mail gibt es kein Konto.');
if (ctx === 'reset') return t(LINK_INVALID);
break;
case 'general_argument_invalid': {
const param = invalidParam(err);
if (param === 'email') return 'Bitte gib eine gültige E-Mail-Adresse ein.';
if (param === 'email') return t('Bitte gib eine gültige E-Mail-Adresse ein.');
if (param === 'password') return passwordMessage(err, ctx);
if (param === 'userId' || param === 'secret') return LINK_INVALID;
if (param === 'userId' || param === 'secret') return t(LINK_INVALID);
break;
}
case 'row_not_found':
return 'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.';
return t('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.');
case 'storage_file_not_found':
return 'Das Bild dieser Karte fehlt.';
return t('Das Bild dieser Karte fehlt.');
case 'general_unknown_origin':
return 'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.';
return t('Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.');
case 'user_session_already_exists':
return 'Du bist schon angemeldet. Bitte lade die Seite neu.';
return t('Du bist schon angemeldet. Bitte lade die Seite neu.');
case 'user_count_exceeded':
return 'Zurzeit sind keine neuen Konten möglich.';
return t('Zurzeit sind keine neuen Konten möglich.');
}
if (field(err, 'code') === 429) return 'Zu viele Versuche – bitte kurz warten.';
if (err instanceof TypeError) return 'Keine Verbindung zum Server.';
return GENERIC;
if (field(err, 'code') === 429) return t('Zu viele Versuche – bitte kurz warten.');
if (err instanceof TypeError) return t('Keine Verbindung zum Server.');
return t(GENERIC);
}
+30 -25
View File
@@ -1,19 +1,20 @@
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“.
import './account.css';
import { el } from '../editor/dom';
import { msg, t } from '../i18n/i18n';
import { login, register, requestPasswordReset, validatePassword } from './auth';
import { errorMessage, errorType, type ErrorContext } from './errors';
export type LoginView = 'login' | 'register' | 'forgot';
const TITLES: Record<LoginView, string> = {
login: 'Anmelden',
register: 'Registrieren',
forgot: 'Passwort vergessen',
login: msg('Anmelden'),
register: msg('Registrieren'),
forgot: msg('Passwort vergessen'),
};
const MISMATCH = 'Die Passwörter stimmen nicht überein.';
const RESET_SENT = 'Wir haben dir einen Link geschickt. Er gilt eine Stunde.';
const MISMATCH = msg('Die Passwörter stimmen nicht überein.');
const RESET_SENT = msg('Wir haben dir einen Link geschickt. Er gilt eine Stunde.');
type Field = { label: HTMLLabelElement; input: HTMLInputElement };
@@ -37,7 +38,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const closeButton = el(
'button',
{ type: 'button', class: 'auth-close', 'aria-label': 'Schließen' },
{ type: 'button', class: 'auth-close', 'aria-label': t('Schließen') },
el('span', { 'aria-hidden': 'true' }, '×'),
);
const title = el('h2', { id: 'auth-title', class: 'auth-title' });
@@ -135,7 +136,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
'aria-controls': 'auth-panel',
tabindex: selected ? 0 : -1,
},
TITLES[tabView],
t(TITLES[tabView]),
);
tab.addEventListener('click', () => select(tabView));
tab.addEventListener('keydown', (event) => {
@@ -145,7 +146,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
});
return tab;
});
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': 'Konto' }, ...tabs);
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': t('Konto') }, ...tabs);
}
/** Setzt oder sperrt alle Bedienelemente, solange eine Anfrage läuft; Eingabefelder bleiben fokussierbar. */
@@ -196,10 +197,10 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
if (forView === 'login') {
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const password = field('Passwort', 'password', 'password', 'current-password');
const forgot = linkButton('Passwort vergessen?', () => showView('forgot'));
form.append(email.label, password.label, errorEl, okEl, submit('Anmelden'), el('p', { class: 'auth-aside' }, forgot));
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const password = field(t('Passwort'), 'password', 'password', 'current-password');
const forgot = linkButton(t('Passwort vergessen?'), () => showView('forgot'));
form.append(email.label, password.label, errorEl, okEl, submit(t('Anmelden')), el('p', { class: 'auth-aside' }, forgot));
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
@@ -214,20 +215,22 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
}
if (forView === 'register') {
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const password = field('Passwort', 'password', 'password', 'new-password', '', 'auth-pw-hint');
const repeat = field('Passwort wiederholen', 'password-repeat', 'password', 'new-password');
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, 'Mindestens 8 Zeichen.');
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit('Registrieren'));
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint');
const repeat = field(t('Passwort wiederholen'), 'password-repeat', 'password', 'new-password');
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, t('Mindestens 8 Zeichen.'));
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy) return;
const address = email.input.value.trim();
const problem = password.input.value !== repeat.input.value ? MISMATCH : validatePassword(password.input.value);
// Der Fehler liegt am Feld, das ihn verursacht; verglichen wird nie mit dem (übersetzten) Text.
const mismatch = password.input.value !== repeat.input.value;
const problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value);
if (problem) {
showError(panel, problem);
(problem === MISMATCH ? repeat : password).input.focus();
(mismatch ? repeat : password).input.focus();
return;
}
void run(
@@ -239,16 +242,16 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
},
(err) => {
if (errorType(err) !== 'user_already_exists') return undefined;
return linkButton('Anmelden', () => showView('login'));
return linkButton(t('Anmelden'), () => showView('login'));
},
);
});
return panel;
}
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const back = linkButton('Zurück zur Anmeldung', () => showView('login'));
form.append(email.label, errorEl, okEl, submit('Link senden'), el('p', { class: 'auth-aside' }, back));
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const back = linkButton(t('Zurück zur Anmeldung'), () => showView('login'));
form.append(email.label, errorEl, okEl, submit(t('Link senden')), el('p', { class: 'auth-aside' }, back));
const panel: Panel = { form, first: email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
@@ -256,7 +259,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const address = email.input.value.trim();
void run(panel, 'recover', async () => {
await requestPasswordReset(address);
showOk(panel, RESET_SENT);
showOk(panel, t(RESET_SENT));
});
});
return panel;
@@ -268,7 +271,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
if (typed) draftEmail = typed.value.trim();
view = next;
title.textContent = TITLES[next];
title.textContent = t(TITLES[next]);
const panel = buildPanel(next);
if (next === 'forgot') {
content.replaceChildren(panel.form);
@@ -288,6 +291,8 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
pending = new Promise<boolean>((resolve) => {
settle = resolve;
});
// Der Dialog bleibt zwischen den Aufrufen im DOM; die Sprache der Seite kann sich seit dem Bauen geändert haben.
closeButton.setAttribute('aria-label', t('Schließen'));
showView(initial);
try {
dialog.showModal();
+3 -2
View File
@@ -1,10 +1,11 @@
// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401, oder 404 für Gäste) einmal neu an.
import { msg } from '../i18n/i18n';
import { getCurrentUser } from './auth';
import { errorType, isUnauthorized } from './errors';
import { requireLogin } from './login-dialog';
/** Hinweis, wenn eine Aktion an einer abgebrochenen Anmeldung scheitert. */
export const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.';
/** Hinweis, wenn eine Aktion an einer abgebrochenen Anmeldung scheitert; angezeigt wird er mit `t(LOGIN_NEEDED)`. */
export const LOGIN_NEEDED = msg('Bitte melde dich an, um fortzufahren.');
/** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */
export class LoginCancelled extends Error {