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

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

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

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

No files matched your search

+22
View File
@@ -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([]);
});
});
+5 -4
View File
@@ -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<void> {
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);
}
+2 -1
View File
@@ -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 {
+113
View File
@@ -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<PublishFormValues>) => 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');
});
});
+49 -39
View File
@@ -1,5 +1,5 @@
// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags, Anzeigename und die Pflicht-Checkbox.
// Eigenes <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML.
// Eigenes <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
import '../account/account.css';
import './community.css';
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name';
@@ -7,15 +7,15 @@ import { errorMessage } from '../account/errors';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { el } from '../editor/dom';
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
import { ApiError, isAlreadyPublished } from './api';
import { msg, t } from '../i18n/i18n';
import { ApiError, apiErrorText, isAlreadyPublished } from './api';
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
/** Wortlaut aus Spec § 6. */
export const CONSENT_TEXT = 'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.';
export const CONSENT_TEXT = msg('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
/** Wortlaut aus Spec § 11. */
export const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
const FAILED_PREFIX = 'Veröffentlichen fehlgeschlagen – ';
const RUNNING = 'Wird veröffentlicht …';
export const ALREADY_PUBLISHED = msg('Diese Karte ist schon veröffentlicht.');
const RUNNING = msg('Wird veröffentlicht …');
/** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */
const TITLE_MAX = 60;
@@ -37,47 +37,57 @@ export interface PublishFormValues extends PublishForm {
export type PublishField = 'title' | 'issuer' | 'tags' | 'displayName' | 'consent';
/** Das erste Problem in Formularreihenfolge (mit deutschem Text) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */
/** Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */
export function validatePublishForm(values: PublishFormValues): { field: PublishField; message: string } | null {
const title = values.title.trim();
if (title === '') return { field: 'title', message: 'Bitte gib einen Titel ein.' };
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: 'Der Titel enthält ungültige Zeichen.' };
if (title.length > TITLE_MAX) return { field: 'title', message: `Der Titel ist zu lang (höchstens ${TITLE_MAX} Zeichen).` };
if (title === '') return { field: 'title', message: t('Bitte gib einen Titel ein.') };
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: t('Der Titel enthält ungültige Zeichen.') };
if (title.length > TITLE_MAX) {
return { field: 'title', message: t('Der Titel ist zu lang (höchstens {max} Zeichen).', { max: TITLE_MAX }) };
}
const issuer = values.issuer.trim();
if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: 'Herausgeber / Bank enthält ungültige Zeichen.' };
if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: t('Herausgeber / Bank enthält ungültige Zeichen.') };
if (issuer.length > ISSUER_MAX) {
return { field: 'issuer', message: `Herausgeber / Bank ist zu lang (höchstens ${ISSUER_MAX} Zeichen).` };
return { field: 'issuer', message: t('Herausgeber / Bank ist zu lang (höchstens {max} Zeichen).', { max: ISSUER_MAX }) };
}
const tags = normalizeTags(values.tags);
if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: 'Tags enthalten ungültige Zeichen.' };
if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: t('Tags enthalten ungültige Zeichen.') };
if (tags.some((tag) => tag.length > TAG_MAX)) {
return { field: 'tags', message: `Ein Tag darf höchstens ${TAG_MAX} Zeichen lang sein.` };
return { field: 'tags', message: t('Ein Tag darf höchstens {max} Zeichen lang sein.', { max: TAG_MAX }) };
}
if (tags.length > TAGS_MAX) return { field: 'tags', message: `Bitte gib höchstens ${TAGS_MAX} Tags an.` };
if (tags.length > TAGS_MAX) return { field: 'tags', message: t('Bitte gib höchstens {max} Tags an.', { max: TAGS_MAX }) };
const displayName = values.displayName.trim();
if (CONTROL_CHARACTERS.test(displayName)) {
return { field: 'displayName', message: 'Der Anzeigename enthält ungültige Zeichen.' };
return { field: 'displayName', message: t('Der Anzeigename enthält ungültige Zeichen.') };
}
if (displayName.length > DISPLAY_NAME_MAX) {
return { field: 'displayName', message: `Der Anzeigename ist zu lang (höchstens ${DISPLAY_NAME_MAX} Zeichen).` };
return {
field: 'displayName',
message: t('Der Anzeigename ist zu lang (höchstens {max} Zeichen).', { max: DISPLAY_NAME_MAX }),
};
}
if (!values.consent) return { field: 'consent', message: 'Bitte bestätige die Erklärung zum Bild.' };
if (!values.consent) return { field: 'consent', message: t('Bitte bestätige die Erklärung zum Bild.') };
return null;
}
/** Meldungen aus dem Rendern und dem Bildlesen sind schon deutsch; alles andere übersetzt `errorMessage`. */
/** Meldungen aus dem Rendern und dem Bildlesen tragen den deutschen Text (msg); angezeigt werden sie mit t(). Alles andere übersetzt `errorMessage`. */
const GERMAN_MESSAGES = new Set<string>([TOO_LARGE, NOT_RENDERED, LOGO_MISSING_PUBLISH, READ_MESSAGE, PIXELS_MESSAGE]);
/** Der Text zu einem fehlgeschlagenen Veröffentlichen (Spec § 11: „Veröffentlichen fehlgeschlagen – …“). */
/**
* Der Text zu einem fehlgeschlagenen Veröffentlichen in der Sprache der Seite (Spec § 11: „Veröffentlichen fehlgeschlagen – …“).
* Fehler des Dienstes kommen über ihren Code (`apiErrorText`).
*/
export function publishFailureText(err: unknown): string {
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
if (err instanceof ApiError) return FAILED_PREFIX + err.message;
if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) return FAILED_PREFIX + err.message;
return FAILED_PREFIX + errorMessage(err, 'save');
if (err instanceof LoginCancelled) return t(LOGIN_NEEDED);
let reason: string;
if (err instanceof ApiError) reason = apiErrorText(err);
else if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) reason = t(err.message);
else reason = errorMessage(err, 'save');
return t('Veröffentlichen fehlgeschlagen – {reason}', { reason });
}
/** `published`: veröffentlicht; `already`: die Karte war schon veröffentlicht (409); `cancelled`: nichts veröffentlicht oder Dialog verlassen. */
@@ -114,7 +124,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
const titleInput = text('title', { 'aria-required': 'true' });
const issuerInput = text('issuer');
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' });
const nameInput = text('displayName', { placeholder: 'Anonym', 'aria-describedby': 'publish-displayName-hint' });
const nameInput = text('displayName', { placeholder: t('Anonym'), 'aria-describedby': 'publish-displayName-hint' });
nameInput.value = rememberedDisplayName(); // der zuletzt benutzte Name, auch aus dem Dialog „Logo hochladen“
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
const inputs: Record<PublishField, HTMLInputElement> = {
@@ -138,30 +148,30 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
'p',
{ class: 'report-text' },
opts.cardName
? `„${opts.cardName}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.`
: 'Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.',
? t('„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.', { name: opts.cardName })
: t('Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.'),
);
const fields = el(
'div',
{ class: 'publish-fields' },
field('Titel (Pflicht)', titleInput),
field('Herausgeber / Bank (optional)', issuerInput),
field('Tags (optional)', tagsInput, 'Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.'),
field('Anzeigename (optional)', nameInput, 'Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.'),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, CONSENT_TEXT)),
field(t('Titel (Pflicht)'), titleInput),
field(t('Herausgeber / Bank (optional)'), issuerInput),
field(t('Tags (optional)'), tagsInput, t('Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.')),
field(t('Anzeigename (optional)'), nameInput, t('Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.')),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen');
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Veröffentlichen');
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Veröffentlichen'));
const actions = el('div', { class: 'report-actions' }, cancel, send);
// novalidate: die Prüfung läuft selbst, mit deutschen Texten statt der des Browsers.
// novalidate: die Prüfung läuft selbst, mit Texten der Seite statt der des Browsers.
const form = el('form', { class: 'report-form', novalidate: true }, intro, fields, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-heading' },
// Eigene ID für die Überschrift: `publish-title` gehört dem Titelfeld (`publish-<name>`), sonst zeigt sein Label auf die Überschrift.
el('div', { class: 'report-card' }, el('h2', { id: 'publish-heading', class: 'report-title' }, 'Karte veröffentlichen'), form),
el('div', { class: 'report-card' }, el('h2', { id: 'publish-heading', class: 'report-title' }, t('Karte veröffentlichen')), form),
);
const finish = () => {
@@ -191,7 +201,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
for (const control of Object.values(inputs)) control.disabled = value;
send.disabled = value;
cancel.disabled = value;
statusEl.textContent = value ? RUNNING : '';
statusEl.textContent = value ? t(RUNNING) : '';
}
function clearInvalid(): void {
@@ -201,10 +211,10 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
function showAlready(): void {
outcome = 'already';
errorEl.textContent = '';
statusEl.textContent = ALREADY_PUBLISHED;
statusEl.textContent = t(ALREADY_PUBLISHED);
intro.remove();
fields.remove();
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Schließen');
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Schließen'));
close.addEventListener('click', finish);
actions.replaceChildren(close);
close.focus();
+3 -2
View File
@@ -14,8 +14,9 @@ import { isDefinitiveFailure, publishCard, type PublishInput } from './api';
/** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
export const PNG_MAX_BYTES = 5_242_880;
export const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).';
export const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.';
// Die Meldungen dieser Datei gehen deutsch markiert in den Fehler; der Veröffentlichen-Dialog zeigt sie mit t().
export const TOO_LARGE = msg('Das Kartenbild ist zu groß für die Galerie (max. 5 MB).');
export const NOT_RENDERED = msg('Das Kartenbild konnte nicht erzeugt werden.');
/** Die Karte wählt ein Logo, das gelöscht oder ausgeblendet wurde: ohne dieses Logo wird sie weder veröffentlicht noch heruntergeladen. */
export const LOGO_MISSING_PUBLISH = msg(
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
+228 -7
View File
@@ -60,8 +60,10 @@ for (const [path, code] of sources()) {
/**
* Bereiche, die schon vollständig übersetzt sind: Ordner (mit „/“ am Ende) oder einzelne Dateien, relativ zu src/.
* Dort darf kein deutsches Text-Literal mehr außerhalb von t()/msg() stehen; ein weiterer übersetzter Bereich kommt hier dazu.
* Mit Task 11 ist die ganze Oberfläche übersetzt. Nicht in der Liste stehen nur Dateien ohne eigene Texte (config.ts, i18n/i18n.ts,
* Typdateien) und das Wörterbuch (i18n/en.ts: seine Werte sind Englisch).
*/
const TRANSLATED_AREAS = ['account/', 'editor/', 'card/', 'pages/reset-page.ts'];
const TRANSLATED_AREAS = ['account/', 'card/', 'community/', 'editor/', 'logos/', 'pages/', 'shell/', 'main.ts', 'i18n/api-messages.ts'];
/**
* Literale, die wie deutscher Text aussehen, aber nie angezeigt werden. Jede Ausnahme nennt ihren Grund; der Test unten
@@ -98,6 +100,46 @@ const ALLOWED_LITERALS: ReadonlyArray<{ file: string; text: string; reason: stri
text: 'Logo konnte nicht geladen werden: ',
reason: 'Entwicklermeldung mit interner ID; Nutzer sehen die eigenen Texte der Seiten (im Editor LOGO_ERROR, „Die Logos konnten nicht geladen werden.“)',
},
{
file: 'community/gallery.ts',
text: 'File not found',
reason: 'Englische Meldung der selbst gebauten AppwriteException (wie vom Server); die Seiten zeigen nur ihre eigenen Texte (Keine Vorschau, IMAGE_ERROR)',
},
{
file: 'logos/logos.ts',
text: 'File not found',
reason: 'Englische Meldung der selbst gebauten AppwriteException (wie vom Server); die Kachel zeigt bei einem Fehler nur den leeren Platzhalter',
},
{
file: 'logos/logo-asset.ts',
text: 'Das Logo konnte nicht gelesen werden.',
reason: 'Interner Fehler von decodeWithImage; decode() fängt ihn ab und meldet „kein Bild“ (null), er wird nie angezeigt',
},
{
file: 'logos/logo-asset.ts',
text: 'Canvas wird in diesem Browser nicht unterstützt.',
reason: 'Interner Fehler von fillShape; die Seiten zeigen bei einem Ladefehler der Logos ihre eigenen Texte (LOGO_ERROR, allgemeine Meldung von errorMessage())',
},
{
file: 'logos/logo-image.ts',
text: 'Das Logo ist zu groß.',
reason: 'Interner Fehler von checkImage; showLogoImage fängt ihn ab und zeigt den leeren Platzhalter (data-state="unusable")',
},
{
file: 'logos/logo-image.ts',
text: 'Das Logo konnte nicht gelesen werden.',
reason: 'Interner Fehler von checkImage; showLogoImage fängt ihn ab und zeigt den leeren Platzhalter (data-state="unusable")',
},
{
file: 'shell/header.ts',
text: 'Switch to English',
reason: 'Sprachknopf: Beschriftung in der Zielsprache (Spec § 7), unabhängig von der Sprache der Seite – darf deshalb nicht durch t()',
},
{
file: 'shell/header.ts',
text: 'Auf Deutsch umschalten',
reason: 'Sprachknopf: Beschriftung in der Zielsprache (Spec § 7), unabhängig von der Sprache der Seite – darf deshalb nicht durch t()',
},
];
/** Liegt die Datei in einem Bereich, in dem alle Texte schon übersetzt sein müssen? */
@@ -106,12 +148,13 @@ const inTranslatedArea = (path: string): boolean =>
/**
* 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<string>; exported: Set<string> } {
const out = { all: new Set<string>(), exported: new Set<string>() };
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, ReadonlySet<string>>): 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<string, ReadonlySet<string>>();
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<string>([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<string, ReadonlySet<string>>([['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']);
});
});
+105
View File
@@ -224,6 +224,111 @@ export const EN: Readonly<Record<string, string>> = {
'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.',
+17
View File
@@ -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);
+1 -15
View File
@@ -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');
+4
View File
@@ -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']);
+7 -12
View File
@@ -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<CardImage> {
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<Drawn> {
function toPng(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise<Blob>((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<PreparedLogo> {
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));
}
}
+176 -4
View File
@@ -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<void> {
async function download(template: TemplateId, label = 'Herunterladen'): Promise<void> {
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<Blob>(() => {});
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<Blob>(() => {}));
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([]);
});
});
+56 -47
View File
@@ -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<string>([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<string> = 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<void> {
const token = ++loadToken;
state = 'loading';
showStatus(LOADING);
showStatus(t(LOADING));
try {
const user = await getCurrentUser();
if (disposed || token !== loadToken) return;
+171
View File
@@ -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<HTMLInputElement>('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.');
});
});
+56 -43
View File
@@ -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<string, string> = 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);
+25
View File
@@ -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');
});
});
+6 -4
View File
@@ -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 });
}
+79
View File
@@ -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([]);
});
});
+39 -33
View File
@@ -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<readonly [GalleryFilter, string]> = [
['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<GalleryCard | null>): 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();
}