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 { 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 type { CheckedImage, GalleryCard } from './gallery';
import { mountGalleryGrid, type GalleryGrid } from './gallery-grid'; import { mountGalleryGrid, type GalleryGrid } from './gallery-grid';
@@ -204,3 +206,23 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
expect(revoked).toEqual(['blob:t-1']); // nicht doppelt freigeben 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. // 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) // 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 { CARD_H, CARD_W, PREVIEW_WIDTH } from '../card/geometry';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
import { t } from '../i18n/i18n';
import { reportsLabel } from '../pages/format'; import { reportsLabel } from '../pages/format';
import type { CheckedImage, GalleryCard } from './gallery'; import type { CheckedImage, GalleryCard } from './gallery';
@@ -37,7 +38,7 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption
let active = 0; let active = 0;
const showEmpty = (thumb: HTMLElement) => 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> { async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise<void> {
try { try {
@@ -89,7 +90,7 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption
if (tags.length > 0) { if (tags.length > 0) {
const row = el('div', { class: 'gallery-tags' }); const row = el('div', { class: 'gallery-tags' });
for (const tag of 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)); button.addEventListener('click', () => opts.onTag(tag));
row.append(button); row.append(button);
} }
@@ -98,7 +99,7 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption
if (opts.showStatus && (card.hidden || card.reportCount > 0)) { if (opts.showStatus && (card.hidden || card.reportCount > 0)) {
const badges = el('div', { class: 'gallery-badges' }); 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))); if (card.reportCount > 0) badges.append(el('span', { class: 'gallery-badge' }, reportsLabel(card.reportCount)));
tile.append(badges); tile.append(badges);
} }
+2 -1
View File
@@ -3,6 +3,7 @@
import { AppwriteException, Query, type Models } from 'appwrite'; import { AppwriteException, Query, type Models } from 'appwrite';
import { ID_PATTERN, fetchFile } from '../account/cards'; import { ID_PATTERN, fetchFile } from '../account/cards';
import { IDS, tables } from '../account/appwrite'; import { IDS, tables } from '../account/appwrite';
import { msg } from '../i18n/i18n';
export interface GalleryCard { export interface GalleryCard {
id: string; 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). */ /** 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; 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. */ /** Ein geprüftes Galerie-Bild; `url` ist eine Object-URL auf `blob`, die der Aufrufer mit `URL.revokeObjectURL` freigibt. */
export interface CheckedImage { 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 { DISPLAY_NAME_KEY } from '../account/display-name';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { germanLeft } from '../i18n/english-check';
import { setLang } from '../i18n/i18n';
import { ApiError } from './api'; import { ApiError } from './api';
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish'; import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
import { import {
@@ -556,3 +558,114 @@ describe('Veröffentlichen-Dialog: Schließen', () => {
expect(document.activeElement).toBe(opener); 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. // 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 '../account/account.css';
import './community.css'; import './community.css';
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name'; 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 { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload'; 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'; import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
/** Wortlaut aus Spec § 6. */ /** 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. */ /** Wortlaut aus Spec § 11. */
export const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.'; export const ALREADY_PUBLISHED = msg('Diese Karte ist schon veröffentlicht.');
const FAILED_PREFIX = 'Veröffentlichen fehlgeschlagen – '; const RUNNING = msg('Wird veröffentlicht …');
const RUNNING = 'Wird veröffentlicht …';
/** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */ /** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */
const TITLE_MAX = 60; const TITLE_MAX = 60;
@@ -37,47 +37,57 @@ export interface PublishFormValues extends PublishForm {
export type PublishField = 'title' | 'issuer' | 'tags' | 'displayName' | 'consent'; 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 { export function validatePublishForm(values: PublishFormValues): { field: PublishField; message: string } | null {
const title = values.title.trim(); const title = values.title.trim();
if (title === '') return { field: 'title', message: 'Bitte gib einen Titel ein.' }; if (title === '') return { field: 'title', message: t('Bitte gib einen Titel ein.') };
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: 'Der Titel enthält ungültige Zeichen.' }; 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: `Der Titel ist zu lang (höchstens ${TITLE_MAX} 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(); 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) { 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); 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)) { 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(); const displayName = values.displayName.trim();
if (CONTROL_CHARACTERS.test(displayName)) { 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) { 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; 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]); 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 { export function publishFailureText(err: unknown): string {
if (err instanceof LoginCancelled) return LOGIN_NEEDED; if (err instanceof LoginCancelled) return t(LOGIN_NEEDED);
if (err instanceof ApiError) return FAILED_PREFIX + err.message; let reason: string;
if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) return FAILED_PREFIX + err.message; if (err instanceof ApiError) reason = apiErrorText(err);
return FAILED_PREFIX + errorMessage(err, 'save'); 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. */ /** `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 titleInput = text('title', { 'aria-required': 'true' });
const issuerInput = text('issuer'); const issuerInput = text('issuer');
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' }); 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“ 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 consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
const inputs: Record<PublishField, HTMLInputElement> = { const inputs: Record<PublishField, HTMLInputElement> = {
@@ -138,30 +148,30 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
'p', 'p',
{ class: 'report-text' }, { class: 'report-text' },
opts.cardName opts.cardName
? `„${opts.cardName}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.` ? t('„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.', { name: opts.cardName })
: 'Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.', : t('Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.'),
); );
const fields = el( const fields = el(
'div', 'div',
{ class: 'publish-fields' }, { class: 'publish-fields' },
field('Titel (Pflicht)', titleInput), field(t('Titel (Pflicht)'), titleInput),
field('Herausgeber / Bank (optional)', issuerInput), field(t('Herausgeber / Bank (optional)'), issuerInput),
field('Tags (optional)', tagsInput, 'Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.'), field(t('Tags (optional)'), tagsInput, t('Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.')),
field('Anzeigename (optional)', nameInput, 'Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.'), field(t('Anzeigename (optional)'), nameInput, t('Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.')),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, CONSENT_TEXT)), el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))),
); );
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' }); const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' }); const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen'); const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Veröffentlichen'); const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Veröffentlichen'));
const actions = el('div', { class: 'report-actions' }, cancel, send); 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 form = el('form', { class: 'report-form', novalidate: true }, intro, fields, errorEl, statusEl, actions);
const dialog = el( const dialog = el(
'dialog', 'dialog',
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-heading' }, { 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. // 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 = () => { const finish = () => {
@@ -191,7 +201,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
for (const control of Object.values(inputs)) control.disabled = value; for (const control of Object.values(inputs)) control.disabled = value;
send.disabled = value; send.disabled = value;
cancel.disabled = value; cancel.disabled = value;
statusEl.textContent = value ? RUNNING : ''; statusEl.textContent = value ? t(RUNNING) : '';
} }
function clearInvalid(): void { function clearInvalid(): void {
@@ -201,10 +211,10 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
function showAlready(): void { function showAlready(): void {
outcome = 'already'; outcome = 'already';
errorEl.textContent = ''; errorEl.textContent = '';
statusEl.textContent = ALREADY_PUBLISHED; statusEl.textContent = t(ALREADY_PUBLISHED);
intro.remove(); intro.remove();
fields.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); close.addEventListener('click', finish);
actions.replaceChildren(close); actions.replaceChildren(close);
close.focus(); 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). */ /** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
export const PNG_MAX_BYTES = 5_242_880; export const PNG_MAX_BYTES = 5_242_880;
export const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).'; // Die Meldungen dieser Datei gehen deutsch markiert in den Fehler; der Veröffentlichen-Dialog zeigt sie mit t().
export const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.'; 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. */ /** 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( 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.', '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/. * 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. * 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 * 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: ', 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.“)', 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? */ /** 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.“; * 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 * ein Wort hat mindestens zwei Buchstaben, Satzzeichen am Ende und eine Klammer davor oder dahinter zählen nicht mit) und bei einem
* Wort („Hell“, „Speichert …“, „E-Mail“). Ein Wort mit Bindestrich-Teilen („auth-btn“) und CamelCase („ArrowLeft“) sind Bezeichner. * 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 { function looksGerman(text: string): boolean {
if (/[äöüÄÖÜß]/.test(text)) return true; 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, ''); 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); 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.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.isCaseClause(parent) || (ts.isBinaryExpression(parent) && EQUALITY.includes(parent.operatorToken.kind))) return true; // key === 'Escape'
if (ts.isPropertyAssignment(parent)) { if (ts.isPropertyAssignment(parent)) {
// { 'aria-label': … } (der Name, nicht der Wert) und { class: 'card tile' } (CSS-Klassen) // { 'aria-label': … } (der Name, nicht der Wert), { class: 'card tile' } (CSS-Klassen) und { rel: 'noopener noreferrer' }
return parent.name === node || ['class', 'className'].includes(ts.isIdentifier(parent.name) ? parent.name.text : ''); 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; if (!ts.isCallExpression(parent)) return false;
const callee = parent.expression; const callee = parent.expression;
if (ts.isIdentifier(callee)) return ['t', 'msg'].includes(callee.text) && parent.arguments[0] === node; 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)) .filter(([path]) => inTranslatedArea(path))
.flatMap(([path, code]) => findUntranslated(code).map((text) => ({ file: path, text }))); .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)', () => { describe('Abdeckung der Übersetzungen (t/msg ↔ en.ts)', () => {
it('der Scanner findet die Texte der Oberfläche (sonst bestünden die Prüfungen leer)', () => { it('der Scanner findet die Texte der Oberfläche (sonst bestünden die Prüfungen leer)', () => {
expect(used.size).toBeGreaterThan(10); 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 f = 'E-Mail';",
"const g = 'Bearbeitet: ';", "const g = 'Bearbeitet: ';",
'const h = `Nur Bild`;', 'const h = `Nur Bild`;',
"const i = 'Ausgeblendet (gemeldet)';",
].join('\n'); ].join('\n');
expect(findUntranslated(code)).toEqual([ expect(findUntranslated(code)).toEqual([
'Schließen', 'Schließen',
@@ -270,6 +412,7 @@ describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => {
'E-Mail', 'E-Mail',
'Bearbeitet: ', 'Bearbeitet: ',
'Nur Bild', 'Nur Bild',
'Ausgeblendet (gemeldet)',
]); ]);
}); });
@@ -290,13 +433,15 @@ describe('Scanner für deutsche Literale außerhalb von t() und msg()', () => {
expect(findUntranslated(code)).toEqual([]); 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 = [ const code = [
"el('button', { type: 'button', class: 'auth-btn auth-btn-small', 'aria-describedby': 'reset-hint reset-error' }, '×');", "el('button', { type: 'button', class: 'auth-btn auth-btn-small', 'aria-describedby': 'reset-hint reset-error' }, '×');",
"el('div', { class: 'card tile' });", "el('div', { class: 'card tile' });",
"const a = 'user_invalid_credentials' + 'image/png' + '#account-slot button' + 'ArrowLeft' + 'LoginCancelled' + 'PLATINUM';", "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 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 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'); ].join('\n');
expect(findUntranslated(code)).toEqual([]); 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']); 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.', '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', '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. // 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.', 'Bitte melde dich an.': 'Please sign in.',
'Nicht gefunden.': 'Not found.', '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 // 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. // 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EN } from './en'; import { germanLeft, texts } from './english-check';
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
acct: { acct: {
@@ -40,20 +40,6 @@ async function freshI18n(lang: 'de' | 'en') {
return i18n; 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', () => { describe('Fehlertexte und Standardname auf Englisch', () => {
it('errorMessage und validatePassword übersetzen beim Zurückgeben', async () => { it('errorMessage und validatePassword übersetzen beim Zurückgeben', async () => {
await freshI18n('en'); await freshI18n('en');
+4
View File
@@ -663,6 +663,10 @@ describe('Admin', () => {
await mount(logo({ reportCount: 1, ownerId: 'u1' })); await mount(logo({ reportCount: 1, ownerId: 'u1' }));
expect(buttons()).toEqual(['Report', 'Delete', 'Hide']); expect(buttons()).toEqual(['Report', 'Delete', 'Hide']);
expect(reasons()).toEqual(['Spam']); 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 }); row = logo({ hidden: true, reportCount: 1 });
info.update(); info.update();
expect(buttons()).toEqual(['Delete', 'Unhide']); 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. */ /** 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; 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_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_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).'); 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“. */ /** Ein Fehler mit fertigem, übersetztem Text für die Oberfläche; jeder andere Fehler beim Verarbeiten gilt als „nicht lesbar“. */
class LogoError extends Error {} 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 RASTER_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
const SVG_TYPE = 'image/svg+xml'; 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 } { export function planLogo(data: Uint8ClampedArray, w: number, h: number): { box: Box; size: Size } {
const box = trimBox(data, w, h); 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); 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 }; return { box, size };
} }
@@ -208,7 +203,7 @@ function newCanvas(w: number, h: number): { canvas: HTMLCanvasElement; ctx: Canv
canvas.width = w; canvas.width = w;
canvas.height = h; canvas.height = h;
const ctx = canvas.getContext('2d', { willReadFrequently: true }); 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 }; return { canvas, ctx };
} }
@@ -228,7 +223,7 @@ async function decode(blob: Blob): Promise<CardImage> {
try { try {
return await decodeImage(blob); return await decodeImage(blob);
} catch (err) { } 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; throw err;
} }
} }
@@ -248,7 +243,7 @@ async function drawSvg(file: File): Promise<Drawn> {
function toPng(canvas: HTMLCanvasElement): Promise<Blob> { function toPng(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise<Blob>((resolve, reject) => { 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), hasTransparency: hasTransparency(out.ctx.getImageData(0, 0, size.w, size.h).data),
}; };
} catch (err) { } 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 { DEFAULT_CONFIG, type TemplateId } from '../card/config';
import { createStore, initialEditorState } from '../editor/state'; import { createStore, initialEditorState } from '../editor/state';
import type { GalleryCard } from '../community/gallery'; import type { GalleryCard } from '../community/gallery';
import { germanLeft } from '../i18n/english-check';
import { setLang } from '../i18n/i18n';
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
list: vi.fn(), list: vi.fn(),
@@ -317,11 +319,11 @@ describe('Meine Karten: Herunterladen mit fehlendem Logo', () => {
mocks.exportCard.mockResolvedValue(png); 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)]); mocks.list.mockResolvedValue([onTemplate(template)]);
mount(); mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1)); await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Herunterladen').click(); button(tileFor('a'), label).click();
} }
it.each(['minimal', 'classic', 'signature'] as const)( it.each(['minimal', 'classic', 'signature'] as const)(
@@ -359,9 +361,8 @@ describe('Meine Karten: Herunterladen mit fehlendem Logo', () => {
}); });
it('Englisch: dieselbe Meldung, übersetzt', async () => { it('Englisch: dieselbe Meldung, übersetzt', async () => {
const { setLang } = await import('../i18n/i18n');
setLang('en', { persist: false }); setLang('en', { persist: false });
await download('signature'); await download('signature', 'Download');
await vi.waitFor(() => 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.'), 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(); 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, // 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). // 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 '../account/account.css';
import './cards.css'; import './cards.css';
import { getCurrentUser, onAuthChange } from '../account/auth'; 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 { clampCrop } from '../card/crop';
import { CARD_W } from '../card/geometry'; import { CARD_W } from '../card/geometry';
import { exportCard, loadCardAssets } from '../card/render'; 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 { myPublications } from '../community/gallery';
import { LOGO_MISSING_PUBLISH, assertLogoAvailable, publishSavedCard, type PublishForm } from '../community/publish'; import { LOGO_MISSING_PUBLISH, assertLogoAvailable, publishSavedCard, type PublishForm } from '../community/publish';
import { publishDialog } from '../community/publish-dialog'; import { publishDialog } from '../community/publish-dialog';
@@ -19,20 +20,24 @@ import { el } from '../editor/dom';
import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download'; import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download';
import type { EditorStore } from '../editor/state'; import type { EditorStore } from '../editor/state';
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload'; 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 { formatDate } from './format';
import { navigate } from './router'; import { navigate } from './router';
const LOADING = 'Karten werden geladen …'; const LOADING = msg('Karten werden geladen …');
const LOGGED_OUT = 'Melde dich an, um deine Karten zu sehen.'; const LOGGED_OUT = msg('Melde dich an, um deine Karten zu sehen.');
const EMPTY = 'Noch keine Karten gespeichert.'; const EMPTY = msg('Noch keine Karten gespeichert.');
const PREVIEW_PARALLEL = 4; 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. // Löschen einer privaten Karte lässt ihre Veröffentlichung bestehen; zurückgezogen wird dann auf der Community-Seite.
const PUBLISHED_NOTE = 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.'; '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 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. */ /** Was die Kachel über die Veröffentlichung wissen muss. */
interface Publication { interface Publication {
@@ -70,8 +75,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
const queue: SavedCard[] = []; const queue: SavedCard[] = [];
let activeLoads = 0; let activeLoads = 0;
const heading = el('h1', { tabindex: '-1' }, 'Meine Karten'); const heading = el('h1', { tabindex: '-1' }, t('Meine Karten'));
const body = el('section', { class: 'cards-page', 'aria-label': 'Gespeicherte Karten' }); const body = el('section', { class: 'cards-page', 'aria-label': t('Gespeicherte Karten') });
root.replaceChildren(heading, body); root.replaceChildren(heading, body);
function revokePreviews(keep: ReadonlySet<string> = new Set()): void { function revokePreviews(keep: ReadonlySet<string> = new Set()): void {
@@ -89,11 +94,11 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
if (!slot) return; if (!slot) return;
const url = previewUrls.get(card.previewFileId); const url = previewUrls.get(card.previewFileId);
if (url) { 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)) { } else if (previewFailed.has(card.previewFileId)) {
slot.replaceChildren(el('span', {}, 'Keine Vorschau')); slot.replaceChildren(el('span', {}, t('Keine Vorschau')));
} else { } 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(); previewQueued.clear();
thumbSlots.clear(); thumbSlots.clear();
revokePreviews(); 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')); 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 { function showError(text: string): void {
state = 'error'; 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()); retry.addEventListener('click', () => void load());
show(el('p', { class: 'cards-msg cards-msg-error', role: 'alert' }, text), el('p', { class: 'cards-actions' }, retry)); 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(); tileUpdaters.clear();
statusNotice = null; statusNotice = null;
if (cards.length === 0) { if (cards.length === 0) {
const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/gestalten', 'data-link': true }, 'Zum Editor'); 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' }, EMPTY), el('p', { class: 'cards-actions' }, toEditor)); show(el('p', { class: 'cards-msg' }, t(EMPTY)), el('p', { class: 'cards-actions' }, toEditor));
return; return;
} }
const grid = el('ul', { class: 'cards-grid' }); const grid = el('ul', { class: 'cards-grid' });
@@ -213,13 +218,13 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
statusNotice.replaceChildren(); statusNotice.replaceChildren();
return; 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', () => { retry.addEventListener('click', () => {
publicationsState = 'loading'; publicationsState = 'loading';
updateAllTiles(); updateAllTiles();
void fetchPublications(); void fetchPublications();
}); });
statusNotice.replaceChildren(`${STATUS_FAILED} `, retry); statusNotice.replaceChildren(`${t(STATUS_FAILED)} `, retry);
} }
function updateAllTiles(): void { 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 message = el('p', { class: 'card-msg', role: 'alert' });
const action = (key: string, text: string, className = 'auth-btn auth-btn-small') => const action = (key: string, text: string, className = 'auth-btn auth-btn-small') =>
el('button', { type: 'button', class: className, 'data-action': key }, text); el('button', { type: 'button', class: className, 'data-action': key }, text);
const openButton = action('open', 'Öffnen', 'auth-btn auth-btn-small auth-btn-primary'); const openButton = action('open', t('Öffnen'), 'auth-btn auth-btn-small auth-btn-primary');
const downloadButton = action('download', 'Herunterladen'); const downloadButton = action('download', t('Herunterladen'));
const renameButton = action('rename', 'Umbenennen'); const renameButton = action('rename', t('Umbenennen'));
const deleteButton = action('delete', 'Löschen', 'auth-btn auth-btn-small auth-btn-danger'); 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). // 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 publishSlot = el('span', { class: 'card-pub-controls' });
const badge = el('p', { class: 'card-pub' }); const badge = el('p', { class: 'card-pub' });
@@ -358,7 +363,11 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
const controls: HTMLElement[] = []; const controls: HTMLElement[] = [];
if (entry) { if (entry) {
badge.append( 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). // Ausgeblendet sehen nur Autor und Admin; „Ansehen“ gibt es dann nicht, „Zurückziehen“ bleibt (aufräumen, neu veröffentlichen).
if (!entry.hidden) { if (!entry.hidden) {
@@ -371,15 +380,15 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
'data-link': true, 'data-link': true,
'data-action': 'view', 'data-action': 'view',
}, },
'Ansehen', t('Ansehen'),
), ),
); );
} }
const withdrawButton = action('withdraw', 'Zurückziehen'); const withdrawButton = action('withdraw', t('Zurückziehen'));
withdrawButton.addEventListener('click', () => startWithdraw(entry)); withdrawButton.addEventListener('click', () => startWithdraw(entry));
controls.push(withdrawButton); controls.push(withdrawButton);
} else if (publicationsState !== 'loading') { } else if (publicationsState !== 'loading') {
const publishButton = action('publish', 'Veröffentlichen'); const publishButton = action('publish', t('Veröffentlichen'));
publishButton.addEventListener('click', () => startPublish()); publishButton.addEventListener('click', () => startPublish());
controls.push(publishButton); 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. */ /** Zusatz im Löschen-Dialog: die Veröffentlichung bleibt. Ist der Status unbekannt, ein allgemeiner Hinweis. */
function deleteNote(c: SavedCard): string { function deleteNote(c: SavedCard): string {
if (publicationsState !== 'ready') return ` ${MAYBE_PUBLISHED_NOTE}`; if (publicationsState !== 'ready') return ` ${t(MAYBE_PUBLISHED_NOTE)}`;
return publications.has(c.id) ? ` ${PUBLISHED_NOTE}` : ''; return publications.has(c.id) ? ` ${t(PUBLISHED_NOTE)}` : '';
} }
function startPublish(): void { function startPublish(): void {
@@ -402,9 +411,9 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
void run(async () => { void run(async () => {
const confirmed = await confirmDialog( const confirmed = await confirmDialog(
{ {
title: `Karte „${entry.title}“ zurückziehen?`, title: t('Karte „{title}“ zurückziehen?', { title: entry.title }),
text: 'Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.', text: t('Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.'),
confirmLabel: 'Zurückziehen', confirmLabel: t('Zurückziehen'),
}, },
disposal.signal, disposal.signal,
); );
@@ -440,7 +449,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
message.textContent = warning; message.textContent = warning;
message.classList.add('is-warning'); message.classList.add('is-warning');
} }
}, downloadButton, 'Wird erstellt …'); }, downloadButton, t('Wird erstellt …'));
}); });
renameButton.addEventListener('click', () => startRename()); renameButton.addEventListener('click', () => startRename());
@@ -449,9 +458,9 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
void run(async () => { void run(async () => {
const confirmed = await confirmDialog( const confirmed = await confirmDialog(
{ {
title: `Karte „${card.name}“ löschen?`, title: t('Karte „{title}“ löschen?', { title: card.name }),
text: `Die Karte und ihr Bild werden endgültig gelöscht.${deleteNote(card)}`, text: `${t('Die Karte und ihr Bild werden endgültig gelöscht.')}${deleteNote(card)}`,
confirmLabel: 'Löschen', confirmLabel: t('Löschen'),
}, },
disposal.signal, disposal.signal,
); );
@@ -481,10 +490,10 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
type: 'text', type: 'text',
value: card.name, value: card.name,
autocomplete: 'off', 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 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' }, 'Abbrechen'); 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 form = el('form', { class: 'card-rename' }, input, save, cancel);
const close = () => { const close = () => {
nameSlot.replaceChildren(el('h2', { class: 'card-name' }, card.name)); nameSlot.replaceChildren(el('h2', { class: 'card-name' }, card.name));
@@ -533,11 +542,11 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
return tile; 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 { function failureText(err: unknown): string {
if (err instanceof LoginCancelled) return LOGIN_NEEDED; if (err instanceof LoginCancelled) return t(LOGIN_NEEDED);
if (err instanceof ApiError) return err.message; // der Dienst antwortet auf Deutsch if (err instanceof ApiError) return apiErrorText(err);
if (err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE)) return err.message; if (err instanceof Error && KNOWN_MESSAGES.has(err.message)) return t(err.message);
if (err instanceof Error && err.message === LOGO_MISSING_PUBLISH) return t(LOGO_MISSING_PUBLISH);
return errorMessage(err, 'load'); return errorMessage(err, 'load');
} }
@@ -546,7 +555,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
async function load(): Promise<void> { async function load(): Promise<void> {
const token = ++loadToken; const token = ++loadToken;
state = 'loading'; state = 'loading';
showStatus(LOADING); showStatus(t(LOADING));
try { try {
const user = await getCurrentUser(); const user = await getCurrentUser();
if (disposed || token !== loadToken) return; 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 type { User } from '../account/auth';
import { ApiError, type ReportReason } from '../community/api'; import { ApiError, type ReportReason } from '../community/api';
import type { CheckedImage, GalleryCard, GalleryReport } from '../community/gallery'; 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. // gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten.
vi.mock('../account/appwrite', () => ({ vi.mock('../account/appwrite', () => ({
@@ -817,3 +819,172 @@ describe('Detailseite: Admin', () => {
expect(q('img')).toBeNull(); 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. // 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, // 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“. // 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 '../community/community.css';
import { currentUser, getCurrentUser, isAdmin, onAuthChange, type User } from '../account/auth'; import { currentUser, getCurrentUser, isAdmin, onAuthChange, type User } from '../account/auth';
import { errorMessage } from '../account/errors'; import { errorMessage } from '../account/errors';
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login'; 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 { import {
getGalleryCard, getGalleryCard,
listReports, listReports,
@@ -20,6 +21,7 @@ import { CARD_H, CARD_W } from '../card/geometry';
import { confirmDialog, type ConfirmOptions } from '../editor/confirm-dialog'; import { confirmDialog, type ConfirmOptions } from '../editor/confirm-dialog';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
import { downloadFileName, saveBlobAs } from '../editor/download'; import { downloadFileName, saveBlobAs } from '../editor/download';
import { msg, t } from '../i18n/i18n';
import { formatDate, reportsLabel } from './format'; import { formatDate, reportsLabel } from './format';
import { navigate, titleFor } from './router'; import { navigate, titleFor } from './router';
@@ -57,27 +59,33 @@ export const defaultDeps: CommunityDeps = {
withLogin: (fn) => withLogin(fn), withLogin: (fn) => withLogin(fn),
}; };
const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.'; const UNAVAILABLE = msg('Diese Karte ist nicht (mehr) verfügbar.');
const LOADING = 'Karte wird geladen …'; const LOADING = msg('Karte wird geladen …');
const LOAD_ERROR = 'Die Karte konnte gerade nicht geladen werden.'; const LOAD_ERROR = msg('Die Karte konnte gerade nicht geladen werden.');
const IMAGE_LOADING = 'Bild wird geladen …'; const IMAGE_LOADING = msg('Bild wird geladen …');
const IMAGE_ERROR = 'Das Bild konnte nicht geladen werden.'; const IMAGE_ERROR = msg('Das Bild konnte nicht geladen werden.');
const REASONS_ERROR = 'Die Gründe konnten nicht geladen werden.'; const REASONS_ERROR = msg('Die Gründe konnten nicht geladen werden.');
const REPORT_ERROR = 'Der Melden-Dialog konnte nicht geöffnet werden.'; const REPORT_ERROR = msg('Der Melden-Dialog konnte nicht geöffnet werden.');
const HIDDEN_NOTICE = 'Diese Karte ist für andere nicht sichtbar.'; 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])); 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; type Busy = 'download' | 'remove' | 'visibility' | null;
interface ReportsState { interface ReportsState {
status: 'idle' | 'loading' | 'ready' | 'error'; status: 'idle' | 'loading' | 'ready' | 'error';
list: GalleryReport[]; 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 { function failureText(err: unknown): string {
if (err instanceof LoginCancelled) return LOGIN_NEEDED; if (err instanceof LoginCancelled) return t(LOGIN_NEEDED);
if (err instanceof ApiError) return err.message; if (err instanceof ApiError) return apiErrorText(err);
return errorMessage(err, 'load'); return errorMessage(err, 'load');
} }
@@ -115,17 +123,17 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
} }
function showImageError(forCard: GalleryCard): void { 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)); retry.addEventListener('click', () => startImage(forCard));
imageSlot.setAttribute('aria-busy', 'false'); 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 { function startImage(forCard: GalleryCard): void {
const token = ++imageToken; const token = ++imageToken;
revokeImage(); revokeImage();
imageSlot.setAttribute('aria-busy', 'true'); 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( deps.loadImage(forCard, 'full').then(
(loaded) => { (loaded) => {
if (disposed || token !== imageToken) { if (disposed || token !== imageToken) {
@@ -136,7 +144,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
imageUrl = loaded.url; imageUrl = loaded.url;
const img = el('img', { const img = el('img', {
src: imageUrl, src: imageUrl,
alt: `Community-Karte „${forCard.title}“`, alt: t('Community-Karte „{title}“', { title: forCard.title }),
width: CARD_W, width: CARD_W,
height: CARD_H, height: CARD_H,
}); });
@@ -176,8 +184,8 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
el( el(
'div', 'div',
{ class: 'detail-unavailable' }, { class: 'detail-unavailable' },
el('h1', { class: 'detail-title' }, UNAVAILABLE), el('h1', { class: 'detail-title' }, t(UNAVAILABLE)),
el('a', { class: 'home-btn home-btn-primary', href: '/#community', 'data-link': true }, 'Zur Galerie'), 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 { function showLoadError(): void {
leaveCard(); leaveCard();
view = 'error'; 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()); retry.addEventListener('click', () => void load());
page.replaceChildren( 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; const focusedAction = focused && infoSlot.contains(focused) ? focused.closest('[data-action]')?.getAttribute('data-action') : null;
noticeSlot.replaceChildren( 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 = [ const buttons = [
button(busy === 'download' ? 'Wird geladen …' : 'Herunterladen', 'download', () => void download(), 'home-btn home-btn-primary'), button(
button('Als Vorlage gestalten', 'template', () => deps.navigate(`/gestalten?vorlage=${encodeURIComponent(c.id)}`)), 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). // 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)) { 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); actionsSlot.replaceChildren(...buttons);
@@ -245,11 +258,11 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
const section = el( const section = el(
'section', 'section',
{ class: 'detail-admin', 'aria-labelledby': 'detail-admin-title' }, { 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)), el('p', { class: 'detail-reports-count' }, reportsLabel(c.reportCount)),
); );
if (reports.status === 'error') { 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) { } else if (reports.list.length > 0) {
section.append( section.append(
el( el(
@@ -257,7 +270,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
{ class: 'detail-reasons' }, { class: 'detail-reasons' },
...reports.list.map((r) => { ...reports.list.map((r) => {
const date = formatDate(r.createdAt); 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( el(
'div', 'div',
{ class: 'detail-admin-actions' }, { class: 'detail-admin-actions' },
button(c.hidden ? 'Einblenden' : 'Ausblenden', 'visibility', () => void toggleVisibility()), button(c.hidden ? t('Einblenden') : t('Ausblenden'), 'visibility', () => void toggleVisibility()),
button('Löschen', 'delete', () => void removeCard('delete'), 'home-btn detail-btn-danger'), button(t('Löschen'), 'delete', () => void removeCard('delete'), 'home-btn detail-btn-danger'),
), ),
); );
return section; return section;
@@ -298,7 +311,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
document.title = titleFor(route, c.title); document.title = titleFor(route, c.title);
const date = formatDate(c.createdAt); 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( infoSlot.replaceChildren(
el('h1', { class: 'detail-title' }, c.title), el('h1', { class: 'detail-title' }, c.title),
...(c.issuer !== '' ? [el('p', { class: 'detail-issuer' }, c.issuer)] : []), ...(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', class: 'detail-tag',
href: `/?tag=${encodeURIComponent(tag)}#community`, href: `/?tag=${encodeURIComponent(tag)}#community`,
'data-link': true, 'data-link': true,
title: 'Karten mit diesem Tag zeigen', title: t('Karten mit diesem Tag zeigen'),
}, },
tag, tag,
), ),
@@ -330,7 +343,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
adminSlot, adminSlot,
); );
page.replaceChildren( 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), el('div', { class: 'detail-layout' }, imageSlot, infoSlot),
); );
renderControls(); 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)); deps.saveBlobAs(blob, downloadFileName(c.title, Number.isNaN(created.getTime()) ? new Date() : created));
} catch { } catch {
if (disposed || mine !== generation) return; if (disposed || mine !== generation) return;
showMessage(IMAGE_ERROR, 'error'); showMessage(t(IMAGE_ERROR), 'error');
} }
setBusy(null); setBusy(null);
} }
@@ -380,7 +393,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
}); });
} catch { } catch {
// Dialog nicht darstellbar (showModal wirft): kein unbehandelter Fehler, sondern ein Hinweis auf der Seite. // 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( const confirmed = await ask(
kind === 'withdraw' kind === 'withdraw'
? { ? {
title: `Karte „${c.title}“ zurückziehen?`, title: t('Karte „{title}“ zurückziehen?', { title: c.title }),
text: 'Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.', text: t('Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.'),
confirmLabel: 'Zurückziehen', confirmLabel: t('Zurückziehen'),
} }
: { : {
title: `Karte „${c.title}“ löschen?`, title: t('Karte „{title}“ löschen?', { title: c.title }),
text: 'Die Karte, ihr Bild und alle Meldungen werden endgültig gelöscht.', text: t('Die Karte, ihr Bild und alle Meldungen werden endgültig gelöscht.'),
confirmLabel: 'Löschen', confirmLabel: t('Löschen'),
}, },
); );
// Während der Rückfrage kann die Sitzung gewechselt haben: dann nichts mehr senden. // 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. // 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 }; card = { ...c, hidden: !show, reportCount: show ? 0 : c.reportCount };
if (show) reports = { status: 'ready', list: [] }; if (show) reports = { status: 'ready', list: [] };
showMessage(show ? 'Karte eingeblendet.' : 'Karte ausgeblendet.', 'info'); showMessage(show ? t('Karte eingeblendet.') : t('Karte ausgeblendet.'), 'info');
} catch (err) { } catch (err) {
if (disposed || mine !== generation) return; if (disposed || mine !== generation) return;
showMessage(failureText(err), 'error'); showMessage(failureText(err), 'error');
@@ -463,7 +476,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
if (!quiet) { if (!quiet) {
leaveCard(); leaveCard();
view = 'loading'; view = 'loading';
page.replaceChildren(el('p', { class: 'detail-status', role: 'status' }, LOADING)); page.replaceChildren(el('p', { class: 'detail-status', role: 'status' }, t(LOADING)));
} }
try { try {
const found = await deps.getCard(id); const found = await deps.getCard(id);
+25
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { setLang } from '../i18n/i18n';
import { formatDate, reportsLabel } from './format'; import { formatDate, reportsLabel } from './format';
describe('formatDate', () => { describe('formatDate', () => {
@@ -10,6 +11,22 @@ describe('formatDate', () => {
it('gibt bei unlesbarem Datum nichts zurück', () => { it('gibt bei unlesbarem Datum nichts zurück', () => {
expect(formatDate('kaputt')).toBe(''); 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', () => { describe('reportsLabel', () => {
@@ -19,4 +36,12 @@ describe('reportsLabel', () => {
expect(reportsLabel(2)).toBe('2 Meldungen'); expect(reportsLabel(2)).toBe('2 Meldungen');
expect(reportsLabel(12)).toBe('12 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 { export function formatDate(iso: string): string {
const date = new Date(iso); const date = new Date(iso);
if (Number.isNaN(date.getTime())) return ''; 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'); const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`; 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 { 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '../account/auth'; import type { User } from '../account/auth';
import type { CheckedImage, GalleryCard, GalleryOptions } from '../community/gallery'; 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. // gallery.ts und auth.ts hängen am Appwrite-Client; hier läuft alles über die Abhängigkeiten von mountHomePage.
vi.mock('../account/appwrite', () => ({ vi.mock('../account/appwrite', () => ({
@@ -460,3 +462,80 @@ describe('Startseite: Aufräumen', () => {
expect(loadImage).not.toHaveBeenCalled(); 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). // 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. // 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 '../community/community.css';
import { currentUser, isAdmin, onAuthChange, type User } from '../account/auth'; import { currentUser, isAdmin, onAuthChange, type User } from '../account/auth';
import { mountGalleryGrid, type GalleryGrid } from '../community/gallery-grid'; import { mountGalleryGrid, type GalleryGrid } from '../community/gallery-grid';
@@ -13,6 +14,7 @@ import {
} from '../community/gallery'; } from '../community/gallery';
import { CARD_H, CARD_W } from '../card/geometry'; import { CARD_H, CARD_W } from '../card/geometry';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
import { msg, t } from '../i18n/i18n';
import { navigate, scrollToHash } from './router'; import { navigate, scrollToHash } from './router';
type Page = { cards: GalleryCard[]; nextCursor: string | null }; type Page = { cards: GalleryCard[]; nextCursor: string | null };
@@ -38,18 +40,18 @@ export const defaultDeps: HomeDeps = {
}; };
const EXAMPLE_IMAGE = '/hero-beispiel.png'; 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 LOADING = msg('Karten werden geladen …');
const LOAD_ERROR = 'Die Community-Karten konnten gerade nicht geladen werden.'; const LOAD_ERROR = msg('Die Community-Karten konnten gerade nicht geladen werden.');
const MORE_ERROR = 'Weitere Karten konnten nicht geladen werden. Bitte nochmal versuchen.'; const MORE_ERROR = msg('Weitere Karten konnten nicht geladen werden. Bitte nochmal versuchen.');
const EMPTY = 'Noch keine Community-Karten – sei die erste!'; const EMPTY = msg('Noch keine Community-Karten – sei die erste!');
const EMPTY_VIEW = 'Keine Karten in dieser Ansicht.'; const EMPTY_VIEW = msg('Keine Karten in dieser Ansicht.');
const FILTERS: ReadonlyArray<readonly [GalleryFilter, string]> = [ const FILTERS: ReadonlyArray<readonly [GalleryFilter, string]> = [
['all', 'Alle'], ['all', msg('Alle')],
['reported', 'Gemeldet'], ['reported', msg('Gemeldet')],
['hidden', 'Ausgeblendet'], ['hidden', msg('Ausgeblendet')],
]; ];
/** `/` plus Suche und/oder Tag, mit Anker auf die Galerie. */ /** `/` 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 heroCard = el('div', { class: 'hero-card is-loading' });
const hero = el( const hero = el(
'section', 'section',
{ class: 'home-hero', 'aria-label': 'Willkommen' }, { class: 'home-hero', 'aria-label': t('Willkommen') },
el( el(
'div', 'div',
{ class: 'home-hero-copy' }, { class: 'home-hero-copy' },
el('p', { class: 'home-hero-eyebrow' }, 'Ein bisschen Kunst für dein Wallet'), el('p', { class: 'home-hero-eyebrow' }, t('Ein bisschen Kunst für dein Wallet')),
el('h1', { class: 'home-hero-title' }, 'Kartendesigns für Apple Wallet'), el('h1', { class: 'home-hero-title' }, t('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-lead' }, t('Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.')),
el( el(
'div', 'div',
{ class: 'home-hero-actions' }, { class: 'home-hero-actions' },
el('a', { class: 'home-btn home-btn-primary', href: '#community', 'data-link': true }, 'Karten entdecken ↓'), 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 }, 'Gestalten'), el('a', { class: 'home-btn', href: '/gestalten', 'data-link': true }, t('Gestalten')),
), ),
), ),
el('div', { class: 'home-hero-visual' }, heroCard), 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. */ /** Die große Karte: Vollbild der neuesten sichtbaren Karte; ohne Karte oder bei Fehlern das Beispielmotiv. */
function showHero(newest: Promise<GalleryCard | null>): void { function showHero(newest: Promise<GalleryCard | null>): void {
@@ -125,7 +127,7 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps):
return; return;
} }
heroUrl = url; heroUrl = url;
setHeroImage(url, `Community-Karte „${card.title}“`, card); setHeroImage(url, t('Community-Karte „{title}“', { title: card.title }), card);
} catch { } catch {
if (!disposed) showExample(); if (!disposed) showExample();
} }
@@ -153,10 +155,14 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps):
el( el(
'div', 'div',
{ class: 'community-head-text' }, { class: 'community-head-text' },
el('h2', { id: 'community-title', class: 'community-title' }, 'Community-Karten'), el('h2', { id: 'community-title', class: 'community-title' }, t('Community-Karten')),
el('p', { class: 'community-sub' }, 'Entdecke Motive der Community – und veröffentliche deine eigenen.'), 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), el('div', { class: 'community-toolbar' }, activeSlot, filterSlot),
statusSlot, statusSlot,
@@ -181,8 +187,8 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps):
if (query || tag) { if (query || tag) {
const active = el('div', { class: 'community-active' }); const active = el('div', { class: 'community-active' });
if (query) active.append(chip('Suche', query, 'Suche zurücksetzen', communityPath({ tag }))); if (query) active.append(chip(t('Suche'), query, t('Suche zurücksetzen'), communityPath({ tag })));
if (tag) active.append(chip('Tag', tag, 'Tag-Filter zurücksetzen', communityPath({ q: query }))); if (tag) active.append(chip(t('Tag'), tag, t('Tag-Filter zurücksetzen'), communityPath({ q: query })));
activeSlot.append(active); activeSlot.append(active);
} }
@@ -198,9 +204,9 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps):
filterSlot.replaceChildren(); filterSlot.replaceChildren();
return; 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) { 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)); button.addEventListener('click', () => setFilter(value));
filterButtons.set(value, button); filterButtons.set(value, button);
group.append(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 message = el('p', { class: `community-status${role === 'alert' ? ' is-error' : ''}`, role }, text);
const nodes: Node[] = [message]; const nodes: Node[] = [message];
if (retry) { 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()))); button.addEventListener('click', () => loadFirst(deps.listGallery(options())));
nodes.push(button); nodes.push(button);
} }
@@ -232,13 +238,13 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps):
function showEmpty(): void { function showEmpty(): void {
const nodes: Node[] = []; const nodes: Node[] = [];
if (filter !== 'all') { if (filter !== 'all') {
nodes.push(el('p', {}, EMPTY_VIEW)); nodes.push(el('p', {}, t(EMPTY_VIEW)));
} else if (query || tag) { } 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 { } else {
nodes.push( nodes.push(
el('p', {}, EMPTY), el('p', {}, t(EMPTY)),
el('a', { class: 'home-btn home-btn-primary', href: '/gestalten', 'data-link': true }, 'Gestalten'), el('a', { class: 'home-btn home-btn-primary', href: '/gestalten', 'data-link': true }, t('Gestalten')),
); );
} }
statusSlot.replaceChildren(el('div', { class: 'community-empty' }, ...nodes)); statusSlot.replaceChildren(el('div', { class: 'community-empty' }, ...nodes));
@@ -249,7 +255,7 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps):
moreSlot.replaceChildren(); moreSlot.replaceChildren();
return; 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) { if (loadingMore) {
button.disabled = true; button.disabled = true;
button.setAttribute('aria-busy', 'true'); button.setAttribute('aria-busy', 'true');
@@ -274,7 +280,7 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps):
loadingMore = false; loadingMore = false;
moreError = ''; moreError = '';
moreSlot.replaceChildren(); moreSlot.replaceChildren();
showStatus(LOADING, 'status'); showStatus(t(LOADING), 'status');
pending.then( pending.then(
(res) => { (res) => {
if (disposed || token !== loadToken) return; if (disposed || token !== loadToken) return;
@@ -286,7 +292,7 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps):
}, },
() => { () => {
if (disposed || token !== loadToken) return; 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 { } catch {
if (disposed || token !== loadToken) return; if (disposed || token !== loadToken) return;
loadingMore = false; loadingMore = false;
moreError = MORE_ERROR; moreError = t(MORE_ERROR);
} }
renderMore(); renderMore();
} }