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:
1 parent
a75be2d44c
commit
1ff1873b27
23 files changed
+857
-169
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite';
|
||||
import { ID_PATTERN, parseConfig, serializeConfig, type CardConfig } from '../card/config';
|
||||
import type { SavedCardRef } from '../editor/state';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite';
|
||||
import { currentUserId } from './auth';
|
||||
import { errorType } from './errors';
|
||||
@@ -44,16 +45,16 @@ export interface SaveInput {
|
||||
previous: SavedCardRef | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_CARD_NAME = 'Meine Karte';
|
||||
export const DEFAULT_CARD_NAME = msg('Meine Karte');
|
||||
const NAME_MAX = 80;
|
||||
/** Zeilen- und Datei-IDs von Appwrite (definiert in card/config.ts, dort braucht sie auch die Logo-ID der Konfiguration). */
|
||||
export { ID_PATTERN };
|
||||
|
||||
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
|
||||
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“ (in der Sprache der Seite). */
|
||||
export function normalizeName(name: string): string {
|
||||
const collapsed = name.replace(/\s+/g, ' ').trim();
|
||||
const cut = Array.from(collapsed).slice(0, NAME_MAX).join('').trim();
|
||||
return cut || DEFAULT_CARD_NAME;
|
||||
return cut || t(DEFAULT_CARD_NAME);
|
||||
}
|
||||
|
||||
/** Nur der Besitzer darf die Zeile bzw. Datei lesen, ändern und löschen. */
|
||||
|
||||
+25
-23
@@ -1,11 +1,13 @@
|
||||
// Deutsche Fehlermeldungen für Appwrite-Fehler. Rein und ohne Appwrite-Import, damit gut testbar.
|
||||
// Fehlermeldungen für Appwrite-Fehler in der Sprache der Seite: Die deutschen Texte sind die Schlüssel für t() (i18n/en.ts).
|
||||
// Rein und ohne Appwrite-Import, damit gut testbar.
|
||||
// Ein AppwriteException trägt `code` (HTTP-Status), `type` (z. B. user_invalid_credentials) und `message`.
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
|
||||
export type ErrorContext = 'login' | 'register' | 'recover' | 'reset' | 'save' | 'load';
|
||||
|
||||
const LINK_INVALID = 'Der Link ist ungültig oder abgelaufen.';
|
||||
const WRONG_CREDENTIALS = 'E-Mail oder Passwort ist falsch.';
|
||||
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.';
|
||||
const LINK_INVALID = msg('Der Link ist ungültig oder abgelaufen.');
|
||||
const WRONG_CREDENTIALS = msg('E-Mail oder Passwort ist falsch.');
|
||||
const GENERIC = msg('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
|
||||
function field(err: unknown, key: 'code' | 'type' | 'message'): unknown {
|
||||
return typeof err === 'object' && err !== null ? (err as Record<string, unknown>)[key] : undefined;
|
||||
@@ -40,46 +42,46 @@ export function isInvalidLinkError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
function passwordMessage(err: unknown, ctx: ErrorContext): string {
|
||||
if (ctx === 'login') return WRONG_CREDENTIALS;
|
||||
if (ctx === 'login') return t(WRONG_CREDENTIALS);
|
||||
const message = field(err, 'message');
|
||||
if (typeof message === 'string' && message.includes('commonly used')) {
|
||||
return 'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.';
|
||||
return t('Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.');
|
||||
}
|
||||
return 'Das Passwort muss 8 bis 256 Zeichen lang sein.';
|
||||
return t('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
}
|
||||
|
||||
/** Meldung für Nutzer: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */
|
||||
/** Meldung für Nutzer in der Sprache der Seite: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */
|
||||
export function errorMessage(err: unknown, ctx: ErrorContext): string {
|
||||
switch (errorType(err)) {
|
||||
case 'user_invalid_credentials':
|
||||
return WRONG_CREDENTIALS;
|
||||
return t(WRONG_CREDENTIALS);
|
||||
case 'user_already_exists':
|
||||
return 'Diese E-Mail ist schon registriert. Melde dich an.';
|
||||
return t('Diese E-Mail ist schon registriert. Melde dich an.');
|
||||
case 'user_invalid_token':
|
||||
return LINK_INVALID;
|
||||
return t(LINK_INVALID);
|
||||
case 'user_not_found':
|
||||
if (ctx === 'recover') return 'Zu dieser E-Mail gibt es kein Konto.';
|
||||
if (ctx === 'reset') return LINK_INVALID;
|
||||
if (ctx === 'recover') return t('Zu dieser E-Mail gibt es kein Konto.');
|
||||
if (ctx === 'reset') return t(LINK_INVALID);
|
||||
break;
|
||||
case 'general_argument_invalid': {
|
||||
const param = invalidParam(err);
|
||||
if (param === 'email') return 'Bitte gib eine gültige E-Mail-Adresse ein.';
|
||||
if (param === 'email') return t('Bitte gib eine gültige E-Mail-Adresse ein.');
|
||||
if (param === 'password') return passwordMessage(err, ctx);
|
||||
if (param === 'userId' || param === 'secret') return LINK_INVALID;
|
||||
if (param === 'userId' || param === 'secret') return t(LINK_INVALID);
|
||||
break;
|
||||
}
|
||||
case 'row_not_found':
|
||||
return 'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.';
|
||||
return t('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.');
|
||||
case 'storage_file_not_found':
|
||||
return 'Das Bild dieser Karte fehlt.';
|
||||
return t('Das Bild dieser Karte fehlt.');
|
||||
case 'general_unknown_origin':
|
||||
return 'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.';
|
||||
return t('Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.');
|
||||
case 'user_session_already_exists':
|
||||
return 'Du bist schon angemeldet. Bitte lade die Seite neu.';
|
||||
return t('Du bist schon angemeldet. Bitte lade die Seite neu.');
|
||||
case 'user_count_exceeded':
|
||||
return 'Zurzeit sind keine neuen Konten möglich.';
|
||||
return t('Zurzeit sind keine neuen Konten möglich.');
|
||||
}
|
||||
if (field(err, 'code') === 429) return 'Zu viele Versuche – bitte kurz warten.';
|
||||
if (err instanceof TypeError) return 'Keine Verbindung zum Server.';
|
||||
return GENERIC;
|
||||
if (field(err, 'code') === 429) return t('Zu viele Versuche – bitte kurz warten.');
|
||||
if (err instanceof TypeError) return t('Keine Verbindung zum Server.');
|
||||
return t(GENERIC);
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“.
|
||||
import './account.css';
|
||||
import { el } from '../editor/dom';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import { login, register, requestPasswordReset, validatePassword } from './auth';
|
||||
import { errorMessage, errorType, type ErrorContext } from './errors';
|
||||
|
||||
export type LoginView = 'login' | 'register' | 'forgot';
|
||||
|
||||
const TITLES: Record<LoginView, string> = {
|
||||
login: 'Anmelden',
|
||||
register: 'Registrieren',
|
||||
forgot: 'Passwort vergessen',
|
||||
login: msg('Anmelden'),
|
||||
register: msg('Registrieren'),
|
||||
forgot: msg('Passwort vergessen'),
|
||||
};
|
||||
|
||||
const MISMATCH = 'Die Passwörter stimmen nicht überein.';
|
||||
const RESET_SENT = 'Wir haben dir einen Link geschickt. Er gilt eine Stunde.';
|
||||
const MISMATCH = msg('Die Passwörter stimmen nicht überein.');
|
||||
const RESET_SENT = msg('Wir haben dir einen Link geschickt. Er gilt eine Stunde.');
|
||||
|
||||
type Field = { label: HTMLLabelElement; input: HTMLInputElement };
|
||||
|
||||
@@ -37,7 +38,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
|
||||
const closeButton = el(
|
||||
'button',
|
||||
{ type: 'button', class: 'auth-close', 'aria-label': 'Schließen' },
|
||||
{ type: 'button', class: 'auth-close', 'aria-label': t('Schließen') },
|
||||
el('span', { 'aria-hidden': 'true' }, '×'),
|
||||
);
|
||||
const title = el('h2', { id: 'auth-title', class: 'auth-title' });
|
||||
@@ -135,7 +136,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
'aria-controls': 'auth-panel',
|
||||
tabindex: selected ? 0 : -1,
|
||||
},
|
||||
TITLES[tabView],
|
||||
t(TITLES[tabView]),
|
||||
);
|
||||
tab.addEventListener('click', () => select(tabView));
|
||||
tab.addEventListener('keydown', (event) => {
|
||||
@@ -145,7 +146,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
});
|
||||
return tab;
|
||||
});
|
||||
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': 'Konto' }, ...tabs);
|
||||
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': t('Konto') }, ...tabs);
|
||||
}
|
||||
|
||||
/** Setzt oder sperrt alle Bedienelemente, solange eine Anfrage läuft; Eingabefelder bleiben fokussierbar. */
|
||||
@@ -196,10 +197,10 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
|
||||
|
||||
if (forView === 'login') {
|
||||
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
|
||||
const password = field('Passwort', 'password', 'password', 'current-password');
|
||||
const forgot = linkButton('Passwort vergessen?', () => showView('forgot'));
|
||||
form.append(email.label, password.label, errorEl, okEl, submit('Anmelden'), el('p', { class: 'auth-aside' }, forgot));
|
||||
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
|
||||
const password = field(t('Passwort'), 'password', 'password', 'current-password');
|
||||
const forgot = linkButton(t('Passwort vergessen?'), () => showView('forgot'));
|
||||
form.append(email.label, password.label, errorEl, okEl, submit(t('Anmelden')), el('p', { class: 'auth-aside' }, forgot));
|
||||
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
@@ -214,20 +215,22 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
}
|
||||
|
||||
if (forView === 'register') {
|
||||
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
|
||||
const password = field('Passwort', 'password', 'password', 'new-password', '', 'auth-pw-hint');
|
||||
const repeat = field('Passwort wiederholen', 'password-repeat', 'password', 'new-password');
|
||||
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, 'Mindestens 8 Zeichen.');
|
||||
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit('Registrieren'));
|
||||
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
|
||||
const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint');
|
||||
const repeat = field(t('Passwort wiederholen'), 'password-repeat', 'password', 'new-password');
|
||||
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, t('Mindestens 8 Zeichen.'));
|
||||
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
|
||||
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
const address = email.input.value.trim();
|
||||
const problem = password.input.value !== repeat.input.value ? MISMATCH : validatePassword(password.input.value);
|
||||
// Der Fehler liegt am Feld, das ihn verursacht; verglichen wird nie mit dem (übersetzten) Text.
|
||||
const mismatch = password.input.value !== repeat.input.value;
|
||||
const problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value);
|
||||
if (problem) {
|
||||
showError(panel, problem);
|
||||
(problem === MISMATCH ? repeat : password).input.focus();
|
||||
(mismatch ? repeat : password).input.focus();
|
||||
return;
|
||||
}
|
||||
void run(
|
||||
@@ -239,16 +242,16 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
},
|
||||
(err) => {
|
||||
if (errorType(err) !== 'user_already_exists') return undefined;
|
||||
return linkButton('Anmelden', () => showView('login'));
|
||||
return linkButton(t('Anmelden'), () => showView('login'));
|
||||
},
|
||||
);
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
|
||||
const back = linkButton('Zurück zur Anmeldung', () => showView('login'));
|
||||
form.append(email.label, errorEl, okEl, submit('Link senden'), el('p', { class: 'auth-aside' }, back));
|
||||
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
|
||||
const back = linkButton(t('Zurück zur Anmeldung'), () => showView('login'));
|
||||
form.append(email.label, errorEl, okEl, submit(t('Link senden')), el('p', { class: 'auth-aside' }, back));
|
||||
const panel: Panel = { form, first: email.input, errorEl, okEl };
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
@@ -256,7 +259,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
const address = email.input.value.trim();
|
||||
void run(panel, 'recover', async () => {
|
||||
await requestPasswordReset(address);
|
||||
showOk(panel, RESET_SENT);
|
||||
showOk(panel, t(RESET_SENT));
|
||||
});
|
||||
});
|
||||
return panel;
|
||||
@@ -268,7 +271,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
|
||||
if (typed) draftEmail = typed.value.trim();
|
||||
view = next;
|
||||
title.textContent = TITLES[next];
|
||||
title.textContent = t(TITLES[next]);
|
||||
const panel = buildPanel(next);
|
||||
if (next === 'forgot') {
|
||||
content.replaceChildren(panel.form);
|
||||
@@ -288,6 +291,8 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
pending = new Promise<boolean>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
// Der Dialog bleibt zwischen den Aufrufen im DOM; die Sprache der Seite kann sich seit dem Bauen geändert haben.
|
||||
closeButton.setAttribute('aria-label', t('Schließen'));
|
||||
showView(initial);
|
||||
try {
|
||||
dialog.showModal();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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;
|
||||
label: string;
|
||||
svg: string;
|
||||
@@ -24,8 +28,8 @@ export interface MarkDef {
|
||||
type MarkEntry = Omit<MarkDef, 'id'>;
|
||||
|
||||
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, 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[] = (
|
||||
|
||||
@@ -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<Blob>((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,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Bestätigungsdialog als eigenes <dialog> (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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <img> 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)),
|
||||
|
||||
+30
-26
@@ -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));
|
||||
|
||||
@@ -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<unknown>;
|
||||
}
|
||||
|
||||
const FAILED = 'Speichern fehlgeschlagen – ';
|
||||
|
||||
/** Liefert die Funktion hinter dem Speichern-Knopf; ein laufendes Speichern blockiert jeden weiteren Aufruf. */
|
||||
export function createSaveAction(deps: SaveDeps): () => Promise<void> {
|
||||
const refreshUser = deps.refreshUser ?? getCurrentUser;
|
||||
@@ -50,7 +49,7 @@ export function createSaveAction(deps: SaveDeps): () => Promise<void> {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
'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.',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLButtonElement>('button.lang-btn')!;
|
||||
const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent);
|
||||
const signIn = () => [...slot().querySelectorAll('button')].find((b) => b.textContent === 'Anmelden');
|
||||
// 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 () => {
|
||||
|
||||
+15
-12
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
Reference in new issue
Block a user