From 7e66cd0f0fc8b21e304289ef3d58c957f75d3fc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:34:05 +0000 Subject: [PATCH] feat(i18n): Community, Startseite und Meine Karten auf Englisch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/community/gallery-grid.test.ts | 22 +++ web/src/community/gallery-grid.ts | 9 +- web/src/community/gallery.ts | 3 +- web/src/community/publish-dialog.test.ts | 113 +++++++++++ web/src/community/publish-dialog.ts | 88 +++++---- web/src/community/publish.ts | 5 +- web/src/i18n/coverage.test.ts | 235 ++++++++++++++++++++++- web/src/i18n/en.ts | 105 ++++++++++ web/src/i18n/english-check.ts | 17 ++ web/src/i18n/english-ui.test.ts | 16 +- web/src/logos/logo-info.test.ts | 4 + web/src/logos/prepare-logo.ts | 19 +- web/src/pages/cards-page.test.ts | 180 ++++++++++++++++- web/src/pages/cards-page.ts | 103 +++++----- web/src/pages/community-page.test.ts | 171 +++++++++++++++++ web/src/pages/community-page.ts | 99 +++++----- web/src/pages/format.test.ts | 25 +++ web/src/pages/format.ts | 10 +- web/src/pages/home-page.test.ts | 79 ++++++++ web/src/pages/home-page.ts | 72 +++---- 20 files changed, 1164 insertions(+), 211 deletions(-) create mode 100644 web/src/i18n/english-check.ts diff --git a/web/src/community/gallery-grid.test.ts b/web/src/community/gallery-grid.test.ts index eda417e..caa6f72 100644 --- a/web/src/community/gallery-grid.test.ts +++ b/web/src/community/gallery-grid.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { germanLeft } from '../i18n/english-check'; +import { setLang } from '../i18n/i18n'; import type { CheckedImage, GalleryCard } from './gallery'; import { mountGalleryGrid, type GalleryGrid } from './gallery-grid'; @@ -204,3 +206,23 @@ describe('mountGalleryGrid: Vorschaubilder', () => { expect(revoked).toEqual(['blob:t-1']); // nicht doppelt freigeben }); }); + +describe('mountGalleryGrid auf Englisch', () => { + it('Tag-Titel, „No preview“ und die Status-Marken der Admin-Ansicht; Nutzerinhalte bleiben unverändert', async () => { + setLang('en', { persist: false }); + const load = vi.fn(async (c: GalleryCard) => { + if (c.id === 'g1') throw new Error('404'); + return image(); + }); + grid = mountGalleryGrid(host, { load, onTag: () => {}, showStatus: true }); + grid.append([card(1, { hidden: true, reportCount: 3 }), card(2, { reportCount: 1 })]); + await flush(); + expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('No preview'); + expect(tiles()[0].querySelector('.gallery-tag')?.getAttribute('title')).toBe('Show cards with this tag'); + const badges = tiles().map((t) => [...t.querySelectorAll('.gallery-badge')].map((b) => b.textContent)); + expect(badges).toEqual([['Hidden', '3 reports'], ['1 report']]); + expect(tiles()[0].querySelector('.gallery-title')?.textContent).toBe('Karte 1'); + expect(tiles()[0].querySelector('.gallery-issuer')?.textContent).toBe('Beispielbank'); + expect(germanLeft(host)).toEqual([]); + }); +}); diff --git a/web/src/community/gallery-grid.ts b/web/src/community/gallery-grid.ts index 55f4629..0168f0f 100644 --- a/web/src/community/gallery-grid.ts +++ b/web/src/community/gallery-grid.ts @@ -1,8 +1,9 @@ // Kachelraster der Community-Galerie: Vorschau, Titel, Herausgeber, bis zu drei Tags. // Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket, mit Pixelgrenze geprüft: loadGalleryImageChecked) -// als fertige Object-URL; `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML. +// als fertige Object-URL; `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML; übersetzt wird beim Aufbau. import { CARD_H, CARD_W, PREVIEW_WIDTH } from '../card/geometry'; import { el } from '../editor/dom'; +import { t } from '../i18n/i18n'; import { reportsLabel } from '../pages/format'; import type { CheckedImage, GalleryCard } from './gallery'; @@ -37,7 +38,7 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption let active = 0; const showEmpty = (thumb: HTMLElement) => - thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, 'Keine Vorschau')); + thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, t('Keine Vorschau'))); async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise { try { @@ -89,7 +90,7 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption if (tags.length > 0) { const row = el('div', { class: 'gallery-tags' }); for (const tag of tags) { - const button = el('button', { type: 'button', class: 'gallery-tag', title: 'Karten mit diesem Tag zeigen' }, tag); + const button = el('button', { type: 'button', class: 'gallery-tag', title: t('Karten mit diesem Tag zeigen') }, tag); button.addEventListener('click', () => opts.onTag(tag)); row.append(button); } @@ -98,7 +99,7 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption if (opts.showStatus && (card.hidden || card.reportCount > 0)) { const badges = el('div', { class: 'gallery-badges' }); - if (card.hidden) badges.append(el('span', { class: 'gallery-badge gallery-badge-hidden' }, 'Ausgeblendet')); + if (card.hidden) badges.append(el('span', { class: 'gallery-badge gallery-badge-hidden' }, t('Ausgeblendet'))); if (card.reportCount > 0) badges.append(el('span', { class: 'gallery-badge' }, reportsLabel(card.reportCount))); tile.append(badges); } diff --git a/web/src/community/gallery.ts b/web/src/community/gallery.ts index 20cf6b4..05c5c29 100644 --- a/web/src/community/gallery.ts +++ b/web/src/community/gallery.ts @@ -3,6 +3,7 @@ import { AppwriteException, Query, type Models } from 'appwrite'; import { ID_PATTERN, fetchFile } from '../account/cards'; import { IDS, tables } from '../account/appwrite'; +import { msg } from '../i18n/i18n'; export interface GalleryCard { id: string; @@ -174,7 +175,7 @@ export async function loadGalleryImage(card: GalleryCard, size: 'full' | 'previe /** Obergrenze für Breite × Höhe eines Galerie-Bildes: die Karte hat 1536 × 969 (rund 1,5 Megapixel). */ export const MAX_GALLERY_PIXELS = 4_000_000; -const IMAGE_UNUSABLE = 'Das Bild konnte nicht geladen werden.'; +const IMAGE_UNUSABLE = msg('Das Bild konnte nicht geladen werden.'); /** Ein geprüftes Galerie-Bild; `url` ist eine Object-URL auf `blob`, die der Aufrufer mit `URL.revokeObjectURL` freigibt. */ export interface CheckedImage { diff --git a/web/src/community/publish-dialog.test.ts b/web/src/community/publish-dialog.test.ts index 3c4b7c7..0b1e46b 100644 --- a/web/src/community/publish-dialog.test.ts +++ b/web/src/community/publish-dialog.test.ts @@ -6,6 +6,8 @@ vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () import { DISPLAY_NAME_KEY } from '../account/display-name'; import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; +import { germanLeft } from '../i18n/english-check'; +import { setLang } from '../i18n/i18n'; import { ApiError } from './api'; import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish'; import { @@ -556,3 +558,114 @@ describe('Veröffentlichen-Dialog: Schließen', () => { expect(document.activeElement).toBe(opener); }); }); + +describe('Veröffentlichen-Dialog auf Englisch', () => { + beforeEach(() => setLang('en', { persist: false })); + + it('Überschrift, Einleitung, Felder, Hinweise, Einwilligung und Knöpfe', () => { + void open({ cardName: 'Urlaub' }); + const dialog = dialogEl()!; + expect(dialog.querySelector('h2')!.textContent).toBe('Publish card'); + expect(dialog.querySelector('.report-text')!.textContent).toBe('“Urlaub” will be shown publicly in the community gallery, including its image.'); + expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([ + 'Title (required)', + 'Issuer / bank (optional)', + 'Tags (optional)', + 'Display name (optional)', + ]); + expect(dialog.querySelector('#publish-tags-hint')!.textContent).toBe('Up to 5, separated by commas, e.g. “visa, gold”.'); + expect(dialog.querySelector('#publish-displayName-hint')!.textContent).toBe('Leave empty for “Anonymous”. Your email address is never shown.'); + expect(input('displayName').placeholder).toBe('Anonymous'); + expect(consent().closest('label')!.textContent).toBe('The image is mine or I’m allowed to use it, and it contains no real card data.'); + expect(buttonByText('Cancel')).toBeTruthy(); + expect(buttonByText('Publish')).toBeTruthy(); + expect(germanLeft(dialog)).toEqual([]); + }); + + it('ohne Kartennamen eine allgemeine Einleitung; die Konstante bleibt der deutsche Schlüssel', () => { + void open(); + expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('The card will be shown publicly in the community gallery, including its image.'); + expect(CONSENT_TEXT).toBe('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.'); + }); + + it('die Prüfung vor dem Senden meldet in der Sprache der Seite', () => { + const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true }; + const problem = (values: Partial) => validatePublishForm({ ...ok, ...values })?.message; + expect(problem({ title: ' ' })).toBe('Please enter a title.'); + expect(problem({ title: 'a\tb' })).toBe('The title contains invalid characters.'); + expect(problem({ title: 'x'.repeat(61) })).toBe('The title is too long (at most 60 characters).'); + expect(problem({ issuer: 'Bank\u200f' })).toBe('Issuer / bank contains invalid characters.'); + expect(problem({ issuer: 'x'.repeat(41) })).toBe('Issuer / bank is too long (at most 40 characters).'); + expect(problem({ tags: 'visa, gol\u200bd' })).toBe('Tags contain invalid characters.'); + expect(problem({ tags: `visa, ${'x'.repeat(21)}` })).toBe('A tag can be at most 20 characters long.'); + expect(problem({ tags: 'a, b, c, d, e, f' })).toBe('Please enter at most 5 tags.'); + expect(problem({ displayName: 'Ken\u2060so' })).toBe('The display name contains invalid characters.'); + expect(problem({ displayName: 'x'.repeat(31) })).toBe('The display name is too long (at most 30 characters).'); + expect(problem({ consent: false })).toBe('Please confirm the statement about the image.'); + }); + + it('Laufzeit, Fehler des Dienstes (über ihren Code), Rendern, Netz und abgebrochene Anmeldung erscheinen auf Englisch', async () => { + let fail!: (e: Error) => void; + submit.mockReturnValueOnce(new Promise((_, reject) => (fail = reject))); + void open(); + fill(); + send(); + expect(statusText()).toBe('Publishing …'); + fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', undefined, 'unavailable')); + await settle(); + expect(errorText()).toBe('Publishing failed – The service is currently unavailable.'); + expect(statusText()).toBe(''); + + // ohne bekannten Code zeigt Englisch die allgemeine Meldung zum Status, nicht den deutschen Servertext + submit.mockRejectedValueOnce(new ApiError(403, 'Irgendein Servertext.')); + send(); + await settle(); + expect(errorText()).toBe('Publishing failed – You are not allowed to do that.'); + + submit.mockRejectedValueOnce(new Error(TOO_LARGE)); + send(); + await settle(); + expect(errorText()).toBe('Publishing failed – The card image is too large for the gallery (max. 5 MB).'); + submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH)); + send(); + await settle(); + expect(errorText()).toBe('Publishing failed – This card’s logo is no longer available. Open the card in the editor and choose another one.'); + submit.mockRejectedValueOnce(new TypeError('Failed to fetch')); + send(); + await settle(); + expect(errorText()).toBe('Publishing failed – Can’t reach the server.'); + submit.mockRejectedValueOnce(new LoginCancelled()); + send(); + await settle(); + expect(errorText()).toBe('Please sign in to continue.'); + expect(germanLeft(dialogEl()!)).toEqual([]); + }); + + it('„Publishing failed – This card is already published.“: ein ApiError mit dem Code alreadyPublished', () => { + const err = new ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'); + expect(publishFailureText(err)).toBe('Publishing failed – This card is already published.'); + // in der anderen Sprache derselbe Fehler mit dem deutschen Text + setLang('de', { persist: false }); + expect(publishFailureText(err)).toBe('Veröffentlichen fehlgeschlagen – Diese Karte ist schon veröffentlicht.'); + }); + + it('ein Fehler mit dem Code alreadyPublished, aber anderem Status, zeigt ihn im Dialog mit dem Vorsatz', async () => { + submit.mockRejectedValueOnce(new ApiError(400, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished')); + void open(); + fill(); + send(); + await settle(); + expect(errorText()).toBe('Publishing failed – This card is already published.'); + }); + + it('409: „This card is already published.“ und „Close“', async () => { + submit.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished')); + const outcome = open(); + fill(); + send(); + await settle(); + expect(statusText()).toBe('This card is already published.'); + buttonByText('Close')!.click(); + await expect(outcome).resolves.toBe('already'); + }); +}); diff --git a/web/src/community/publish-dialog.ts b/web/src/community/publish-dialog.ts index 4526635..fcf958b 100644 --- a/web/src/community/publish-dialog.ts +++ b/web/src/community/publish-dialog.ts @@ -1,5 +1,5 @@ // Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags, Anzeigename und die Pflicht-Checkbox. -// Eigenes wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. +// Eigenes 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([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 = { @@ -138,30 +148,30 @@ export function publishDialog(opts: PublishDialogOptions): Promise`), 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 /** * 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. + * ein Wort hat mindestens zwei Buchstaben, Satzzeichen am Ende und eine Klammer davor oder dahinter 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; + 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); } @@ -125,9 +168,11 @@ function isExemptPosition(node: ts.Node): boolean { 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 : ''); + // { 'aria-label': … } (der Name, nicht der Wert), { class: 'card tile' } (CSS-Klassen) und { rel: 'noopener noreferrer' } + return parent.name === node || ['class', 'className', 'rel'].includes(ts.isIdentifier(parent.name) ? parent.name.text : ''); } + // headers['X-Aircard-Jwt'] = …: ein Name in Klammerschreibweise, kein Text + if (ts.isElementAccessExpression(parent)) return parent.argumentExpression === node; if (!ts.isCallExpression(parent)) return false; const callee = parent.expression; if (ts.isIdentifier(callee)) return ['t', 'msg'].includes(callee.text) && parent.arguments[0] === node; @@ -163,6 +208,102 @@ const untranslated: Array<{ file: string; text: string }> = sources() .filter(([path]) => inTranslatedArea(path)) .flatMap(([path, code]) => findUntranslated(code).map((text) => ({ file: path, text }))); +// ---- Konstanten mit msg() dürfen nur über t() angezeigt werden ------------------------------------------------------------------- +// Die Heuristik oben sieht deutsche Literale, nicht aber eine Konstante `const TEXT = msg('…')`, die ohne t() im DOM landet: +// ihr Literal steht ja in msg(). Dafür liest dieser Teil, wo eine solche Konstante vorkommt. + +const isCall = (node: ts.Node, name: string): node is ts.CallExpression => + ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name; + +/** Die Namen der Konstanten `const NAME = msg('…')` einer Datei, dazu die davon exportierten. */ +function msgConstants(file: ts.SourceFile): { all: Set; exported: Set } { + const out = { all: new Set(), exported: new Set() }; + const visit = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isCall(node.initializer, 'msg')) { + out.all.add(node.name.text); + const statement = node.parent.parent; + if (ts.isVariableStatement(statement) && statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { + out.exported.add(node.name.text); + } + } + ts.forEachChild(node, visit); + }; + visit(file); + return out; +} + +/** Ist der Bezeichner ein Wert-Gebrauch (nicht der Name der Deklaration, einer Eigenschaft oder eines Imports)? */ +function isValueUse(node: ts.Identifier): boolean { + const parent = node.parent; + if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent) || ts.isTypeQueryNode(parent)) return false; + if (ts.isPropertyAccessExpression(parent)) return parent.name !== node; // obj.NAME ist nicht die Konstante + if (ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent) || ts.isParameter(parent)) return parent.name !== node; + return true; +} + +const FALLBACKS = [ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.AmpersandAmpersandToken]; + +/** + * Wird der Text der Konstante dort nur übersetzt, verglichen oder als Fehlermeldung weitergegeben? Das sind `t(NAME)`, + * `new Error(NAME)`, `===`/`!==`/`case` und eine Liste oder Menge zum Vergleichen (`[NAME, …]`, `new Set([NAME, …])`); Klammern, + * `ok ? A : B`, `??`, `||` und `&&` zählen mit. Alles andere (Argument einer anderen Funktion, Wert einer Eigenschaft, Teil eines + * Templates, Zuweisung …) zeigt den deutschen Text an oder reicht ihn an einen weiter, der es tut. Nicht erkannt wird, was später + * aus einer Liste gelesen und ohne t() angezeigt wird. + */ +function isTranslatedOrCompared(node: ts.Identifier): boolean { + let child: ts.Node = node; + let parent = node.parent; + while ( + ts.isParenthesizedExpression(parent) || + (ts.isConditionalExpression(parent) && parent.condition !== child) || + (ts.isBinaryExpression(parent) && FALLBACKS.includes(parent.operatorToken.kind)) + ) { + child = parent; + parent = parent.parent; + } + if (isCall(parent, 't')) return parent.arguments[0] === child; + if (ts.isNewExpression(parent)) return ts.isIdentifier(parent.expression) && parent.expression.text === 'Error'; + if (ts.isBinaryExpression(parent)) return EQUALITY.includes(parent.operatorToken.kind); + return ts.isCaseClause(parent) || ts.isArrayLiteralExpression(parent); +} + +/** + * Die Stellen („Zeile: Quelltext“), an denen eine msg()-Konstante der Datei nicht durch t() geht. Bekannt sind die Konstanten der + * Datei selbst und die, die sie aus einer anderen Datei importiert (`exportsOf`: Pfad relativ zu src/ → exportierte msg()-Konstanten). + */ +function findRawConstantUses(path: string, code: string, exportsOf: ReadonlyMap>): string[] { + const file = ts.createSourceFile(path, code, ts.ScriptTarget.Latest, true); + const known = new Set(msgConstants(file).all); + for (const statement of file.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue; + if (!statement.moduleSpecifier.text.startsWith('.')) continue; + const target = `${join(path, '..', statement.moduleSpecifier.text)}.ts`; + const bindings = statement.importClause?.namedBindings; + if (!bindings || !ts.isNamedImports(bindings)) continue; + for (const item of bindings.elements) { + if (exportsOf.get(target)?.has((item.propertyName ?? item.name).text)) known.add(item.name.text); + } + } + const out: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isIdentifier(node) && known.has(node.text) && isValueUse(node) && !isTranslatedOrCompared(node)) { + const line = file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1; + out.push(`${line}: ${node.parent.getText(file).replace(/\s+/g, ' ').slice(0, 80)}`); + } + ts.forEachChild(node, visit); + }; + visit(file); + return out; +} + +// Pfad → exportierte msg()-Konstanten, dann je Datei die Stellen, an denen eine davon unübersetzt vorkommt. +const msgExports = new Map( + sources().map(([path, code]) => [path, msgConstants(ts.createSourceFile(path, code, ts.ScriptTarget.Latest, true)).exported] as const), +); +const rawConstantUses: string[] = sources().flatMap(([path, code]) => + findRawConstantUses(path, code, msgExports).map((use) => `${path}:${use}`), +); + 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); @@ -259,6 +400,7 @@ describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => { "const f = 'E-Mail';", "const g = 'Bearbeitet: ';", 'const h = `Nur Bild`;', + "const i = 'Ausgeblendet (gemeldet)';", ].join('\n'); expect(findUntranslated(code)).toEqual([ 'Schließen', @@ -270,6 +412,7 @@ describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => { 'E-Mail', 'Bearbeitet: ', 'Nur Bild', + 'Ausgeblendet (gemeldet)', ]); }); @@ -290,13 +433,15 @@ describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => { expect(findUntranslated(code)).toEqual([]); }); - it('ignoriert technische Zeichenketten: CSS-Klassen, Kleinbuchstaben-Wörter, Formate, Bezeichner, Zahlen', () => { + it('ignoriert technische Zeichenketten: CSS-Klassen, rel, Kopfzeilennamen, 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}`;', + "const link = { href, target: '_blank', rel: 'noopener noreferrer' };", + "headers['X-Aircard-Jwt'] = jwt;", ].join('\n'); expect(findUntranslated(code)).toEqual([]); }); @@ -305,3 +450,79 @@ describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => { expect(findUntranslated("const a = t(ok ? 'Gespeichert' : 'Speichern');")).toEqual(['Gespeichert', 'Speichern']); }); }); + +describe('msg()-Konstanten werden nur über t() angezeigt', () => { + it('der Scanner kennt die Konstanten (sonst bestünde die Prüfung leer)', () => { + expect(msgExports.get('account/with-login.ts')).toContain('LOGIN_NEEDED'); + expect(msgExports.get('editor/upload.ts')).toContain('READ_MESSAGE'); + }); + + it('keine Konstante mit msg() steht ohne t() im DOM', () => { + expect(rawConstantUses).toEqual([]); + }); +}); + +describe('Scanner für msg()-Konstanten ohne t()', () => { + const none = new Map>(); + + it('findet eine Konstante als Argument, Eigenschaftswert, Template-Teil, Rückgabewert und Zuweisung', () => { + const code = [ + "const TEXT = msg('Hallo');", + 'el(\'p\', {}, TEXT);', + 'const a = { title: TEXT };', + 'const b = `Fehler: ${TEXT}`;', + 'function c() { return TEXT; }', + 'node.textContent = ok ? TEXT : TEXT;', + 'show(TEXT ?? t(TEXT));', + ].join('\n'); + expect(findRawConstantUses('quelle.ts', code, none).map((use) => use.split(':')[0])).toEqual(['2', '3', '4', '5', '6', '6', '7']); + }); + + it('lässt t(NAME), Bedingungen und Rückfälle darin, new Error, Vergleiche, case, Listen und Mengen durch', () => { + const code = [ + "const A = msg('Eins');", + "const B = msg('Zwei');", + 'const x = t(A) + t(ok ? A : B) + t((A ?? B) || A);', + 'const y = t(A, { n: 1 });', + 'throw new Error(A);', + 'throw new Error(ok ? A : B);', + 'if (err.message === A || B !== err.message) f();', + 'switch (text) { case A: break; }', + 'const known = new Set([A, B]);', + 'const list = [A, B];', + ].join('\n'); + expect(findRawConstantUses('quelle.ts', code, none)).toEqual([]); + }); + + it('die Bedingung einer Bedingung zählt nicht als Weitergabe des Textes', () => { + const code = ["const A = msg('Eins');", 'const x = t(A ? 1 : 2);'].join('\n'); + expect(findRawConstantUses('quelle.ts', code, none).map((use) => use.split(':')[0])).toEqual(['2']); + }); + + it('ignoriert Deklaration, Eigenschaftsnamen, Importe, Typen und gleich lautende Eigenschaften', () => { + const code = [ + "import { A } from './x';", + "export const B = msg('Zwei');", + 'const o = { B: 1, C: obj.B };', + 'type T = typeof B;', + 'export { B };', + ].join('\n'); + expect(findRawConstantUses('quelle.ts', code, new Map([['x.ts', new Set(['A'])]]))).toEqual([]); + }); + + it('kennt Konstanten aus anderen Dateien über den Import (auch umbenannt) und nur die exportierten', () => { + const exportsOf = new Map>([['account/with-login.ts', new Set(['LOGIN_NEEDED'])]]); + const code = [ + "import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';", + "import { LOGIN_NEEDED as NEEDED } from '../account/with-login';", + "import { OTHER } from '../account/with-login';", + "import { LOGIN_NEEDED as ELSE } from '../account/anders';", + 'const a = LOGIN_NEEDED;', + 'const b = NEEDED;', + 'const c = OTHER;', + 'const d = ELSE;', + 'const e = t(LOGIN_NEEDED) + t(NEEDED);', + ].join('\n'); + expect(findRawConstantUses('pages/seite.ts', code, exportsOf).map((use) => use.split(':')[0])).toEqual(['5', '6']); + }); +}); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 7beeec1..9a21c08 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -224,6 +224,111 @@ export const EN: Readonly> = { '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', + // Galerie-Raster, Galeriebilder und Zahl der Meldungen (community/gallery-grid.ts, community/gallery.ts, pages/format.ts). + // „Ausgeblendet“ steht weiter oben. + 'Keine Vorschau': 'No preview', + 'Karten mit diesem Tag zeigen': 'Show cards with this tag', + 'Das Bild konnte nicht geladen werden.': 'The image couldn’t be loaded.', + '1 Meldung': '1 report', + '{count} Meldungen': '{count} reports', + + // Veröffentlichen-Dialog (community/publish-dialog.ts, community/publish.ts). „Abbrechen“, „Schließen“, „Anonym“, „Anzeigename (optional)“, + // die Erklärung dazu und „Diese Karte ist schon veröffentlicht.“ stehen an anderer Stelle. + 'Karte veröffentlichen': 'Publish card', + '„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.': + '“{name}” will be shown publicly in the community gallery, including its image.', + 'Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.': + 'The card will be shown publicly in the community gallery, including its image.', + 'Titel (Pflicht)': 'Title (required)', + 'Herausgeber / Bank (optional)': 'Issuer / bank (optional)', + 'Tags (optional)': 'Tags (optional)', + 'Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.': 'Up to 5, separated by commas, e.g. “visa, gold”.', + 'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.': + 'The image is mine or I’m allowed to use it, and it contains no real card data.', + 'Veröffentlichen': 'Publish', + 'Wird veröffentlicht …': 'Publishing …', + 'Veröffentlichen fehlgeschlagen – {reason}': 'Publishing failed – {reason}', + 'Bitte gib einen Titel ein.': 'Please enter a title.', + 'Der Titel enthält ungültige Zeichen.': 'The title contains invalid characters.', + 'Der Titel ist zu lang (höchstens {max} Zeichen).': 'The title is too long (at most {max} characters).', + 'Herausgeber / Bank enthält ungültige Zeichen.': 'Issuer / bank contains invalid characters.', + 'Herausgeber / Bank ist zu lang (höchstens {max} Zeichen).': 'Issuer / bank is too long (at most {max} characters).', + 'Tags enthalten ungültige Zeichen.': 'Tags contain invalid characters.', + 'Ein Tag darf höchstens {max} Zeichen lang sein.': 'A tag can be at most {max} characters long.', + 'Bitte gib höchstens {max} Tags an.': 'Please enter at most {max} tags.', + 'Bitte bestätige die Erklärung zum Bild.': 'Please confirm the statement about the image.', + 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).': 'The card image is too large for the gallery (max. 5 MB).', + 'Das Kartenbild konnte nicht erzeugt werden.': 'The card image couldn’t be created.', + + // Startseite (pages/home-page.ts). „Gestalten“, „Gemeldet“, „Ausgeblendet“, „Nochmal versuchen“ und „Mehr laden“ stehen an anderer Stelle. + 'Willkommen': 'Welcome', + 'Ein bisschen Kunst für dein Wallet': 'A little art for your Wallet', + 'Kartendesigns für Apple Wallet': 'Card designs for Apple Wallet', + 'Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.': + 'Discover designs from the community, create your own and download it as a PNG.', + 'Karten entdecken ↓': 'Discover cards ↓', + 'Beispielmotiv: violetter Verlauf mit Wellen und dem AirCard-IOS-Symbol': + 'Sample design: purple gradient with waves and the AirCard-IOS symbol', + 'Community-Karte „{title}“': 'Community card “{title}”', + 'Community-Karten': 'Community cards', + 'Entdecke Motive der Community – und veröffentliche deine eigenen.': 'Discover designs from the community – and publish your own.', + 'Gestalten & veröffentlichen': 'Create & publish', + 'Suche': 'Search', + 'Suche zurücksetzen': 'Clear search', + 'Tag': 'Tag', + 'Tag-Filter zurücksetzen': 'Clear tag filter', + 'Ansicht': 'View', + 'Alle': 'All', + 'Karten werden geladen …': 'Loading cards …', + 'Die Community-Karten konnten gerade nicht geladen werden.': 'The community cards couldn’t be loaded right now.', + 'Weitere Karten konnten nicht geladen werden. Bitte nochmal versuchen.': 'More cards couldn’t be loaded. Please try again.', + 'Noch keine Community-Karten – sei die erste!': 'No community cards yet – be the first!', + 'Keine Karten in dieser Ansicht.': 'No cards in this view.', + 'Nichts gefunden für „{query}“': 'Nothing found for “{query}”', + + // Community-Detailseite (pages/community-page.ts). „Ausgeblendet“, „Melden“, „Löschen“, „Ausblenden“, „Einblenden“, „Herunterladen“, + // „Nochmal versuchen“, „von {name}“, „Anonym“, „Karte wird geladen …“, „Diese Karte ist nicht (mehr) verfügbar.“ und + // „Die Gründe konnten nicht geladen werden.“ stehen an anderer Stelle. + 'Die Karte konnte gerade nicht geladen werden.': 'The card couldn’t be loaded right now.', + 'Bild wird geladen …': 'Loading image …', + 'Der Melden-Dialog konnte nicht geöffnet werden.': 'The report dialog couldn’t be opened.', + 'Diese Karte ist für andere nicht sichtbar.': 'This card isn’t visible to others.', + 'Zur Galerie': 'Back to gallery', + '← Zur Galerie': '← Back to gallery', + 'Wird geladen …': 'Downloading …', + 'Als Vorlage gestalten': 'Use as template', + 'Zurückziehen': 'Withdraw', + 'Moderation': 'Moderation', + 'Karte eingeblendet.': 'Card unhidden.', + 'Karte ausgeblendet.': 'Card hidden.', + 'Karte „{title}“ zurückziehen?': 'Withdraw card “{title}”?', + 'Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.': + 'The card disappears from the gallery, and its image and reports are deleted. Your private card is kept.', + 'Karte „{title}“ löschen?': 'Delete card “{title}”?', + 'Die Karte, ihr Bild und alle Meldungen werden endgültig gelöscht.': 'The card, its image and all reports will be deleted permanently.', + + // Meine Karten (pages/cards-page.ts). „Meine Karten“, „Anmelden“, „Herunterladen“, „Löschen“, „Speichern“, „Abbrechen“, + // „Nochmal versuchen“ und „Karten werden geladen …“ stehen an anderer Stelle. + 'Melde dich an, um deine Karten zu sehen.': 'Sign in to see your cards.', + 'Noch keine Karten gespeichert.': 'No cards saved yet.', + 'Zum Editor': 'Go to editor', + 'Gespeicherte Karten': 'Saved cards', + 'Vorschau von „{name}“': 'Preview of “{name}”', + 'Vorschau lädt …': 'Loading preview …', + 'Der Veröffentlichungsstatus konnte nicht geladen werden.': 'The publishing status couldn’t be loaded.', + 'Öffnen': 'Open', + 'Umbenennen': 'Rename', + 'Ansehen': 'View', + 'Veröffentlicht': 'Published', + 'Ausgeblendet (gemeldet)': 'Hidden (reported)', + 'Wird erstellt …': 'Creating …', + 'Neuer Name für „{name}“': 'New name for “{name}”', + 'Die Karte und ihr Bild werden endgültig gelöscht.': 'The card and its image will be deleted permanently.', + 'Die veröffentlichte Karte bleibt in der Community-Galerie, bis du sie zurückziehst – das geht dann nur noch auf ihrer Community-Seite.': + 'The published card stays in the community gallery until you withdraw it – after deleting this card, you can only do that on its community page.', + 'Falls diese Karte veröffentlicht ist, bleibt die Veröffentlichung bestehen – zurückziehen kannst du sie auf ihrer Community-Seite.': + 'If this card is published, it stays in the gallery – you can withdraw it on its community page.', + // API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich. 'Bitte melde dich an.': 'Please sign in.', 'Nicht gefunden.': 'Not found.', diff --git a/web/src/i18n/english-check.ts b/web/src/i18n/english-check.ts new file mode 100644 index 0000000..c8ff018 --- /dev/null +++ b/web/src/i18n/english-check.ts @@ -0,0 +1,17 @@ +// Hilfen der Englisch-Tests (nur Tests importieren sie): die sichtbaren Texte eines Teilbaums und die deutschen Texte, die auf einer +// englischen Seite noch darin stehen. +import { EN } from './en'; + +/** Alle sichtbaren Texte (Textknoten) und Textattribute unter `root`. */ +export 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. */ +export const germanLeft = (root: ParentNode): string[] => texts(root).filter((text) => Object.hasOwn(EN, text) && EN[text] !== text); diff --git a/web/src/i18n/english-ui.test.ts b/web/src/i18n/english-ui.test.ts index 8a73cd1..c00f249 100644 --- a/web/src/i18n/english-ui.test.ts +++ b/web/src/i18n/english-ui.test.ts @@ -2,7 +2,7 @@ // 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'; +import { germanLeft, texts } from './english-check'; const mocks = vi.hoisted(() => ({ acct: { @@ -40,20 +40,6 @@ async function freshI18n(lang: 'de' | 'en') { 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'); diff --git a/web/src/logos/logo-info.test.ts b/web/src/logos/logo-info.test.ts index e6a960a..e893000 100644 --- a/web/src/logos/logo-info.test.ts +++ b/web/src/logos/logo-info.test.ts @@ -663,6 +663,10 @@ describe('Admin', () => { await mount(logo({ reportCount: 1, ownerId: 'u1' })); expect(buttons()).toEqual(['Report', 'Delete', 'Hide']); expect(reasons()).toEqual(['Spam']); + expect(reportCount()).toBe('1 report'); + row = logo({ hidden: true, reportCount: 3 }); + info.update(); + expect(reportCount()).toBe('3 reports'); row = logo({ hidden: true, reportCount: 1 }); info.update(); expect(buttons()).toEqual(['Delete', 'Unhide']); diff --git a/web/src/logos/prepare-logo.ts b/web/src/logos/prepare-logo.ts index 801301f..995045f 100644 --- a/web/src/logos/prepare-logo.ts +++ b/web/src/logos/prepare-logo.ts @@ -23,7 +23,7 @@ export const LOGO_SOURCE_MAX_BYTES = 5_242_880; /** Dieselbe Grenze wie beim Foto im Editor (40 Megapixel): `decodeImage` prüft sie nach dem Laden der Maße, bevor es dekodiert. */ export const LOGO_SOURCE_MAX_PIXELS = MAX_IMAGE_PIXELS; -// Die Meldungen (Spec § 4 und § 11); übersetzt wird beim Auslösen, siehe `problem`. +// Die Meldungen (Spec § 4 und § 11); übersetzt wird beim Auslösen: `new LogoError(t(MELDUNG))`. export const LOGO_TYPE_MESSAGE = msg('Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.'); export const LOGO_SIZE_MESSAGE = msg('Die Datei ist größer als 5 MB.'); export const LOGO_PIXELS_MESSAGE = msg('Das Bild ist zu groß (max. 40 Megapixel).'); @@ -54,11 +54,6 @@ export interface Size { /** Ein Fehler mit fertigem, übersetztem Text für die Oberfläche; jeder andere Fehler beim Verarbeiten gilt als „nicht lesbar“. */ class LogoError extends Error {} -/** Ein `LogoError` mit der Meldung in der Sprache der Seite. */ -function problem(text: string): LogoError { - return new LogoError(t(text)); -} - const RASTER_TYPES = ['image/png', 'image/jpeg', 'image/webp']; const SVG_TYPE = 'image/svg+xml'; @@ -127,9 +122,9 @@ export function hasTransparency(data: Uint8ClampedArray): boolean { */ export function planLogo(data: Uint8ClampedArray, w: number, h: number): { box: Box; size: Size } { const box = trimBox(data, w, h); - if (!box) throw problem(LOGO_EMPTY_MESSAGE); + if (!box) throw new LogoError(t(LOGO_EMPTY_MESSAGE)); const size = fitWithin(box.w, box.h, LOGO_MAX_W, LOGO_MAX_H); - if (size.w < LOGO_MIN || size.h < LOGO_MIN) throw problem(LOGO_SMALL_MESSAGE); + if (size.w < LOGO_MIN || size.h < LOGO_MIN) throw new LogoError(t(LOGO_SMALL_MESSAGE)); return { box, size }; } @@ -208,7 +203,7 @@ function newCanvas(w: number, h: number): { canvas: HTMLCanvasElement; ctx: Canv canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d', { willReadFrequently: true }); - if (!ctx) throw problem(LOGO_READ_MESSAGE); + if (!ctx) throw new LogoError(t(LOGO_READ_MESSAGE)); return { canvas, ctx }; } @@ -228,7 +223,7 @@ async function decode(blob: Blob): Promise { try { return await decodeImage(blob); } catch (err) { - if (err instanceof Error && err.message === PIXELS_MESSAGE) throw problem(LOGO_PIXELS_MESSAGE); + if (err instanceof Error && err.message === PIXELS_MESSAGE) throw new LogoError(t(LOGO_PIXELS_MESSAGE)); throw err; } } @@ -248,7 +243,7 @@ async function drawSvg(file: File): Promise { function toPng(canvas: HTMLCanvasElement): Promise { return new Promise((resolve, reject) => { - canvas.toBlob((blob) => (blob ? resolve(blob) : reject(problem(LOGO_READ_MESSAGE))), 'image/png'); + canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new LogoError(t(LOGO_READ_MESSAGE)))), 'image/png'); }); } @@ -273,6 +268,6 @@ export async function prepareLogo(file: File): Promise { hasTransparency: hasTransparency(out.ctx.getImageData(0, 0, size.w, size.h).data), }; } catch (err) { - throw err instanceof LogoError ? err : problem(LOGO_READ_MESSAGE); + throw err instanceof LogoError ? err : new LogoError(t(LOGO_READ_MESSAGE)); } } diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts index 8d09415..92e8f97 100644 --- a/web/src/pages/cards-page.test.ts +++ b/web/src/pages/cards-page.test.ts @@ -4,6 +4,8 @@ import type { SavedCard } from '../account/cards'; import { DEFAULT_CONFIG, type TemplateId } from '../card/config'; import { createStore, initialEditorState } from '../editor/state'; import type { GalleryCard } from '../community/gallery'; +import { germanLeft } from '../i18n/english-check'; +import { setLang } from '../i18n/i18n'; const mocks = vi.hoisted(() => ({ list: vi.fn(), @@ -317,11 +319,11 @@ describe('Meine Karten: Herunterladen mit fehlendem Logo', () => { mocks.exportCard.mockResolvedValue(png); }); - async function download(template: TemplateId): Promise { + async function download(template: TemplateId, label = 'Herunterladen'): Promise { mocks.list.mockResolvedValue([onTemplate(template)]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); - button(tileFor('a'), 'Herunterladen').click(); + button(tileFor('a'), label).click(); } it.each(['minimal', 'classic', 'signature'] as const)( @@ -359,9 +361,8 @@ describe('Meine Karten: Herunterladen mit fehlendem Logo', () => { }); it('Englisch: dieselbe Meldung, übersetzt', async () => { - const { setLang } = await import('../i18n/i18n'); setLang('en', { persist: false }); - await download('signature'); + await download('signature', 'Download'); await vi.waitFor(() => expect(msg().textContent).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.'), ); @@ -1269,3 +1270,174 @@ describe('Meine Karten: Zurückziehen', () => { expect(mocks.withdrawCard).not.toHaveBeenCalled(); }); }); + +describe('Meine Karten auf Englisch', () => { + beforeEach(() => setLang('en', { persist: false })); + + const withdrawNote = + 'The published card stays in the community gallery until you withdraw it – after deleting this card, you can only do that on its community page.'; + const maybeNote = 'If this card is published, it stays in the gallery – you can withdraw it on its community page.'; + + it('Zustände: Laden, abgemeldet, leer, Fehler', async () => { + mocks.getCurrentUser.mockReturnValue(new Promise(() => {})); + mount(); + expect(text()).toContain('Loading cards …'); + expect(root.querySelector('h1')!.textContent).toBe('My cards'); + expect(root.querySelector('section')!.getAttribute('aria-label')).toBe('Saved cards'); + unmount?.(); + + mocks.getCurrentUser.mockResolvedValue(null); + mount(); + await vi.waitFor(() => expect(text()).toContain('Sign in to see your cards.')); + expect(button(root, 'Sign in')).toBeTruthy(); + expect(germanLeft(root)).toEqual([]); + unmount?.(); + + mocks.getCurrentUser.mockResolvedValue(user); + mocks.list.mockResolvedValue([]); + mount(); + await vi.waitFor(() => expect(text()).toContain('No cards saved yet.')); + expect(root.querySelector('a[data-link]')!.textContent).toBe('Go to editor'); + unmount?.(); + + mocks.list.mockRejectedValue(new TypeError('offline')); + mount(); + await vi.waitFor(() => expect(text()).toContain('Can’t reach the server.')); + expect(button(root, 'Try again')).toBeTruthy(); + expect(germanLeft(root)).toEqual([]); + }); + + it('Kacheln: Datum, Vorschau-Texte, Knöpfe, Badges, Umbenennen und der Fehler beim Status', async () => { + const updatedAt = new Date(2026, 9, 4, 12).toISOString(); + mocks.list.mockResolvedValue([card('a', 'Alpha', { updatedAt }), card('b', 'Beta'), card('c', 'Gamma'), card('d', 'Delta')]); + mocks.myPublications.mockResolvedValue( + new Map([ + ['a', published('g1', 'a')], + ['c', published('g3', 'c', { hidden: true })], + ]), + ); + mocks.loadPreview.mockImplementation(async (c: SavedCard) => { + if (c.id === 'b') throw Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }); + if (c.id === 'd') return new Promise(() => {}); + return new Blob(['p'], { type: 'image/webp' }); + }); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(4)); + await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull()); + expect(tileFor('a').querySelector('.card-date')!.textContent).toBe('Oct 4, 2026'); + expect(tileFor('a').querySelector('img')!.getAttribute('alt')).toBe('Preview of “Alpha”'); + await vi.waitFor(() => expect(tileFor('b').textContent).toContain('No preview')); + expect(tileFor('d').textContent).toContain('Loading preview …'); + await vi.waitFor(() => expect(badge('a')).toBe('Published')); + expect(labels(tileFor('a'))).toEqual(['Open', 'Download', 'Rename', 'View', 'Withdraw', 'Delete']); + expect(labels(tileFor('b'))).toEqual(['Open', 'Download', 'Rename', 'Publish', 'Delete']); + expect(badge('c')).toBe('Hidden (reported)'); + expect(labels(tileFor('c'))).toEqual(['Open', 'Download', 'Rename', 'Withdraw', 'Delete']); + + button(tileFor('b'), 'Rename').click(); + expect(tileFor('b').querySelector('input')!.getAttribute('aria-label')).toBe('New name for “Beta”'); + expect([...tileFor('b').querySelectorAll('.card-rename button')].map((b) => b.textContent)).toEqual(['Save', 'Cancel']); + expect(germanLeft(root)).toEqual([]); + unmount?.(); + + mocks.list.mockResolvedValue([card('a', 'Alpha')]); + mocks.myPublications.mockRejectedValue(new TypeError('offline')); + mount(); + await vi.waitFor(() => expect(text()).toContain('The publishing status couldn’t be loaded.')); + expect(button(root, 'Try again')).toBeTruthy(); + expect(germanLeft(root)).toEqual([]); + }); + + it('Löschen: Rückfrage mit den Hinweisen zur Veröffentlichung (bekannt, unbekannt, keine)', async () => { + mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); + mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]])); + mount(); + await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Publish')); + const ask = async (id: string) => { + button(tileFor(id), 'Delete').click(); + await vi.waitFor(() => expect(dialog()?.open).toBe(true)); + const shown = dialog()!; + return { title: shown.querySelector('h2')!.textContent, text: shown.querySelector('p')!.textContent, buttons: [...shown.querySelectorAll('button')].map((b) => b.textContent) }; + }; + const cancel = async () => { + button(dialog()!, 'Cancel').click(); + await vi.waitFor(() => expect(dialog()).toBeNull()); + }; + + expect(await ask('a')).toEqual({ + title: 'Delete card “Alpha”?', + text: `The card and its image will be deleted permanently. ${withdrawNote}`, + buttons: ['Cancel', 'Delete'], + }); + await cancel(); + expect((await ask('b')).text).toBe('The card and its image will be deleted permanently.'); + await cancel(); + unmount?.(); + + // der Status ist noch unbekannt: allgemeiner Hinweis + mocks.myPublications.mockReturnValue(new Promise(() => {})); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(2)); + expect((await ask('a')).text).toBe(`The card and its image will be deleted permanently. ${maybeNote}`); + await cancel(); + }); + + it('Zurückziehen: Rückfrage und der Fehler des Dienstes über seinen Code', async () => { + mocks.list.mockResolvedValue([card('a', 'Alpha')]); + mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]])); + mocks.withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', undefined, 'unavailable')); + mount(); + await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Withdraw')); + button(tileFor('a'), 'Withdraw').click(); + await vi.waitFor(() => expect(dialog()?.open).toBe(true)); + expect(dialog()!.querySelector('h2')!.textContent).toBe('Withdraw card “Mein Motiv”?'); + expect(dialog()!.querySelector('p')!.textContent).toBe( + 'The card disappears from the gallery, and its image and reports are deleted. Your private card is kept.', + ); + expect([...dialog()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Cancel', 'Withdraw']); + button(dialog()!, 'Withdraw').click(); + await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('The service is currently unavailable.')); + + // ohne bekannten Code zeigt Englisch die allgemeine Meldung zum Status + mocks.withdrawCard.mockRejectedValue(new ApiError(403, 'Nur für Administratoren.')); + await vi.waitFor(() => expect(button(tileFor('a'), 'Withdraw').disabled).toBe(false)); + button(tileFor('a'), 'Withdraw').click(); + await vi.waitFor(() => expect(dialog()?.open).toBe(true)); + button(dialog()!, 'Withdraw').click(); + await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('You are not allowed to do that.')); + expect(germanLeft(root)).toEqual([]); + }); + + it('Herunterladen: „Creating …“ unterwegs, die Fehler (Lesen, Pixel, Anmeldung) als Text der Seite', async () => { + mocks.list.mockResolvedValue([card('a', 'Alpha')]); + mocks.loadImage.mockReturnValueOnce(new Promise(() => {})); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(1)); + button(tileFor('a'), 'Download').click(); + expect(labels(tileFor('a'))).toContain('Creating …'); + unmount?.(); + + mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/png' })); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(1)); + const msg = () => tileFor('a').querySelector('.card-msg')!.textContent; + mocks.decodeImage.mockRejectedValueOnce(new Error('Das Bild konnte nicht gelesen werden.')); + button(tileFor('a'), 'Download').click(); + await vi.waitFor(() => expect(msg()).toBe('The image couldn’t be read.')); + mocks.decodeImage.mockRejectedValueOnce(new Error('Das Bild ist zu groß (max. 40 Megapixel).')); + await vi.waitFor(() => expect(button(tileFor('a'), 'Download').disabled).toBe(false)); + button(tileFor('a'), 'Download').click(); + await vi.waitFor(() => expect(msg()).toBe('The image is too large (max. 40 megapixels).')); + + // die Sitzung ist weg und der Login-Dialog wird geschlossen + unmount?.(); + mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null); + mocks.requireLogin.mockResolvedValue(false); + mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(1)); + button(tileFor('a'), 'Download').click(); + await vi.waitFor(() => expect(msg()).toBe('Please sign in to continue.')); + expect(germanLeft(root)).toEqual([]); + }); +}); diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts index 5fb450a..8f94854 100644 --- a/web/src/pages/cards-page.ts +++ b/web/src/pages/cards-page.ts @@ -1,5 +1,6 @@ // Seite /karten („Meine Karten“): Kacheln mit Vorschau; Öffnen, Herunterladen, Umbenennen, Löschen, // Veröffentlichen in der Community-Galerie und Zurückziehen (Status je Kachel aus den eigenen Veröffentlichungen). +// Alle Texte kommen in der Sprache der Seite (t()); ein Sprachwechsel baut die Seite neu auf (main.ts). import '../account/account.css'; import './cards.css'; import { getCurrentUser, onAuthChange } from '../account/auth'; @@ -10,7 +11,7 @@ import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login'; import { clampCrop } from '../card/crop'; import { CARD_W } from '../card/geometry'; import { exportCard, loadCardAssets } from '../card/render'; -import { ApiError, isAlreadyPublished, isDefinitiveFailure, withdrawCard } from '../community/api'; +import { ApiError, apiErrorText, isAlreadyPublished, isDefinitiveFailure, withdrawCard } from '../community/api'; import { myPublications } from '../community/gallery'; import { LOGO_MISSING_PUBLISH, assertLogoAvailable, publishSavedCard, type PublishForm } from '../community/publish'; import { publishDialog } from '../community/publish-dialog'; @@ -19,20 +20,24 @@ import { el } from '../editor/dom'; import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download'; import type { EditorStore } from '../editor/state'; import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload'; -import { t } from '../i18n/i18n'; +import { msg, t } from '../i18n/i18n'; import { formatDate } from './format'; import { navigate } from './router'; -const LOADING = 'Karten werden geladen …'; -const LOGGED_OUT = 'Melde dich an, um deine Karten zu sehen.'; -const EMPTY = 'Noch keine Karten gespeichert.'; +const LOADING = msg('Karten werden geladen …'); +const LOGGED_OUT = msg('Melde dich an, um deine Karten zu sehen.'); +const EMPTY = msg('Noch keine Karten gespeichert.'); const PREVIEW_PARALLEL = 4; -const STATUS_FAILED = 'Der Veröffentlichungsstatus konnte nicht geladen werden.'; +const STATUS_FAILED = msg('Der Veröffentlichungsstatus konnte nicht geladen werden.'); // Löschen einer privaten Karte lässt ihre Veröffentlichung bestehen; zurückgezogen wird dann auf der Community-Seite. -const PUBLISHED_NOTE = - 'Die veröffentlichte Karte bleibt in der Community-Galerie, bis du sie zurückziehst – das geht dann nur noch auf ihrer Community-Seite.'; -const MAYBE_PUBLISHED_NOTE = - 'Falls diese Karte veröffentlicht ist, bleibt die Veröffentlichung bestehen – zurückziehen kannst du sie auf ihrer Community-Seite.'; +const PUBLISHED_NOTE = msg( + 'Die veröffentlichte Karte bleibt in der Community-Galerie, bis du sie zurückziehst – das geht dann nur noch auf ihrer Community-Seite.', +); +const MAYBE_PUBLISHED_NOTE = msg( + 'Falls diese Karte veröffentlicht ist, bleibt die Veröffentlichung bestehen – zurückziehen kannst du sie auf ihrer Community-Seite.', +); +/** Meldungen, die beim Herunterladen mit ihrem deutschen Text als Fehler ankommen (Bild lesen, Pixelgrenze, fehlendes Logo); angezeigt mit t(). */ +const KNOWN_MESSAGES = new Set([READ_MESSAGE, PIXELS_MESSAGE, LOGO_MISSING_PUBLISH]); /** Was die Kachel über die Veröffentlichung wissen muss. */ interface Publication { @@ -70,8 +75,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi const queue: SavedCard[] = []; let activeLoads = 0; - const heading = el('h1', { tabindex: '-1' }, 'Meine Karten'); - const body = el('section', { class: 'cards-page', 'aria-label': 'Gespeicherte Karten' }); + const heading = el('h1', { tabindex: '-1' }, t('Meine Karten')); + const body = el('section', { class: 'cards-page', 'aria-label': t('Gespeicherte Karten') }); root.replaceChildren(heading, body); function revokePreviews(keep: ReadonlySet = new Set()): void { @@ -89,11 +94,11 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi if (!slot) return; const url = previewUrls.get(card.previewFileId); if (url) { - slot.replaceChildren(el('img', { src: url, alt: `Vorschau von „${card.name}“`, width: 480, height: 303 })); + slot.replaceChildren(el('img', { src: url, alt: t('Vorschau von „{name}“', { name: card.name }), width: 480, height: 303 })); } else if (previewFailed.has(card.previewFileId)) { - slot.replaceChildren(el('span', {}, 'Keine Vorschau')); + slot.replaceChildren(el('span', {}, t('Keine Vorschau'))); } else { - slot.replaceChildren(el('span', {}, 'Vorschau lädt …')); + slot.replaceChildren(el('span', {}, t('Vorschau lädt …'))); } } @@ -154,14 +159,14 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi previewQueued.clear(); thumbSlots.clear(); revokePreviews(); - 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')); - show(el('p', { class: 'cards-msg' }, LOGGED_OUT), el('p', { class: 'cards-actions' }, signIn)); + show(el('p', { class: 'cards-msg' }, t(LOGGED_OUT)), el('p', { class: 'cards-actions' }, signIn)); } function showError(text: string): void { state = 'error'; - const retry = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Nochmal versuchen'); + const retry = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Nochmal versuchen')); retry.addEventListener('click', () => void load()); show(el('p', { class: 'cards-msg cards-msg-error', role: 'alert' }, text), el('p', { class: 'cards-actions' }, retry)); } @@ -186,8 +191,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi tileUpdaters.clear(); statusNotice = null; if (cards.length === 0) { - const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/gestalten', 'data-link': true }, 'Zum Editor'); - show(el('p', { class: 'cards-msg' }, EMPTY), el('p', { class: 'cards-actions' }, toEditor)); + const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/gestalten', 'data-link': true }, t('Zum Editor')); + show(el('p', { class: 'cards-msg' }, t(EMPTY)), el('p', { class: 'cards-actions' }, toEditor)); return; } const grid = el('ul', { class: 'cards-grid' }); @@ -213,13 +218,13 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi statusNotice.replaceChildren(); return; } - const retry = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Nochmal versuchen'); + const retry = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, t('Nochmal versuchen')); retry.addEventListener('click', () => { publicationsState = 'loading'; updateAllTiles(); void fetchPublications(); }); - statusNotice.replaceChildren(`${STATUS_FAILED} `, retry); + statusNotice.replaceChildren(`${t(STATUS_FAILED)} `, retry); } function updateAllTiles(): void { @@ -305,10 +310,10 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi const message = el('p', { class: 'card-msg', role: 'alert' }); const action = (key: string, text: string, className = 'auth-btn auth-btn-small') => el('button', { type: 'button', class: className, 'data-action': key }, text); - const openButton = action('open', 'Öffnen', 'auth-btn auth-btn-small auth-btn-primary'); - const downloadButton = action('download', 'Herunterladen'); - const renameButton = action('rename', 'Umbenennen'); - const deleteButton = action('delete', 'Löschen', 'auth-btn auth-btn-small auth-btn-danger'); + const openButton = action('open', t('Öffnen'), 'auth-btn auth-btn-small auth-btn-primary'); + const downloadButton = action('download', t('Herunterladen')); + const renameButton = action('rename', t('Umbenennen')); + const deleteButton = action('delete', t('Löschen'), 'auth-btn auth-btn-small auth-btn-danger'); // Die Knöpfe zur Veröffentlichung wechseln mit dem Status; die übrigen bleiben als dieselben Elemente stehen (Fokus). const publishSlot = el('span', { class: 'card-pub-controls' }); const badge = el('p', { class: 'card-pub' }); @@ -358,7 +363,11 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi const controls: HTMLElement[] = []; if (entry) { badge.append( - el('span', { class: entry.hidden ? 'card-badge is-hidden' : 'card-badge' }, entry.hidden ? 'Ausgeblendet (gemeldet)' : 'Veröffentlicht'), + el( + 'span', + { class: entry.hidden ? 'card-badge is-hidden' : 'card-badge' }, + entry.hidden ? t('Ausgeblendet (gemeldet)') : t('Veröffentlicht'), + ), ); // Ausgeblendet sehen nur Autor und Admin; „Ansehen“ gibt es dann nicht, „Zurückziehen“ bleibt (aufräumen, neu veröffentlichen). if (!entry.hidden) { @@ -371,15 +380,15 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi 'data-link': true, 'data-action': 'view', }, - 'Ansehen', + t('Ansehen'), ), ); } - const withdrawButton = action('withdraw', 'Zurückziehen'); + const withdrawButton = action('withdraw', t('Zurückziehen')); withdrawButton.addEventListener('click', () => startWithdraw(entry)); controls.push(withdrawButton); } else if (publicationsState !== 'loading') { - const publishButton = action('publish', 'Veröffentlichen'); + const publishButton = action('publish', t('Veröffentlichen')); publishButton.addEventListener('click', () => startPublish()); controls.push(publishButton); } @@ -388,8 +397,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi /** Zusatz im Löschen-Dialog: die Veröffentlichung bleibt. Ist der Status unbekannt, ein allgemeiner Hinweis. */ function deleteNote(c: SavedCard): string { - if (publicationsState !== 'ready') return ` ${MAYBE_PUBLISHED_NOTE}`; - return publications.has(c.id) ? ` ${PUBLISHED_NOTE}` : ''; + if (publicationsState !== 'ready') return ` ${t(MAYBE_PUBLISHED_NOTE)}`; + return publications.has(c.id) ? ` ${t(PUBLISHED_NOTE)}` : ''; } function startPublish(): void { @@ -402,9 +411,9 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi void run(async () => { const confirmed = await confirmDialog( { - title: `Karte „${entry.title}“ zurückziehen?`, - text: 'Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.', - confirmLabel: 'Zurückziehen', + title: t('Karte „{title}“ zurückziehen?', { title: entry.title }), + text: t('Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.'), + confirmLabel: t('Zurückziehen'), }, disposal.signal, ); @@ -440,7 +449,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi message.textContent = warning; message.classList.add('is-warning'); } - }, downloadButton, 'Wird erstellt …'); + }, downloadButton, t('Wird erstellt …')); }); renameButton.addEventListener('click', () => startRename()); @@ -449,9 +458,9 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi void run(async () => { const confirmed = await confirmDialog( { - title: `Karte „${card.name}“ löschen?`, - text: `Die Karte und ihr Bild werden endgültig gelöscht.${deleteNote(card)}`, - confirmLabel: 'Löschen', + title: t('Karte „{title}“ löschen?', { title: card.name }), + text: `${t('Die Karte und ihr Bild werden endgültig gelöscht.')}${deleteNote(card)}`, + confirmLabel: t('Löschen'), }, disposal.signal, ); @@ -481,10 +490,10 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi type: 'text', value: card.name, autocomplete: 'off', - 'aria-label': `Neuer Name für „${card.name}“`, + 'aria-label': t('Neuer Name für „{name}“', { name: card.name }), }); - const save = el('button', { type: 'submit', class: 'auth-btn auth-btn-small auth-btn-primary' }, 'Speichern'); - const cancel = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abbrechen'); + const save = el('button', { type: 'submit', class: 'auth-btn auth-btn-small auth-btn-primary' }, t('Speichern')); + const cancel = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, t('Abbrechen')); const form = el('form', { class: 'card-rename' }, input, save, cancel); const close = () => { nameSlot.replaceChildren(el('h2', { class: 'card-name' }, card.name)); @@ -533,11 +542,11 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi return tile; } + /** Der Text für die Kachel in der Sprache der Seite: Fehler des Dienstes über ihren Code, bekannte Meldungen über den deutschen Text. */ function failureText(err: unknown): string { - if (err instanceof LoginCancelled) return LOGIN_NEEDED; - if (err instanceof ApiError) return err.message; // der Dienst antwortet auf Deutsch - if (err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE)) return err.message; - if (err instanceof Error && err.message === LOGO_MISSING_PUBLISH) return t(LOGO_MISSING_PUBLISH); + if (err instanceof LoginCancelled) return t(LOGIN_NEEDED); + if (err instanceof ApiError) return apiErrorText(err); + if (err instanceof Error && KNOWN_MESSAGES.has(err.message)) return t(err.message); return errorMessage(err, 'load'); } @@ -546,7 +555,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi async function load(): Promise { const token = ++loadToken; state = 'loading'; - showStatus(LOADING); + showStatus(t(LOADING)); try { const user = await getCurrentUser(); if (disposed || token !== loadToken) return; diff --git a/web/src/pages/community-page.test.ts b/web/src/pages/community-page.test.ts index 842917a..80fc950 100644 --- a/web/src/pages/community-page.test.ts +++ b/web/src/pages/community-page.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { User } from '../account/auth'; import { ApiError, type ReportReason } from '../community/api'; import type { CheckedImage, GalleryCard, GalleryReport } from '../community/gallery'; +import { germanLeft } from '../i18n/english-check'; +import { setLang } from '../i18n/i18n'; // gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten. vi.mock('../account/appwrite', () => ({ @@ -817,3 +819,172 @@ describe('Detailseite: Admin', () => { expect(q('img')).toBeNull(); }); }); + +describe('Detailseite auf Englisch', () => { + beforeEach(() => setLang('en', { persist: false })); + + it('Ansichten ohne Karte: nicht verfügbar, Laden, Ladefehler', async () => { + getCard.mockResolvedValue(null); + await mount(); + expect(q('h1')?.textContent).toBe('This card is no longer available.'); + expect(q('a[href="/#community"]')?.textContent).toBe('Back to gallery'); + expect(document.title).toBe('Community – AirCard-IOS'); + expect(germanLeft(root)).toEqual([]); + unmount?.(); + + getCard.mockReturnValue(new Promise(() => {})); + await mount(); + expect(q('[role="status"]')?.textContent).toBe('Loading card …'); + unmount?.(); + + getCard.mockReset(); + getCard.mockRejectedValueOnce(new TypeError('offline')); + await mount(); + expect(errorText()).toBe('The card couldn’t be loaded right now.'); + expect(buttonByText('Try again')).toBeDefined(); + expect(germanLeft(root)).toEqual([]); + }); + + it('Karte eines Gastes: Datum, Verfasser, Alt-Text, Knöpfe, Bildfehler und Herunterladen', async () => { + getCard.mockResolvedValue(card({ displayName: '' })); + loadImage.mockReturnValueOnce(new Promise(() => {})); + await mount(); + expect(q('.detail-card')?.textContent).toBe('Loading image …'); + unmount?.(); + + loadImage.mockImplementation(async () => image()); + await mount(); + expect(q('.detail-meta')?.textContent).toBe('by Anonymous · Oct 4, 2026'); + expect(q('.detail-card img')?.getAttribute('alt')).toBe('Community card “Mitternachtsblau”'); + expect(q('.detail-tag')?.getAttribute('title')).toBe('Show cards with this tag'); + expect(q('.detail-back')?.textContent).toBe('← Back to gallery'); + expect(buttonTexts()).toEqual(['Download', 'Use as template', 'Report']); + expect(germanLeft(root)).toEqual([]); + + // Bild nicht darstellbar, Herunterladen holt es nach: unterwegs „Downloading …“, danach der Fehler + q('.detail-card img')!.dispatchEvent(new Event('error')); + expect(q('.detail-card')?.textContent).toContain('The image couldn’t be loaded.'); + loadImage.mockReturnValueOnce(new Promise(() => {})); + buttonByText('Download')!.click(); + expect(buttonTexts()).toContain('Downloading …'); + unmount?.(); + + await mount(); + q('.detail-card img')!.dispatchEvent(new Event('error')); + loadImage.mockRejectedValueOnce(new TypeError('offline')); + buttonByText('Download')!.click(); + await settle(); + expect(errorText()).toBe('The image couldn’t be loaded.'); + }); + + it('Melden-Dialog: Hinweis, wenn er sich nicht öffnen lässt, und der Dialog selbst auf Englisch', async () => { + reportCard.mockRejectedValue(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.', undefined, 'tooManyReports')); + await mount(); + buttonByText('Report')!.click(); + await settle(); + expect([...reportEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([ + 'Inappropriate', + 'Real card data', + 'Copyright/trademark', + 'Spam', + 'Other', + ]); + const radio = reportEl()!.querySelector('input[value="spam"]')!; + radio.checked = true; + reportEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await settle(); + expect(reportEl()!.querySelector('[role="alert"]')!.textContent).toBe('Too many reports – please try again later.'); + unmount?.(); + reportEl()?.remove(); + + const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementation(() => { + throw new Error('nicht darstellbar'); + }); + try { + await mount(); + buttonByText('Report')!.click(); + await settle(); + expect(errorText()).toBe('The report dialog couldn’t be opened.'); + } finally { + spy.mockRestore(); + } + }); + + it('Autor: Zurückziehen mit Rückfrage und die Fehler als Text der Seite (Code, Anmeldung, Netz)', async () => { + user = owner; + await mount(); + expect(buttonTexts()).toEqual(['Download', 'Use as template', 'Report', 'Withdraw']); + buttonByText('Withdraw')!.click(); + await settle(); + expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Withdraw card “Mitternachtsblau”?'); + expect(confirmEl()!.querySelector('p')!.textContent).toBe( + 'The card disappears from the gallery, and its image and reports are deleted. Your private card is kept.', + ); + expect([...confirmEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Cancel', 'Withdraw']); + withdrawCard.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', undefined, 'unavailable')); + confirmButton('Withdraw').click(); + await settle(); + expect(errorText()).toBe('The service is currently unavailable.'); + + // ohne bekannten Code zeigt Englisch die allgemeine Meldung zum Status + withdrawCard.mockRejectedValueOnce(new ApiError(403, 'Nur für Administratoren.')); + buttonByText('Withdraw')!.click(); + await settle(); + confirmButton('Withdraw').click(); + await settle(); + expect(errorText()).toBe('You are not allowed to do that.'); + + withLogin.mockRejectedValueOnce(new LoginCancelled()); + buttonByText('Withdraw')!.click(); + await settle(); + confirmButton('Withdraw').click(); + await settle(); + expect(errorText()).toBe('Please sign in to continue.'); + + withLogin.mockRejectedValueOnce(new TypeError('offline')); + buttonByText('Withdraw')!.click(); + await settle(); + confirmButton('Withdraw').click(); + await settle(); + expect(errorText()).toBe('Can’t reach the server.'); + expect(germanLeft(root)).toEqual([]); + }); + + it('Admin: Moderation, Meldungen mit Grund und Datum, Ausblenden/Einblenden mit Hinweis, Löschen mit Rückfrage', async () => { + user = admin; + getCard.mockResolvedValue(card({ reportCount: 3 })); + listReports.mockResolvedValue([report(1, 'spam'), report(2, 'kartendaten'), report(3, 'unbekannt')]); + await mount(); + expect(q('.detail-admin-title')?.textContent).toBe('Moderation'); + expect(q('.detail-reports-count')?.textContent).toBe('3 reports'); + expect(qa('.detail-reasons li').map((li) => li.textContent)).toEqual([ + 'Spam · Oct 5, 2026', + 'Real card data · Oct 5, 2026', + 'unbekannt · Oct 5, 2026', // unbekannte Werte der API bleiben unverändert + ]); + expect(buttonTexts()).toEqual(['Download', 'Use as template', 'Report', 'Hide', 'Delete']); + expect(germanLeft(root)).toEqual([]); + + buttonByText('Hide')!.click(); + await settle(); + expect(q('.detail-hidden')?.textContent).toBe('Hidden – This card isn’t visible to others.'); + expect(statusText()).toBe('Card hidden.'); + buttonByText('Unhide')!.click(); + await settle(); + expect(statusText()).toBe('Card unhidden.'); + + buttonByText('Delete')!.click(); + await settle(); + expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Delete card “Mitternachtsblau”?'); + expect(confirmEl()!.querySelector('p')!.textContent).toBe('The card, its image and all reports will be deleted permanently.'); + expect([...confirmEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Cancel', 'Delete']); + confirmButton('Cancel').click(); + await settle(); + + // die Gründe lassen sich nicht laden + unmount?.(); + listReports.mockRejectedValue(new TypeError('offline')); + await mount(); + expect(q('.detail-admin')?.textContent).toContain('The reasons couldn’t be loaded.'); + }); +}); diff --git a/web/src/pages/community-page.ts b/web/src/pages/community-page.ts index c35a2fe..bb523fd 100644 --- a/web/src/pages/community-page.ts +++ b/web/src/pages/community-page.ts @@ -2,11 +2,12 @@ // Autor: Zurückziehen. Admin (Label `admin`): Ausblenden/Einblenden, Löschen, Meldungszahl und Gründe. // Ausgeblendete oder fehlende Karten sieht ein Besucher nur als „nicht (mehr) verfügbar“: Appwrite antwortet ihm mit 404, // Autor und Admin dürfen die Zeile lesen und bekommen die Seite mit dem Hinweis „Ausgeblendet“. +// Alle Texte kommen in der Sprache der Seite (t()); ein Sprachwechsel baut die Seite neu auf (main.ts). import '../community/community.css'; import { currentUser, getCurrentUser, isAdmin, onAuthChange, type User } from '../account/auth'; import { errorMessage } from '../account/errors'; import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login'; -import { ApiError, reportCard, setVisibility, withdrawCard, type ReportReason } from '../community/api'; +import { ApiError, apiErrorText, reportCard, setVisibility, withdrawCard, type ReportReason } from '../community/api'; import { getGalleryCard, listReports, @@ -20,6 +21,7 @@ import { CARD_H, CARD_W } from '../card/geometry'; import { confirmDialog, type ConfirmOptions } from '../editor/confirm-dialog'; import { el } from '../editor/dom'; import { downloadFileName, saveBlobAs } from '../editor/download'; +import { msg, t } from '../i18n/i18n'; import { formatDate, reportsLabel } from './format'; import { navigate, titleFor } from './router'; @@ -57,27 +59,33 @@ export const defaultDeps: CommunityDeps = { withLogin: (fn) => withLogin(fn), }; -const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.'; -const LOADING = 'Karte wird geladen …'; -const LOAD_ERROR = 'Die Karte konnte gerade nicht geladen werden.'; -const IMAGE_LOADING = 'Bild wird geladen …'; -const IMAGE_ERROR = 'Das Bild konnte nicht geladen werden.'; -const REASONS_ERROR = 'Die Gründe konnten nicht geladen werden.'; -const REPORT_ERROR = 'Der Melden-Dialog konnte nicht geöffnet werden.'; -const HIDDEN_NOTICE = 'Diese Karte ist für andere nicht sichtbar.'; +const UNAVAILABLE = msg('Diese Karte ist nicht (mehr) verfügbar.'); +const LOADING = msg('Karte wird geladen …'); +const LOAD_ERROR = msg('Die Karte konnte gerade nicht geladen werden.'); +const IMAGE_LOADING = msg('Bild wird geladen …'); +const IMAGE_ERROR = msg('Das Bild konnte nicht geladen werden.'); +const REASONS_ERROR = msg('Die Gründe konnten nicht geladen werden.'); +const REPORT_ERROR = msg('Der Melden-Dialog konnte nicht geöffnet werden.'); +const HIDDEN_NOTICE = msg('Diese Karte ist für andere nicht sichtbar.'); const REASON_LABELS: ReadonlyMap = new Map(REPORT_REASONS.map((r) => [r.value, r.label])); +/** Der Grund einer Meldung als Text: bekannte Werte mit ihrer Beschriftung (übersetzt), alles andere unverändert. */ +function reasonText(reason: string): string { + const label = REASON_LABELS.get(reason); + return label === undefined ? reason : t(label); +} + type Busy = 'download' | 'remove' | 'visibility' | null; interface ReportsState { status: 'idle' | 'loading' | 'ready' | 'error'; list: GalleryReport[]; } -/** Der Text für den Nutzer: Servertexte (deutsch) unverändert, alles andere über errorMessage. */ +/** Der Text für den Nutzer in der Sprache der Seite: Fehler des Dienstes über ihren Code, alles andere über errorMessage. */ function failureText(err: unknown): string { - if (err instanceof LoginCancelled) return LOGIN_NEEDED; - if (err instanceof ApiError) return err.message; + if (err instanceof LoginCancelled) return t(LOGIN_NEEDED); + if (err instanceof ApiError) return apiErrorText(err); return errorMessage(err, 'load'); } @@ -115,17 +123,17 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit } function showImageError(forCard: GalleryCard): void { - const retry = el('button', { type: 'button', class: 'home-btn' }, 'Nochmal versuchen'); + const retry = el('button', { type: 'button', class: 'home-btn' }, t('Nochmal versuchen')); retry.addEventListener('click', () => startImage(forCard)); imageSlot.setAttribute('aria-busy', 'false'); - imageSlot.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, IMAGE_ERROR), retry)); + imageSlot.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, t(IMAGE_ERROR)), retry)); } function startImage(forCard: GalleryCard): void { const token = ++imageToken; revokeImage(); imageSlot.setAttribute('aria-busy', 'true'); - imageSlot.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, IMAGE_LOADING))); + imageSlot.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, t(IMAGE_LOADING)))); deps.loadImage(forCard, 'full').then( (loaded) => { if (disposed || token !== imageToken) { @@ -136,7 +144,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit imageUrl = loaded.url; const img = el('img', { src: imageUrl, - alt: `Community-Karte „${forCard.title}“`, + alt: t('Community-Karte „{title}“', { title: forCard.title }), width: CARD_W, height: CARD_H, }); @@ -176,8 +184,8 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit el( 'div', { class: 'detail-unavailable' }, - el('h1', { class: 'detail-title' }, UNAVAILABLE), - el('a', { class: 'home-btn home-btn-primary', href: '/#community', 'data-link': true }, 'Zur Galerie'), + el('h1', { class: 'detail-title' }, t(UNAVAILABLE)), + el('a', { class: 'home-btn home-btn-primary', href: '/#community', 'data-link': true }, t('Zur Galerie')), ), ); } @@ -185,10 +193,10 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit function showLoadError(): void { leaveCard(); view = 'error'; - const retry = el('button', { type: 'button', class: 'home-btn home-btn-primary' }, 'Nochmal versuchen'); + const retry = el('button', { type: 'button', class: 'home-btn home-btn-primary' }, t('Nochmal versuchen')); retry.addEventListener('click', () => void load()); page.replaceChildren( - el('div', { class: 'detail-unavailable' }, el('p', { class: 'detail-load-error', role: 'alert' }, LOAD_ERROR), retry), + el('div', { class: 'detail-unavailable' }, el('p', { class: 'detail-load-error', role: 'alert' }, t(LOAD_ERROR)), retry), ); } @@ -222,17 +230,22 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit const focusedAction = focused && infoSlot.contains(focused) ? focused.closest('[data-action]')?.getAttribute('data-action') : null; noticeSlot.replaceChildren( - ...(c.hidden ? [el('p', { class: 'detail-hidden' }, el('strong', {}, 'Ausgeblendet'), ` – ${HIDDEN_NOTICE}`)] : []), + ...(c.hidden ? [el('p', { class: 'detail-hidden' }, el('strong', {}, t('Ausgeblendet')), ` – ${t(HIDDEN_NOTICE)}`)] : []), ); const buttons = [ - button(busy === 'download' ? 'Wird geladen …' : 'Herunterladen', 'download', () => void download(), 'home-btn home-btn-primary'), - button('Als Vorlage gestalten', 'template', () => deps.navigate(`/gestalten?vorlage=${encodeURIComponent(c.id)}`)), + button( + busy === 'download' ? t('Wird geladen …') : t('Herunterladen'), + 'download', + () => void download(), + 'home-btn home-btn-primary', + ), + button(t('Als Vorlage gestalten'), 'template', () => deps.navigate(`/gestalten?vorlage=${encodeURIComponent(c.id)}`)), ]; // Eine ausgeblendete Karte lässt sich nicht melden (der Dienst kennt sie für Besucher nicht). - if (!c.hidden) buttons.push(button('Melden', 'report', () => void reportThis())); + if (!c.hidden) buttons.push(button(t('Melden'), 'report', () => void reportThis())); if (isOwner(c, user)) { - buttons.push(button('Zurückziehen', 'withdraw', () => void removeCard('withdraw'), 'home-btn detail-btn-danger')); + buttons.push(button(t('Zurückziehen'), 'withdraw', () => void removeCard('withdraw'), 'home-btn detail-btn-danger')); } actionsSlot.replaceChildren(...buttons); @@ -245,11 +258,11 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit const section = el( 'section', { class: 'detail-admin', 'aria-labelledby': 'detail-admin-title' }, - el('h2', { id: 'detail-admin-title', class: 'detail-admin-title' }, 'Moderation'), + el('h2', { id: 'detail-admin-title', class: 'detail-admin-title' }, t('Moderation')), el('p', { class: 'detail-reports-count' }, reportsLabel(c.reportCount)), ); if (reports.status === 'error') { - section.append(el('p', { class: 'detail-admin-note' }, REASONS_ERROR)); + section.append(el('p', { class: 'detail-admin-note' }, t(REASONS_ERROR))); } else if (reports.list.length > 0) { section.append( el( @@ -257,7 +270,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit { class: 'detail-reasons' }, ...reports.list.map((r) => { const date = formatDate(r.createdAt); - return el('li', {}, `${REASON_LABELS.get(r.reason) ?? r.reason}${date ? ` · ${date}` : ''}`); + return el('li', {}, `${reasonText(r.reason)}${date ? ` · ${date}` : ''}`); }), ), ); @@ -266,8 +279,8 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit el( 'div', { class: 'detail-admin-actions' }, - button(c.hidden ? 'Einblenden' : 'Ausblenden', 'visibility', () => void toggleVisibility()), - button('Löschen', 'delete', () => void removeCard('delete'), 'home-btn detail-btn-danger'), + button(c.hidden ? t('Einblenden') : t('Ausblenden'), 'visibility', () => void toggleVisibility()), + button(t('Löschen'), 'delete', () => void removeCard('delete'), 'home-btn detail-btn-danger'), ), ); return section; @@ -298,7 +311,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit document.title = titleFor(route, c.title); const date = formatDate(c.createdAt); - const author = `von ${c.displayName !== '' ? c.displayName : 'Anonym'}`; + const author = t('von {name}', { name: c.displayName !== '' ? c.displayName : t('Anonym') }); infoSlot.replaceChildren( el('h1', { class: 'detail-title' }, c.title), ...(c.issuer !== '' ? [el('p', { class: 'detail-issuer' }, c.issuer)] : []), @@ -315,7 +328,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit class: 'detail-tag', href: `/?tag=${encodeURIComponent(tag)}#community`, 'data-link': true, - title: 'Karten mit diesem Tag zeigen', + title: t('Karten mit diesem Tag zeigen'), }, tag, ), @@ -330,7 +343,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit adminSlot, ); page.replaceChildren( - el('p', { class: 'detail-back' }, el('a', { href: '/#community', 'data-link': true }, '← Zur Galerie')), + el('p', { class: 'detail-back' }, el('a', { href: '/#community', 'data-link': true }, t('← Zur Galerie'))), el('div', { class: 'detail-layout' }, imageSlot, infoSlot), ); renderControls(); @@ -364,7 +377,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit deps.saveBlobAs(blob, downloadFileName(c.title, Number.isNaN(created.getTime()) ? new Date() : created)); } catch { if (disposed || mine !== generation) return; - showMessage(IMAGE_ERROR, 'error'); + showMessage(t(IMAGE_ERROR), 'error'); } setBusy(null); } @@ -380,7 +393,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit }); } catch { // Dialog nicht darstellbar (showModal wirft): kein unbehandelter Fehler, sondern ein Hinweis auf der Seite. - if (!disposed) showMessage(REPORT_ERROR, 'error'); + if (!disposed) showMessage(t(REPORT_ERROR), 'error'); } } @@ -406,14 +419,14 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit const confirmed = await ask( kind === 'withdraw' ? { - title: `Karte „${c.title}“ zurückziehen?`, - text: 'Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.', - confirmLabel: 'Zurückziehen', + title: t('Karte „{title}“ zurückziehen?', { title: c.title }), + text: t('Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.'), + confirmLabel: t('Zurückziehen'), } : { - title: `Karte „${c.title}“ löschen?`, - text: 'Die Karte, ihr Bild und alle Meldungen werden endgültig gelöscht.', - confirmLabel: 'Löschen', + title: t('Karte „{title}“ löschen?', { title: c.title }), + text: t('Die Karte, ihr Bild und alle Meldungen werden endgültig gelöscht.'), + confirmLabel: t('Löschen'), }, ); // Während der Rückfrage kann die Sitzung gewechselt haben: dann nichts mehr senden. @@ -447,7 +460,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit // Einblenden setzt beim Dienst die Meldungen zurück (Zähler 0, Meldungen gelöscht); Ausblenden lässt sie stehen. card = { ...c, hidden: !show, reportCount: show ? 0 : c.reportCount }; if (show) reports = { status: 'ready', list: [] }; - showMessage(show ? 'Karte eingeblendet.' : 'Karte ausgeblendet.', 'info'); + showMessage(show ? t('Karte eingeblendet.') : t('Karte ausgeblendet.'), 'info'); } catch (err) { if (disposed || mine !== generation) return; showMessage(failureText(err), 'error'); @@ -463,7 +476,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit if (!quiet) { leaveCard(); view = 'loading'; - page.replaceChildren(el('p', { class: 'detail-status', role: 'status' }, LOADING)); + page.replaceChildren(el('p', { class: 'detail-status', role: 'status' }, t(LOADING))); } try { const found = await deps.getCard(id); diff --git a/web/src/pages/format.test.ts b/web/src/pages/format.test.ts index 1c9289d..94ad9c5 100644 --- a/web/src/pages/format.test.ts +++ b/web/src/pages/format.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { setLang } from '../i18n/i18n'; import { formatDate, reportsLabel } from './format'; describe('formatDate', () => { @@ -10,6 +11,22 @@ describe('formatDate', () => { it('gibt bei unlesbarem Datum nichts zurück', () => { expect(formatDate('kaputt')).toBe(''); }); + + it('schreibt auf Englisch „Oct 4, 2026“ (Monat abgekürzt, Tag ohne führende Null)', () => { + setLang('en', { persist: false }); + expect(formatDate(new Date(2026, 9, 4, 12).toISOString())).toBe('Oct 4, 2026'); + expect(formatDate(new Date(2026, 0, 31, 12).toISOString())).toBe('Jan 31, 2026'); + expect(formatDate('kaputt')).toBe(''); + }); + + it('folgt der Sprache bei jedem Aufruf', () => { + const iso = new Date(2026, 9, 4, 12).toISOString(); + expect(formatDate(iso)).toBe('04.10.2026'); + setLang('en', { persist: false }); + expect(formatDate(iso)).toBe('Oct 4, 2026'); + setLang('de', { persist: false }); + expect(formatDate(iso)).toBe('04.10.2026'); + }); }); describe('reportsLabel', () => { @@ -19,4 +36,12 @@ describe('reportsLabel', () => { expect(reportsLabel(2)).toBe('2 Meldungen'); expect(reportsLabel(12)).toBe('12 Meldungen'); }); + + it('auf Englisch „1 report“, sonst „n reports“', () => { + setLang('en', { persist: false }); + expect(reportsLabel(1)).toBe('1 report'); + expect(reportsLabel(0)).toBe('0 reports'); + expect(reportsLabel(3)).toBe('3 reports'); + expect(reportsLabel(12)).toBe('12 reports'); + }); }); diff --git a/web/src/pages/format.ts b/web/src/pages/format.ts index a8e4545..9572a7d 100644 --- a/web/src/pages/format.ts +++ b/web/src/pages/format.ts @@ -1,14 +1,16 @@ -// Gemeinsame Formatierung der Seiten (Meine Karten, Community-Detailseite, Galerie-Raster). +// Gemeinsame Formatierung der Seiten (Meine Karten, Community-Detailseite, Galerie-Raster, Logo-Infozeile). +import { currentLang, t } from '../i18n/i18n'; -/** `dd.mm.yyyy` in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */ +/** Deutsch `dd.mm.yyyy`, Englisch `Oct 4, 2026`, jeweils in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */ export function formatDate(iso: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) return ''; + if (currentLang() === 'en') return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date); const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`; } -/** „1 Meldung“, „3 Meldungen“. */ +/** „1 Meldung“, „3 Meldungen“ (Englisch „1 report“, „3 reports“). */ export function reportsLabel(count: number): string { - return count === 1 ? '1 Meldung' : `${count} Meldungen`; + return count === 1 ? t('1 Meldung') : t('{count} Meldungen', { count }); } diff --git a/web/src/pages/home-page.test.ts b/web/src/pages/home-page.test.ts index 86fabbe..d223680 100644 --- a/web/src/pages/home-page.test.ts +++ b/web/src/pages/home-page.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { User } from '../account/auth'; import type { CheckedImage, GalleryCard, GalleryOptions } from '../community/gallery'; +import { germanLeft } from '../i18n/english-check'; +import { setLang } from '../i18n/i18n'; // gallery.ts und auth.ts hängen am Appwrite-Client; hier läuft alles über die Abhängigkeiten von mountHomePage. vi.mock('../account/appwrite', () => ({ @@ -460,3 +462,80 @@ describe('Startseite: Aufräumen', () => { expect(loadImage).not.toHaveBeenCalled(); }); }); + +describe('Startseite auf Englisch', () => { + beforeEach(() => setLang('en', { persist: false })); + + it('Hero, Kopf der Galerie und Leertext', async () => { + await mount(); + expect(q('.home-hero')?.getAttribute('aria-label')).toBe('Welcome'); + expect(q('.home-hero-eyebrow')?.textContent).toBe('A little art for your Wallet'); + expect(q('h1')?.textContent).toBe('Card designs for Apple Wallet'); + expect(q('.home-hero-lead')?.textContent).toBe('Discover designs from the community, create your own and download it as a PNG.'); + expect(q('.home-hero a[href="#community"]')?.textContent).toBe('Discover cards ↓'); + expect(q('.home-hero a[href="/gestalten"]')?.textContent).toBe('Create'); + expect(q('.hero-card img')?.getAttribute('alt')).toBe('Sample design: purple gradient with waves and the AirCard-IOS symbol'); + expect(q('section#community h2')?.textContent).toBe('Community cards'); + expect(q('.community-sub')?.textContent).toBe('Discover designs from the community – and publish your own.'); + expect(q('section#community a[href="/gestalten"]')?.textContent).toBe('Create & publish'); + expect(q('.community-empty')?.textContent).toContain('No community cards yet – be the first!'); + expect(q('.community-empty a[href="/gestalten"]')?.textContent).toBe('Create'); + expect(germanLeft(root)).toEqual([]); + }); + + it('Karten mit Suche und Tag: Alt-Text der Hero-Karte, Chips mit „Clear …“, Tag-Knopf', async () => { + listGallery.mockResolvedValue(page([card(1)])); + await mount('/?q=visa&tag=gold#community'); + expect(q('.hero-card img')?.getAttribute('alt')).toBe('Community card “Karte 1”'); + expect(qa('.community-chip-label').map((label) => label.textContent)).toEqual(['Search:', 'Tag:']); + expect(qa('.community-chip-value').map((value) => value.textContent)).toEqual(['visa', 'gold']); + expect(qa('.community-chip-clear').map((clear) => clear.getAttribute('aria-label'))).toEqual(['Clear search', 'Clear tag filter']); + expect(q('button.gallery-tag')?.getAttribute('title')).toBe('Show cards with this tag'); + expect(germanLeft(root)).toEqual([]); + }); + + it('keine Treffer: „Nothing found for “…”“', async () => { + listGallery.mockResolvedValue(page([])); + await mount('/?q=xyz&tag=selten'); + expect(q('.community-empty')?.textContent).toContain('Nothing found for “xyz · selten”'); + }); + + it('Admin-Ansicht: Filter, Marken an den Kacheln und der Leertext der Ansicht', async () => { + user = admin; + listGallery.mockResolvedValue(page([card(1)])); + await mount(); + expect(q('.community-filter')?.getAttribute('aria-label')).toBe('View'); + expect(qa('.community-filter button').map((b) => b.textContent)).toEqual(['All', 'Reported', 'Hidden']); + listGallery.mockResolvedValue(page([card(5, { hidden: true, reportCount: 3 })])); + buttonByText('Hidden')!.click(); + await settle(); + expect(qa('.gallery-badge').map((b) => b.textContent)).toEqual(['Hidden', '3 reports']); + listGallery.mockResolvedValue(page([])); + buttonByText('Reported')!.click(); + await settle(); + expect(q('.community-empty')?.textContent).toContain('No cards in this view.'); + expect(germanLeft(root)).toEqual([]); + }); + + it('Lade- und Fehlertexte, „Load more“ und „Try again“', async () => { + listGallery.mockReturnValue(new Promise(() => {})); + await mount(); + expect(q('.community-status')?.textContent).toBe('Loading cards …'); + unmount!(); + + listGallery.mockReset(); + listGallery.mockRejectedValueOnce(new Error('offline')); + await mount(); + expect(q('[role="alert"]')?.textContent).toContain('The community cards couldn’t be loaded right now.'); + expect(buttonByText('Try again')).toBeDefined(); + unmount!(); + + listGallery.mockResolvedValueOnce(page([card(1)], 'g1')); + await mount(); + listGallery.mockRejectedValueOnce(new Error('offline')); + buttonByText('Load more')!.click(); + await settle(); + expect(q('.community-more-msg')?.textContent).toBe('More cards couldn’t be loaded. Please try again.'); + expect(germanLeft(root)).toEqual([]); + }); +}); diff --git a/web/src/pages/home-page.ts b/web/src/pages/home-page.ts index 385f7bb..35ac271 100644 --- a/web/src/pages/home-page.ts +++ b/web/src/pages/home-page.ts @@ -1,5 +1,6 @@ // Startseite „/“: kompakter Hero mit großer Karte und direkt darunter die Community-Galerie (Anker #community). // Suche (`?q=`) und Tag (`?tag=`) kommen aus der Adresse; Label-Admins sehen zusätzlich die Filter Alle/Gemeldet/Ausgeblendet. +// Alle Texte kommen in der Sprache der Seite (t()); ein Sprachwechsel baut die Seite neu auf (main.ts). import '../community/community.css'; import { currentUser, isAdmin, onAuthChange, type User } from '../account/auth'; import { mountGalleryGrid, type GalleryGrid } from '../community/gallery-grid'; @@ -13,6 +14,7 @@ import { } from '../community/gallery'; import { CARD_H, CARD_W } from '../card/geometry'; import { el } from '../editor/dom'; +import { msg, t } from '../i18n/i18n'; import { navigate, scrollToHash } from './router'; type Page = { cards: GalleryCard[]; nextCursor: string | null }; @@ -38,18 +40,18 @@ export const defaultDeps: HomeDeps = { }; const EXAMPLE_IMAGE = '/hero-beispiel.png'; -const EXAMPLE_ALT = 'Beispielmotiv: violetter Verlauf mit Wellen und dem AirCard-IOS-Symbol'; +const EXAMPLE_ALT = msg('Beispielmotiv: violetter Verlauf mit Wellen und dem AirCard-IOS-Symbol'); -const LOADING = 'Karten werden geladen …'; -const LOAD_ERROR = 'Die Community-Karten konnten gerade nicht geladen werden.'; -const MORE_ERROR = 'Weitere Karten konnten nicht geladen werden. Bitte nochmal versuchen.'; -const EMPTY = 'Noch keine Community-Karten – sei die erste!'; -const EMPTY_VIEW = 'Keine Karten in dieser Ansicht.'; +const LOADING = msg('Karten werden geladen …'); +const LOAD_ERROR = msg('Die Community-Karten konnten gerade nicht geladen werden.'); +const MORE_ERROR = msg('Weitere Karten konnten nicht geladen werden. Bitte nochmal versuchen.'); +const EMPTY = msg('Noch keine Community-Karten – sei die erste!'); +const EMPTY_VIEW = msg('Keine Karten in dieser Ansicht.'); const FILTERS: ReadonlyArray = [ - ['all', 'Alle'], - ['reported', 'Gemeldet'], - ['hidden', 'Ausgeblendet'], + ['all', msg('Alle')], + ['reported', msg('Gemeldet')], + ['hidden', msg('Ausgeblendet')], ]; /** `/` plus Suche und/oder Tag, mit Anker auf die Galerie. */ @@ -85,18 +87,18 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): const heroCard = el('div', { class: 'hero-card is-loading' }); const hero = el( 'section', - { class: 'home-hero', 'aria-label': 'Willkommen' }, + { class: 'home-hero', 'aria-label': t('Willkommen') }, el( 'div', { class: 'home-hero-copy' }, - el('p', { class: 'home-hero-eyebrow' }, 'Ein bisschen Kunst für dein Wallet'), - el('h1', { class: 'home-hero-title' }, 'Kartendesigns für Apple Wallet'), - el('p', { class: 'home-hero-lead' }, 'Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.'), + el('p', { class: 'home-hero-eyebrow' }, t('Ein bisschen Kunst für dein Wallet')), + el('h1', { class: 'home-hero-title' }, t('Kartendesigns für Apple Wallet')), + el('p', { class: 'home-hero-lead' }, t('Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.')), el( 'div', { class: 'home-hero-actions' }, - el('a', { class: 'home-btn home-btn-primary', href: '#community', 'data-link': true }, 'Karten entdecken ↓'), - el('a', { class: 'home-btn', href: '/gestalten', 'data-link': true }, 'Gestalten'), + el('a', { class: 'home-btn home-btn-primary', href: '#community', 'data-link': true }, t('Karten entdecken ↓')), + el('a', { class: 'home-btn', href: '/gestalten', 'data-link': true }, t('Gestalten')), ), ), el('div', { class: 'home-hero-visual' }, heroCard), @@ -110,7 +112,7 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): ); } - const showExample = () => setHeroImage(EXAMPLE_IMAGE, EXAMPLE_ALT, null); + const showExample = () => setHeroImage(EXAMPLE_IMAGE, t(EXAMPLE_ALT), null); /** Die große Karte: Vollbild der neuesten sichtbaren Karte; ohne Karte oder bei Fehlern das Beispielmotiv. */ function showHero(newest: Promise): void { @@ -125,7 +127,7 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): return; } heroUrl = url; - setHeroImage(url, `Community-Karte „${card.title}“`, card); + setHeroImage(url, t('Community-Karte „{title}“', { title: card.title }), card); } catch { if (!disposed) showExample(); } @@ -153,10 +155,14 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): el( 'div', { class: 'community-head-text' }, - el('h2', { id: 'community-title', class: 'community-title' }, 'Community-Karten'), - el('p', { class: 'community-sub' }, 'Entdecke Motive der Community – und veröffentliche deine eigenen.'), + el('h2', { id: 'community-title', class: 'community-title' }, t('Community-Karten')), + el('p', { class: 'community-sub' }, t('Entdecke Motive der Community – und veröffentliche deine eigenen.')), + ), + el( + 'a', + { class: 'home-btn home-btn-primary community-publish', href: '/gestalten', 'data-link': true }, + t('Gestalten & veröffentlichen'), ), - el('a', { class: 'home-btn home-btn-primary community-publish', href: '/gestalten', 'data-link': true }, 'Gestalten & veröffentlichen'), ), el('div', { class: 'community-toolbar' }, activeSlot, filterSlot), statusSlot, @@ -181,8 +187,8 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): if (query || tag) { const active = el('div', { class: 'community-active' }); - if (query) active.append(chip('Suche', query, 'Suche zurücksetzen', communityPath({ tag }))); - if (tag) active.append(chip('Tag', tag, 'Tag-Filter zurücksetzen', communityPath({ q: query }))); + if (query) active.append(chip(t('Suche'), query, t('Suche zurücksetzen'), communityPath({ tag }))); + if (tag) active.append(chip(t('Tag'), tag, t('Tag-Filter zurücksetzen'), communityPath({ q: query }))); activeSlot.append(active); } @@ -198,9 +204,9 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): filterSlot.replaceChildren(); return; } - const group = el('div', { class: 'community-filter', role: 'group', 'aria-label': 'Ansicht' }); + const group = el('div', { class: 'community-filter', role: 'group', 'aria-label': t('Ansicht') }); for (const [value, label] of FILTERS) { - const button = el('button', { type: 'button', class: 'community-filter-btn' }, label); + const button = el('button', { type: 'button', class: 'community-filter-btn' }, t(label)); button.addEventListener('click', () => setFilter(value)); filterButtons.set(value, button); group.append(button); @@ -222,7 +228,7 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): const message = el('p', { class: `community-status${role === 'alert' ? ' is-error' : ''}`, role }, text); const nodes: Node[] = [message]; if (retry) { - const button = el('button', { type: 'button', class: 'home-btn home-btn-primary' }, 'Nochmal versuchen'); + const button = el('button', { type: 'button', class: 'home-btn home-btn-primary' }, t('Nochmal versuchen')); button.addEventListener('click', () => loadFirst(deps.listGallery(options()))); nodes.push(button); } @@ -232,13 +238,13 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): function showEmpty(): void { const nodes: Node[] = []; if (filter !== 'all') { - nodes.push(el('p', {}, EMPTY_VIEW)); + nodes.push(el('p', {}, t(EMPTY_VIEW))); } else if (query || tag) { - nodes.push(el('p', {}, `Nichts gefunden für „${[query, tag].filter(Boolean).join(' · ')}“`)); + nodes.push(el('p', {}, t('Nichts gefunden für „{query}“', { query: [query, tag].filter(Boolean).join(' · ') }))); } else { nodes.push( - el('p', {}, EMPTY), - el('a', { class: 'home-btn home-btn-primary', href: '/gestalten', 'data-link': true }, 'Gestalten'), + el('p', {}, t(EMPTY)), + el('a', { class: 'home-btn home-btn-primary', href: '/gestalten', 'data-link': true }, t('Gestalten')), ); } statusSlot.replaceChildren(el('div', { class: 'community-empty' }, ...nodes)); @@ -249,7 +255,7 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): moreSlot.replaceChildren(); return; } - const button = el('button', { type: 'button', class: 'home-btn community-more-btn' }, 'Mehr laden'); + const button = el('button', { type: 'button', class: 'home-btn community-more-btn' }, t('Mehr laden')); if (loadingMore) { button.disabled = true; button.setAttribute('aria-busy', 'true'); @@ -274,7 +280,7 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): loadingMore = false; moreError = ''; moreSlot.replaceChildren(); - showStatus(LOADING, 'status'); + showStatus(t(LOADING), 'status'); pending.then( (res) => { if (disposed || token !== loadToken) return; @@ -286,7 +292,7 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): }, () => { if (disposed || token !== loadToken) return; - showStatus(LOAD_ERROR, 'alert', true); + showStatus(t(LOAD_ERROR), 'alert', true); }, ); } @@ -306,7 +312,7 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): } catch { if (disposed || token !== loadToken) return; loadingMore = false; - moreError = MORE_ERROR; + moreError = t(MORE_ERROR); } renderMore(); }