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 // 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 './account.css';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
import { t } from '../i18n/i18n';
import { currentUser, logout, onAuthChange } from './auth'; import { currentUser, logout, onAuthChange } from './auth';
import { errorMessage } from './errors'; import { errorMessage } from './errors';
import { requireLogin } from './login-dialog'; import { requireLogin } from './login-dialog';
@@ -26,7 +28,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
} }
const user = currentUser(); const user = currentUser();
if (!user) { 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')); signIn.addEventListener('click', () => void requireLogin('login'));
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn)); slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
return; return;
@@ -38,18 +40,18 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
{ {
type: 'button', type: 'button',
class: 'account-avatar', class: 'account-avatar',
'aria-label': 'Konto-Menü', 'aria-label': t('Konto-Menü'),
'aria-expanded': 'false', 'aria-expanded': 'false',
'aria-controls': 'account-dropdown', 'aria-controls': 'account-dropdown',
}, },
initial, initial,
); );
const email = el('span', { class: 'account-email', title: user.email }, user.email); 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 myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, t('Meine Karten'));
const signOut = el('button', { type: 'button', class: 'account-item' }, 'Abmelden'); const signOut = el('button', { type: 'button', class: 'account-item' }, t('Abmelden'));
const error = el('span', { class: 'account-error', role: 'alert' }); 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 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; let isOpen = false;
function setOpen(open: boolean): void { 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 // Anmelden, Registrieren, Abmelden, Passwort zurücksetzen. Hält den angemeldeten Nutzer im Speicher
// und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite). // und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite).
import { ID } from 'appwrite'; import { ID } from 'appwrite';
import { t } from '../i18n/i18n';
import { account } from './appwrite'; import { account } from './appwrite';
import { errorType, isUnauthorized } from './errors'; 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 { export function validatePassword(pw: string): string | null {
return pw.length >= PASSWORD_MIN && pw.length <= PASSWORD_MAX return pw.length >= PASSWORD_MIN && pw.length <= PASSWORD_MAX
? null ? 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 { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite';
import { ID_PATTERN, parseConfig, serializeConfig, type CardConfig } from '../card/config'; import { ID_PATTERN, parseConfig, serializeConfig, type CardConfig } from '../card/config';
import type { SavedCardRef } from '../editor/state'; import type { SavedCardRef } from '../editor/state';
import { msg, t } from '../i18n/i18n';
import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite'; import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite';
import { currentUserId } from './auth'; import { currentUserId } from './auth';
import { errorType } from './errors'; import { errorType } from './errors';
@@ -44,16 +45,16 @@ export interface SaveInput {
previous: SavedCardRef | null; previous: SavedCardRef | null;
} }
export const DEFAULT_CARD_NAME = 'Meine Karte'; export const DEFAULT_CARD_NAME = msg('Meine Karte');
const NAME_MAX = 80; const NAME_MAX = 80;
/** Zeilen- und Datei-IDs von Appwrite (definiert in card/config.ts, dort braucht sie auch die Logo-ID der Konfiguration). */ /** Zeilen- und Datei-IDs von Appwrite (definiert in card/config.ts, dort braucht sie auch die Logo-ID der Konfiguration). */
export { ID_PATTERN }; 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 { export function normalizeName(name: string): string {
const collapsed = name.replace(/\s+/g, ' ').trim(); const collapsed = name.replace(/\s+/g, ' ').trim();
const cut = Array.from(collapsed).slice(0, NAME_MAX).join('').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. */ /** 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`. // 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'; export type ErrorContext = 'login' | 'register' | 'recover' | 'reset' | 'save' | 'load';
const LINK_INVALID = 'Der Link ist ungültig oder abgelaufen.'; const LINK_INVALID = msg('Der Link ist ungültig oder abgelaufen.');
const WRONG_CREDENTIALS = 'E-Mail oder Passwort ist falsch.'; const WRONG_CREDENTIALS = msg('E-Mail oder Passwort ist falsch.');
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.'; const GENERIC = msg('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
function field(err: unknown, key: 'code' | 'type' | 'message'): unknown { function field(err: unknown, key: 'code' | 'type' | 'message'): unknown {
return typeof err === 'object' && err !== null ? (err as Record<string, unknown>)[key] : undefined; 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 { 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'); const message = field(err, 'message');
if (typeof message === 'string' && message.includes('commonly used')) { 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 { export function errorMessage(err: unknown, ctx: ErrorContext): string {
switch (errorType(err)) { switch (errorType(err)) {
case 'user_invalid_credentials': case 'user_invalid_credentials':
return WRONG_CREDENTIALS; return t(WRONG_CREDENTIALS);
case 'user_already_exists': 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': case 'user_invalid_token':
return LINK_INVALID; return t(LINK_INVALID);
case 'user_not_found': case 'user_not_found':
if (ctx === 'recover') return 'Zu dieser E-Mail gibt es kein Konto.'; if (ctx === 'recover') return t('Zu dieser E-Mail gibt es kein Konto.');
if (ctx === 'reset') return LINK_INVALID; if (ctx === 'reset') return t(LINK_INVALID);
break; break;
case 'general_argument_invalid': { case 'general_argument_invalid': {
const param = invalidParam(err); 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 === 'password') return passwordMessage(err, ctx);
if (param === 'userId' || param === 'secret') return LINK_INVALID; if (param === 'userId' || param === 'secret') return t(LINK_INVALID);
break; break;
} }
case 'row_not_found': 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': case 'storage_file_not_found':
return 'Das Bild dieser Karte fehlt.'; return t('Das Bild dieser Karte fehlt.');
case 'general_unknown_origin': 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': 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': 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 (field(err, 'code') === 429) return t('Zu viele Versuche – bitte kurz warten.');
if (err instanceof TypeError) return 'Keine Verbindung zum Server.'; if (err instanceof TypeError) return t('Keine Verbindung zum Server.');
return GENERIC; return t(GENERIC);
} }
+30 -25
View File
@@ -1,19 +1,20 @@
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“. // Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“.
import './account.css'; import './account.css';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
import { msg, t } from '../i18n/i18n';
import { login, register, requestPasswordReset, validatePassword } from './auth'; import { login, register, requestPasswordReset, validatePassword } from './auth';
import { errorMessage, errorType, type ErrorContext } from './errors'; import { errorMessage, errorType, type ErrorContext } from './errors';
export type LoginView = 'login' | 'register' | 'forgot'; export type LoginView = 'login' | 'register' | 'forgot';
const TITLES: Record<LoginView, string> = { const TITLES: Record<LoginView, string> = {
login: 'Anmelden', login: msg('Anmelden'),
register: 'Registrieren', register: msg('Registrieren'),
forgot: 'Passwort vergessen', forgot: msg('Passwort vergessen'),
}; };
const MISMATCH = 'Die Passwörter stimmen nicht überein.'; const MISMATCH = msg('Die Passwörter stimmen nicht überein.');
const RESET_SENT = 'Wir haben dir einen Link geschickt. Er gilt eine Stunde.'; const RESET_SENT = msg('Wir haben dir einen Link geschickt. Er gilt eine Stunde.');
type Field = { label: HTMLLabelElement; input: HTMLInputElement }; type Field = { label: HTMLLabelElement; input: HTMLInputElement };
@@ -37,7 +38,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const closeButton = el( const closeButton = el(
'button', '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' }, '×'), el('span', { 'aria-hidden': 'true' }, '×'),
); );
const title = el('h2', { id: 'auth-title', class: 'auth-title' }); const title = el('h2', { id: 'auth-title', class: 'auth-title' });
@@ -135,7 +136,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
'aria-controls': 'auth-panel', 'aria-controls': 'auth-panel',
tabindex: selected ? 0 : -1, tabindex: selected ? 0 : -1,
}, },
TITLES[tabView], t(TITLES[tabView]),
); );
tab.addEventListener('click', () => select(tabView)); tab.addEventListener('click', () => select(tabView));
tab.addEventListener('keydown', (event) => { tab.addEventListener('keydown', (event) => {
@@ -145,7 +146,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
}); });
return tab; 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. */ /** 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); const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
if (forView === 'login') { if (forView === 'login') {
const email = field('E-Mail', 'email', 'email', 'email', draftEmail); const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const password = field('Passwort', 'password', 'password', 'current-password'); const password = field(t('Passwort'), 'password', 'password', 'current-password');
const forgot = linkButton('Passwort vergessen?', () => showView('forgot')); const forgot = linkButton(t('Passwort vergessen?'), () => showView('forgot'));
form.append(email.label, password.label, errorEl, okEl, submit('Anmelden'), el('p', { class: 'auth-aside' }, 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 }; const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
form.addEventListener('submit', (event) => { form.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
@@ -214,20 +215,22 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
} }
if (forView === 'register') { if (forView === 'register') {
const email = field('E-Mail', 'email', 'email', 'email', draftEmail); const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const password = field('Passwort', 'password', 'password', 'new-password', '', 'auth-pw-hint'); const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint');
const repeat = field('Passwort wiederholen', 'password-repeat', 'password', 'new-password'); const repeat = field(t('Passwort wiederholen'), 'password-repeat', 'password', 'new-password');
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, 'Mindestens 8 Zeichen.'); 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('Registrieren')); 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 }; const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
form.addEventListener('submit', (event) => { form.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
if (busy) return; if (busy) return;
const address = email.input.value.trim(); 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) { if (problem) {
showError(panel, problem); showError(panel, problem);
(problem === MISMATCH ? repeat : password).input.focus(); (mismatch ? repeat : password).input.focus();
return; return;
} }
void run( void run(
@@ -239,16 +242,16 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
}, },
(err) => { (err) => {
if (errorType(err) !== 'user_already_exists') return undefined; if (errorType(err) !== 'user_already_exists') return undefined;
return linkButton('Anmelden', () => showView('login')); return linkButton(t('Anmelden'), () => showView('login'));
}, },
); );
}); });
return panel; return panel;
} }
const email = field('E-Mail', 'email', 'email', 'email', draftEmail); const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const back = linkButton('Zurück zur Anmeldung', () => showView('login')); const back = linkButton(t('Zurück zur Anmeldung'), () => showView('login'));
form.append(email.label, errorEl, okEl, submit('Link senden'), el('p', { class: 'auth-aside' }, back)); 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 }; const panel: Panel = { form, first: email.input, errorEl, okEl };
form.addEventListener('submit', (event) => { form.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
@@ -256,7 +259,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const address = email.input.value.trim(); const address = email.input.value.trim();
void run(panel, 'recover', async () => { void run(panel, 'recover', async () => {
await requestPasswordReset(address); await requestPasswordReset(address);
showOk(panel, RESET_SENT); showOk(panel, t(RESET_SENT));
}); });
}); });
return panel; return panel;
@@ -268,7 +271,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const typed = content.querySelector<HTMLInputElement>('input[name="email"]'); const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
if (typed) draftEmail = typed.value.trim(); if (typed) draftEmail = typed.value.trim();
view = next; view = next;
title.textContent = TITLES[next]; title.textContent = t(TITLES[next]);
const panel = buildPanel(next); const panel = buildPanel(next);
if (next === 'forgot') { if (next === 'forgot') {
content.replaceChildren(panel.form); content.replaceChildren(panel.form);
@@ -288,6 +291,8 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
pending = new Promise<boolean>((resolve) => { pending = new Promise<boolean>((resolve) => {
settle = 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); showView(initial);
try { try {
dialog.showModal(); 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. // 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 { getCurrentUser } from './auth';
import { errorType, isUnauthorized } from './errors'; import { errorType, isUnauthorized } from './errors';
import { requireLogin } from './login-dialog'; import { requireLogin } from './login-dialog';
/** Hinweis, wenn eine Aktion an einer abgebrochenen Anmeldung scheitert. */ /** Hinweis, wenn eine Aktion an einer abgebrochenen Anmeldung scheitert; angezeigt wird er mit `t(LOGIN_NEEDED)`. */
export const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.'; export const LOGIN_NEEDED = msg('Bitte melde dich an, um fortzufahren.');
/** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */ /** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */
export class LoginCancelled extends Error { export class LoginCancelled extends Error {
+7 -3
View File
@@ -2,6 +2,7 @@
// Reine Daten plus ein kleiner Browser-Lader; die Größe der Logos bestimmt allein die viewBox. // Reine Daten plus ein kleiner Browser-Lader; die Größe der Logos bestimmt allein die viewBox.
// Die Logos unten rechts kommen von der Community (logos/logo-asset.ts), hier steht keine fremde Marke. // Die Logos unten rechts kommen von der Community (logos/logo-asset.ts), hier steht keine fremde Marke.
import { msg } from '../i18n/i18n';
import { TONE_COLOR, type BrandId, type DetailsTone } from './config'; import { TONE_COLOR, type BrandId, type DetailsTone } from './config';
import aircardsSymbol from '../assets/marks/aircards-symbol.svg?raw'; import aircardsSymbol from '../assets/marks/aircards-symbol.svg?raw';
@@ -11,7 +12,10 @@ export type MarkGroup = 'Logo';
export interface MarkDef { export interface MarkDef {
id: Exclude<BrandId, 'none'>; id: Exclude<BrandId, 'none'>;
/** Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Logo – AirCard-IOS · Symbol“). */ /**
* Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Logo – AirCard-IOS · Symbol“).
* Beide sind deutsch markiert (`msg`) und werden erst beim Anzeigen mit `t()` übersetzt.
*/
group: MarkGroup; group: MarkGroup;
label: string; label: string;
svg: string; svg: string;
@@ -24,8 +28,8 @@ export interface MarkDef {
type MarkEntry = Omit<MarkDef, 'id'>; type MarkEntry = Omit<MarkDef, 'id'>;
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, MarkEntry> = { const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, MarkEntry> = {
'aircards-symbol': { group: 'Logo', label: 'AirCard-IOS · Symbol', svg: aircardsSymbol, tint: true, maxH: 100 }, 'aircards-symbol': { group: msg('Logo'), label: msg('AirCard-IOS · Symbol'), svg: aircardsSymbol, tint: true, maxH: 100 },
'aircards-text': { group: 'Logo', label: 'AirCard-IOS · Text', svg: aircardsText, tint: true, maxH: 100 }, 'aircards-text': { group: msg('Logo'), label: msg('AirCard-IOS · Text'), svg: aircardsText, tint: true, maxH: 100 },
}; };
export const BRAND_MARKS: readonly MarkDef[] = ( export const BRAND_MARKS: readonly MarkDef[] = (
+4 -2
View File
@@ -1,5 +1,6 @@
// Kartenrenderer: zeichnet eine Karte in 1536 x 969-Koordinaten auf einen Canvas-Kontext. // Kartenrenderer: zeichnet eine Karte in 1536 x 969-Koordinaten auf einen Canvas-Kontext.
// Vorschau, Vorlagen-Miniaturen, PNG-Download und Vorschaubild beim Speichern benutzen alle drawCard. // Vorschau, Vorlagen-Miniaturen, PNG-Download und Vorschaubild beim Speichern benutzen alle drawCard.
import { msg } from '../i18n/i18n';
import { loadLogoAsset } from '../logos/logo-asset'; import { loadLogoAsset } from '../logos/logo-asset';
import { TONE_COLOR, type CardConfig, type DetailsTone } from './config'; import { TONE_COLOR, type CardConfig, type DetailsTone } from './config';
import { clampCrop, imageRect } from './crop'; import { clampCrop, imageRect } from './crop';
@@ -180,7 +181,8 @@ export function drawCard(
): void { ): void {
// Die Überladungen beider Canvas-Typen lassen sich als Union nicht aufrufen; zur Laufzeit gleich. // Die Überladungen beider Canvas-Typen lassen sich als Union nicht aufrufen; zur Laufzeit gleich.
const ctx = (canvas as HTMLCanvasElement).getContext('2d', { alpha: false }); const ctx = (canvas as HTMLCanvasElement).getContext('2d', { alpha: false });
if (!ctx) throw new Error('Canvas wird in diesem Browser nicht unterstützt.'); // Die Meldung geht deutsch markiert in den Fehler; der Editor zeigt sie mit `t(error.message)`.
if (!ctx) throw new Error(msg('Canvas wird in diesem Browser nicht unterstützt.'));
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(canvas.width / CARD_W, canvas.height / CARD_H); ctx.scale(canvas.width / CARD_W, canvas.height / CARD_H);
// Fotos werden stark verkleinert; die Standardqualität (low) zeigt dabei Treppenstufen. // Fotos werden stark verkleinert; die Standardqualität (low) zeigt dabei Treppenstufen.
@@ -221,7 +223,7 @@ export async function exportCard(
drawCard(canvas, config, assets); drawCard(canvas, config, assets);
return new Promise<Blob>((resolve, reject) => { return new Promise<Blob>((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('Das Bild konnte nicht erzeugt werden.'))), (blob) => (blob ? resolve(blob) : reject(new Error(msg('Das Bild konnte nicht erzeugt werden.')))),
opts.type, opts.type,
opts.quality, opts.quality,
); );
+2 -1
View File
@@ -1,6 +1,7 @@
// Bestätigungsdialog als eigenes <dialog> (kein window.confirm): Löschen einer Karte, Verwerfen von Änderungen. // Bestätigungsdialog als eigenes <dialog> (kein window.confirm): Löschen einer Karte, Verwerfen von Änderungen.
import '../account/account.css'; import '../account/account.css';
import './confirm-dialog.css'; import './confirm-dialog.css';
import { t } from '../i18n/i18n';
import { el } from './dom'; import { el } from './dom';
export interface ConfirmOptions { export interface ConfirmOptions {
@@ -20,7 +21,7 @@ export function confirmDialog(options: ConfirmOptions, signal?: AbortSignal): Pr
resolve(false); resolve(false);
return; return;
} }
const cancel = el('button', { type: 'button', class: 'auth-btn' }, options.cancelLabel ?? 'Abbrechen'); const cancel = el('button', { type: 'button', class: 'auth-btn' }, options.cancelLabel ?? t('Abbrechen'));
const confirm = el('button', { type: 'button', class: 'auth-btn auth-btn-danger' }, options.confirmLabel); const confirm = el('button', { type: 'button', class: 'auth-btn auth-btn-danger' }, options.confirmLabel);
const dialog = el( const dialog = el(
'dialog', 'dialog',
+4 -3
View File
@@ -1,8 +1,9 @@
// PNG-Download: Dateiname, Größenhinweis und das Anstoßen des Browser-Downloads. // PNG-Download: Dateiname, Größenhinweis und das Anstoßen des Browser-Downloads.
import { msg, t } from '../i18n/i18n';
/** Wallet nimmt eventuell nur Bilder unter 4 MB. */ /** Wallet nimmt eventuell nur Bilder unter 4 MB. */
export const PNG_WARN_BYTES = 4_000_000; export const PNG_WARN_BYTES = 4_000_000;
export const PNG_SIZE_WARNING = 'Das PNG ist größer als 4 MB – Wallet nimmt eventuell nur kleinere Bilder.'; export const PNG_SIZE_WARNING = msg('Das PNG ist größer als 4 MB – Wallet nimmt eventuell nur kleinere Bilder.');
const MAX_SLUG_LENGTH = 40; const MAX_SLUG_LENGTH = 40;
@@ -29,9 +30,9 @@ export function downloadFileName(cardName: string, date: Date): string {
return `aircard-${slugify(cardName) || isoDay(date)}.png`; return `aircard-${slugify(cardName) || isoDay(date)}.png`;
} }
/** Hinweistext, wenn das PNG über der Wallet-Grenze liegt, sonst null. */ /** Hinweistext (in der Sprache der Seite), wenn das PNG über der Wallet-Grenze liegt, sonst null. */
export function pngSizeWarning(size: number): string | null { export function pngSizeWarning(size: number): string | null {
return size > PNG_WARN_BYTES ? PNG_SIZE_WARNING : null; return size > PNG_WARN_BYTES ? t(PNG_SIZE_WARNING) : null;
} }
// Manche Browser starten den Download erst nach dem Klick; sofort freigeben würde ihn abbrechen. // Manche Browser starten den Download erst nach dem Klick; sofort freigeben würde ihn abbrechen.
+23 -22
View File
@@ -10,7 +10,7 @@ import { clampCrop, panBy, zoomTo } from '../card/crop';
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry'; import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render'; import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render';
import { getGalleryCard, loadGalleryImage } from '../community/gallery'; import { getGalleryCard, loadGalleryImage } from '../community/gallery';
import { t } from '../i18n/i18n'; import { msg, t } from '../i18n/i18n';
import { LOGO_MISSING } from '../logos/logo-asset'; import { LOGO_MISSING } from '../logos/logo-asset';
import { createLogoPicker } from '../logos/logo-picker'; import { createLogoPicker } from '../logos/logo-picker';
import { dropCardParam, dropTemplateParam } from './card-url'; import { dropCardParam, dropTemplateParam } from './card-url';
@@ -24,13 +24,14 @@ import { initialEditorState, setImage, type EditorState, type EditorStore, type
import { createTemplatesPanel } from './templates-panel'; import { createTemplatesPanel } from './templates-panel';
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage, validateImageFile } from './upload'; import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage, validateImageFile } from './upload';
const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.'; // Deutsch markierte Texte: Fehler werden mit ihnen geworfen (`new Error(LOGO_ERROR)`) und erst beim Anzeigen mit `t()` übersetzt.
const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.'; const LOGO_ERROR = msg('Die Logos konnten nicht geladen werden.');
const NO_IMAGE = 'Bitte zuerst ein Bild wählen.'; const EXPORT_ERROR = msg('Das PNG konnte nicht erzeugt werden.');
const LOADING_CARD = 'Karte wird geladen …'; const NO_IMAGE = msg('Bitte zuerst ein Bild wählen.');
const LOADING_TEMPLATE = 'Vorlage wird geladen …'; const LOADING_CARD = msg('Karte wird geladen …');
const TEMPLATE_UNAVAILABLE = 'Diese Vorlage ist nicht (mehr) verfügbar.'; const LOADING_TEMPLATE = msg('Vorlage wird geladen …');
const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.'; const TEMPLATE_UNAVAILABLE = msg('Diese Vorlage ist nicht (mehr) verfügbar.');
const LOGIN_TO_OPEN = msg('Melde dich an, um diese Karte zu öffnen.');
const SAVED_FLASH_MS = 2000; const SAVED_FLASH_MS = 2000;
/** Die geladenen Logo-Bilder; `missing`: die Karte wählt ein Community-Logo, das es nicht (mehr) gibt. */ /** Die geladenen Logo-Bilder; `missing`: die Karte wählt ein Community-Logo, das es nicht (mehr) gibt. */
@@ -94,7 +95,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
onSave() { onSave() {
if (store.get().saving) return; if (store.get().saving) return;
if (!store.get().imageBlob) { if (!store.get().imageBlob) {
preview.setError(NO_IMAGE); preview.setError(t(NO_IMAGE));
return; return;
} }
// Der Schnappschuss gilt für alles, was dieser Klick auslöst – auch für die Wiederholung nach einem 401. // Der Schnappschuss gilt für alles, was dieser Klick auslöst – auch für die Wiederholung nach einem 401.
@@ -129,7 +130,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
showWarning(); showWarning();
} }
const heading = el('h1', { class: 'sr-only' }, 'Kartenmotiv gestalten'); const heading = el('h1', { class: 'sr-only' }, t('Kartenmotiv gestalten'));
const page = el('div', { class: 'editor' }, templates.el, preview.el, options.el); const page = el('div', { class: 'editor' }, templates.el, preview.el, options.el);
root.replaceChildren(heading, page); root.replaceChildren(heading, page);
@@ -153,7 +154,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
setImage(store, image, file); setImage(store, image, file);
} catch (error) { } catch (error) {
if (disposed || token !== fileToken) return; if (disposed || token !== fileToken) return;
preview.setError(error instanceof Error ? error.message : 'Das Bild konnte nicht gelesen werden.'); preview.setError(error instanceof Error ? t(error.message) : t(READ_MESSAGE));
} }
} }
attachFileDrop(page, (file) => void handleFile(file), (active) => preview.setDragging(active && !store.get().saving)); attachFileDrop(page, (file) => void handleFile(file), (active) => preview.setDragging(active && !store.get().saving));
@@ -175,7 +176,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
saveBlobAs(blob, downloadFileName(s.cardName, new Date())); saveBlobAs(blob, downloadFileName(s.cardName, new Date()));
setWarning(pngSizeWarning(blob.size)); setWarning(pngSizeWarning(blob.size));
} catch (error) { } catch (error) {
preview.setError(error instanceof Error && error.message ? error.message : EXPORT_ERROR); preview.setError(error instanceof Error && error.message ? t(error.message) : t(EXPORT_ERROR));
} finally { } finally {
exporting = false; exporting = false;
if (!disposed) sync(store.get()); if (!disposed) sync(store.get());
@@ -250,7 +251,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
const service = cardsService(); const service = cardsService();
preview.setError(null); preview.setError(null);
setWarning(null); setWarning(null);
preview.setNotice(LOADING_CARD); preview.setNotice(t(LOADING_CARD));
try { try {
// Appwrite beantwortet Gäste mit 404 statt 401: ohne Sitzung zuerst anmelden, statt „nicht gefunden“ zu melden. // Appwrite beantwortet Gäste mit 404 statt 401: ohne Sitzung zuerst anmelden, statt „nicht gefunden“ zu melden.
if (!(await getCurrentUser())) { if (!(await getCurrentUser())) {
@@ -298,7 +299,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
} catch (err) { } catch (err) {
if (stale()) return; if (stale()) return;
if (err instanceof LoginCancelled) { if (err instanceof LoginCancelled) {
setWarning(LOGIN_TO_OPEN); setWarning(t(LOGIN_TO_OPEN));
} else if (errorType(err) === 'row_not_found') { } else if (errorType(err) === 'row_not_found') {
// Die Karte gibt es nicht (für dieses Konto). Ein leerer Editor wird zurückgesetzt; hält er Arbeit, bleibt sie. // Die Karte gibt es nicht (für dieses Konto). Ein leerer Editor wird zurückgesetzt; hält er Arbeit, bleibt sie.
const s = store.get(); const s = store.get();
@@ -306,7 +307,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
preview.setError(errorMessage(err, 'load')); preview.setError(errorMessage(err, 'load'));
} else { } else {
const imageProblem = err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE); const imageProblem = err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE);
preview.setError(imageProblem ? err.message : errorMessage(err, 'load')); preview.setError(imageProblem ? t(err.message) : errorMessage(err, 'load'));
} }
} finally { } finally {
if (!stale()) preview.setNotice(null); if (!stale()) preview.setNotice(null);
@@ -320,7 +321,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
const stale = () => disposed || token !== loadToken; const stale = () => disposed || token !== loadToken;
preview.setError(null); preview.setError(null);
setWarning(null); setWarning(null);
preview.setNotice(LOADING_TEMPLATE); preview.setNotice(t(LOADING_TEMPLATE));
try { try {
// Eine ausgeblendete oder zurückgezogene Karte liefert null (Appwrite antwortet Fremden mit 404). // Eine ausgeblendete oder zurückgezogene Karte liefert null (Appwrite antwortet Fremden mit 404).
const card = await getGalleryCard(galleryId); const card = await getGalleryCard(galleryId);
@@ -353,7 +354,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
dropTemplateParam(); dropTemplateParam();
} catch { } catch {
if (stale()) return; if (stale()) return;
preview.setError(TEMPLATE_UNAVAILABLE); preview.setError(t(TEMPLATE_UNAVAILABLE));
} finally { } finally {
if (!stale()) preview.setNotice(null); if (!stale()) preview.setNotice(null);
} }
@@ -366,9 +367,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
try { try {
return await confirmDialog( return await confirmDialog(
{ {
title: 'Ungespeicherte Änderungen verwerfen?', title: t('Ungespeicherte Änderungen verwerfen?'),
text: 'Alles, was du seit dem letzten Speichern geändert hast, geht verloren.', text: t('Alles, was du seit dem letzten Speichern geändert hast, geht verloren.'),
confirmLabel: 'Verwerfen', confirmLabel: t('Verwerfen'),
}, },
disposal.signal, disposal.signal,
); );
@@ -427,7 +428,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
if (disposed || token !== logoToken) return; if (disposed || token !== logoToken) return;
pendingKey = ''; pendingKey = '';
failedKey = key; failedKey = key;
preview.setError(LOGO_ERROR); preview.setError(t(LOGO_ERROR));
logoErrorShown = true; logoErrorShown = true;
}, },
); );
@@ -452,7 +453,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
if (s.image) drawCard(preview.canvas, s.config, assets); if (s.image) drawCard(preview.canvas, s.config, assets);
templates.draw(s.config, assets); templates.draw(s.config, assets);
} catch (error) { } catch (error) {
preview.setError(error instanceof Error ? error.message : 'Die Vorschau konnte nicht gezeichnet werden.'); preview.setError(error instanceof Error ? t(error.message) : t('Die Vorschau konnte nicht gezeichnet werden.'));
} }
} }
+11 -10
View File
@@ -2,7 +2,7 @@
// dann Details (Hell/Dunkel) und das Logo oben links. // dann Details (Hell/Dunkel) und das Logo oben links.
import type { BrandId, CardConfig, DetailsTone } from '../card/config'; import type { BrandId, CardConfig, DetailsTone } from '../card/config';
import { BRAND_MARKS, tintSvg, type MarkDef } from '../card/marks'; import { BRAND_MARKS, tintSvg, type MarkDef } from '../card/marks';
import { t } from '../i18n/i18n'; import { msg, t } from '../i18n/i18n';
import { el, icon } from './dom'; import { el, icon } from './dom';
export interface OptionsHandlers { export interface OptionsHandlers {
@@ -18,8 +18,8 @@ export interface OptionsPanel {
} }
const TONES: readonly { id: DetailsTone; label: string }[] = [ const TONES: readonly { id: DetailsTone; label: string }[] = [
{ id: 'light', label: 'Hell' }, { id: 'light', label: msg('Hell') },
{ id: 'dark', label: 'Dunkel' }, { id: 'dark', label: msg('Dunkel') },
]; ];
/** Logo der Kachel als Bild: einfarbige Logos in Weiß (currentColor hätte im <img> die Farbe Schwarz). */ /** Logo der Kachel als Bild: einfarbige Logos in Weiß (currentColor hätte im <img> die Farbe Schwarz). */
@@ -28,12 +28,12 @@ function markSrc(def: MarkDef): string {
} }
function markTile(def: MarkDef, onClick: () => void) { function markTile(def: MarkDef, onClick: () => void) {
const name = `${def.group} – ${def.label}`; const name = `${t(def.group)} – ${t(def.label)}`;
const button = el( const button = el(
'button', 'button',
{ type: 'button', class: 'tile mark-tile', 'aria-pressed': 'false', 'aria-label': name, title: name }, { type: 'button', class: 'tile mark-tile', 'aria-pressed': 'false', 'aria-label': name, title: name },
el('img', { class: 'mark-img', src: markSrc(def), alt: '', draggable: 'false' }), el('img', { class: 'mark-img', src: markSrc(def), alt: '', draggable: 'false' }),
el('span', { class: 'mark-label' }, def.label), el('span', { class: 'mark-label' }, t(def.label)),
); );
button.addEventListener('click', onClick); button.addEventListener('click', onClick);
return button; return button;
@@ -48,11 +48,12 @@ export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel {
id: def.id as BrandId, id: def.id as BrandId,
button: markTile(def, () => handlers.onBrand(def.id as BrandId)), button: markTile(def, () => handlers.onBrand(def.id as BrandId)),
})); }));
const noLogo = t('Kein Logo');
const none = el( const none = el(
'button', 'button',
{ type: 'button', class: 'tile mark-tile', 'aria-pressed': 'false', 'aria-label': 'Kein Logo', title: 'Kein Logo' }, { type: 'button', class: 'tile mark-tile', 'aria-pressed': 'false', 'aria-label': noLogo, title: noLogo },
icon('ban', 'mark-none'), icon('ban', 'mark-none'),
el('span', { class: 'mark-label' }, 'Kein Logo'), el('span', { class: 'mark-label' }, noLogo),
); );
none.addEventListener('click', () => handlers.onBrand('none')); none.addEventListener('click', () => handlers.onBrand('none'));
brandTiles.push({ id: 'none', button: none }); brandTiles.push({ id: 'none', button: none });
@@ -66,7 +67,7 @@ export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel {
'label', 'label',
{ class: 'pill' }, { class: 'pill' },
input, input,
el('span', { class: 'pill-face' }, el('span', { class: `swatch swatch-${tone.id}` }), tone.label), el('span', { class: 'pill-face' }, el('span', { class: `swatch swatch-${tone.id}` }), t(tone.label)),
); );
return { id: tone.id, input, label }; return { id: tone.id, input, label };
}); });
@@ -76,9 +77,9 @@ export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel {
const section = el( const section = el(
'section', 'section',
{ class: 'editor-side editor-options' }, { class: 'editor-side editor-options' },
heading('editor-options-heading', 'Individuell gestalten', 'h2'), heading('editor-options-heading', t('Individuell gestalten'), 'h2'),
logoSlot, logoSlot,
heading('editor-details-heading', 'Details', 'h3'), heading('editor-details-heading', t('Details'), 'h3'),
el('div', { class: 'pill-row', role: 'radiogroup', 'aria-labelledby': 'editor-details-heading' }, ...toneInputs.map((input) => input.label)), el('div', { class: 'pill-row', role: 'radiogroup', 'aria-labelledby': 'editor-details-heading' }, ...toneInputs.map((input) => input.label)),
heading('editor-brand-heading', t('Logo oben links'), 'h3'), heading('editor-brand-heading', t('Logo oben links'), 'h3'),
el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-brand-heading' }, ...brandTiles.map((tile) => tile.button)), el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-brand-heading' }, ...brandTiles.map((tile) => tile.button)),
+30 -26
View File
@@ -1,6 +1,7 @@
// Mittlere Spalte: Ablagefläche bzw. Kartenvorschau (Verschieben, Zoom) und die Knopfleiste. // Mittlere Spalte: Ablagefläche bzw. Kartenvorschau (Verschieben, Zoom) und die Knopfleiste.
import { MAX_ZOOM, MIN_ZOOM } from '../card/config'; import { MAX_ZOOM, MIN_ZOOM } from '../card/config';
import { CARD_H, CARD_W } from '../card/geometry'; import { CARD_H, CARD_W } from '../card/geometry';
import { msg, t } from '../i18n/i18n';
import { el, icon } from './dom'; import { el, icon } from './dom';
import { ACCEPTED_TYPES } from './upload'; import { ACCEPTED_TYPES } from './upload';
@@ -32,15 +33,14 @@ export interface Preview {
setDragging(active: boolean): void; setDragging(active: boolean): void;
setError(message: string | null): void; setError(message: string | null): void;
setWarning(message: string | null): void; setWarning(message: string | null): void;
/** Neutraler Hinweis, z. B. „Karte wird geladen …“. */ /** Neutraler Hinweis, z. B. „Karte wird geladen …“ (der Text kommt schon in der Sprache der Seite an). */
setNotice(message: string | null): void; setNotice(message: string | null): void;
} }
const NO_IMAGE_HINT = 'Erst ein Bild wählen'; const NO_IMAGE_HINT = msg('Erst ein Bild wählen');
const SAVE_LABEL = 'Speichern'; const SAVE_LABEL = msg('Speichern');
const SAVING_LABEL = 'Speichert …'; const SAVING_LABEL = msg('Speichert …');
const SAVED_LABEL = 'Gespeichert'; const SAVED_LABEL = msg('Gespeichert');
const BOUND_PREFIX = 'Bearbeitet: ';
const KEY_STEP = 24; const KEY_STEP = 24;
const KEY_STEP_BIG = 96; const KEY_STEP_BIG = 96;
const KEY_ZOOM_STEP = 0.1; const KEY_ZOOM_STEP = 0.1;
@@ -80,6 +80,9 @@ export function attachFileDrop(
}); });
} }
/** Der Zoom als Prozentzahl, in der Schreibweise der Sprache der Seite („100 %“ / „100%“). */
const percent = (zoom: number): string => t('{percent} %', { percent: Math.round(zoom * 100) });
export function createPreview(handlers: PreviewHandlers): Preview { export function createPreview(handlers: PreviewHandlers): Preview {
let zoom = MIN_ZOOM; let zoom = MIN_ZOOM;
@@ -87,7 +90,7 @@ export function createPreview(handlers: PreviewHandlers): Preview {
type: 'file', type: 'file',
accept: ACCEPTED_TYPES.join(','), accept: ACCEPTED_TYPES.join(','),
hidden: true, hidden: true,
'aria-label': 'Bilddatei wählen', 'aria-label': t('Bilddatei wählen'),
}); });
const openPicker = () => input.click(); const openPicker = () => input.click();
input.addEventListener('change', () => { input.addEventListener('change', () => {
@@ -98,14 +101,14 @@ export function createPreview(handlers: PreviewHandlers): Preview {
}); });
// Ablagefläche (ohne Bild) // Ablagefläche (ohne Bild)
const chooseButton = el('button', { type: 'button', class: 'btn btn-primary btn-large' }, 'Bild auswählen'); const chooseButton = el('button', { type: 'button', class: 'btn btn-primary btn-large' }, t('Bild auswählen'));
const dropzone = el( const dropzone = el(
'div', 'div',
{ class: 'dropzone' }, { class: 'dropzone' },
icon('imagePlus', 'dropzone-icon'), icon('imagePlus', 'dropzone-icon'),
el('p', { class: 'dropzone-title' }, 'Ziehe deine Idee hierher'), el('p', { class: 'dropzone-title' }, t('Ziehe deine Idee hierher')),
chooseButton, chooseButton,
el('p', { class: 'dropzone-hint' }, 'PNG / JPG / WebP · Max. 5 MiB'), el('p', { class: 'dropzone-hint' }, t('PNG / JPG / WebP · Max. 5 MiB')),
); );
dropzone.addEventListener('click', openPicker); dropzone.addEventListener('click', openPicker);
@@ -116,7 +119,7 @@ export function createPreview(handlers: PreviewHandlers): Preview {
height: CARD_H, height: CARD_H,
tabindex: 0, tabindex: 0,
role: 'application', role: 'application',
'aria-label': 'Kartenvorschau. Mit der Maus ziehen oder die Pfeiltasten benutzen, um das Bild zu verschieben.', 'aria-label': t('Kartenvorschau. Mit der Maus ziehen oder die Pfeiltasten benutzen, um das Bild zu verschieben.'),
}); });
const cardWrap = el('div', { class: 'card-wrap', hidden: true }, canvas); const cardWrap = el('div', { class: 'card-wrap', hidden: true }, canvas);
@@ -177,7 +180,7 @@ export function createPreview(handlers: PreviewHandlers): Preview {
// Bindung an eine gespeicherte Karte: Name und Ausweg „Neue Karte“ // Bindung an eine gespeicherte Karte: Name und Ausweg „Neue Karte“
const boundLabel = el('span', { class: 'bound-label' }); const boundLabel = el('span', { class: 'bound-label' });
const newCardButton = el('button', { type: 'button', class: 'btn btn-small' }, 'Neue Karte'); const newCardButton = el('button', { type: 'button', class: 'btn btn-small' }, t('Neue Karte'));
newCardButton.addEventListener('click', () => handlers.onNewCard()); newCardButton.addEventListener('click', () => handlers.onNewCard());
const boundRow = el('div', { class: 'bound-row', hidden: true }, boundLabel, newCardButton); const boundRow = el('div', { class: 'bound-row', hidden: true }, boundLabel, newCardButton);
@@ -191,12 +194,12 @@ export function createPreview(handlers: PreviewHandlers): Preview {
step: 0.01, step: 0.01,
value: MIN_ZOOM, value: MIN_ZOOM,
}); });
const zoomValue = el('output', { class: 'zoom-value', for: 'editor-zoom' }, '100 %'); const zoomValue = el('output', { class: 'zoom-value', for: 'editor-zoom' }, percent(MIN_ZOOM));
zoomInput.addEventListener('input', () => handlers.onZoom(Number(zoomInput.value))); zoomInput.addEventListener('input', () => handlers.onZoom(Number(zoomInput.value)));
const zoomRow = el( const zoomRow = el(
'div', 'div',
{ class: 'zoom-row', hidden: true }, { class: 'zoom-row', hidden: true },
el('label', { class: 'zoom-label', for: 'editor-zoom' }, 'Zoom'), el('label', { class: 'zoom-label', for: 'editor-zoom' }, t('Zoom')),
zoomInput, zoomInput,
zoomValue, zoomValue,
); );
@@ -207,11 +210,11 @@ export function createPreview(handlers: PreviewHandlers): Preview {
const noticeBox = el('p', { class: 'msg msg-notice', role: 'status' }); const noticeBox = el('p', { class: 'msg msg-notice', role: 'status' });
// Knopfleiste // Knopfleiste
const replaceButton = el('button', { type: 'button', class: 'btn btn-ghost' }, 'Bild ersetzen'); const replaceButton = el('button', { type: 'button', class: 'btn btn-ghost' }, t('Bild ersetzen'));
replaceButton.addEventListener('click', openPicker); replaceButton.addEventListener('click', openPicker);
const downloadButton = el('button', { type: 'button', class: 'btn btn-primary' }, 'Herunterladen'); const downloadButton = el('button', { type: 'button', class: 'btn btn-primary' }, t('Herunterladen'));
downloadButton.addEventListener('click', () => handlers.onDownload()); downloadButton.addEventListener('click', () => handlers.onDownload());
const saveButton = el('button', { type: 'button', class: 'btn btn-primary btn-save' }, SAVE_LABEL); const saveButton = el('button', { type: 'button', class: 'btn btn-primary btn-save' }, t(SAVE_LABEL));
saveButton.addEventListener('click', () => handlers.onSave()); saveButton.addEventListener('click', () => handlers.onSave());
const actions = el( const actions = el(
'div', 'div',
@@ -220,10 +223,10 @@ export function createPreview(handlers: PreviewHandlers): Preview {
el('div', { class: 'action-buttons' }, replaceButton, downloadButton, saveButton), el('div', { class: 'action-buttons' }, replaceButton, downloadButton, saveButton),
); );
const note = el('p', { class: 'note' }, icon('lock', 'note-icon'), 'Dein Bild bleibt auf deinem Gerät, bis du speicherst.'); const note = el('p', { class: 'note' }, icon('lock', 'note-icon'), t('Dein Bild bleibt auf deinem Gerät, bis du speicherst.'));
const inner = el('div', { class: 'preview-inner' }, boundRow, stage, zoomRow, errorBox, warningBox, noticeBox, actions, note, input); const inner = el('div', { class: 'preview-inner' }, boundRow, stage, zoomRow, errorBox, warningBox, noticeBox, actions, note, input);
const section = el('section', { class: 'editor-center', 'aria-label': 'Kartenvorschau' }, inner); const section = el('section', { class: 'editor-center', 'aria-label': t('Kartenvorschau') }, inner);
function setDisabled(button: HTMLButtonElement, disabled: boolean, hint: string | null): void { function setDisabled(button: HTMLButtonElement, disabled: boolean, hint: string | null): void {
button.disabled = disabled; button.disabled = disabled;
@@ -244,18 +247,19 @@ export function createPreview(handlers: PreviewHandlers): Preview {
cardWrap.hidden = !view.hasImage; cardWrap.hidden = !view.hasImage;
zoomRow.hidden = !view.hasImage; zoomRow.hidden = !view.hasImage;
zoomInput.value = String(view.zoom); zoomInput.value = String(view.zoom);
zoomValue.textContent = `${Math.round(view.zoom * 100)} %`; zoomValue.textContent = percent(view.zoom);
setDisabled(downloadButton, !view.hasImage || view.exporting, view.hasImage ? null : NO_IMAGE_HINT); setDisabled(downloadButton, !view.hasImage || view.exporting, view.hasImage ? null : t(NO_IMAGE_HINT));
setDisabled(saveButton, !view.hasImage || view.saving, view.hasImage ? null : NO_IMAGE_HINT); setDisabled(saveButton, !view.hasImage || view.saving, view.hasImage ? null : t(NO_IMAGE_HINT));
saveButton.textContent = view.saving ? SAVING_LABEL : view.saved ? SAVED_LABEL : SAVE_LABEL; saveButton.textContent = t(view.saving ? SAVING_LABEL : view.saved ? SAVED_LABEL : SAVE_LABEL);
// Ein Bildwechsel mitten im Speichern würde die gespeicherte Karte durcheinanderbringen. // Ein Bildwechsel mitten im Speichern würde die gespeicherte Karte durcheinanderbringen.
replaceButton.disabled = view.saving; replaceButton.disabled = view.saving;
input.disabled = view.saving; input.disabled = view.saving;
boundRow.hidden = view.boundName === null; boundRow.hidden = view.boundName === null;
// Nur als Text, nie als HTML: der Name kommt vom Nutzer. // Nur als Text, nie als HTML: der Name kommt vom Nutzer.
boundLabel.textContent = view.boundName === null ? '' : BOUND_PREFIX + view.boundName; const bound = view.boundName === null ? null : t('Bearbeitet: {name}', { name: view.boundName });
if (view.boundName === null) boundLabel.removeAttribute('title'); boundLabel.textContent = bound ?? '';
else boundLabel.setAttribute('title', BOUND_PREFIX + view.boundName); if (bound === null) boundLabel.removeAttribute('title');
else boundLabel.setAttribute('title', bound);
newCardButton.disabled = view.saving; newCardButton.disabled = view.saving;
downloadButton.setAttribute('aria-busy', String(view.exporting)); downloadButton.setAttribute('aria-busy', String(view.exporting));
saveButton.setAttribute('aria-busy', String(view.saving)); saveButton.setAttribute('aria-busy', String(view.saving));
+2 -3
View File
@@ -2,6 +2,7 @@
// Kennt weder DOM noch Appwrite; alles Konkrete kommt über `deps`. // Kennt weder DOM noch Appwrite; alles Konkrete kommt über `deps`.
import { getCurrentUser, type User } from '../account/auth'; import { getCurrentUser, type User } from '../account/auth';
import { errorMessage, isUnauthorized } from '../account/errors'; import { errorMessage, isUnauthorized } from '../account/errors';
import { t } from '../i18n/i18n';
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
@@ -16,8 +17,6 @@ export interface SaveDeps {
refreshUser?(): Promise<unknown>; refreshUser?(): Promise<unknown>;
} }
const FAILED = 'Speichern fehlgeschlagen – ';
/** Liefert die Funktion hinter dem Speichern-Knopf; ein laufendes Speichern blockiert jeden weiteren Aufruf. */ /** Liefert die Funktion hinter dem Speichern-Knopf; ein laufendes Speichern blockiert jeden weiteren Aufruf. */
export function createSaveAction(deps: SaveDeps): () => Promise<void> { export function createSaveAction(deps: SaveDeps): () => Promise<void> {
const refreshUser = deps.refreshUser ?? getCurrentUser; const refreshUser = deps.refreshUser ?? getCurrentUser;
@@ -50,7 +49,7 @@ export function createSaveAction(deps: SaveDeps): () => Promise<void> {
} }
deps.onStatus('saved'); deps.onStatus('saved');
} catch (err) { } catch (err) {
deps.onStatus('error', FAILED + errorMessage(err, 'save')); deps.onStatus('error', t('Speichern fehlgeschlagen – {reason}', { reason: errorMessage(err, 'save') }));
} finally { } finally {
running = false; running = false;
} }
+3 -2
View File
@@ -2,6 +2,7 @@
// Der Store wird einmal in main.ts erzeugt und überlebt Seitenwechsel. // Der Store wird einmal in main.ts erzeugt und überlebt Seitenwechsel.
import { DEFAULT_CONFIG, DEFAULT_CROP, type CardConfig } from '../card/config'; import { DEFAULT_CONFIG, DEFAULT_CROP, type CardConfig } from '../card/config';
import type { CardImage } from '../card/render'; import type { CardImage } from '../card/render';
import { t } from '../i18n/i18n';
export interface SavedCardRef { export interface SavedCardRef {
id: string; id: string;
@@ -49,7 +50,7 @@ export function createStore(initial: EditorState) {
export type EditorStore = ReturnType<typeof createStore>; export type EditorStore = ReturnType<typeof createStore>;
/** Frischer Anfangszustand; DEFAULT_CONFIG und DEFAULT_CROP werden kopiert, nie geteilt. */ /** Frischer Anfangszustand (der Standardname der Karte in der Sprache der Seite); DEFAULT_CONFIG und DEFAULT_CROP werden kopiert, nie geteilt. */
export function initialEditorState(): EditorState { export function initialEditorState(): EditorState {
return { return {
config: structuredClone(DEFAULT_CONFIG), config: structuredClone(DEFAULT_CONFIG),
@@ -58,7 +59,7 @@ export function initialEditorState(): EditorState {
imageChanged: false, imageChanged: false,
saved: null, saved: null,
savedConfig: null, savedConfig: null,
cardName: 'Meine Karte', cardName: t('Meine Karte'),
saving: false, saving: false,
}; };
} }
+14 -13
View File
@@ -1,16 +1,17 @@
// Linke Spalte „Vorlage“: vier Kacheln mit Live-Miniatur der Karte. // Linke Spalte „Vorlage“: vier Kacheln mit Live-Miniatur der Karte.
import type { CardConfig, TemplateId } from '../card/config'; import type { CardConfig, TemplateId } from '../card/config';
import { drawCard, type CardAssets } from '../card/render'; import { drawCard, type CardAssets } from '../card/render';
import { msg, t } from '../i18n/i18n';
import { el, icon } from './dom'; import { el, icon } from './dom';
export const THUMB_W = 384; export const THUMB_W = 384;
export const THUMB_H = 242; export const THUMB_H = 242;
const TEMPLATES: readonly { id: TemplateId; title: string; subtitle: string }[] = [ const TEMPLATES: readonly { id: TemplateId; title: string; subtitle: string }[] = [
{ id: 'image', title: 'Nur Bild', subtitle: 'Bild unverändert lassen' }, { id: 'image', title: msg('Nur Bild'), subtitle: msg('Bild unverändert lassen') },
{ id: 'minimal', title: 'Minimalistisch', subtitle: 'Dein Bild steht im Mittelpunkt' }, { id: 'minimal', title: msg('Minimalistisch'), subtitle: msg('Dein Bild steht im Mittelpunkt') },
{ id: 'classic', title: 'Klassisch', subtitle: 'Die vertrauten Kartendetails' }, { id: 'classic', title: msg('Klassisch'), subtitle: msg('Die vertrauten Kartendetails') },
{ id: 'signature', title: 'Signature', subtitle: 'Ein bisschen mehr Persönlichkeit' }, { id: 'signature', title: msg('Signature'), subtitle: msg('Ein bisschen mehr Persönlichkeit') },
]; ];
export interface TemplatesPanel { export interface TemplatesPanel {
@@ -22,35 +23,35 @@ export interface TemplatesPanel {
} }
export function createTemplatesPanel(onSelect: (template: TemplateId) => void): TemplatesPanel { export function createTemplatesPanel(onSelect: (template: TemplateId) => void): TemplatesPanel {
const tiles = TEMPLATES.map((t) => { const tiles = TEMPLATES.map((tpl) => {
const canvas = el('canvas', { class: 'template-thumb', width: THUMB_W, height: THUMB_H, 'aria-hidden': 'true' }); const canvas = el('canvas', { class: 'template-thumb', width: THUMB_W, height: THUMB_H, 'aria-hidden': 'true' });
const button = el( const button = el(
'button', 'button',
{ type: 'button', class: 'tile template-tile', 'aria-pressed': 'false' }, { type: 'button', class: 'tile template-tile', 'aria-pressed': 'false' },
canvas, canvas,
el('span', { class: 'template-title' }, t.title), el('span', { class: 'template-title' }, t(tpl.title)),
el('span', { class: 'template-sub' }, t.subtitle), el('span', { class: 'template-sub' }, t(tpl.subtitle)),
icon('check', 'template-check'), icon('check', 'template-check'),
); );
button.addEventListener('click', () => onSelect(t.id)); button.addEventListener('click', () => onSelect(tpl.id));
return { id: t.id, canvas, button }; return { id: tpl.id, canvas, button };
}); });
const heading = el('h2', { id: 'editor-templates-heading', class: 'panel-title' }, 'Vorlage'); const heading = el('h2', { id: 'editor-templates-heading', class: 'panel-title' }, t('Vorlage'));
const list = el( const list = el(
'div', 'div',
{ class: 'template-list', role: 'group', 'aria-labelledby': 'editor-templates-heading' }, { class: 'template-list', role: 'group', 'aria-labelledby': 'editor-templates-heading' },
...tiles.map((t) => t.button), ...tiles.map((tile) => tile.button),
); );
const section = el('section', { class: 'editor-side editor-templates' }, heading, list); const section = el('section', { class: 'editor-side editor-templates' }, heading, list);
return { return {
el: section, el: section,
select(template) { select(template) {
for (const t of tiles) t.button.setAttribute('aria-pressed', String(t.id === template)); for (const tile of tiles) tile.button.setAttribute('aria-pressed', String(tile.id === template));
}, },
draw(config, assets) { draw(config, assets) {
for (const t of tiles) drawCard(t.canvas, { ...config, template: t.id }, assets); for (const tile of tiles) drawCard(tile.canvas, { ...config, template: tile.id }, assets);
}, },
}; };
} }
+11 -8
View File
@@ -1,23 +1,26 @@
// Bild-Upload: Datei prüfen und in ein Bild für den Canvas dekodieren. Kein Netzwerk, kein Appwrite. // Bild-Upload: Datei prüfen und in ein Bild für den Canvas dekodieren. Kein Netzwerk, kein Appwrite.
import type { CardImage } from '../card/render'; import type { CardImage } from '../card/render';
import { msg, t } from '../i18n/i18n';
export const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const; export const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
export const MAX_IMAGE_BYTES = 5_242_880; export const MAX_IMAGE_BYTES = 5_242_880;
/** Obergrenze für Breite × Höhe: ein kleines, stark komprimiertes PNG kann sonst Gigabytes an Pixeln enthalten. */ /** Obergrenze für Breite × Höhe: ein kleines, stark komprimiertes PNG kann sonst Gigabytes an Pixeln enthalten. */
export const MAX_IMAGE_PIXELS = 40_000_000; export const MAX_IMAGE_PIXELS = 40_000_000;
export const TYPE_MESSAGE = 'Bitte ein PNG-, JPG- oder WebP-Bild wählen.'; // Die Texte sind deutsch markiert: `decodeImage` wirft sie unübersetzt (Aufrufer vergleichen `err.message` mit READ_MESSAGE und
export const SIZE_MESSAGE = 'Das Bild ist größer als 5 MiB.'; // PIXELS_MESSAGE), angezeigt werden sie mit `t(err.message)`. `validateImageFile` liefert den Text schon übersetzt.
export const READ_MESSAGE = 'Das Bild konnte nicht gelesen werden.'; export const TYPE_MESSAGE = msg('Bitte ein PNG-, JPG- oder WebP-Bild wählen.');
export const PIXELS_MESSAGE = 'Das Bild ist zu groß (max. 40 Megapixel).'; export const SIZE_MESSAGE = msg('Das Bild ist größer als 5 MiB.');
export const READ_MESSAGE = msg('Das Bild konnte nicht gelesen werden.');
export const PIXELS_MESSAGE = msg('Das Bild ist zu groß (max. 40 Megapixel).');
export type FileCheck = { ok: true } | { ok: false; message: string }; export type FileCheck = { ok: true } | { ok: false; message: string };
/** Prüft Typ und Größe, bevor das Bild dekodiert wird. Der Typ hat Vorrang vor der Größe. */ /** Prüft Typ und Größe, bevor das Bild dekodiert wird. Der Typ hat Vorrang vor der Größe. Die Meldung steht in der Sprache der Seite. */
export function validateImageFile(f: { type: string; size: number }): FileCheck { export function validateImageFile(f: { type: string; size: number }): FileCheck {
if (!(ACCEPTED_TYPES as readonly string[]).includes(f.type)) return { ok: false, message: TYPE_MESSAGE }; if (!(ACCEPTED_TYPES as readonly string[]).includes(f.type)) return { ok: false, message: t(TYPE_MESSAGE) };
if (f.size > MAX_IMAGE_BYTES) return { ok: false, message: SIZE_MESSAGE }; if (f.size > MAX_IMAGE_BYTES) return { ok: false, message: t(SIZE_MESSAGE) };
if (f.size <= 0) return { ok: false, message: READ_MESSAGE }; if (f.size <= 0) return { ok: false, message: t(READ_MESSAGE) };
return { ok: true }; return { ok: true };
} }
+184
View File
@@ -57,6 +57,112 @@ for (const [path, code] of sources()) {
dynamic.push(...found.dynamic.map((call) => `${path}: ${call}`)); dynamic.push(...found.dynamic.map((call) => `${path}: ${call}`));
} }
/**
* Bereiche, die schon vollständig übersetzt sind: Ordner (mit „/“ am Ende) oder einzelne Dateien, relativ zu src/.
* Dort darf kein deutsches Text-Literal mehr außerhalb von t()/msg() stehen; ein weiterer übersetzter Bereich kommt hier dazu.
*/
const TRANSLATED_AREAS = ['account/', 'editor/', 'card/', 'pages/reset-page.ts'];
/**
* Literale, die wie deutscher Text aussehen, aber nie angezeigt werden. Jede Ausnahme nennt ihren Grund; der Test unten
* verlangt, dass sie noch zutrifft, damit die Liste nicht veraltet.
*/
const ALLOWED_LITERALS: ReadonlyArray<{ file: string; text: string; reason: string }> = [
{
file: 'account/auth.ts',
text: 'Unerwartete Antwort',
reason: 'Meldung eines internen Fehlers (Antwort ohne $id/email); angezeigt wird nur der allgemeine Text aus errorMessage()',
},
{
file: 'account/cards.ts',
text: 'Unerwartete Antwort',
reason: 'Meldung der AppwriteException für eine Datei, die kein Bild ist; errorMessage() liest nur Typ und Code, nie diese Meldung',
},
{
file: 'account/cards.ts',
text: 'Row not found',
reason: 'Englische Meldung der selbst gebauten AppwriteException (wie vom Server); errorMessage() liest nur den Typ',
},
{
file: 'account/cards.ts',
text: 'Bildtyp nicht erlaubt: ',
reason: 'Programmierfehler-Meldung (nur Blob-Typen aus dem Editor kommen an); angezeigt wird der allgemeine Text aus errorMessage()',
},
{
file: 'account/with-login.ts',
text: 'Anmeldung abgebrochen',
reason: 'Meldung von LoginCancelled; die Aufrufer erkennen den Fehler mit instanceof und zeigen LOGIN_NEEDED',
},
{
file: 'card/marks.ts',
text: 'Logo konnte nicht geladen werden: ',
reason: 'Entwicklermeldung mit interner ID; Nutzer sehen die eigenen Texte der Seiten (im Editor LOGO_ERROR, „Die Logos konnten nicht geladen werden.“)',
},
];
/** Liegt die Datei in einem Bereich, in dem alle Texte schon übersetzt sein müssen? */
const inTranslatedArea = (path: string): boolean =>
TRANSLATED_AREAS.some((area) => path === area || (area.endsWith('/') && path.startsWith(area)));
/**
* Sieht ein Text-Literal wie deutscher Text aus? Ja bei einem Umlaut oder ß, bei mindestens zwei Wörtern („Bitte melde dich an.“;
* ein Wort hat mindestens zwei Buchstaben, Satzzeichen am Ende zählen nicht mit) und bei einem einzelnen großgeschriebenen
* Wort („Hell“, „Speichert …“, „E-Mail“). Ein Wort mit Bindestrich-Teilen („auth-btn“) und CamelCase („ArrowLeft“) sind Bezeichner.
*/
function looksGerman(text: string): boolean {
if (/[äöüÄÖÜß]/.test(text)) return true;
if (text.split(/\s+/).filter((word) => /^\p{L}{2,}[.,:;!?…]*$/u.test(word)).length >= 2) return true;
const single = text.replace(/[\s.,:;!?…]+$/u, '');
return /^\p{Lu}[\p{L}-]*$/u.test(single) && /\p{Ll}/u.test(single) && !/\p{Ll}\p{Lu}/u.test(single);
}
const EQUALITY = [ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken];
const SEARCH_METHODS = ['includes', 'startsWith', 'endsWith', 'indexOf'];
/** Steht das Literal dort, wo es übersetzt wird oder nie angezeigt wird (Importpfad, Typ, Eigenschaftsname, Vergleichswert …)? */
function isExemptPosition(node: ts.Node): boolean {
const parent = node.parent;
if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent) || ts.isLiteralTypeNode(parent)) return true;
if (ts.isCaseClause(parent) || (ts.isBinaryExpression(parent) && EQUALITY.includes(parent.operatorToken.kind))) return true; // key === 'Escape'
if (ts.isPropertyAssignment(parent)) {
// { 'aria-label': … } (der Name, nicht der Wert) und { class: 'card tile' } (CSS-Klassen)
return parent.name === node || ['class', 'className'].includes(ts.isIdentifier(parent.name) ? parent.name.text : '');
}
if (!ts.isCallExpression(parent)) return false;
const callee = parent.expression;
if (ts.isIdentifier(callee)) return ['t', 'msg'].includes(callee.text) && parent.arguments[0] === node;
if (!ts.isPropertyAccessExpression(callee)) return false;
// console.warn('…') und Suchen in Daten: liste.includes('Files'), text.startsWith('…')
const isConsole = ts.isIdentifier(callee.expression) && callee.expression.text === 'console';
return isConsole || SEARCH_METHODS.includes(callee.name.text);
}
/**
* Die Literale (Zeichenketten und Teile von Template-Literalen), die wie deutscher Text aussehen und nicht durch t()/msg() gehen.
* Gelesen wird der Syntaxbaum: Kommentare und reguläre Ausdrücke zählen nicht.
*/
function findUntranslated(code: string): string[] {
const out: string[] = [];
const file = ts.createSourceFile('quelle.ts', code, ts.ScriptTarget.Latest, true);
const check = (node: ts.Node, text: string): void => {
if (looksGerman(text) && !isExemptPosition(node)) out.push(text);
};
const visit = (node: ts.Node): void => {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) check(node, node.text);
else if (ts.isTemplateExpression(node)) {
for (const text of [node.head.text, ...node.templateSpans.map((span) => span.literal.text)]) check(node, text);
}
ts.forEachChild(node, visit);
};
visit(file);
return out;
}
// Dateien der übersetzten Bereiche → ihre verdächtigen Literale.
const untranslated: Array<{ file: string; text: string }> = sources()
.filter(([path]) => inTranslatedArea(path))
.flatMap(([path, code]) => findUntranslated(code).map((text) => ({ file: path, text })));
describe('Abdeckung der Übersetzungen (t/msg ↔ en.ts)', () => { describe('Abdeckung der Übersetzungen (t/msg ↔ en.ts)', () => {
it('der Scanner findet die Texte der Oberfläche (sonst bestünden die Prüfungen leer)', () => { it('der Scanner findet die Texte der Oberfläche (sonst bestünden die Prüfungen leer)', () => {
expect(used.size).toBeGreaterThan(10); expect(used.size).toBeGreaterThan(10);
@@ -121,3 +227,81 @@ describe('Scanner für t() und msg()', () => {
expect(findTexts(code).texts).toEqual(['Innen', 'Außen', 'Wenn', 'Sonst']); expect(findTexts(code).texts).toEqual(['Innen', 'Außen', 'Wenn', 'Sonst']);
}); });
}); });
describe('Deutsche Texte außerhalb von t()/msg() (übersetzte Bereiche)', () => {
it('die übersetzten Bereiche gibt es und enthalten Quelldateien (sonst bestünde die Prüfung leer)', () => {
const files = sources().filter(([path]) => inTranslatedArea(path));
expect(files.length).toBeGreaterThan(20);
expect(files.some(([path]) => path === 'pages/reset-page.ts')).toBe(true);
});
it('kein deutsches Text-Literal steht noch außerhalb von t()/msg(), außer den begründeten Ausnahmen', () => {
const allowed = (file: string, text: string) => ALLOWED_LITERALS.some((entry) => entry.file === file && entry.text === text);
const open = untranslated.filter(({ file, text }) => !allowed(file, text)).map(({ file, text }) => `${file}: ${text}`);
expect(open).toEqual([]);
});
it('jede Ausnahme hat einen Grund und trifft noch ein Literal', () => {
for (const entry of ALLOWED_LITERALS) expect(entry.reason.trim(), `${entry.file}: ${entry.text}`).not.toBe('');
const stale = ALLOWED_LITERALS.filter((entry) => !untranslated.some(({ file, text }) => file === entry.file && text === entry.text));
expect(stale.map((entry) => `${entry.file}: ${entry.text}`)).toEqual([]);
});
});
describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => {
it('findet Umlaute, mindestens zwei Wörter und einzelne großgeschriebene Wörter, auch mit Satzzeichen und in Template-Teilen', () => {
const code = [
"const a = 'Schließen';",
"const b = 'Bitte melde dich an.';",
"const c = 'Speichert …';",
"const d = 'Anmelden';",
'const e = `Das Passwort muss ${min} bis ${max} Zeichen lang sein.`;',
"const f = 'E-Mail';",
"const g = 'Bearbeitet: ';",
'const h = `Nur Bild`;',
].join('\n');
expect(findUntranslated(code)).toEqual([
'Schließen',
'Bitte melde dich an.',
'Speichert …',
'Anmelden',
'Das Passwort muss ',
' Zeichen lang sein.',
'E-Mail',
'Bearbeitet: ',
'Nur Bild',
]);
});
it('ignoriert Texte in t()/msg(), Kommentare, Importpfade, Typen, Eigenschaftsnamen, console, Vergleiche und Suchen', () => {
const code = [
"import './konto-menü.css';",
"export * from './über-uns';",
"type Ansicht = 'Anmelden' | 'Registrieren';",
"// const kommentar = 'Das ist ein Kommentar';",
"const a = t('Schließen') + msg('Bitte melde dich an.') + t('Speichern', { was: 1 });",
"const b = { 'aria-label': 1, 'Zwei Wörter': 2 };",
"console.warn('Das ist nur eine Konsolenmeldung');",
"if (event.key !== 'Escape' && kind === 'Anmelden') x();",
"switch (key) { case 'Schließen': break; }",
"const c = types.includes('Files') && text.startsWith('Das ist ein Satz') && list.indexOf('Zwei Wörter');",
'const d = /[äöüß]/.test(text);',
].join('\n');
expect(findUntranslated(code)).toEqual([]);
});
it('ignoriert technische Zeichenketten: CSS-Klassen, Kleinbuchstaben-Wörter, Formate, Bezeichner, Zahlen', () => {
const code = [
"el('button', { type: 'button', class: 'auth-btn auth-btn-small', 'aria-describedby': 'reset-hint reset-error' }, '×');",
"el('div', { class: 'card tile' });",
"const a = 'user_invalid_credentials' + 'image/png' + '#account-slot button' + 'ArrowLeft' + 'LoginCancelled' + 'PLATINUM';",
"const b = '100 %' + '1536 × 969 PNG' + '•••• •••• •••• 8888' + 'M548.75 379.89a98.21 98.21 0 0 1 0 98.21';",
'const c = `aircard-${name}.png` + `data:image/svg+xml;charset=utf-8,${svg}`;',
].join('\n');
expect(findUntranslated(code)).toEqual([]);
});
it('übersetzt wird erst im Argument von t()/msg(): eine Bedingung davor zählt als offener Text', () => {
expect(findUntranslated("const a = t(ok ? 'Gespeichert' : 'Speichern');")).toEqual(['Gespeichert', 'Speichern']);
});
});
+104
View File
@@ -120,6 +120,110 @@ export const EN: Readonly<Record<string, string>> = {
'Danke, du hast dieses Logo schon gemeldet.': 'Thanks, you’ve already reported this logo.', 'Danke, du hast dieses Logo schon gemeldet.': 'Thanks, you’ve already reported this logo.',
'Schließen': 'Close', 'Schließen': 'Close',
// Konto-Menü, Anmelden-Dialog und Seite „Passwort neu setzen“ (account/account-menu.ts, account/login-dialog.ts, pages/reset-page.ts).
// „Abbrechen“ und „Schließen“ stehen weiter oben.
'Anmelden': 'Sign in',
'Registrieren': 'Sign up',
'Abmelden': 'Sign out',
'Konto': 'Account',
'Konto-Menü': 'Account menu',
'Passwort vergessen': 'Forgot password',
'Passwort vergessen?': 'Forgot password?',
'E-Mail': 'Email',
'Passwort': 'Password',
'Passwort wiederholen': 'Confirm password',
'Mindestens 8 Zeichen.': 'At least 8 characters.',
'Zurück zur Anmeldung': 'Back to sign in',
'Link senden': 'Send link',
'Die Passwörter stimmen nicht überein.': 'The passwords don’t match.',
'Wir haben dir einen Link geschickt. Er gilt eine Stunde.': 'We’ve sent you a link. It’s valid for one hour.',
'Bitte melde dich an, um fortzufahren.': 'Please sign in to continue.',
'Passwort neu setzen': 'Set new password',
'Neues Passwort': 'New password',
'Neues Passwort wiederholen': 'Confirm new password',
'Passwort speichern': 'Save password',
'Neuen Link anfordern': 'Request a new link',
'Passwort geändert. Du kannst dich jetzt anmelden.': 'Password changed. You can now sign in.',
// Fehlermeldungen zu Konto und Karten (account/errors.ts, account/auth.ts)
'Der Link ist ungültig oder abgelaufen.': 'The link is invalid or has expired.',
'E-Mail oder Passwort ist falsch.': 'Email or password is incorrect.',
'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.': 'This password is too common. Please choose a different one.',
'Das Passwort muss 8 bis 256 Zeichen lang sein.': 'The password must be 8 to 256 characters long.',
'Das Passwort muss {min} bis {max} Zeichen lang sein.': 'The password must be {min} to {max} characters long.',
'Diese E-Mail ist schon registriert. Melde dich an.': 'This email is already registered. Please sign in.',
'Zu dieser E-Mail gibt es kein Konto.': 'There is no account for this email.',
'Bitte gib eine gültige E-Mail-Adresse ein.': 'Please enter a valid email address.',
'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.':
'This card no longer exists or doesn’t belong to your account.',
'Das Bild dieser Karte fehlt.': 'This card’s image is missing.',
'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.':
'This site isn’t enabled on the server. Please contact the site owner.',
'Du bist schon angemeldet. Bitte lade die Seite neu.': 'You’re already signed in. Please reload the page.',
'Zurzeit sind keine neuen Konten möglich.': 'New accounts can’t be created right now.',
'Zu viele Versuche – bitte kurz warten.': 'Too many attempts – please wait a moment.',
'Keine Verbindung zum Server.': 'Can’t reach the server.',
'Meine Karte': 'My card',
// Editor, Spalte „Vorlage“ (editor/templates-panel.ts)
'Vorlage': 'Template',
'Nur Bild': 'Image only',
'Bild unverändert lassen': 'Keep the image as is',
'Minimalistisch': 'Minimalist',
'Dein Bild steht im Mittelpunkt': 'Your image takes center stage',
'Klassisch': 'Classic',
'Die vertrauten Kartendetails': 'The familiar card details',
'Signature': 'Signature',
'Ein bisschen mehr Persönlichkeit': 'A little more personality',
// Editor, Spalte „Individuell gestalten“ (editor/options-panel.ts, card/marks.ts). „Hell“, „Dunkel“ und „Kein Logo“ stehen weiter oben.
'Individuell gestalten': 'Customize',
'Details': 'Details',
'Logo': 'Logo',
'AirCard-IOS · Symbol': 'AirCard-IOS · Symbol',
'AirCard-IOS · Text': 'AirCard-IOS · Text',
// Editor, Vorschau und Knöpfe (editor/editor-page.ts, editor/preview.ts)
'Kartenmotiv gestalten': 'Create a card design',
'Kartenvorschau': 'Card preview',
'Kartenvorschau. Mit der Maus ziehen oder die Pfeiltasten benutzen, um das Bild zu verschieben.':
'Card preview. Drag with the mouse or use the arrow keys to move the image.',
'Bilddatei wählen': 'Choose image file',
'Bild auswählen': 'Choose image',
'Ziehe deine Idee hierher': 'Drag your idea here',
'PNG / JPG / WebP · Max. 5 MiB': 'PNG / JPG / WebP · Max. 5 MiB',
'Zoom': 'Zoom',
'{percent} %': '{percent}%',
'Bild ersetzen': 'Replace image',
'Herunterladen': 'Download',
'Speichern': 'Save',
'Speichert …': 'Saving …',
'Gespeichert': 'Saved',
'Erst ein Bild wählen': 'Choose an image first',
'Neue Karte': 'New card',
'Bearbeitet: {name}': 'Editing: {name}',
'Dein Bild bleibt auf deinem Gerät, bis du speicherst.': 'Your image stays on your device until you save.',
// Editor, Meldungen und Rückfragen (editor/editor-page.ts, editor/upload.ts, editor/download.ts, editor/save.ts, card/render.ts)
'Bitte zuerst ein Bild wählen.': 'Please choose an image first.',
'Bitte ein PNG-, JPG- oder WebP-Bild wählen.': 'Please choose a PNG, JPG or WebP image.',
'Das Bild ist größer als 5 MiB.': 'The image is larger than 5 MiB.',
'Das Bild konnte nicht gelesen werden.': 'The image couldn’t be read.',
'Das PNG ist größer als 4 MB – Wallet nimmt eventuell nur kleinere Bilder.':
'The PNG is larger than 4 MB – Wallet may only accept smaller images.',
'Das PNG konnte nicht erzeugt werden.': 'The PNG couldn’t be created.',
'Das Bild konnte nicht erzeugt werden.': 'The image couldn’t be created.',
'Die Vorschau konnte nicht gezeichnet werden.': 'The preview couldn’t be drawn.',
'Canvas wird in diesem Browser nicht unterstützt.': 'This browser doesn’t support canvas.',
'Speichern fehlgeschlagen – {reason}': 'Saving failed – {reason}',
'Karte wird geladen …': 'Loading card …',
'Vorlage wird geladen …': 'Loading template …',
'Diese Vorlage ist nicht (mehr) verfügbar.': 'This template is no longer available.',
'Melde dich an, um diese Karte zu öffnen.': 'Sign in to open this card.',
'Ungespeicherte Änderungen verwerfen?': 'Discard unsaved changes?',
'Alles, was du seit dem letzten Speichern geändert hast, geht verloren.': 'Everything you’ve changed since the last save will be lost.',
'Verwerfen': 'Discard',
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich. // API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
'Bitte melde dich an.': 'Please sign in.', 'Bitte melde dich an.': 'Please sign in.',
'Nicht gefunden.': 'Not found.', 'Nicht gefunden.': 'Not found.',
+360
View File
@@ -0,0 +1,360 @@
// Stichproben der englischen Oberfläche von Konto, Editor und Passwort-Seite (Task 10): Die Texte erscheinen bei Englisch, und was an
// Texten hängt (Abgleich der Passwörter, übersetzte Fehler, Beschriftung des wiederverwendeten Login-Dialogs) arbeitet in beiden
// Sprachen gleich. Wer Module mit `vi.resetModules()` frisch lädt, stellt die Sprache an der frischen Kopie von i18n.ts ein.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EN } from './en';
const mocks = vi.hoisted(() => ({
acct: {
get: vi.fn(),
create: vi.fn(),
createEmailPasswordSession: vi.fn(),
deleteSession: vi.fn(),
createRecovery: vi.fn(),
updateRecovery: vi.fn(),
},
getCard: vi.fn(),
saveCard: vi.fn(),
getCurrentUser: vi.fn(),
requireLogin: vi.fn(),
decodeImage: vi.fn(),
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
getGalleryCard: vi.fn(),
}));
vi.mock('../account/appwrite', () => ({ account: mocks.acct, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
beforeEach(() => {
vi.resetAllMocks();
});
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
const submit = (form: HTMLFormElement | null) => form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
/** Frische Kopie von i18n.ts in der gewünschten Sprache (die Module danach laden dieselbe Kopie). */
async function freshI18n(lang: 'de' | 'en') {
vi.resetModules();
const i18n = await import('./i18n');
i18n.setLang(lang, { persist: false });
return i18n;
}
/** Alle sichtbaren Texte (Textknoten) und Textattribute unter `root`. */
function texts(root: ParentNode): string[] {
const out: string[] = [];
const walker = document.createTreeWalker(root as Node, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) if (node.textContent?.trim()) out.push(node.textContent.trim());
for (const el of root.querySelectorAll('*')) {
for (const attr of ['aria-label', 'title', 'placeholder', 'alt']) if (el.getAttribute(attr)) out.push(el.getAttribute(attr) as string);
}
return out;
}
/** Deutsche Wörterbuchtexte, die auf einer englischen Seite stehen: also Texte, die an `t()` vorbeigegangen sind. */
const germanLeft = (root: ParentNode) => texts(root).filter((text) => Object.hasOwn(EN, text) && EN[text] !== text);
describe('Fehlertexte und Standardname auf Englisch', () => {
it('errorMessage und validatePassword übersetzen beim Zurückgeben', async () => {
await freshI18n('en');
const { errorMessage } = await import('../account/errors');
const { validatePassword } = await import('../account/auth');
expect(errorMessage(fail(401, 'user_invalid_credentials'), 'login')).toBe('Email or password is incorrect.');
expect(errorMessage(fail(404, 'user_not_found'), 'reset')).toBe('The link is invalid or has expired.');
expect(errorMessage(fail(429, ''), 'login')).toBe('Too many attempts – please wait a moment.');
expect(errorMessage(new TypeError('x'), 'load')).toBe('Can’t reach the server.');
expect(errorMessage(new Error('x'), 'load')).toBe('Something went wrong. Please try again.');
expect(validatePassword('kurz')).toBe('The password must be 8 to 256 characters long.');
});
it('der Standardname einer neuen Karte folgt der Sprache, die Konstante bleibt der deutsche Schlüssel', async () => {
const i18n = await freshI18n('en');
const { DEFAULT_CARD_NAME, normalizeName } = await import('../account/cards');
const { initialEditorState } = await import('../editor/state');
expect(normalizeName(' ')).toBe('My card');
expect(initialEditorState().cardName).toBe('My card');
expect(DEFAULT_CARD_NAME).toBe('Meine Karte');
i18n.setLang('de', { persist: false });
expect(normalizeName(' ')).toBe('Meine Karte');
});
});
describe('Konto-Menü und Login-Dialog auf Englisch', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="account-slot"></div>';
});
it('das Menü zeigt „Sign in“ bzw. Konto-Menü mit englischen Texten und Fehlern', async () => {
await freshI18n('en');
const { mountAccountMenu } = await import('../account/account-menu');
const auth = await import('../account/auth');
const slot = document.getElementById('account-slot')!;
const menu = mountAccountMenu(slot);
menu.ready();
expect(texts(slot)).toEqual(['Sign in']);
mocks.acct.get.mockResolvedValue({ $id: 'u1', email: 'kenso@example.com' });
await auth.getCurrentUser();
expect(texts(slot)).toEqual(expect.arrayContaining(['Account menu', 'Account', 'My cards', 'Sign out']));
expect(germanLeft(slot)).toEqual([]);
mocks.acct.deleteSession.mockRejectedValue(new TypeError('Failed to fetch'));
[...slot.querySelectorAll('button')].find((b) => b.textContent === 'Sign out')!.click();
await vi.waitFor(() => expect(slot.querySelector('.account-error')!.textContent).toBe('Can’t reach the server.'));
menu.dispose();
});
it('alle drei Ansichten, der Abgleich der Passwörter (Fokus am Feld) und die Beschriftung nach dem Sprachwechsel', async () => {
const i18n = await freshI18n('de');
const { requireLogin } = await import('../account/login-dialog');
const dialog = () => document.querySelector<HTMLDialogElement>('dialog.auth-dialog')!;
const button = (text: string) => [...dialog().querySelectorAll('button')].find((b) => b.textContent === text)!;
const input = (name: string) => dialog().querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
const alert = () => dialog().querySelector('[role="alert"]')!.textContent;
// Der Dialog wird einmal gebaut: Deutsch öffnen und schließen, dann auf Englisch wieder öffnen.
const first = requireLogin('login');
expect(dialog().querySelector('.auth-close')!.getAttribute('aria-label')).toBe('Schließen');
dialog().querySelector<HTMLButtonElement>('.auth-close')!.click();
await first;
i18n.setLang('en', { persist: false });
void requireLogin('login');
expect(dialog().querySelector('h2')!.textContent).toBe('Sign in');
expect(dialog().querySelector('.auth-close')!.getAttribute('aria-label')).toBe('Close');
expect(dialog().querySelector('[role="tablist"]')!.getAttribute('aria-label')).toBe('Account');
expect(texts(dialog())).toEqual(expect.arrayContaining(['Sign in', 'Sign up', 'Email', 'Password', 'Forgot password?']));
expect(germanLeft(dialog())).toEqual([]);
button('Sign up').click();
expect(dialog().querySelector('h2')!.textContent).toBe('Sign up');
expect(texts(dialog())).toEqual(expect.arrayContaining(['At least 8 characters.', 'Confirm password']));
input('email').value = 'a@b.de';
input('password').value = 'passwort123';
input('password-repeat').value = 'passwort124';
submit(dialog().querySelector('form'));
expect(alert()).toBe('The passwords don’t match.');
expect(document.activeElement).toBe(input('password-repeat'));
input('password').value = 'kurz';
input('password-repeat').value = 'kurz';
submit(dialog().querySelector('form'));
expect(alert()).toBe('The password must be 8 to 256 characters long.');
expect(document.activeElement).toBe(input('password'));
expect(germanLeft(dialog())).toEqual([]);
// vorhandene E-Mail: Meldung mit dem Link „Sign in“
mocks.acct.create.mockRejectedValue(fail(409, 'user_already_exists'));
input('password').value = 'passwort123';
input('password-repeat').value = 'passwort123';
submit(dialog().querySelector('form'));
await vi.waitFor(() => expect(alert()).toBe('This email is already registered. Please sign in. Sign in'));
button('Sign in').click();
button('Forgot password?').click();
expect(dialog().querySelector('h2')!.textContent).toBe('Forgot password');
expect(texts(dialog())).toEqual(expect.arrayContaining(['Send link', 'Back to sign in']));
mocks.acct.createRecovery.mockResolvedValue({});
submit(dialog().querySelector('form'));
await vi.waitFor(() => expect(dialog().querySelector('[role="status"]')!.textContent).toBe('We’ve sent you a link. It’s valid for one hour.'));
expect(germanLeft(dialog())).toEqual([]);
});
});
describe('Seite „Passwort neu setzen“ auf Englisch', () => {
beforeEach(() => {
document.body.innerHTML = '<main id="app-root"></main>';
});
it('ohne gültigen Link', async () => {
await freshI18n('en');
history.replaceState(null, '', '/passwort-neu');
const { mountResetPage } = await import('../pages/reset-page');
const root = document.getElementById('app-root')!;
mountResetPage(root);
expect(root.querySelector('h1')!.textContent).toBe('Set new password');
expect(root.querySelector('[role="alert"]')!.textContent).toBe('The link is invalid or has expired.');
expect(texts(root)).toContain('Request a new link');
expect(germanLeft(root)).toEqual([]);
});
it('mit Link: Formular, Abgleich der Passwörter (Fokus am Feld) und Erfolg', async () => {
await freshI18n('en');
history.replaceState(null, '', '/passwort-neu?userId=u1&secret=s3cret');
const { mountResetPage } = await import('../pages/reset-page');
const root = document.getElementById('app-root')!;
mountResetPage(root);
expect(texts(root)).toEqual(expect.arrayContaining(['New password', 'Confirm new password', 'At least 8 characters.', 'Save password']));
expect(germanLeft(root)).toEqual([]);
const input = (name: string) => root.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
input('password').value = 'passwort123';
input('password-repeat').value = 'passwort124';
submit(root.querySelector('form'));
expect(root.querySelector('[role="alert"]')!.textContent).toBe('The passwords don’t match.');
expect(document.activeElement).toBe(input('password-repeat'));
input('password').value = 'kurz';
input('password-repeat').value = 'kurz';
submit(root.querySelector('form'));
expect(root.querySelector('[role="alert"]')!.textContent).toBe('The password must be 8 to 256 characters long.');
expect(document.activeElement).toBe(input('password'));
mocks.acct.updateRecovery.mockResolvedValue({});
input('password').value = 'passwort123';
input('password-repeat').value = 'passwort123';
submit(root.querySelector('form'));
await vi.waitFor(() => expect(root.querySelector('[role="status"]')!.textContent).toBe('Password changed. You can now sign in.'));
expect(germanLeft(root)).toEqual([]);
history.replaceState(null, '', '/');
});
});
describe('Editor auf Englisch', () => {
let root: HTMLElement;
/** Lädt den Editor frisch in der Sprache `en`; Karten-Dienst, Anmeldung, Renderer und Logo-Auswahl sind ersetzt. */
async function mountEnglishEditor(url = '/gestalten') {
vi.resetModules();
vi.doMock('../account/auth', () => ({
getCurrentUser: mocks.getCurrentUser,
currentUserId: () => 'u1',
onAuthChange: () => () => {},
onLogout: () => () => {},
}));
vi.doMock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
vi.doMock('../account/cards', () => ({ cardsService: () => ({ get: mocks.getCard, save: mocks.saveCard, loadImage: vi.fn() }) }));
vi.doMock('../card/render', () => ({ drawCard: vi.fn(), loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
vi.doMock('../community/gallery', () => ({ getGalleryCard: mocks.getGalleryCard, loadGalleryImage: vi.fn() }));
vi.doMock('../logos/logo-picker', () => ({
createLogoPicker: () => ({ el: document.createElement('div'), select: vi.fn(), selected: vi.fn(), dispose: vi.fn() }),
}));
vi.doMock('../editor/download', async (importOriginal) => ({
...(await importOriginal<typeof import('../editor/download')>()),
saveBlobAs: vi.fn(),
}));
vi.doMock('../editor/upload', async (importOriginal) => ({
...(await importOriginal<typeof import('../editor/upload')>()),
decodeImage: mocks.decodeImage,
}));
(await import('./i18n')).setLang('en', { persist: false });
history.replaceState(null, '', url);
document.body.innerHTML = '<main id="app-root"></main>';
root = document.getElementById('app-root')!;
const { mountEditor } = await import('../editor/editor-page');
const { createStore, initialEditorState } = await import('../editor/state');
const store = createStore(initialEditorState());
return { store, unmount: mountEditor(root, store) };
}
beforeEach(() => {
mocks.getCurrentUser.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
mocks.requireLogin.mockResolvedValue(true);
mocks.loadCardAssets.mockResolvedValue({ image: null, logo: null, brand: {}, logoMissing: false });
vi.stubGlobal('requestAnimationFrame', () => 0);
vi.stubGlobal('cancelAnimationFrame', () => {});
});
afterEach(() => {
for (const id of ['auth', 'login-dialog', 'cards']) vi.doUnmock(`../account/${id}`);
vi.doUnmock('../card/render');
vi.doUnmock('../community/gallery');
vi.doUnmock('../logos/logo-picker');
vi.doUnmock('../editor/download');
vi.doUnmock('../editor/upload');
vi.unstubAllGlobals();
history.replaceState(null, '', '/');
});
const errorText = () => root.querySelector('.msg-error')!.textContent;
const button = (text: string) => [...root.querySelectorAll('button')].find((b) => b.textContent === text)!;
const pick = (file: File) => {
const input = root.querySelector<HTMLInputElement>('input[type="file"]')!;
Object.defineProperty(input, 'files', { configurable: true, value: [file] });
input.dispatchEvent(new Event('change'));
};
it('die Seite ist englisch; Dateifehler, Speichern, Download und Rückfrage ebenso', async () => {
const { store, unmount } = await mountEnglishEditor();
expect(store.get().cardName).toBe('My card');
expect(germanLeft(root)).toEqual([]);
expect(texts(root)).toEqual(
expect.arrayContaining([
'Template',
'Image only',
'Card preview',
'Choose image',
'Drag your idea here',
'Save',
'Customize',
'Light',
'Dark',
'No logo',
'Logo – AirCard-IOS · Symbol',
]),
);
// Typ und Größe prüft validateImageFile (übersetzt beim Zurückgeben); die Fehler von decodeImage tragen den deutschen Schlüssel
pick(new File(['x'], 'a.gif', { type: 'image/gif' }));
expect(errorText()).toBe('Please choose a PNG, JPG or WebP image.');
const { PIXELS_MESSAGE, READ_MESSAGE } = await import('../editor/upload');
mocks.decodeImage.mockRejectedValueOnce(new Error(PIXELS_MESSAGE));
pick(new File(['x'], 'a.png', { type: 'image/png' }));
await vi.waitFor(() => expect(errorText()).toBe('The image is too large (max. 40 megapixels).'));
mocks.decodeImage.mockRejectedValueOnce(new Error(READ_MESSAGE));
pick(new File(['x'], 'a.png', { type: 'image/png' }));
await vi.waitFor(() => expect(errorText()).toBe('The image couldn’t be read.'));
// Mit Bild: Zoom in der englischen Schreibweise, Speichern scheitert, dann klappt es
const image = { source: {} as CanvasImageSource, w: 3000, h: 2000 };
store.set({ image, imageBlob: new Blob(['png'], { type: 'image/png' }), imageChanged: true });
expect(root.querySelector('.zoom-value')!.textContent).toBe('100%');
mocks.saveCard.mockRejectedValueOnce(new TypeError('Failed to fetch'));
button('Save').click();
await vi.waitFor(() => expect(errorText()).toBe('Saving failed – Can’t reach the server.'));
mocks.saveCard.mockResolvedValueOnce({ id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x', config: store.get().config });
button('Save').click();
await vi.waitFor(() => expect(button('Saved')).toBeDefined());
expect(root.querySelector('.bound-label')!.textContent).toBe('Editing: Urlaub');
// Download: Logos nicht ladbar, dann ein PNG über der Wallet-Grenze, dann ein Export-Fehler mit deutscher Meldung aus card/render.ts
mocks.loadCardAssets.mockRejectedValueOnce(new Error('Failed to fetch'));
button('Download').click();
await vi.waitFor(() => expect(errorText()).toBe('The logos couldn’t be loaded.'));
mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: {}, logoMissing: false });
mocks.exportCard.mockResolvedValueOnce(new Blob([new Uint8Array(4_000_100)], { type: 'image/png' }));
button('Download').click();
const warning = 'The PNG is larger than 4 MB – Wallet may only accept smaller images.';
await vi.waitFor(() => expect(root.querySelector('.msg-warning')!.textContent).toBe(warning));
mocks.exportCard.mockRejectedValueOnce(new Error('Das Bild konnte nicht erzeugt werden.'));
button('Download').click();
await vi.waitFor(() => expect(errorText()).toBe('The image couldn’t be created.'));
// „Neue Karte“ mit ungespeicherten Änderungen fragt nach
store.set({ imageChanged: true });
button('New card').click();
await vi.waitFor(() => expect(document.querySelector('dialog.confirm-dialog')).not.toBeNull());
const dialog = document.querySelector('dialog.confirm-dialog')!;
expect(texts(dialog)).toEqual(['Discard unsaved changes?', 'Everything you’ve changed since the last save will be lost.', 'Cancel', 'Discard']);
unmount();
dialog.remove();
});
it('Karte oder Vorlage öffnen: Ladehinweis, abgebrochene Anmeldung, fehlende Karte, fehlende Vorlage', async () => {
let known!: (user: null) => void;
mocks.getCurrentUser.mockReturnValue(new Promise<null>((resolve) => (known = resolve)));
mocks.requireLogin.mockResolvedValue(false);
let editor = await mountEnglishEditor('/gestalten?karte=abc');
expect(root.querySelector('.msg-notice')!.textContent).toBe('Loading card …');
known(null);
await vi.waitFor(() => expect(root.querySelector('.msg-warning')!.textContent).toBe('Sign in to open this card.'));
editor.unmount();
mocks.getCurrentUser.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
mocks.getCard.mockRejectedValue(fail(404, 'row_not_found'));
editor = await mountEnglishEditor('/gestalten?karte=abc');
await vi.waitFor(() => expect(errorText()).toBe('This card no longer exists or doesn’t belong to your account.'));
editor.unmount();
let answer!: (card: null) => void;
mocks.getGalleryCard.mockReturnValue(new Promise<null>((resolve) => (answer = resolve)));
editor = await mountEnglishEditor('/gestalten?vorlage=abc');
expect(root.querySelector('.msg-notice')!.textContent).toBe('Loading template …');
answer(null);
await vi.waitFor(() => expect(errorText()).toBe('This template is no longer available.'));
editor.unmount();
});
});
+4 -3
View File
@@ -35,7 +35,8 @@ const EN_FOOTER = 'Unofficial community site for the AirCard-iOS app';
const slot = () => document.getElementById('account-slot') as HTMLElement; const slot = () => document.getElementById('account-slot') as HTMLElement;
const langButton = () => document.querySelector<HTMLButtonElement>('button.lang-btn')!; const langButton = () => document.querySelector<HTMLButtonElement>('button.lang-btn')!;
const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent); const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent);
const signIn = () => [...slot().querySelectorAll('button')].find((b) => b.textContent === 'Anmelden'); // Der Anmelden-Knopf des Konto-Menüs in der Sprache der Seite („Anmelden“ auf Deutsch, „Sign in“ nach dem Wechsel auf Englisch).
const signIn = (label = 'Anmelden') => [...slot().querySelectorAll('button')].find((b) => b.textContent === label);
/** Startet main.ts frisch (es läuft beim Import) auf der Adresse `path`; `storedLang` ist die gespeicherte Sprachwahl (null: keine). */ /** 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') { async function start(path = '/', storedLang: string | null = 'de') {
@@ -139,7 +140,7 @@ describe('Sprachwechsel', () => {
langButton().click(); langButton().click();
expect(signIn()).toBeDefined(); // kein leerer Platz bis zur nächsten Änderung expect(signIn('Sign in')).toBeDefined(); // kein leerer Platz bis zur nächsten Änderung, jetzt auf Englisch
expect(mocks.authListeners.size).toBe(1); // das alte Menü hat sich abgemeldet expect(mocks.authListeners.size).toBe(1); // das alte Menü hat sich abgemeldet
expect(mocks.getCurrentUser).toHaveBeenCalledTimes(1); // der Status wird nicht noch einmal geholt expect(mocks.getCurrentUser).toHaveBeenCalledTimes(1); // der Status wird nicht noch einmal geholt
}); });
@@ -155,7 +156,7 @@ describe('Sprachwechsel', () => {
expect(mocks.authListeners.size).toBe(1); expect(mocks.authListeners.size).toBe(1);
answer(null); answer(null);
await vi.waitFor(() => expect(signIn()).toBeDefined()); await vi.waitFor(() => expect(signIn('Sign in')).toBeDefined());
}); });
it('der Fokus bleibt auf dem Sprachknopf, auch wenn die Kopfzeile neu gebaut wird', async () => { it('der Fokus bleibt auf dem Sprachknopf, auch wenn die Kopfzeile neu gebaut wird', async () => {
+15 -12
View File
@@ -4,9 +4,10 @@ import { completePasswordReset, validatePassword } from '../account/auth';
import { errorMessage, isInvalidLinkError } from '../account/errors'; import { errorMessage, isInvalidLinkError } from '../account/errors';
import { requireLogin } from '../account/login-dialog'; import { requireLogin } from '../account/login-dialog';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
import { msg, t } from '../i18n/i18n';
import { navigate } from './router'; import { navigate } from './router';
const MISMATCH = 'Die Passwörter stimmen nicht überein.'; const MISMATCH = msg('Die Passwörter stimmen nicht überein.');
export function mountResetPage(root: HTMLElement): () => void { export function mountResetPage(root: HTMLElement): () => void {
let disposed = false; let disposed = false;
@@ -14,15 +15,15 @@ export function mountResetPage(root: HTMLElement): () => void {
const userId = params.get('userId') ?? ''; const userId = params.get('userId') ?? '';
const secret = params.get('secret') ?? ''; const secret = params.get('secret') ?? '';
const heading = el('h1', {}, 'Passwort neu setzen'); const heading = el('h1', {}, t('Passwort neu setzen'));
const page = el('section', { class: 'reset-page' }); const page = el('section', { class: 'reset-page' });
root.replaceChildren(heading, page); root.replaceChildren(heading, page);
function showInvalidLink(): void { function showInvalidLink(): void {
const again = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Neuen Link anfordern'); const again = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Neuen Link anfordern'));
again.addEventListener('click', () => void requireLogin('forgot')); again.addEventListener('click', () => void requireLogin('forgot'));
page.replaceChildren( page.replaceChildren(
el('p', { class: 'auth-msg auth-msg-error', role: 'alert' }, 'Der Link ist ungültig oder abgelaufen.'), el('p', { class: 'auth-msg auth-msg-error', role: 'alert' }, t('Der Link ist ungültig oder abgelaufen.')),
el('p', { class: 'reset-actions' }, again), el('p', { class: 'reset-actions' }, again),
); );
} }
@@ -30,14 +31,14 @@ export function mountResetPage(root: HTMLElement): () => void {
function showDone(): void { function showDone(): void {
// Das Geheimnis ist verbraucht; es soll nicht in der Adresszeile und im Verlauf stehen bleiben. // Das Geheimnis ist verbraucht; es soll nicht in der Adresszeile und im Verlauf stehen bleiben.
history.replaceState(null, '', location.pathname); history.replaceState(null, '', location.pathname);
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Anmelden'); const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Anmelden'));
signIn.addEventListener('click', () => { signIn.addEventListener('click', () => {
void requireLogin('login').then((ok) => { void requireLogin('login').then((ok) => {
if (ok && !disposed) navigate('/'); if (ok && !disposed) navigate('/');
}); });
}); });
page.replaceChildren( page.replaceChildren(
el('p', { class: 'auth-msg auth-msg-ok', role: 'status' }, 'Passwort geändert. Du kannst dich jetzt anmelden.'), el('p', { class: 'auth-msg auth-msg-ok', role: 'status' }, t('Passwort geändert. Du kannst dich jetzt anmelden.')),
el('p', { class: 'reset-actions' }, signIn), el('p', { class: 'reset-actions' }, signIn),
); );
} }
@@ -59,15 +60,15 @@ export function mountResetPage(root: HTMLElement): () => void {
required: true, required: true,
'aria-describedby': 'reset-error', 'aria-describedby': 'reset-error',
}); });
const hint = el('p', { class: 'auth-hint', id: 'reset-hint' }, 'Mindestens 8 Zeichen.'); const hint = el('p', { class: 'auth-hint', id: 'reset-hint' }, t('Mindestens 8 Zeichen.'));
const error = el('p', { class: 'auth-msg auth-msg-error', id: 'reset-error', role: 'alert' }); const error = el('p', { class: 'auth-msg auth-msg-error', id: 'reset-error', role: 'alert' });
const submit = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Passwort speichern'); const submit = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Passwort speichern'));
const form = el( const form = el(
'form', 'form',
{ class: 'auth-form' }, { class: 'auth-form' },
el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, 'Neues Passwort'), password), el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, t('Neues Passwort')), password),
hint, hint,
el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, 'Neues Passwort wiederholen'), repeat), el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, t('Neues Passwort wiederholen')), repeat),
error, error,
submit, submit,
); );
@@ -93,9 +94,11 @@ export function mountResetPage(root: HTMLElement): () => void {
error.textContent = ''; error.textContent = '';
password.removeAttribute('aria-invalid'); password.removeAttribute('aria-invalid');
repeat.removeAttribute('aria-invalid'); repeat.removeAttribute('aria-invalid');
const problem = password.value !== repeat.value ? MISMATCH : validatePassword(password.value); // Der Fehler liegt am Feld, das ihn verursacht; verglichen wird nie mit dem (übersetzten) Text.
const mismatch = password.value !== repeat.value;
const problem = mismatch ? t(MISMATCH) : validatePassword(password.value);
if (problem) { if (problem) {
fail(problem, problem === MISMATCH ? repeat : password); fail(problem, mismatch ? repeat : password);
return; return;
} }
setBusy(true); setBusy(true);
+6
View File
@@ -2,4 +2,10 @@
import { beforeEach } from 'vitest'; import { beforeEach } from 'vitest';
import { setLang } from './i18n/i18n'; import { setLang } from './i18n/i18n';
// Wer Module mit `vi.resetModules()` frisch lädt, bekommt eine neue Kopie von i18n.ts: Sie kennt das `setLang` unten nicht und
// startet mit der Browsersprache (happy-dom meldet en-US). Damit auch diese Tests deutsch beginnen, meldet der Browser hier Deutsch;
// Tests zur Startsprache ersetzen `navigator` selbst (vi.stubGlobal).
Object.defineProperty(navigator, 'languages', { configurable: true, value: ['de-DE'] });
Object.defineProperty(navigator, 'language', { configurable: true, value: 'de-DE' });
beforeEach(() => setLang('de', { persist: false })); beforeEach(() => setLang('de', { persist: false }));