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:
1 parent
1ff1873b27
commit
7e66cd0f0f
20 files changed
+1164
-211
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user