diff --git a/web/src/account/account-menu.ts b/web/src/account/account-menu.ts index a409825..da542c9 100644 --- a/web/src/account/account-menu.ts +++ b/web/src/account/account-menu.ts @@ -1,7 +1,9 @@ // Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale -// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“). +// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel +// baut main.ts das Menü neu auf. import './account.css'; import { el } from '../editor/dom'; +import { t } from '../i18n/i18n'; import { currentUser, logout, onAuthChange } from './auth'; import { errorMessage } from './errors'; import { requireLogin } from './login-dialog'; @@ -26,7 +28,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu { } const user = currentUser(); if (!user) { - const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Anmelden'); + const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, t('Anmelden')); signIn.addEventListener('click', () => void requireLogin('login')); slot.replaceChildren(el('div', { class: 'account-menu' }, signIn)); return; @@ -38,18 +40,18 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu { { type: 'button', class: 'account-avatar', - 'aria-label': 'Konto-Menü', + 'aria-label': t('Konto-Menü'), 'aria-expanded': 'false', 'aria-controls': 'account-dropdown', }, initial, ); const email = el('span', { class: 'account-email', title: user.email }, user.email); - const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, 'Meine Karten'); - const signOut = el('button', { type: 'button', class: 'account-item' }, 'Abmelden'); + const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, t('Meine Karten')); + const signOut = el('button', { type: 'button', class: 'account-item' }, t('Abmelden')); const error = el('span', { class: 'account-error', role: 'alert' }); const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, email, myCards, signOut, error); - const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': 'Konto' }, avatar, dropdown); + const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': t('Konto') }, avatar, dropdown); let isOpen = false; function setOpen(open: boolean): void { diff --git a/web/src/account/auth.ts b/web/src/account/auth.ts index 4af7672..528e5d9 100644 --- a/web/src/account/auth.ts +++ b/web/src/account/auth.ts @@ -1,6 +1,7 @@ // Anmelden, Registrieren, Abmelden, Passwort zurücksetzen. Hält den angemeldeten Nutzer im Speicher // und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite). import { ID } from 'appwrite'; +import { t } from '../i18n/i18n'; import { account } from './appwrite'; import { errorType, isUnauthorized } from './errors'; @@ -132,9 +133,9 @@ export function onLogout(fn: () => void): () => void { }; } -/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer. */ +/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer (in der Sprache der Seite). */ export function validatePassword(pw: string): string | null { return pw.length >= PASSWORD_MIN && pw.length <= PASSWORD_MAX ? null - : `Das Passwort muss ${PASSWORD_MIN} bis ${PASSWORD_MAX} Zeichen lang sein.`; + : t('Das Passwort muss {min} bis {max} Zeichen lang sein.', { min: PASSWORD_MIN, max: PASSWORD_MAX }); } diff --git a/web/src/account/cards.ts b/web/src/account/cards.ts index 89b24a9..b50f63f 100644 --- a/web/src/account/cards.ts +++ b/web/src/account/cards.ts @@ -3,6 +3,7 @@ import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite'; import { ID_PATTERN, parseConfig, serializeConfig, type CardConfig } from '../card/config'; import type { SavedCardRef } from '../editor/state'; +import { msg, t } from '../i18n/i18n'; import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite'; import { currentUserId } from './auth'; import { errorType } from './errors'; @@ -44,16 +45,16 @@ export interface SaveInput { previous: SavedCardRef | null; } -export const DEFAULT_CARD_NAME = 'Meine Karte'; +export const DEFAULT_CARD_NAME = msg('Meine Karte'); const NAME_MAX = 80; /** Zeilen- und Datei-IDs von Appwrite (definiert in card/config.ts, dort braucht sie auch die Logo-ID der Konfiguration). */ export { ID_PATTERN }; -/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */ +/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“ (in der Sprache der Seite). */ export function normalizeName(name: string): string { const collapsed = name.replace(/\s+/g, ' ').trim(); const cut = Array.from(collapsed).slice(0, NAME_MAX).join('').trim(); - return cut || DEFAULT_CARD_NAME; + return cut || t(DEFAULT_CARD_NAME); } /** Nur der Besitzer darf die Zeile bzw. Datei lesen, ändern und löschen. */ diff --git a/web/src/account/errors.ts b/web/src/account/errors.ts index b7acfab..99307e9 100644 --- a/web/src/account/errors.ts +++ b/web/src/account/errors.ts @@ -1,11 +1,13 @@ -// Deutsche Fehlermeldungen für Appwrite-Fehler. Rein und ohne Appwrite-Import, damit gut testbar. +// Fehlermeldungen für Appwrite-Fehler in der Sprache der Seite: Die deutschen Texte sind die Schlüssel für t() (i18n/en.ts). +// Rein und ohne Appwrite-Import, damit gut testbar. // Ein AppwriteException trägt `code` (HTTP-Status), `type` (z. B. user_invalid_credentials) und `message`. +import { msg, t } from '../i18n/i18n'; export type ErrorContext = 'login' | 'register' | 'recover' | 'reset' | 'save' | 'load'; -const LINK_INVALID = 'Der Link ist ungültig oder abgelaufen.'; -const WRONG_CREDENTIALS = 'E-Mail oder Passwort ist falsch.'; -const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.'; +const LINK_INVALID = msg('Der Link ist ungültig oder abgelaufen.'); +const WRONG_CREDENTIALS = msg('E-Mail oder Passwort ist falsch.'); +const GENERIC = msg('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); function field(err: unknown, key: 'code' | 'type' | 'message'): unknown { return typeof err === 'object' && err !== null ? (err as Record)[key] : undefined; @@ -40,46 +42,46 @@ export function isInvalidLinkError(err: unknown): boolean { } function passwordMessage(err: unknown, ctx: ErrorContext): string { - if (ctx === 'login') return WRONG_CREDENTIALS; + if (ctx === 'login') return t(WRONG_CREDENTIALS); const message = field(err, 'message'); if (typeof message === 'string' && message.includes('commonly used')) { - return 'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.'; + return t('Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.'); } - return 'Das Passwort muss 8 bis 256 Zeichen lang sein.'; + return t('Das Passwort muss 8 bis 256 Zeichen lang sein.'); } -/** Meldung für Nutzer: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */ +/** Meldung für Nutzer in der Sprache der Seite: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */ export function errorMessage(err: unknown, ctx: ErrorContext): string { switch (errorType(err)) { case 'user_invalid_credentials': - return WRONG_CREDENTIALS; + return t(WRONG_CREDENTIALS); case 'user_already_exists': - return 'Diese E-Mail ist schon registriert. Melde dich an.'; + return t('Diese E-Mail ist schon registriert. Melde dich an.'); case 'user_invalid_token': - return LINK_INVALID; + return t(LINK_INVALID); case 'user_not_found': - if (ctx === 'recover') return 'Zu dieser E-Mail gibt es kein Konto.'; - if (ctx === 'reset') return LINK_INVALID; + if (ctx === 'recover') return t('Zu dieser E-Mail gibt es kein Konto.'); + if (ctx === 'reset') return t(LINK_INVALID); break; case 'general_argument_invalid': { const param = invalidParam(err); - if (param === 'email') return 'Bitte gib eine gültige E-Mail-Adresse ein.'; + if (param === 'email') return t('Bitte gib eine gültige E-Mail-Adresse ein.'); if (param === 'password') return passwordMessage(err, ctx); - if (param === 'userId' || param === 'secret') return LINK_INVALID; + if (param === 'userId' || param === 'secret') return t(LINK_INVALID); break; } case 'row_not_found': - return 'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.'; + return t('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.'); case 'storage_file_not_found': - return 'Das Bild dieser Karte fehlt.'; + return t('Das Bild dieser Karte fehlt.'); case 'general_unknown_origin': - return 'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.'; + return t('Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.'); case 'user_session_already_exists': - return 'Du bist schon angemeldet. Bitte lade die Seite neu.'; + return t('Du bist schon angemeldet. Bitte lade die Seite neu.'); case 'user_count_exceeded': - return 'Zurzeit sind keine neuen Konten möglich.'; + return t('Zurzeit sind keine neuen Konten möglich.'); } - if (field(err, 'code') === 429) return 'Zu viele Versuche – bitte kurz warten.'; - if (err instanceof TypeError) return 'Keine Verbindung zum Server.'; - return GENERIC; + if (field(err, 'code') === 429) return t('Zu viele Versuche – bitte kurz warten.'); + if (err instanceof TypeError) return t('Keine Verbindung zum Server.'); + return t(GENERIC); } diff --git a/web/src/account/login-dialog.ts b/web/src/account/login-dialog.ts index 772e209..8c58a18 100644 --- a/web/src/account/login-dialog.ts +++ b/web/src/account/login-dialog.ts @@ -1,19 +1,20 @@ // Login-Dialog: ein gemeinsames mit Anmelden, Registrieren und „Passwort vergessen“. import './account.css'; import { el } from '../editor/dom'; +import { msg, t } from '../i18n/i18n'; import { login, register, requestPasswordReset, validatePassword } from './auth'; import { errorMessage, errorType, type ErrorContext } from './errors'; export type LoginView = 'login' | 'register' | 'forgot'; const TITLES: Record = { - login: 'Anmelden', - register: 'Registrieren', - forgot: 'Passwort vergessen', + login: msg('Anmelden'), + register: msg('Registrieren'), + forgot: msg('Passwort vergessen'), }; -const MISMATCH = 'Die Passwörter stimmen nicht überein.'; -const RESET_SENT = 'Wir haben dir einen Link geschickt. Er gilt eine Stunde.'; +const MISMATCH = msg('Die Passwörter stimmen nicht überein.'); +const RESET_SENT = msg('Wir haben dir einen Link geschickt. Er gilt eine Stunde.'); type Field = { label: HTMLLabelElement; input: HTMLInputElement }; @@ -37,7 +38,7 @@ function createLoginDialog(): (view: LoginView) => Promise { const closeButton = el( 'button', - { type: 'button', class: 'auth-close', 'aria-label': 'Schließen' }, + { type: 'button', class: 'auth-close', 'aria-label': t('Schließen') }, el('span', { 'aria-hidden': 'true' }, '×'), ); const title = el('h2', { id: 'auth-title', class: 'auth-title' }); @@ -135,7 +136,7 @@ function createLoginDialog(): (view: LoginView) => Promise { 'aria-controls': 'auth-panel', tabindex: selected ? 0 : -1, }, - TITLES[tabView], + t(TITLES[tabView]), ); tab.addEventListener('click', () => select(tabView)); tab.addEventListener('keydown', (event) => { @@ -145,7 +146,7 @@ function createLoginDialog(): (view: LoginView) => Promise { }); return tab; }); - return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': 'Konto' }, ...tabs); + return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': t('Konto') }, ...tabs); } /** Setzt oder sperrt alle Bedienelemente, solange eine Anfrage läuft; Eingabefelder bleiben fokussierbar. */ @@ -196,10 +197,10 @@ function createLoginDialog(): (view: LoginView) => Promise { const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text); if (forView === 'login') { - const email = field('E-Mail', 'email', 'email', 'email', draftEmail); - const password = field('Passwort', 'password', 'password', 'current-password'); - const forgot = linkButton('Passwort vergessen?', () => showView('forgot')); - form.append(email.label, password.label, errorEl, okEl, submit('Anmelden'), el('p', { class: 'auth-aside' }, forgot)); + const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail); + const password = field(t('Passwort'), 'password', 'password', 'current-password'); + const forgot = linkButton(t('Passwort vergessen?'), () => showView('forgot')); + form.append(email.label, password.label, errorEl, okEl, submit(t('Anmelden')), el('p', { class: 'auth-aside' }, forgot)); const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl }; form.addEventListener('submit', (event) => { event.preventDefault(); @@ -214,20 +215,22 @@ function createLoginDialog(): (view: LoginView) => Promise { } if (forView === 'register') { - const email = field('E-Mail', 'email', 'email', 'email', draftEmail); - const password = field('Passwort', 'password', 'password', 'new-password', '', 'auth-pw-hint'); - const repeat = field('Passwort wiederholen', 'password-repeat', 'password', 'new-password'); - const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, 'Mindestens 8 Zeichen.'); - form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit('Registrieren')); + const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail); + const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint'); + const repeat = field(t('Passwort wiederholen'), 'password-repeat', 'password', 'new-password'); + const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, t('Mindestens 8 Zeichen.')); + form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren'))); const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl }; form.addEventListener('submit', (event) => { event.preventDefault(); if (busy) return; const address = email.input.value.trim(); - const problem = password.input.value !== repeat.input.value ? MISMATCH : validatePassword(password.input.value); + // Der Fehler liegt am Feld, das ihn verursacht; verglichen wird nie mit dem (übersetzten) Text. + const mismatch = password.input.value !== repeat.input.value; + const problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value); if (problem) { showError(panel, problem); - (problem === MISMATCH ? repeat : password).input.focus(); + (mismatch ? repeat : password).input.focus(); return; } void run( @@ -239,16 +242,16 @@ function createLoginDialog(): (view: LoginView) => Promise { }, (err) => { if (errorType(err) !== 'user_already_exists') return undefined; - return linkButton('Anmelden', () => showView('login')); + return linkButton(t('Anmelden'), () => showView('login')); }, ); }); return panel; } - const email = field('E-Mail', 'email', 'email', 'email', draftEmail); - const back = linkButton('Zurück zur Anmeldung', () => showView('login')); - form.append(email.label, errorEl, okEl, submit('Link senden'), el('p', { class: 'auth-aside' }, back)); + const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail); + const back = linkButton(t('Zurück zur Anmeldung'), () => showView('login')); + form.append(email.label, errorEl, okEl, submit(t('Link senden')), el('p', { class: 'auth-aside' }, back)); const panel: Panel = { form, first: email.input, errorEl, okEl }; form.addEventListener('submit', (event) => { event.preventDefault(); @@ -256,7 +259,7 @@ function createLoginDialog(): (view: LoginView) => Promise { const address = email.input.value.trim(); void run(panel, 'recover', async () => { await requestPasswordReset(address); - showOk(panel, RESET_SENT); + showOk(panel, t(RESET_SENT)); }); }); return panel; @@ -268,7 +271,7 @@ function createLoginDialog(): (view: LoginView) => Promise { const typed = content.querySelector('input[name="email"]'); if (typed) draftEmail = typed.value.trim(); view = next; - title.textContent = TITLES[next]; + title.textContent = t(TITLES[next]); const panel = buildPanel(next); if (next === 'forgot') { content.replaceChildren(panel.form); @@ -288,6 +291,8 @@ function createLoginDialog(): (view: LoginView) => Promise { pending = new Promise((resolve) => { settle = resolve; }); + // Der Dialog bleibt zwischen den Aufrufen im DOM; die Sprache der Seite kann sich seit dem Bauen geändert haben. + closeButton.setAttribute('aria-label', t('Schließen')); showView(initial); try { dialog.showModal(); diff --git a/web/src/account/with-login.ts b/web/src/account/with-login.ts index 6b9b705..fac8fe7 100644 --- a/web/src/account/with-login.ts +++ b/web/src/account/with-login.ts @@ -1,10 +1,11 @@ // Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401, oder 404 für Gäste) einmal neu an. +import { msg } from '../i18n/i18n'; import { getCurrentUser } from './auth'; import { errorType, isUnauthorized } from './errors'; import { requireLogin } from './login-dialog'; -/** Hinweis, wenn eine Aktion an einer abgebrochenen Anmeldung scheitert. */ -export const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.'; +/** Hinweis, wenn eine Aktion an einer abgebrochenen Anmeldung scheitert; angezeigt wird er mit `t(LOGIN_NEEDED)`. */ +export const LOGIN_NEEDED = msg('Bitte melde dich an, um fortzufahren.'); /** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */ export class LoginCancelled extends Error { diff --git a/web/src/card/marks.ts b/web/src/card/marks.ts index 70be8d9..1ae35e8 100644 --- a/web/src/card/marks.ts +++ b/web/src/card/marks.ts @@ -2,6 +2,7 @@ // 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. +import { msg } from '../i18n/i18n'; import { TONE_COLOR, type BrandId, type DetailsTone } from './config'; import aircardsSymbol from '../assets/marks/aircards-symbol.svg?raw'; @@ -11,7 +12,10 @@ export type MarkGroup = 'Logo'; export interface MarkDef { id: Exclude; - /** 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; label: string; svg: string; @@ -24,8 +28,8 @@ export interface MarkDef { type MarkEntry = Omit; const BRAND_TABLE: Record, MarkEntry> = { - 'aircards-symbol': { group: 'Logo', label: 'AirCard-IOS · Symbol', svg: aircardsSymbol, tint: true, maxH: 100 }, - 'aircards-text': { group: 'Logo', label: 'AirCard-IOS · Text', svg: aircardsText, tint: true, maxH: 100 }, + 'aircards-symbol': { group: msg('Logo'), label: msg('AirCard-IOS · Symbol'), svg: aircardsSymbol, 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[] = ( diff --git a/web/src/card/render.ts b/web/src/card/render.ts index 176c0ca..c326cd4 100644 --- a/web/src/card/render.ts +++ b/web/src/card/render.ts @@ -1,5 +1,6 @@ // 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. +import { msg } from '../i18n/i18n'; import { loadLogoAsset } from '../logos/logo-asset'; import { TONE_COLOR, type CardConfig, type DetailsTone } from './config'; import { clampCrop, imageRect } from './crop'; @@ -180,7 +181,8 @@ export function drawCard( ): void { // Die Überladungen beider Canvas-Typen lassen sich als Union nicht aufrufen; zur Laufzeit gleich. 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.scale(canvas.width / CARD_W, canvas.height / CARD_H); // Fotos werden stark verkleinert; die Standardqualität (low) zeigt dabei Treppenstufen. @@ -221,7 +223,7 @@ export async function exportCard( drawCard(canvas, config, assets); return new Promise((resolve, reject) => { 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.quality, ); diff --git a/web/src/editor/confirm-dialog.ts b/web/src/editor/confirm-dialog.ts index d304853..6ef2991 100644 --- a/web/src/editor/confirm-dialog.ts +++ b/web/src/editor/confirm-dialog.ts @@ -1,6 +1,7 @@ // Bestätigungsdialog als eigenes (kein window.confirm): Löschen einer Karte, Verwerfen von Änderungen. import '../account/account.css'; import './confirm-dialog.css'; +import { t } from '../i18n/i18n'; import { el } from './dom'; export interface ConfirmOptions { @@ -20,7 +21,7 @@ export function confirmDialog(options: ConfirmOptions, signal?: AbortSignal): Pr resolve(false); 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 dialog = el( 'dialog', diff --git a/web/src/editor/download.ts b/web/src/editor/download.ts index 424c6cc..d66a423 100644 --- a/web/src/editor/download.ts +++ b/web/src/editor/download.ts @@ -1,8 +1,9 @@ // 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. */ 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; @@ -29,9 +30,9 @@ export function downloadFileName(cardName: string, date: Date): string { 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 { - 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. diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts index d5a9f47..04f8bb3 100644 --- a/web/src/editor/editor-page.ts +++ b/web/src/editor/editor-page.ts @@ -10,7 +10,7 @@ import { clampCrop, panBy, zoomTo } from '../card/crop'; import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry'; import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render'; 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 { createLogoPicker } from '../logos/logo-picker'; import { dropCardParam, dropTemplateParam } from './card-url'; @@ -24,13 +24,14 @@ import { initialEditorState, setImage, type EditorState, type EditorStore, type import { createTemplatesPanel } from './templates-panel'; import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage, validateImageFile } from './upload'; -const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.'; -const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.'; -const NO_IMAGE = 'Bitte zuerst ein Bild wählen.'; -const LOADING_CARD = 'Karte wird geladen …'; -const LOADING_TEMPLATE = 'Vorlage wird geladen …'; -const TEMPLATE_UNAVAILABLE = 'Diese Vorlage ist nicht (mehr) verfügbar.'; -const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.'; +// Deutsch markierte Texte: Fehler werden mit ihnen geworfen (`new Error(LOGO_ERROR)`) und erst beim Anzeigen mit `t()` übersetzt. +const LOGO_ERROR = msg('Die Logos konnten nicht geladen werden.'); +const EXPORT_ERROR = msg('Das PNG konnte nicht erzeugt werden.'); +const NO_IMAGE = msg('Bitte zuerst ein Bild wählen.'); +const LOADING_CARD = msg('Karte wird geladen …'); +const LOADING_TEMPLATE = msg('Vorlage wird geladen …'); +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; /** 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() { if (store.get().saving) return; if (!store.get().imageBlob) { - preview.setError(NO_IMAGE); + preview.setError(t(NO_IMAGE)); return; } // 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(); } - 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); root.replaceChildren(heading, page); @@ -153,7 +154,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { setImage(store, image, file); } catch (error) { 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)); @@ -175,7 +176,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { saveBlobAs(blob, downloadFileName(s.cardName, new Date())); setWarning(pngSizeWarning(blob.size)); } 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 { exporting = false; if (!disposed) sync(store.get()); @@ -250,7 +251,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { const service = cardsService(); preview.setError(null); setWarning(null); - preview.setNotice(LOADING_CARD); + preview.setNotice(t(LOADING_CARD)); try { // Appwrite beantwortet Gäste mit 404 statt 401: ohne Sitzung zuerst anmelden, statt „nicht gefunden“ zu melden. if (!(await getCurrentUser())) { @@ -298,7 +299,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { } catch (err) { if (stale()) return; if (err instanceof LoginCancelled) { - setWarning(LOGIN_TO_OPEN); + setWarning(t(LOGIN_TO_OPEN)); } 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. const s = store.get(); @@ -306,7 +307,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { preview.setError(errorMessage(err, 'load')); } else { 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 { if (!stale()) preview.setNotice(null); @@ -320,7 +321,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { const stale = () => disposed || token !== loadToken; preview.setError(null); setWarning(null); - preview.setNotice(LOADING_TEMPLATE); + preview.setNotice(t(LOADING_TEMPLATE)); try { // Eine ausgeblendete oder zurückgezogene Karte liefert null (Appwrite antwortet Fremden mit 404). const card = await getGalleryCard(galleryId); @@ -353,7 +354,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { dropTemplateParam(); } catch { if (stale()) return; - preview.setError(TEMPLATE_UNAVAILABLE); + preview.setError(t(TEMPLATE_UNAVAILABLE)); } finally { if (!stale()) preview.setNotice(null); } @@ -366,9 +367,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { try { return await confirmDialog( { - title: 'Ungespeicherte Änderungen verwerfen?', - text: 'Alles, was du seit dem letzten Speichern geändert hast, geht verloren.', - confirmLabel: 'Verwerfen', + title: t('Ungespeicherte Änderungen verwerfen?'), + text: t('Alles, was du seit dem letzten Speichern geändert hast, geht verloren.'), + confirmLabel: t('Verwerfen'), }, disposal.signal, ); @@ -427,7 +428,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { if (disposed || token !== logoToken) return; pendingKey = ''; failedKey = key; - preview.setError(LOGO_ERROR); + preview.setError(t(LOGO_ERROR)); logoErrorShown = true; }, ); @@ -452,7 +453,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { if (s.image) drawCard(preview.canvas, s.config, assets); templates.draw(s.config, assets); } 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.')); } } diff --git a/web/src/editor/options-panel.ts b/web/src/editor/options-panel.ts index 770f817..c677d69 100644 --- a/web/src/editor/options-panel.ts +++ b/web/src/editor/options-panel.ts @@ -2,7 +2,7 @@ // dann Details (Hell/Dunkel) und das Logo oben links. import type { BrandId, CardConfig, DetailsTone } from '../card/config'; 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'; export interface OptionsHandlers { @@ -18,8 +18,8 @@ export interface OptionsPanel { } const TONES: readonly { id: DetailsTone; label: string }[] = [ - { id: 'light', label: 'Hell' }, - { id: 'dark', label: 'Dunkel' }, + { id: 'light', label: msg('Hell') }, + { id: 'dark', label: msg('Dunkel') }, ]; /** Logo der Kachel als Bild: einfarbige Logos in Weiß (currentColor hätte im die Farbe Schwarz). */ @@ -28,12 +28,12 @@ function markSrc(def: MarkDef): string { } function markTile(def: MarkDef, onClick: () => void) { - const name = `${def.group} – ${def.label}`; + const name = `${t(def.group)} – ${t(def.label)}`; const button = el( 'button', { 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('span', { class: 'mark-label' }, def.label), + el('span', { class: 'mark-label' }, t(def.label)), ); button.addEventListener('click', onClick); return button; @@ -48,11 +48,12 @@ export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel { id: def.id as BrandId, button: markTile(def, () => handlers.onBrand(def.id as BrandId)), })); + const noLogo = t('Kein Logo'); const none = el( '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'), - el('span', { class: 'mark-label' }, 'Kein Logo'), + el('span', { class: 'mark-label' }, noLogo), ); none.addEventListener('click', () => handlers.onBrand('none')); brandTiles.push({ id: 'none', button: none }); @@ -66,7 +67,7 @@ export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel { 'label', { class: 'pill' }, 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 }; }); @@ -76,9 +77,9 @@ export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel { const section = el( 'section', { class: 'editor-side editor-options' }, - heading('editor-options-heading', 'Individuell gestalten', 'h2'), + heading('editor-options-heading', t('Individuell gestalten'), 'h2'), 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)), 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)), diff --git a/web/src/editor/preview.ts b/web/src/editor/preview.ts index 06aef20..abfc72c 100644 --- a/web/src/editor/preview.ts +++ b/web/src/editor/preview.ts @@ -1,6 +1,7 @@ // Mittlere Spalte: Ablagefläche bzw. Kartenvorschau (Verschieben, Zoom) und die Knopfleiste. import { MAX_ZOOM, MIN_ZOOM } from '../card/config'; import { CARD_H, CARD_W } from '../card/geometry'; +import { msg, t } from '../i18n/i18n'; import { el, icon } from './dom'; import { ACCEPTED_TYPES } from './upload'; @@ -32,15 +33,14 @@ export interface Preview { setDragging(active: boolean): void; setError(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; } -const NO_IMAGE_HINT = 'Erst ein Bild wählen'; -const SAVE_LABEL = 'Speichern'; -const SAVING_LABEL = 'Speichert …'; -const SAVED_LABEL = 'Gespeichert'; -const BOUND_PREFIX = 'Bearbeitet: '; +const NO_IMAGE_HINT = msg('Erst ein Bild wählen'); +const SAVE_LABEL = msg('Speichern'); +const SAVING_LABEL = msg('Speichert …'); +const SAVED_LABEL = msg('Gespeichert'); const KEY_STEP = 24; const KEY_STEP_BIG = 96; 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 { let zoom = MIN_ZOOM; @@ -87,7 +90,7 @@ export function createPreview(handlers: PreviewHandlers): Preview { type: 'file', accept: ACCEPTED_TYPES.join(','), hidden: true, - 'aria-label': 'Bilddatei wählen', + 'aria-label': t('Bilddatei wählen'), }); const openPicker = () => input.click(); input.addEventListener('change', () => { @@ -98,14 +101,14 @@ export function createPreview(handlers: PreviewHandlers): Preview { }); // 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( 'div', { class: 'dropzone' }, icon('imagePlus', 'dropzone-icon'), - el('p', { class: 'dropzone-title' }, 'Ziehe deine Idee hierher'), + el('p', { class: 'dropzone-title' }, t('Ziehe deine Idee hierher')), 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); @@ -116,7 +119,7 @@ export function createPreview(handlers: PreviewHandlers): Preview { height: CARD_H, tabindex: 0, 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); @@ -177,7 +180,7 @@ export function createPreview(handlers: PreviewHandlers): Preview { // Bindung an eine gespeicherte Karte: Name und Ausweg „Neue Karte“ 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()); const boundRow = el('div', { class: 'bound-row', hidden: true }, boundLabel, newCardButton); @@ -191,12 +194,12 @@ export function createPreview(handlers: PreviewHandlers): Preview { step: 0.01, 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))); const zoomRow = el( 'div', { 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, zoomValue, ); @@ -207,11 +210,11 @@ export function createPreview(handlers: PreviewHandlers): Preview { const noticeBox = el('p', { class: 'msg msg-notice', role: 'status' }); // 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); - 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()); - 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()); const actions = el( 'div', @@ -220,10 +223,10 @@ export function createPreview(handlers: PreviewHandlers): Preview { 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 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 { button.disabled = disabled; @@ -244,18 +247,19 @@ export function createPreview(handlers: PreviewHandlers): Preview { cardWrap.hidden = !view.hasImage; zoomRow.hidden = !view.hasImage; zoomInput.value = String(view.zoom); - zoomValue.textContent = `${Math.round(view.zoom * 100)} %`; - setDisabled(downloadButton, !view.hasImage || view.exporting, view.hasImage ? null : NO_IMAGE_HINT); - setDisabled(saveButton, !view.hasImage || view.saving, view.hasImage ? null : NO_IMAGE_HINT); - saveButton.textContent = view.saving ? SAVING_LABEL : view.saved ? SAVED_LABEL : SAVE_LABEL; + zoomValue.textContent = percent(view.zoom); + setDisabled(downloadButton, !view.hasImage || view.exporting, view.hasImage ? null : t(NO_IMAGE_HINT)); + setDisabled(saveButton, !view.hasImage || view.saving, view.hasImage ? null : t(NO_IMAGE_HINT)); + saveButton.textContent = t(view.saving ? SAVING_LABEL : view.saved ? SAVED_LABEL : SAVE_LABEL); // Ein Bildwechsel mitten im Speichern würde die gespeicherte Karte durcheinanderbringen. replaceButton.disabled = view.saving; input.disabled = view.saving; boundRow.hidden = view.boundName === null; // Nur als Text, nie als HTML: der Name kommt vom Nutzer. - boundLabel.textContent = view.boundName === null ? '' : BOUND_PREFIX + view.boundName; - if (view.boundName === null) boundLabel.removeAttribute('title'); - else boundLabel.setAttribute('title', BOUND_PREFIX + view.boundName); + const bound = view.boundName === null ? null : t('Bearbeitet: {name}', { name: view.boundName }); + boundLabel.textContent = bound ?? ''; + if (bound === null) boundLabel.removeAttribute('title'); + else boundLabel.setAttribute('title', bound); newCardButton.disabled = view.saving; downloadButton.setAttribute('aria-busy', String(view.exporting)); saveButton.setAttribute('aria-busy', String(view.saving)); diff --git a/web/src/editor/save.ts b/web/src/editor/save.ts index dab5b11..8dd9e55 100644 --- a/web/src/editor/save.ts +++ b/web/src/editor/save.ts @@ -2,6 +2,7 @@ // Kennt weder DOM noch Appwrite; alles Konkrete kommt über `deps`. import { getCurrentUser, type User } from '../account/auth'; import { errorMessage, isUnauthorized } from '../account/errors'; +import { t } from '../i18n/i18n'; export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; @@ -16,8 +17,6 @@ export interface SaveDeps { refreshUser?(): Promise; } -const FAILED = 'Speichern fehlgeschlagen – '; - /** Liefert die Funktion hinter dem Speichern-Knopf; ein laufendes Speichern blockiert jeden weiteren Aufruf. */ export function createSaveAction(deps: SaveDeps): () => Promise { const refreshUser = deps.refreshUser ?? getCurrentUser; @@ -50,7 +49,7 @@ export function createSaveAction(deps: SaveDeps): () => Promise { } deps.onStatus('saved'); } catch (err) { - deps.onStatus('error', FAILED + errorMessage(err, 'save')); + deps.onStatus('error', t('Speichern fehlgeschlagen – {reason}', { reason: errorMessage(err, 'save') })); } finally { running = false; } diff --git a/web/src/editor/state.ts b/web/src/editor/state.ts index 1bb28ff..3504118 100644 --- a/web/src/editor/state.ts +++ b/web/src/editor/state.ts @@ -2,6 +2,7 @@ // Der Store wird einmal in main.ts erzeugt und überlebt Seitenwechsel. import { DEFAULT_CONFIG, DEFAULT_CROP, type CardConfig } from '../card/config'; import type { CardImage } from '../card/render'; +import { t } from '../i18n/i18n'; export interface SavedCardRef { id: string; @@ -49,7 +50,7 @@ export function createStore(initial: EditorState) { export type EditorStore = ReturnType; -/** 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 { return { config: structuredClone(DEFAULT_CONFIG), @@ -58,7 +59,7 @@ export function initialEditorState(): EditorState { imageChanged: false, saved: null, savedConfig: null, - cardName: 'Meine Karte', + cardName: t('Meine Karte'), saving: false, }; } diff --git a/web/src/editor/templates-panel.ts b/web/src/editor/templates-panel.ts index 045c3aa..4dd7d14 100644 --- a/web/src/editor/templates-panel.ts +++ b/web/src/editor/templates-panel.ts @@ -1,16 +1,17 @@ // Linke Spalte „Vorlage“: vier Kacheln mit Live-Miniatur der Karte. import type { CardConfig, TemplateId } from '../card/config'; import { drawCard, type CardAssets } from '../card/render'; +import { msg, t } from '../i18n/i18n'; import { el, icon } from './dom'; export const THUMB_W = 384; export const THUMB_H = 242; const TEMPLATES: readonly { id: TemplateId; title: string; subtitle: string }[] = [ - { id: 'image', title: 'Nur Bild', subtitle: 'Bild unverändert lassen' }, - { id: 'minimal', title: 'Minimalistisch', subtitle: 'Dein Bild steht im Mittelpunkt' }, - { id: 'classic', title: 'Klassisch', subtitle: 'Die vertrauten Kartendetails' }, - { id: 'signature', title: 'Signature', subtitle: 'Ein bisschen mehr Persönlichkeit' }, + { id: 'image', title: msg('Nur Bild'), subtitle: msg('Bild unverändert lassen') }, + { id: 'minimal', title: msg('Minimalistisch'), subtitle: msg('Dein Bild steht im Mittelpunkt') }, + { id: 'classic', title: msg('Klassisch'), subtitle: msg('Die vertrauten Kartendetails') }, + { id: 'signature', title: msg('Signature'), subtitle: msg('Ein bisschen mehr Persönlichkeit') }, ]; export interface TemplatesPanel { @@ -22,35 +23,35 @@ export interface 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 button = el( 'button', { type: 'button', class: 'tile template-tile', 'aria-pressed': 'false' }, canvas, - el('span', { class: 'template-title' }, t.title), - el('span', { class: 'template-sub' }, t.subtitle), + el('span', { class: 'template-title' }, t(tpl.title)), + el('span', { class: 'template-sub' }, t(tpl.subtitle)), icon('check', 'template-check'), ); - button.addEventListener('click', () => onSelect(t.id)); - return { id: t.id, canvas, button }; + button.addEventListener('click', () => onSelect(tpl.id)); + 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( 'div', { 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); return { el: section, 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) { - 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); }, }; } diff --git a/web/src/editor/upload.ts b/web/src/editor/upload.ts index ab774d1..e388cde 100644 --- a/web/src/editor/upload.ts +++ b/web/src/editor/upload.ts @@ -1,23 +1,26 @@ // 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 { msg, t } from '../i18n/i18n'; export const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const; 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. */ export const MAX_IMAGE_PIXELS = 40_000_000; -export const TYPE_MESSAGE = 'Bitte ein PNG-, JPG- oder WebP-Bild wählen.'; -export const SIZE_MESSAGE = 'Das Bild ist größer als 5 MiB.'; -export const READ_MESSAGE = 'Das Bild konnte nicht gelesen werden.'; -export const PIXELS_MESSAGE = 'Das Bild ist zu groß (max. 40 Megapixel).'; +// Die Texte sind deutsch markiert: `decodeImage` wirft sie unübersetzt (Aufrufer vergleichen `err.message` mit READ_MESSAGE und +// PIXELS_MESSAGE), angezeigt werden sie mit `t(err.message)`. `validateImageFile` liefert den Text schon übersetzt. +export const TYPE_MESSAGE = msg('Bitte ein PNG-, JPG- oder WebP-Bild wählen.'); +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 }; -/** 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 { - if (!(ACCEPTED_TYPES as readonly string[]).includes(f.type)) return { ok: false, message: TYPE_MESSAGE }; - if (f.size > MAX_IMAGE_BYTES) return { ok: false, message: SIZE_MESSAGE }; - if (f.size <= 0) return { ok: false, message: READ_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: t(SIZE_MESSAGE) }; + if (f.size <= 0) return { ok: false, message: t(READ_MESSAGE) }; return { ok: true }; } diff --git a/web/src/i18n/coverage.test.ts b/web/src/i18n/coverage.test.ts index 65950af..d8d9901 100644 --- a/web/src/i18n/coverage.test.ts +++ b/web/src/i18n/coverage.test.ts @@ -57,6 +57,112 @@ for (const [path, code] of sources()) { 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)', () => { it('der Scanner findet die Texte der Oberfläche (sonst bestünden die Prüfungen leer)', () => { 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']); }); }); + +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']); + }); +}); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index f609ae6..7beeec1 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -120,6 +120,110 @@ export const EN: Readonly> = { 'Danke, du hast dieses Logo schon gemeldet.': 'Thanks, you’ve already reported this logo.', '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. 'Bitte melde dich an.': 'Please sign in.', 'Nicht gefunden.': 'Not found.', diff --git a/web/src/i18n/english-ui.test.ts b/web/src/i18n/english-ui.test.ts new file mode 100644 index 0000000..8a73cd1 --- /dev/null +++ b/web/src/i18n/english-ui.test.ts @@ -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 = '
'; + }); + + 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('dialog.auth-dialog')!; + const button = (text: string) => [...dialog().querySelectorAll('button')].find((b) => b.textContent === text)!; + const input = (name: string) => dialog().querySelector(`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('.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 = '
'; + }); + + 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(`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()), + saveBlobAs: vi.fn(), + })); + vi.doMock('../editor/upload', async (importOriginal) => ({ + ...(await importOriginal()), + decodeImage: mocks.decodeImage, + })); + (await import('./i18n')).setLang('en', { persist: false }); + history.replaceState(null, '', url); + document.body.innerHTML = '
'; + 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('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((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((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(); + }); +}); diff --git a/web/src/main.test.ts b/web/src/main.test.ts index ad52e63..7009de5 100644 --- a/web/src/main.test.ts +++ b/web/src/main.test.ts @@ -35,7 +35,8 @@ const EN_FOOTER = 'Unofficial community site for the AirCard-iOS app'; const slot = () => document.getElementById('account-slot') as HTMLElement; const langButton = () => document.querySelector('button.lang-btn')!; const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent); -const signIn = () => [...slot().querySelectorAll('button')].find((b) => b.textContent === 'Anmelden'); +// 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). */ async function start(path = '/', storedLang: string | null = 'de') { @@ -139,7 +140,7 @@ describe('Sprachwechsel', () => { 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.getCurrentUser).toHaveBeenCalledTimes(1); // der Status wird nicht noch einmal geholt }); @@ -155,7 +156,7 @@ describe('Sprachwechsel', () => { expect(mocks.authListeners.size).toBe(1); 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 () => { diff --git a/web/src/pages/reset-page.ts b/web/src/pages/reset-page.ts index 50b320a..1bfb3ab 100644 --- a/web/src/pages/reset-page.ts +++ b/web/src/pages/reset-page.ts @@ -4,9 +4,10 @@ import { completePasswordReset, validatePassword } from '../account/auth'; import { errorMessage, isInvalidLinkError } from '../account/errors'; import { requireLogin } from '../account/login-dialog'; import { el } from '../editor/dom'; +import { msg, t } from '../i18n/i18n'; 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 { let disposed = false; @@ -14,15 +15,15 @@ export function mountResetPage(root: HTMLElement): () => void { const userId = params.get('userId') ?? ''; 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' }); root.replaceChildren(heading, page); 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')); 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), ); } @@ -30,14 +31,14 @@ export function mountResetPage(root: HTMLElement): () => void { function showDone(): void { // Das Geheimnis ist verbraucht; es soll nicht in der Adresszeile und im Verlauf stehen bleiben. 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', () => { void requireLogin('login').then((ok) => { if (ok && !disposed) navigate('/'); }); }); 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), ); } @@ -59,15 +60,15 @@ export function mountResetPage(root: HTMLElement): () => void { required: true, '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 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( '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, - 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, submit, ); @@ -93,9 +94,11 @@ export function mountResetPage(root: HTMLElement): () => void { error.textContent = ''; password.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) { - fail(problem, problem === MISMATCH ? repeat : password); + fail(problem, mismatch ? repeat : password); return; } setBusy(true); diff --git a/web/src/test-setup.ts b/web/src/test-setup.ts index 7c12f49..595c5d6 100644 --- a/web/src/test-setup.ts +++ b/web/src/test-setup.ts @@ -2,4 +2,10 @@ import { beforeEach } from 'vitest'; 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 }));