feat(i18n): Community, Startseite und Meine Karten auf Englisch

Task 11: alle sichtbaren Texte von community/, pages/home-page.ts, pages/community-page.ts, pages/cards-page.ts und pages/format.ts
(Texte, aria-label, title, Platzhalter, Alt-Texte, Rückfragen, Meldungen) gehen durch t()/msg(); en.ts bekommt 82 Einträge (214 → 296).

- Konstanten bleiben deutsche Strings per msg() und gehen beim Anzeigen durch t() (publish.ts, publish-dialog.ts, gallery.ts und die Seiten);
  geworfene Fehler tragen weiter den deutschen Text, damit die Vergleiche gehen. Funktionen, die Text für Nutzer liefern, übersetzen beim
  Zurückgeben (validatePublishForm, publishFailureText, formatDate, reportsLabel). Template-Literale sind Platzhalter geworden ({max},
  {reason}, {name}, {title}, {query}, {count}); der deutsche Text bleibt Zeichen für Zeichen derselbe.
- API-Fehler: jeder ApiError wird mit apiErrorText() angezeigt (Veröffentlichen-Dialog, Detailseite, Meine Karten), nach dem Code des Dienstes
  statt nach dem deutschen Servertext. Logik, die err.message vergleicht (publishCard, 404 beim Zurückziehen), bleibt unverändert.
- Bekannte Reste aus Task 10 behoben: LOGIN_NEEDED ging in publish-dialog.ts, community-page.ts und cards-page.ts ohne t() an die Oberfläche;
  die Meldungen zu Bild lesen/Pixelgrenze/fehlendem Logo (cards-page.ts, GERMAN_MESSAGES in publish-dialog.ts) erscheinen jetzt übersetzt;
  die Zahl der Meldungen in der Logo-Infozeile („3 reports“).
- pages/format.ts: formatDate liefert Deutsch dd.mm.yyyy und Englisch „Oct 4, 2026“ (Intl.DateTimeFormat en-US), reportsLabel „1 report“ /
  „3 reports“. „Herausgeber“ heißt als Bank „Issuer / bank“.
- logos/prepare-logo.ts: der Helfer problem() ist weg, die sechs Stellen schreiben new LogoError(t(MELDUNG)) selbst (gleiches Verhalten);
  so sieht der neue Prüfer unten jede Verwendung einer Konstante.
- i18n/coverage.test.ts: Die Heuristik für deutsche Literale deckt jetzt die ganze Oberfläche ab (account/, card/, community/, editor/, logos/,
  pages/, shell/, main.ts, i18n/api-messages.ts). Sie erkennt zusätzlich Wörter mit Klammer („Ausgeblendet (gemeldet)“) und nimmt rel und
  Namen in Klammerschreibweise (headers['X-Aircard-Jwt']) aus. Acht begründete Ausnahmen kommen dazu (zwei AppwriteException-Meldungen,
  vier interne Logo-Fehler, der Sprachknopf in der Zielsprache). Neu: ein Prüfer über den Syntaxbaum, der verlangt, dass jede Konstante
  mit msg() nur in t(), new Error(), Vergleichen, case und Listen/Mengen vorkommt (auch über Importe und Umbenennungen); ohne Ausnahmen.
- Tests: format.test.ts (beide Sprachen), Englisch-Blöcke in publish-dialog, gallery-grid, home-page, community-page, cards-page und
  logo-info (Texte, Fehlerwege, „Publishing failed – This card is already published.“, kein deutscher Wörterbuchtext auf der Seite). Die
  Hilfen texts()/germanLeft() liegen jetzt in i18n/english-check.ts (vorher in english-ui.test.ts). Der bestehende Englisch-Test in
  cards-page.test.ts sucht den Knopf „Download“ statt „Herunterladen“.
- Vitest 53 Dateien, 1812 Tests grün (vorher 1779), tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 00:34:10 +00:00
1 parent 1ff1873b27
commit 7e66cd0f0f
20 files changed
+1164 -211

No files matched your search

