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