+49 -39
View File
@@ -1,5 +1,5 @@
// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags, Anzeigename und die Pflicht-Checkbox.
// Eigenes <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML.
// Eigenes <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
import '../account/account.css';
import './community.css';
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name';
@@ -7,15 +7,15 @@ import { errorMessage } from '../account/errors';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { el } from '../editor/dom';
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
import { ApiError, isAlreadyPublished } from './api';
import { msg, t } from '../i18n/i18n';
import { ApiError, apiErrorText, isAlreadyPublished } from './api';
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
/** Wortlaut aus Spec § 6. */
export const CONSENT_TEXT = 'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.';
export const CONSENT_TEXT = msg('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
/** Wortlaut aus Spec § 11. */
export const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
const FAILED_PREFIX = 'Veröffentlichen fehlgeschlagen – ';
const RUNNING = 'Wird veröffentlicht …';
export const ALREADY_PUBLISHED = msg('Diese Karte ist schon veröffentlicht.');
const RUNNING = msg('Wird veröffentlicht …');
/** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */
const TITLE_MAX = 60;
@@ -37,47 +37,57 @@ export interface PublishFormValues extends PublishForm {
export type PublishField = 'title' | 'issuer' | 'tags' | 'displayName' | 'consent';
/** Das erste Problem in Formularreihenfolge (mit deutschem Text) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */
/** Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */
export function validatePublishForm(values: PublishFormValues): { field: PublishField; message: string } | null {
const title = values.title.trim();
if (title === '') return { field: 'title', message: 'Bitte gib einen Titel ein.' };
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: 'Der Titel enthält ungültige Zeichen.' };
if (title.length > TITLE_MAX) return { field: 'title', message: `Der Titel ist zu lang (höchstens ${TITLE_MAX} Zeichen).` };
if (title === '') return { field: 'title', message: t('Bitte gib einen Titel ein.') };
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: t('Der Titel enthält ungültige Zeichen.') };
if (title.length > TITLE_MAX) {
return { field: 'title', message: t('Der Titel ist zu lang (höchstens {max} Zeichen).', { max: TITLE_MAX }) };
}
const issuer = values.issuer.trim();
if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: 'Herausgeber / Bank enthält ungültige Zeichen.' };
if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: t('Herausgeber / Bank enthält ungültige Zeichen.') };
if (issuer.length > ISSUER_MAX) {
return { field: 'issuer', message: `Herausgeber / Bank ist zu lang (höchstens ${ISSUER_MAX} Zeichen).` };
return { field: 'issuer', message: t('Herausgeber / Bank ist zu lang (höchstens {max} Zeichen).', { max: ISSUER_MAX }) };
}
const tags = normalizeTags(values.tags);
if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: 'Tags enthalten ungültige Zeichen.' };
if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: t('Tags enthalten ungültige Zeichen.') };
if (tags.some((tag) => tag.length > TAG_MAX)) {
return { field: 'tags', message: `Ein Tag darf höchstens ${TAG_MAX} Zeichen lang sein.` };
return { field: 'tags', message: t('Ein Tag darf höchstens {max} Zeichen lang sein.', { max: TAG_MAX }) };
}
if (tags.length > TAGS_MAX) return { field: 'tags', message: `Bitte gib höchstens ${TAGS_MAX} Tags an.` };
if (tags.length > TAGS_MAX) return { field: 'tags', message: t('Bitte gib höchstens {max} Tags an.', { max: TAGS_MAX }) };
const displayName = values.displayName.trim();
if (CONTROL_CHARACTERS.test(displayName)) {
return { field: 'displayName', message: 'Der Anzeigename enthält ungültige Zeichen.' };
return { field: 'displayName', message: t('Der Anzeigename enthält ungültige Zeichen.') };
}
if (displayName.length > DISPLAY_NAME_MAX) {
return { field: 'displayName', message: `Der Anzeigename ist zu lang (höchstens ${DISPLAY_NAME_MAX} Zeichen).` };
return {
field: 'displayName',
message: t('Der Anzeigename ist zu lang (höchstens {max} Zeichen).', { max: DISPLAY_NAME_MAX }),
};
}
if (!values.consent) return { field: 'consent', message: 'Bitte bestätige die Erklärung zum Bild.' };
if (!values.consent) return { field: 'consent', message: t('Bitte bestätige die Erklärung zum Bild.') };
return null;
}
/** Meldungen aus dem Rendern und dem Bildlesen sind schon deutsch; alles andere übersetzt `errorMessage`. */
/** Meldungen aus dem Rendern und dem Bildlesen tragen den deutschen Text (msg); angezeigt werden sie mit t(). Alles andere übersetzt `errorMessage`. */
const GERMAN_MESSAGES = new Set<string>([TOO_LARGE, NOT_RENDERED, LOGO_MISSING_PUBLISH, READ_MESSAGE, PIXELS_MESSAGE]);
/** Der Text zu einem fehlgeschlagenen Veröffentlichen (Spec § 11: „Veröffentlichen fehlgeschlagen – …“). */
/**
* Der Text zu einem fehlgeschlagenen Veröffentlichen in der Sprache der Seite (Spec § 11: „Veröffentlichen fehlgeschlagen – …“).
* Fehler des Dienstes kommen über ihren Code (`apiErrorText`).
*/
export function publishFailureText(err: unknown): string {
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
if (err instanceof ApiError) return FAILED_PREFIX + err.message;
if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) return FAILED_PREFIX + err.message;
return FAILED_PREFIX + errorMessage(err, 'save');
if (err instanceof LoginCancelled) return t(LOGIN_NEEDED);
let reason: string;
if (err instanceof ApiError) reason = apiErrorText(err);
else if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) reason = t(err.message);
else reason = errorMessage(err, 'save');
return t('Veröffentlichen fehlgeschlagen – {reason}', { reason });
}
/** `published`: veröffentlicht; `already`: die Karte war schon veröffentlicht (409); `cancelled`: nichts veröffentlicht oder Dialog verlassen. */
@@ -114,7 +124,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
const titleInput = text('title', { 'aria-required': 'true' });
const issuerInput = text('issuer');
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' });
const nameInput = text('displayName', { placeholder: 'Anonym', 'aria-describedby': 'publish-displayName-hint' });
const nameInput = text('displayName', { placeholder: t('Anonym'), 'aria-describedby': 'publish-displayName-hint' });
nameInput.value = rememberedDisplayName(); // der zuletzt benutzte Name, auch aus dem Dialog „Logo hochladen“
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
const inputs: Record<PublishField, HTMLInputElement> = {
@@ -138,30 +148,30 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
'p',
{ class: 'report-text' },
opts.cardName
? `„${opts.cardName}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.`
: 'Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.',
? t('„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.', { name: opts.cardName })
: t('Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.'),
);
const fields = el(
'div',
{ class: 'publish-fields' },
field('Titel (Pflicht)', titleInput),
field('Herausgeber / Bank (optional)', issuerInput),
field('Tags (optional)', tagsInput, 'Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.'),
field('Anzeigename (optional)', nameInput, 'Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.'),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, CONSENT_TEXT)),
field(t('Titel (Pflicht)'), titleInput),
field(t('Herausgeber / Bank (optional)'), issuerInput),
field(t('Tags (optional)'), tagsInput, t('Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.')),
field(t('Anzeigename (optional)'), nameInput, t('Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.')),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen');
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Veröffentlichen');
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Veröffentlichen'));
const actions = el('div', { class: 'report-actions' }, cancel, send);
// novalidate: die Prüfung läuft selbst, mit deutschen Texten statt der des Browsers.
// novalidate: die Prüfung läuft selbst, mit Texten der Seite statt der des Browsers.
const form = el('form', { class: 'report-form', novalidate: true }, intro, fields, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-heading' },
// Eigene ID für die Überschrift: `publish-title` gehört dem Titelfeld (`publish-<name>`), sonst zeigt sein Label auf die Überschrift.
el('div', { class: 'report-card' }, el('h2', { id: 'publish-heading', class: 'report-title' }, 'Karte veröffentlichen'), form),
el('div', { class: 'report-card' }, el('h2', { id: 'publish-heading', class: 'report-title' }, t('Karte veröffentlichen')), form),
);
const finish = () => {
@@ -191,7 +201,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
for (const control of Object.values(inputs)) control.disabled = value;
send.disabled = value;
cancel.disabled = value;
statusEl.textContent = value ? RUNNING : '';
statusEl.textContent = value ? t(RUNNING) : '';
}
function clearInvalid(): void {
@@ -201,10 +211,10 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
function showAlready(): void {
outcome = 'already';
errorEl.textContent = '';
statusEl.textContent = ALREADY_PUBLISHED;
statusEl.textContent = t(ALREADY_PUBLISHED);
intro.remove();
fields.remove();
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Schließen');
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Schließen'));
close.addEventListener('click', finish);
actions.replaceChildren(close);
close.focus();