feat(logos): Dialog „Logo hochladen“ und gemerkter Anzeigename

Task 7: der Dialog zum Hochladen eines Community-Logos (Spec § 4) und der zuletzt benutzte Anzeigename, den er mit dem
Veröffentlichen-Dialog teilt.

- logos/logo-dialog.ts (neu): logoUploadDialog öffnet ein eigenes <dialog> nach dem Muster von publish-dialog.ts (showModal, Fokus
  zurück an den Auslöser, Texte nur als Text; beim Hochladen sind Abbrechen, Esc und Hintergrund wirkungslos, nur das Signal schließt
  immer; ein zweites Absenden wird ignoriert). Die gewählte Datei geht erst durch validateLogoFile, dann durch prepare (Standard
  prepareLogo); währenddessen ist „Hochladen“ gesperrt, eine neue Wahl macht die laufende Verarbeitung hinfällig. Das fertige PNG
  erscheint als Object-URL zweimal, auf dunklem und hellem Grund; „Einfarbig“ (nur wählbar mit transparenten Bereichen, bei jeder
  neuen Datei zurückgesetzt) zeigt stattdessen eine Maske auf dieselbe URL, gefüllt mit TONE_COLOR (Weiß bzw. #111111). Die URL wird
  beim Schließen und beim Ersetzen freigegeben. Der Name wird aus dem Dateinamen ohne Endung vorbelegt (höchstens 40 Zeichen, nie
  mitten in einem Emoji), ein selbst getippter Name bleibt. Hochladen über uploadPreparedLogo mit withLogin (abgelaufene Sitzung →
  Anmeldung); Fehler erscheinen als „Hochladen fehlgeschlagen – <Grund>“ (ApiError über apiErrorText, Texte von prepareLogo im
  Wortlaut, sonst errorMessage), eine abgebrochene Anmeldung als „Bitte melde dich an“. validateLogoForm prüft Datei, Name (leer,
  Steuerzeichen, 40), Anzeigename (Steuerzeichen, 30) und Pflicht-Häkchen mit den Texten aus Spec § 4 und § 11.
- logos/logos.css (neu): Breite des Dialogs, Datei-Feld, die zwei Vorschauflächen und die Maske; Hülle, Felder und Knöpfe kommen aus
  community.css und account.css.
- account/display-name.ts (neu): DISPLAY_NAME_KEY (aircard.displayName), rememberedDisplayName und rememberDisplayName; jeder Zugriff
  in try/catch, ein leerer Name entfernt den Schlüssel.
- community/publish-dialog.ts: der Anzeigename ist aus dem gemerkten Namen vorbelegt und wird nach erfolgreichem Veröffentlichen
  gemerkt; CONTROL_CHARACTERS und DISPLAY_NAME_MAX werden exportiert (der Logo-Dialog prüft mit denselben Regeln).
- i18n/en.ts: englische Texte zu allen neuen Beschriftungen, Hinweisen und Meldungen („Einfarbig“ heißt „Single color“).
- Tests: logo-dialog.test.ts (validateLogoForm, Aufbau, Dateiwahl mit Vorschau, Namensvorschlag, Maske, Object-URLs, veraltete
  Verarbeitung, Prüfung, Senden mit Doppelklick und Sperren, Fehlertexte in beiden Sprachen, Standardwerte, Schließen),
  display-name.test.ts, publish-dialog.test.ts (Vorbelegung und Speichern). Vitest 49 Dateien, 1566 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 22:00:12 +00:00
1 parent 56d841b8e2
commit e22919f4dc
8 files changed
+1665 -2

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DISPLAY_NAME_KEY, rememberDisplayName, rememberedDisplayName } from './display-name';
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
describe('gemerkter Anzeigename (localStorage)', () => {
it('der Schlüssel heißt aircard.displayName', () => {
expect(DISPLAY_NAME_KEY).toBe('aircard.displayName');
});
it('ohne gespeicherten Namen ist er leer', () => {
expect(rememberedDisplayName()).toBe('');
});
it('liest den gespeicherten Namen', () => {
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
expect(rememberedDisplayName()).toBe('Kenso');
});
it('speichert den Namen getrimmt unter dem Schlüssel und überschreibt den alten', () => {
rememberDisplayName(' Kenso ');
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
rememberDisplayName('Zoë');
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Zoë');
expect(rememberedDisplayName()).toBe('Zoë');
});
it.each([[''], [' ']])('ein leerer Name (%j) entfernt den Schlüssel („Anonym“)', (empty) => {
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
rememberDisplayName(empty);
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
expect(rememberedDisplayName()).toBe('');
});
it('gesperrter Speicher beim Lesen: leer, kein Fehler', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('gesperrt', 'SecurityError');
});
expect(rememberedDisplayName()).toBe('');
});
it('gesperrter Speicher beim Schreiben und beim Entfernen: kein Fehler', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('voll', 'QuotaExceededError');
});
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
throw new DOMException('gesperrt', 'SecurityError');
});
expect(() => rememberDisplayName('Kenso')).not.toThrow();
expect(() => rememberDisplayName('')).not.toThrow();
});
});
+25
View File
@@ -0,0 +1,25 @@
// Der zuletzt benutzte Anzeigename (Spec § 4): Der Veröffentlichen-Dialog und der Dialog „Logo hochladen“ lesen und schreiben
// denselben Schlüssel in localStorage. Jeder Zugriff steht in try/catch: ist der Speicher gesperrt (z. B. privates Fenster),
// bleibt das Feld leer und der Name wird nicht gemerkt, ohne dass etwas scheitert.
export const DISPLAY_NAME_KEY = 'aircard.displayName';
/** Der gemerkte Anzeigename; leer, wenn keiner gemerkt ist oder der Speicher nicht lesbar ist. */
export function rememberedDisplayName(): string {
try {
return localStorage.getItem(DISPLAY_NAME_KEY) ?? '';
} catch {
return '';
}
}
/** Merkt den Anzeigenamen (getrimmt); ein leerer Name („Anonym“) entfernt den gemerkten. */
export function rememberDisplayName(name: string): void {
const trimmed = name.trim();
try {
if (trimmed === '') localStorage.removeItem(DISPLAY_NAME_KEY);
else localStorage.setItem(DISPLAY_NAME_KEY, trimmed);
} catch {
// Gesperrt oder voll: der Name wird dann nur nicht gemerkt.
}
}
+79
View File
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} })); vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
import { DISPLAY_NAME_KEY } from '../account/display-name';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { ApiError } from './api'; import { ApiError } from './api';
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish'; import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
@@ -48,10 +49,13 @@ function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise<P
beforeEach(() => { beforeEach(() => {
submit.mockReset(); submit.mockReset();
document.body.innerHTML = ''; document.body.innerHTML = '';
localStorage.clear(); // der Anzeigename wird gemerkt und im nächsten Test wieder vorbelegt
}); });
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
localStorage.clear();
vi.restoreAllMocks();
}); });
describe('Veröffentlichen-Dialog: Felder', () => { describe('Veröffentlichen-Dialog: Felder', () => {
@@ -402,6 +406,81 @@ describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
}); });
}); });
describe('Veröffentlichen-Dialog: gemerkter Anzeigename', () => {
it('der Anzeigename ist mit dem zuletzt benutzten Namen (aircard.displayName) vorbelegt, die anderen Felder bleiben leer', () => {
expect(DISPLAY_NAME_KEY).toBe('aircard.displayName');
localStorage.setItem('aircard.displayName', 'Kenso');
void open();
expect(input('displayName').value).toBe('Kenso');
for (const name of ['title', 'issuer', 'tags']) expect(input(name).value).toBe('');
expect(input('displayName').placeholder).toBe('Anonym');
});
it('gesperrter Speicher: das Feld bleibt leer, der Dialog öffnet sich trotzdem', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('gesperrt', 'SecurityError');
});
void open();
expect(dialogEl()?.open).toBe(true);
expect(input('displayName').value).toBe('');
});
it('nach dem Veröffentlichen wird der (getrimmte) Anzeigename gemerkt', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ displayName: ' Kenso ' });
send();
await expect(outcome).resolves.toBe('published');
expect(localStorage.getItem('aircard.displayName')).toBe('Kenso');
});
it('ein geleerter Anzeigename entfernt den gemerkten (Anonym)', async () => {
localStorage.setItem('aircard.displayName', 'Kenso');
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ displayName: '' });
send();
await outcome;
expect(localStorage.getItem('aircard.displayName')).toBeNull();
});
it('bei einem Fehler wird nichts gemerkt, auch nicht bei „schon veröffentlicht“', async () => {
localStorage.setItem('aircard.displayName', 'Alt');
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
const outcome = open();
fill({ displayName: 'Neu' });
send();
await settle();
expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
submit.mockRejectedValueOnce(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
send();
await settle();
expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
buttonByText('Schließen')!.click();
await expect(outcome).resolves.toBe('already');
expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
});
it('beim Abbrechen und beim Schließen durch das Signal wird nichts gemerkt', async () => {
const controller = new AbortController();
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
const outcome = open({ signal: controller.signal });
fill({ displayName: 'Kenso' });
send();
controller.abort();
await outcome;
finish({ id: 'g1' });
await settle();
expect(localStorage.getItem('aircard.displayName')).toBeNull();
void open();
fill({ displayName: 'Kenso' });
buttonByText('Abbrechen')!.click();
expect(localStorage.getItem('aircard.displayName')).toBeNull();
});
});
describe('Veröffentlichen-Dialog: Schließen', () => { describe('Veröffentlichen-Dialog: Schließen', () => {
it('„Abbrechen“ schließt ohne zu senden', async () => { it('„Abbrechen“ schließt ohne zu senden', async () => {
const outcome = open(); const outcome = open();
+7 -2
View File
@@ -2,6 +2,7 @@
// 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.
import '../account/account.css'; import '../account/account.css';
import './community.css'; import './community.css';
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name';
import { errorMessage } from '../account/errors'; import { errorMessage } from '../account/errors';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
@@ -19,14 +20,16 @@ const RUNNING = 'Wird veröffentlicht …';
/** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */ /** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */
const TITLE_MAX = 60; const TITLE_MAX = 60;
const ISSUER_MAX = 40; const ISSUER_MAX = 40;
const DISPLAY_NAME_MAX = 30; /** Auch für den Anzeigenamen im Dialog „Logo hochladen“ (logos/logo-dialog.ts). */
export const DISPLAY_NAME_MAX = 30;
const TAGS_MAX = 5; const TAGS_MAX = 5;
const TAG_MAX = 20; const TAG_MAX = 20;
/** /**
* Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF): * Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
* unsichtbar oder umordnend. Dasselbe Muster prüft der Server (deploy/api/app.js, CONTROL_CHARACTERS). * unsichtbar oder umordnend. Dasselbe Muster prüft der Server (deploy/api/app.js, CONTROL_CHARACTERS).
* Auch für Name und Anzeigename im Dialog „Logo hochladen“ (logos/logo-dialog.ts).
*/ */
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/; export const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
export interface PublishFormValues extends PublishForm { export interface PublishFormValues extends PublishForm {
consent: boolean; consent: boolean;
@@ -112,6 +115,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
const issuerInput = text('issuer'); const issuerInput = text('issuer');
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' }); const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' });
const nameInput = text('displayName', { placeholder: 'Anonym', 'aria-describedby': 'publish-displayName-hint' }); const nameInput = text('displayName', { placeholder: '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 consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
const inputs: Record<PublishField, HTMLInputElement> = { const inputs: Record<PublishField, HTMLInputElement> = {
title: titleInput, title: titleInput,
@@ -240,6 +244,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
return; return;
} }
if (closed) return; if (closed) return;
rememberDisplayName(values.displayName);
outcome = 'published'; outcome = 'published';
finish(); finish();
} }
+29
View File
@@ -45,6 +45,35 @@ export const EN: Readonly<Record<string, string>> = {
'Das Logo ist leer.': 'The logo is empty.', 'Das Logo ist leer.': 'The logo is empty.',
'Das Logo ist zu klein (mindestens 8 × 8 Pixel).': 'The logo is too small (at least 8 × 8 pixels).', 'Das Logo ist zu klein (mindestens 8 × 8 Pixel).': 'The logo is too small (at least 8 × 8 pixels).',
// Dialog „Logo hochladen“ (logos/logo-dialog.ts). „Bitte gib einen Namen an.“ steht unter den API-Fehlertexten.
'Logo hochladen': 'Upload logo',
'Datei (Pflicht)': 'File (required)',
'PNG, JPG, WebP oder SVG, höchstens 5 MB und 40 Megapixel.': 'PNG, JPG, WebP or SVG, up to 5 MB and 40 megapixels.',
'Vorschau': 'Preview',
'Dunkel': 'Dark',
'Hell': 'Light',
'Logo-Vorschau auf dunklem Grund': 'Logo preview on a dark background',
'Logo-Vorschau auf hellem Grund': 'Logo preview on a light background',
'Einfarbig – passt sich Hell/Dunkel an': 'Single color – adapts to light/dark mode',
'Nur wählbar, wenn das Logo transparente Bereiche hat.': 'Only available if the logo has transparent areas.',
'Name (Pflicht)': 'Name (required)',
'Anzeigename (optional)': 'Display name (optional)',
'Anonym': 'Anonymous',
'Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.': 'Leave empty for “Anonymous”. Your email address is never shown.',
'Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.':
'I own the rights to this logo or have permission from the owner, and it may be shared publicly.',
'Abbrechen': 'Cancel',
'Hochladen': 'Upload',
'Logo wird verarbeitet …': 'Processing logo …',
'Wird hochgeladen …': 'Uploading …',
'Hochladen fehlgeschlagen – {reason}': 'Upload failed – {reason}',
'Bitte wähle eine Datei.': 'Please choose a file.',
'Der Name enthält ungültige Zeichen.': 'The name contains invalid characters.',
'Der Name ist zu lang (höchstens {max} Zeichen).': 'The name is too long (at most {max} characters).',
'Der Anzeigename enthält ungültige Zeichen.': 'The display name contains invalid characters.',
'Der Anzeigename ist zu lang (höchstens {max} Zeichen).': 'The display name is too long (at most {max} characters).',
'Bitte bestätige, dass du das Logo teilen darfst.': 'Please confirm that you’re allowed to share the logo.',
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich. // API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
'Bitte melde dich an.': 'Please sign in.', 'Bitte melde dich an.': 'Please sign in.',
'Nicht gefunden.': 'Not found.', 'Nicht gefunden.': 'Not found.',
+913
View File
@@ -0,0 +1,913 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
prepareLogo: vi.fn(),
uploadPreparedLogo: vi.fn(),
withLogin: vi.fn(),
}));
// api.ts und with-login.ts hängen am Appwrite-Client; `prepare` und `upload` kommen meist von außen, nur die Standardwerte
// (prepareLogo, uploadPreparedLogo über withLogin) laufen über die Fakes hier.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
vi.mock('../account/with-login', async (importOriginal) => ({
...(await importOriginal<typeof import('../account/with-login')>()),
withLogin: mocks.withLogin,
}));
vi.mock('./prepare-logo', async (importOriginal) => ({
...(await importOriginal<typeof import('./prepare-logo')>()),
prepareLogo: mocks.prepareLogo,
}));
vi.mock('./upload-logo', () => ({ uploadPreparedLogo: mocks.uploadPreparedLogo }));
import { DISPLAY_NAME_KEY } from '../account/display-name';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { ApiError } from '../community/api';
import { setLang, t } from '../i18n/i18n';
import { LOGO_CONSENT_TEXT, logoUploadDialog, validateLogoForm, type LogoDialogOptions } from './logo-dialog';
import type { CommunityLogo } from './logos';
import { LOGO_ACCEPT, LOGO_EMPTY_MESSAGE, type PreparedLogo } from './prepare-logo';
import type { LogoForm } from './upload-logo';
type Prepare = (file: File) => Promise<PreparedLogo>;
type Upload = (prepared: PreparedLogo, form: LogoForm) => Promise<CommunityLogo>;
const prepare = vi.fn<Prepare>();
const upload = vi.fn<Upload>();
/** Die Blobs, für die der Dialog Object-URLs angelegt hat (blob:logo-1, blob:logo-2, …), und die wieder freigegebenen URLs. */
const created: Blob[] = [];
const revoked: string[] = [];
const prepared = (patch: Partial<PreparedLogo> = {}): PreparedLogo => ({
png: new Blob(['png-bytes'], { type: 'image/png' }),
width: 200,
height: 80,
hasTransparency: true,
...patch,
});
const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id: 'L1',
name: 'Beispielbank',
displayName: '',
fileId: 'L1',
ownerId: 'u1',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T12:00:00.000Z',
...patch,
});
const imageFile = (name = 'Beispielbank.png', type = 'image/png') => new File(['x'.repeat(100)], name, { type });
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.logo-dialog');
const input = (name: string) => dialogEl()!.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
const buttonByText = (text: string) =>
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const form = () => dialogEl()!.querySelector('form')!;
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
const previews = () => [...dialogEl()!.querySelectorAll<HTMLElement>('.logo-preview')];
const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
/** Wählt eine Datei im Datei-Feld (oder hebt die Auswahl auf), wie es der Browser mit `change` meldet. */
function choose(file: File | null): void {
Object.defineProperty(input('file'), 'files', { value: file ? [file] : [], configurable: true });
input('file').dispatchEvent(new Event('change', { bubbles: true }));
}
/** Tippt in ein Textfeld, so dass auch das `input`-Ereignis kommt (das Setzen von `value` allein löst es nicht aus). */
function typeInto(name: string, value: string): void {
input(name).value = value;
input(name).dispatchEvent(new Event('input', { bubbles: true }));
}
function fill(values: { name?: string; displayName?: string; consent?: boolean } = {}): void {
input('name').value = values.name ?? 'Beispielbank';
input('displayName').value = values.displayName ?? '';
input('consent').checked = values.consent ?? true;
}
function open(opts: LogoDialogOptions = {}): Promise<CommunityLogo | null> {
return logoUploadDialog({ prepare, upload, ...opts });
}
/** Öffnet den Dialog und wählt eine Datei; wartet, bis die Verarbeitung durch ist. Das Ergebnis steckt in einem Objekt (kein Warten auf das Schließen). */
async function setup(opts: LogoDialogOptions = {}, file: File = imageFile()): Promise<{ outcome: Promise<CommunityLogo | null> }> {
const outcome = open(opts);
choose(file);
await settle();
return { outcome };
}
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = '';
for (const fn of [prepare, upload, mocks.prepareLogo, mocks.uploadPreparedLogo, mocks.withLogin]) fn.mockReset();
mocks.withLogin.mockImplementation((fn: () => Promise<unknown>) => fn());
prepare.mockResolvedValue(prepared());
created.length = 0;
revoked.length = 0;
let counter = 0;
URL.createObjectURL = vi.fn((blob: Blob) => {
created.push(blob);
return `blob:logo-${++counter}`;
});
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
});
afterEach(() => {
document.body.innerHTML = '';
localStorage.clear();
});
describe('validateLogoForm', () => {
const ok = { hasFile: true, name: 'Beispielbank', displayName: '', consent: true };
it('gültige Werte: kein Problem', () => {
expect(validateLogoForm(ok)).toBeNull();
expect(validateLogoForm({ ...ok, displayName: 'Kenso' })).toBeNull();
});
it('ohne Datei', () => {
expect(validateLogoForm({ ...ok, hasFile: false })).toEqual({ field: 'file', message: 'Bitte wähle eine Datei.' });
});
it('Name leer, auch nur aus Leerzeichen', () => {
const problem = { field: 'name', message: 'Bitte gib einen Namen an.' };
expect(validateLogoForm({ ...ok, name: '' })).toEqual(problem);
expect(validateLogoForm({ ...ok, name: ' ' })).toEqual(problem);
});
it('Name länger als 40 Zeichen; 40 Zeichen gehen durch, Leerzeichen außen zählen nicht', () => {
expect(validateLogoForm({ ...ok, name: 'x'.repeat(41) })).toEqual({
field: 'name',
message: 'Der Name ist zu lang (höchstens 40 Zeichen).',
});
expect(validateLogoForm({ ...ok, name: 'x'.repeat(40) })).toBeNull();
expect(validateLogoForm({ ...ok, name: ` ${'x'.repeat(40)} ` })).toBeNull();
});
// Dasselbe Muster wie der Dienst (deploy/api/common.js): Steuer-, Nullbreiten- und Bidi-Zeichen und das BOM.
it.each([
['Tabulator', 'a\tb'],
['Nullbreiten-Leerzeichen', 'a​b'],
['Rechts-nach-links-Überschreibung', 'logo‮gnp.exe'],
['C1-Steuerzeichen', 'a\u0085b'],
['BOM mitten im Namen', 'ab'],
])('Steuerzeichen im Namen werden abgelehnt: %s', (_label, name) => {
expect(validateLogoForm({ ...ok, name })).toEqual({ field: 'name', message: 'Der Name enthält ungültige Zeichen.' });
});
it('Anzeigename: höchstens 30 Zeichen, keine Steuerzeichen, leer ist erlaubt', () => {
expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(31) })).toEqual({
field: 'displayName',
message: 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).',
});
expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(30) })).toBeNull();
expect(validateLogoForm({ ...ok, displayName: ' ' })).toBeNull();
expect(validateLogoForm({ ...ok, displayName: 'Ken⁠so' })).toEqual({
field: 'displayName',
message: 'Der Anzeigename enthält ungültige Zeichen.',
});
});
it('ohne Häkchen', () => {
expect(validateLogoForm({ ...ok, consent: false })).toEqual({
field: 'consent',
message: 'Bitte bestätige, dass du das Logo teilen darfst.',
});
});
it('meldet das erste Problem in Formularreihenfolge: Datei, Name, Anzeigename, Häkchen', () => {
const bad = { hasFile: false, name: '', displayName: 'x'.repeat(31), consent: false };
expect(validateLogoForm(bad)?.field).toBe('file');
expect(validateLogoForm({ ...bad, hasFile: true })?.field).toBe('name');
expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank' })?.field).toBe('displayName');
expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank', displayName: '' })?.field).toBe('consent');
});
it('Englisch: die Meldungen kommen in der Sprache der Seite', () => {
setLang('en');
expect(validateLogoForm({ ...ok, hasFile: false })?.message).toBe('Please choose a file.');
expect(validateLogoForm({ ...ok, name: '' })?.message).toBe('Please enter a name.');
expect(validateLogoForm({ ...ok, name: 'x'.repeat(41) })?.message).toBe('The name is too long (at most 40 characters).');
expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(31) })?.message).toBe(
'The display name is too long (at most 30 characters).',
);
expect(validateLogoForm({ ...ok, consent: false })?.message).toBe('Please confirm that you’re allowed to share the logo.');
});
});
describe('Logo-Dialog: Aufbau', () => {
it('ist ein eigenes <dialog> mit Überschrift, den Feldern in Formularreihenfolge und beiden Knöpfen', () => {
void open();
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo hochladen');
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['file', 'mono', 'name', 'displayName', 'consent']);
expect([...form().querySelectorAll('label')].map((l) => l.textContent)).toEqual([
'Datei (Pflicht)',
'Einfarbig – passt sich Hell/Dunkel an',
'Name (Pflicht)',
'Anzeigename (optional)',
LOGO_CONSENT_TEXT,
]);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Hochladen')).toBeTruthy();
});
it('das Datei-Feld nimmt PNG, JPG, WebP und SVG und nennt die Grenzen der Spec', () => {
void open();
expect(input('file').type).toBe('file');
expect(input('file').getAttribute('accept')).toBe(LOGO_ACCEPT);
expect(dialogEl()!.textContent).toContain('PNG, JPG, WebP oder SVG, höchstens 5 MB und 40 Megapixel.');
});
it('beim Öffnen: Name leer, Häkchen ungesetzt, „Einfarbig“ gesperrt, keine Vorschau, Fokus im Datei-Feld', () => {
void open();
expect(input('name').value).toBe('');
expect(input('displayName').value).toBe('');
expect(input('displayName').placeholder).toBe('Anonym');
expect(input('consent').checked).toBe(false);
expect(input('mono').checked).toBe(false);
expect(input('mono').disabled).toBe(true);
expect(previews()).toHaveLength(0);
expect(document.activeElement).toBe(input('file'));
expect(dialogEl()!.textContent).not.toMatch(/@/);
});
it('der Anzeigename ist mit dem zuletzt benutzten Namen vorbelegt', () => {
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
void open();
expect(input('displayName').value).toBe('Kenso');
});
it('das Pflicht-Häkchen hat genau den Wortlaut der Spec', () => {
void open();
expect(LOGO_CONSENT_TEXT).toBe(
'Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.',
);
expect(input('consent').closest('label')!.textContent).toBe(LOGO_CONSENT_TEXT);
});
it('jedes Feld hat sein Label, alle IDs sind eindeutig, der Titel des Dialogs ist die Überschrift', () => {
void open();
const labels = [...form().querySelectorAll('label')];
const inputs = [...form().querySelectorAll('input')];
expect(labels.map((l) => l.control)).toEqual(inputs);
for (const control of inputs) expect(control.labels).toHaveLength(1);
const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id);
expect(new Set(ids).size).toBe(ids.length);
const heading = dialogEl()!.querySelector('h2')!;
expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(heading.id);
expect(inputs.map((i) => i.id)).not.toContain(heading.id);
});
it('Englisch: Überschrift, Beschriftungen, Platzhalter und Knöpfe', () => {
setLang('en');
void open();
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Upload logo');
expect([...form().querySelectorAll('label')].map((l) => l.textContent)).toEqual([
'File (required)',
'Single color – adapts to light/dark mode',
'Name (required)',
'Display name (optional)',
'I own the rights to this logo or have permission from the owner, and it may be shared publicly.',
]);
expect(input('displayName').placeholder).toBe('Anonymous');
expect(buttonByText('Cancel')).toBeTruthy();
expect(buttonByText('Upload')).toBeTruthy();
});
});
describe('Logo-Dialog: Datei wählen', () => {
it('ruft prepare auf, zeigt „Logo wird verarbeitet …“ und danach zwei Vorschauen (dunkel, hell) mit dem fertigen PNG', async () => {
const p = prepared();
prepare.mockResolvedValue(p);
const file = imageFile();
void open();
choose(file);
expect(prepare).toHaveBeenCalledExactlyOnceWith(file);
expect(statusText()).toBe('Logo wird verarbeitet …');
expect(previews()).toHaveLength(0);
await settle();
expect(statusText()).toBe('');
expect(errorText()).toBe('');
const [dark, light] = previews();
expect(previews()).toHaveLength(2);
expect(dark.classList.contains('logo-preview-dark')).toBe(true);
expect(light.classList.contains('logo-preview-light')).toBe(true);
expect(dark.querySelector('figcaption')!.textContent).toBe('Dunkel');
expect(light.querySelector('figcaption')!.textContent).toBe('Hell');
// Beide zeigen dieselbe Object-URL des fertigen PNGs; sie wird nur einmal angelegt.
expect(created).toHaveLength(1);
expect(created[0]).toBe(p.png);
for (const preview of previews()) {
const img = preview.querySelector('img')!;
expect(img.getAttribute('src')).toBe('blob:logo-1');
expect(img.getAttribute('alt')).toMatch(/^Logo-Vorschau auf (dunklem|hellem) Grund$/);
}
});
it.each([
['einfacher Dateiname', 'Beispielbank.png', 'Beispielbank'],
['nur die letzte Endung fällt weg', 'Meine Bank.final.svg', 'Meine Bank.final'],
['ohne Endung', 'logo', 'logo'],
['Leerzeichen außen fallen weg', ' Mit Leerzeichen .webp', 'Mit Leerzeichen'],
['über 40 Zeichen: gekürzt', `${'x'.repeat(60)}.png`, 'x'.repeat(40)],
['nach dem Kürzen bleibt kein Leerzeichen am Ende', `${'a'.repeat(39)} bbb.png`, 'a'.repeat(39)],
// 😀 belegt zwei Zeichen (UTF-16): ein Schnitt mitten darin würde ein halbes Zeichen hinterlassen.
['der Schnitt trennt kein Emoji', `${'a'.repeat(39)}😀b.png`, 'a'.repeat(39)],
])('der Name wird aus dem Dateinamen ohne Endung vorbelegt: %s', async (_label, fileName, expected) => {
await setup({}, imageFile(fileName));
expect(input('name').value).toBe(expected);
expect(validateLogoForm({ hasFile: true, name: input('name').value, displayName: '', consent: true })).toBeNull();
});
it('eine zweite Datei ersetzt den vorbelegten Namen, aber keinen selbst getippten', async () => {
await setup({}, imageFile('Erste.png'));
expect(input('name').value).toBe('Erste');
choose(imageFile('Zweite.png'));
await settle();
expect(input('name').value).toBe('Zweite');
typeInto('name', 'Mein Name');
choose(imageFile('Dritte.png'));
await settle();
expect(input('name').value).toBe('Mein Name');
// Wer das Feld leert, bekommt beim nächsten Logo wieder einen Vorschlag.
typeInto('name', '');
choose(imageFile('Vierte.png'));
await settle();
expect(input('name').value).toBe('Vierte');
});
it('„Einfarbig“ ist gesperrt, wenn das Logo keine transparenten Bereiche hat', async () => {
prepare.mockResolvedValue(prepared({ hasTransparency: false }));
await setup();
expect(input('mono').disabled).toBe(true);
expect(input('mono').checked).toBe(false);
});
it('„Einfarbig“ ist wählbar, wenn das Logo transparente Bereiche hat', async () => {
await setup();
expect(input('mono').disabled).toBe(false);
expect(input('mono').checked).toBe(false);
});
it('„Einfarbig“ zeigt beide Vorschauen als Maske auf dieselbe URL (Weiß auf dunklem, #111111 auf hellem Grund); abwählen bringt das Bild zurück', async () => {
await setup();
input('mono').click();
expect(input('mono').checked).toBe(true);
expect(previews()).toHaveLength(2);
const [dark, light] = previews();
for (const preview of previews()) {
expect(preview.querySelector('img')).toBeNull();
const mask = preview.querySelector<HTMLElement>('.logo-preview-mask')!;
expect(mask.getAttribute('style')).toContain('url("blob:logo-1")');
expect(mask.getAttribute('role')).toBe('img');
expect(mask.getAttribute('aria-label')).toMatch(/^Logo-Vorschau auf (dunklem|hellem) Grund$/);
}
// Gefüllt wie auf der Karte (TONE_COLOR): Weiß auf dunklem, #111111 auf hellem Grund.
expect(dark.querySelector<HTMLElement>('.logo-preview-mask')!.style.getPropertyValue('background-color')).toBe('#FFFFFF');
expect(light.querySelector<HTMLElement>('.logo-preview-mask')!.style.getPropertyValue('background-color')).toBe('#111111');
expect(created).toHaveLength(1); // keine neue URL für die Maske
input('mono').click();
expect(input('mono').checked).toBe(false);
for (const preview of previews()) {
expect(preview.querySelector('.logo-preview-mask')).toBeNull();
expect(preview.querySelector('img')!.getAttribute('src')).toBe('blob:logo-1');
}
});
it('eine neue Datei gibt die Object-URL der alten frei und setzt „Einfarbig“ zurück', async () => {
await setup({}, imageFile('Erste.png'));
input('mono').click();
expect(input('mono').checked).toBe(true);
prepare.mockResolvedValue(prepared());
choose(imageFile('Zweite.png'));
expect(revoked).toEqual(['blob:logo-1']); // sofort beim Wählen, die Vorschau gehört nicht mehr zur Auswahl
expect(previews()).toHaveLength(0);
await settle();
expect(input('mono').checked).toBe(false);
expect(previews()).toHaveLength(2);
expect(previews()[0].querySelector('img')!.getAttribute('src')).toBe('blob:logo-2');
expect(revoked).toEqual(['blob:logo-1']);
});
it('die Auswahl aufzuheben räumt Vorschau und Object-URL weg', async () => {
await setup();
choose(null);
await settle();
expect(previews()).toHaveLength(0);
expect(revoked).toEqual(['blob:logo-1']);
expect(input('mono').disabled).toBe(true);
expect(buttonByText('Hochladen')!.disabled).toBe(false);
});
it('falscher Dateityp oder zu groß: Meldung der Spec, prepare wird nicht gerufen, keine Vorschau', async () => {
void open();
choose(imageFile('notizen.txt', 'text/plain'));
await settle();
expect(prepare).not.toHaveBeenCalled();
expect(errorText()).toBe('Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.');
expect(previews()).toHaveLength(0);
const big = imageFile('gross.png');
Object.defineProperty(big, 'size', { value: 5_242_881 });
choose(big);
await settle();
expect(prepare).not.toHaveBeenCalled();
expect(errorText()).toBe('Die Datei ist größer als 5 MB.');
});
it('schlägt die Verarbeitung fehl, erscheint ihr Text (z. B. „Das Logo ist leer.“), ohne Vorschau und ohne Name', async () => {
prepare.mockRejectedValue(new Error(t(LOGO_EMPTY_MESSAGE)));
await setup();
expect(errorText()).toBe('Das Logo ist leer.');
expect(statusText()).toBe('');
expect(previews()).toHaveLength(0);
expect(input('name').value).toBe('');
expect(created).toHaveLength(0);
expect(buttonByText('Hochladen')!.disabled).toBe(false);
});
it('der Text der Verarbeitung erscheint in der Sprache der Seite', async () => {
setLang('en');
prepare.mockRejectedValue(new Error(t(LOGO_EMPTY_MESSAGE)));
await setup();
expect(errorText()).toBe('The logo is empty.');
});
it('ein unerwarteter Fehler der Verarbeitung zeigt nicht seinen Text, sondern die allgemeine Meldung', async () => {
prepare.mockRejectedValue(new Error('boom: interne Details'));
await setup();
expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
});
it('nach einer gescheiterten Verarbeitung gilt keine Datei als gewählt', async () => {
prepare.mockRejectedValue(new Error(t(LOGO_EMPTY_MESSAGE)));
await setup();
fill();
send();
expect(errorText()).toBe('Bitte wähle eine Datei.');
expect(upload).not.toHaveBeenCalled();
});
it('eine neue Wahl macht die laufende Verarbeitung der alten Datei hinfällig', async () => {
let finishFirst!: (p: PreparedLogo) => void;
const first = prepared({ png: new Blob(['erste'], { type: 'image/png' }) });
const second = prepared({ png: new Blob(['zweite'], { type: 'image/png' }), hasTransparency: false });
prepare.mockReturnValueOnce(new Promise((resolve) => (finishFirst = resolve)));
prepare.mockResolvedValueOnce(second);
void open();
choose(imageFile('Erste.png'));
choose(imageFile('Zweite.png'));
await settle();
expect(created).toHaveLength(1);
expect(created[0]).toBe(second.png);
expect(input('name').value).toBe('Zweite');
expect(input('mono').disabled).toBe(true);
finishFirst(first); // kommt zu spät und bleibt folgenlos
await settle();
expect(created).toHaveLength(1);
expect(input('name').value).toBe('Zweite');
expect(input('mono').disabled).toBe(true);
expect(previews()).toHaveLength(2);
expect(statusText()).toBe('');
});
it('während der Verarbeitung ist „Hochladen“ gesperrt und ein Absenden bleibt wirkungslos', async () => {
let finish!: (p: PreparedLogo) => void;
prepare.mockReturnValue(new Promise((resolve) => (finish = resolve)));
void open();
choose(imageFile());
fill();
expect(buttonByText('Hochladen')!.disabled).toBe(true);
send();
expect(errorText()).toBe('');
expect(upload).not.toHaveBeenCalled();
finish(prepared());
await settle();
expect(buttonByText('Hochladen')!.disabled).toBe(false);
});
it('eine Verarbeitung, die erst nach dem Schließen fertig wird, legt keine Object-URL mehr an', async () => {
let finish!: (p: PreparedLogo) => void;
prepare.mockReturnValue(new Promise((resolve) => (finish = resolve)));
const outcome = open();
choose(imageFile());
buttonByText('Abbrechen')!.click();
await outcome;
finish(prepared());
await settle();
expect(created).toHaveLength(0);
});
it('der Dateiname erscheint als Text, nie als HTML', async () => {
await setup({}, imageFile('<img src=x onerror=alert(1)>.png'));
expect(input('name').value).toBe('<img src=x onerror=alert(1)>');
for (const img of dialogEl()!.querySelectorAll('img')) expect(img.getAttribute('src')).toBe('blob:logo-1');
expect(dialogEl()!.querySelectorAll('img')).toHaveLength(2);
});
});
describe('Logo-Dialog: Prüfung vor dem Senden', () => {
const invalid = async (values: Parameters<typeof fill>[0], message: string, field: string) => {
await setup();
fill(values);
send();
await settle();
expect(errorText()).toBe(message);
expect(upload).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true);
expect(input(field).getAttribute('aria-invalid')).toBe('true');
expect(document.activeElement).toBe(input(field));
};
it('keine Datei gewählt', () => {
void open();
fill();
send();
expect(errorText()).toBe('Bitte wähle eine Datei.');
expect(upload).not.toHaveBeenCalled();
expect(input('file').getAttribute('aria-invalid')).toBe('true');
expect(document.activeElement).toBe(input('file'));
});
it('Name leer', async () => {
await invalid({ name: ' ' }, 'Bitte gib einen Namen an.', 'name');
});
it('Name länger als 40 Zeichen', async () => {
await invalid({ name: 'x'.repeat(41) }, 'Der Name ist zu lang (höchstens 40 Zeichen).', 'name');
});
it('Steuerzeichen im Namen', async () => {
await invalid({ name: 'a\tb' }, 'Der Name enthält ungültige Zeichen.', 'name');
});
it('Anzeigename länger als 30 Zeichen', async () => {
await invalid({ displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', 'displayName');
});
it('Pflicht-Häkchen fehlt', async () => {
await invalid({ consent: false }, 'Bitte bestätige, dass du das Logo teilen darfst.', 'consent');
});
it('eine behobene Eingabe verliert die Markierung beim nächsten Senden', async () => {
upload.mockResolvedValue(logo());
await setup();
fill({ name: '' });
send();
expect(input('name').getAttribute('aria-invalid')).toBe('true');
fill({ name: 'Ok' });
send();
expect(input('name').getAttribute('aria-invalid')).toBeNull();
expect(errorText()).toBe('');
});
it('Grenzen sind inklusive: 40 Zeichen Name und 30 Zeichen Anzeigename gehen durch', async () => {
upload.mockResolvedValue(logo());
const { outcome } = await setup();
fill({ name: 'x'.repeat(40), displayName: 'y'.repeat(30) });
send();
await outcome;
expect(upload).toHaveBeenCalledOnce();
});
it('Umlaute, Emoji und Bindestriche bleiben erlaubt', async () => {
upload.mockResolvedValue(logo());
const { outcome } = await setup();
fill({ name: 'Größe 😀 – Möwe-Bank', displayName: 'Zoë' });
send();
await outcome;
expect(upload).toHaveBeenCalledOnce();
});
});
describe('Logo-Dialog: Senden', () => {
it('sendet getrimmte Eingaben genau einmal, merkt den Anzeigenamen und löst mit dem Logo auf', async () => {
const p = prepared();
prepare.mockResolvedValue(p);
const result = logo({ id: 'L9' });
upload.mockResolvedValue(result);
const { outcome } = await setup();
fill({ name: ' Meine Bank ', displayName: ' Kenso ' });
send();
await expect(outcome).resolves.toBe(result);
expect(upload).toHaveBeenCalledOnce();
expect(upload).toHaveBeenCalledWith(p, { name: 'Meine Bank', displayName: 'Kenso', mono: false });
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
expect(dialogEl()).toBeNull();
});
it('„Einfarbig“ geht mit: gewählt → mono true', async () => {
upload.mockResolvedValue(logo());
const { outcome } = await setup();
input('mono').click();
fill();
send();
await outcome;
expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', displayName: '', mono: true });
});
it('ohne transparente Bereiche geht mono nie als true mit', async () => {
prepare.mockResolvedValue(prepared({ hasTransparency: false }));
upload.mockResolvedValue(logo());
const { outcome } = await setup();
input('mono').click(); // gesperrt: ändert nichts
expect(input('mono').checked).toBe(false);
input('mono').checked = true; // selbst ein von außen gesetztes Häkchen zählt nicht
fill();
send();
await outcome;
expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', displayName: '', mono: false });
});
it('ein geleerter Anzeigename entfernt den gemerkten (Anonym)', async () => {
localStorage.setItem(DISPLAY_NAME_KEY, 'Alt');
upload.mockResolvedValue(logo());
const { outcome } = await setup();
expect(input('displayName').value).toBe('Alt');
fill({ displayName: '' });
send();
await outcome;
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
});
it('ein Doppelklick auf „Hochladen“ sendet nicht zweimal', async () => {
let finish!: (l: CommunityLogo) => void;
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
const { outcome } = await setup();
fill();
const dialogForm = form();
send();
send();
expect(upload).toHaveBeenCalledOnce();
finish(logo());
await outcome;
// Ein verspätetes Absenden am schon geschlossenen Dialog bleibt wirkungslos.
dialogForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
expect(upload).toHaveBeenCalledOnce();
});
it('während des Sendens: Knöpfe und Felder gesperrt, Statustext, danach alles wieder frei', async () => {
let fail!: (e: Error) => void;
upload.mockReturnValue(new Promise((_, reject) => (fail = reject)));
await setup();
fill();
send();
expect(buttonByText('Hochladen')!.disabled).toBe(true);
expect(buttonByText('Abbrechen')!.disabled).toBe(true);
for (const name of ['file', 'mono', 'name', 'displayName', 'consent']) expect(input(name).disabled).toBe(true);
expect(form().getAttribute('aria-busy')).toBe('true');
expect(statusText()).toBe('Wird hochgeladen …');
fail(new Error('weg'));
await settle();
expect(buttonByText('Hochladen')!.disabled).toBe(false);
expect(buttonByText('Abbrechen')!.disabled).toBe(false);
for (const name of ['file', 'mono', 'name', 'displayName', 'consent']) expect(input(name).disabled).toBe(false);
expect(form().getAttribute('aria-busy')).toBe('false');
expect(statusText()).toBe('');
});
it('während des Sendens schließen weder Esc noch ein Klick auf den Hintergrund den Dialog', async () => {
let finish!: (l: CommunityLogo) => void;
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
const { outcome } = await setup();
fill();
send();
const esc = new Event('cancel', { cancelable: true });
dialogEl()!.dispatchEvent(esc);
expect(esc.defaultPrevented).toBe(true);
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
finish(logo());
await outcome;
});
it('schließt der Browser den Dialog mitten im Senden, geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => {
let finish!: (l: CommunityLogo) => void;
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
let resolved: CommunityLogo | null | undefined;
const result = logo();
const { outcome } = await setup();
void outcome.then((value) => (resolved = value));
fill();
send();
dialogEl()!.close(); // wie ein Browser, der Esc trotz verhindertem `cancel` auswertet
await settle();
expect(dialogEl()?.open).toBe(true);
dialogEl()!.dispatchEvent(new Event('close'));
await settle();
expect(resolved).toBeUndefined();
expect(dialogEl()?.open).toBe(true);
finish(result);
await outcome;
expect(resolved).toBe(result);
expect(dialogEl()).toBeNull();
});
it('scheitert das Senden, nachdem der Browser den Dialog geschlossen hat, ist der Fehler sichtbar (Dialog wieder offen)', async () => {
let fail!: (e: Error) => void;
upload.mockReturnValue(new Promise((_, reject) => (fail = reject)));
await setup();
fill();
send();
dialogEl()!.close();
fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
await settle();
expect(dialogEl()?.open).toBe(true);
expect(errorText()).toBe('Hochladen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.');
});
it('Fehler des Dienstes: „Hochladen fehlgeschlagen – <Text>“, der Dialog bleibt offen, ein neuer Versuch ist möglich', async () => {
upload.mockRejectedValueOnce(
new ApiError(
429,
'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.',
true,
'tooManyLogos',
),
);
const { outcome } = await setup();
fill({ displayName: 'Kenso' });
send();
await settle();
expect(errorText()).toBe(
'Hochladen fehlgeschlagen – Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.',
);
expect(dialogEl()?.open).toBe(true);
// Die Eingaben bleiben, und der Anzeigename wird erst bei Erfolg gemerkt.
expect(input('name').value).toBe('Beispielbank');
expect(input('displayName').value).toBe('Kenso');
expect(input('consent').checked).toBe(true);
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
upload.mockResolvedValueOnce(logo());
send();
await expect(outcome).resolves.toMatchObject({ id: 'L1' });
expect(upload).toHaveBeenCalledTimes(2);
expect(upload.mock.calls[1][0]).toBe(upload.mock.calls[0][0]); // dasselbe fertige Logo, nicht neu verarbeitet
expect(prepare).toHaveBeenCalledOnce();
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
});
it('Fehlertext des Dienstes in der Sprache der Seite (nach Fehlerschlüssel)', async () => {
setLang('en');
upload.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
await setup();
fill();
send();
await settle();
expect(errorText()).toBe('Upload failed – You are not allowed to do that.');
});
it('Servertexte erscheinen als Text, nie als HTML', async () => {
upload.mockRejectedValue(new ApiError(400, '<img src=x onerror=alert(1)>'));
await setup();
fill();
send();
await settle();
expect(errorText()).toBe('Hochladen fehlgeschlagen – <img src=x onerror=alert(1)>');
expect(dialogEl()!.querySelector('[role="alert"] img')).toBeNull();
});
it('abgebrochene Anmeldung: Hinweis „Bitte melde dich an“ statt „Hochladen fehlgeschlagen“', async () => {
upload.mockRejectedValue(new LoginCancelled());
await setup();
fill();
send();
await settle();
expect(errorText()).toBe(LOGIN_NEEDED);
});
it('andere Fehler werden übersetzt, interne Details erscheinen nie', async () => {
upload.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await setup();
fill();
send();
await settle();
expect(errorText()).toBe('Hochladen fehlgeschlagen – Keine Verbindung zum Server.');
upload.mockRejectedValueOnce(new Error('boom: interne Details'));
send();
await settle();
expect(errorText()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
});
});
describe('Logo-Dialog: Standardwerte', () => {
it('ohne prepare und upload: prepareLogo, und uploadPreparedLogo über withLogin (abgelaufene Sitzung → Anmeldung)', async () => {
const p = prepared();
mocks.prepareLogo.mockResolvedValue(p);
const result = logo();
mocks.uploadPreparedLogo.mockResolvedValue(result);
const file = imageFile();
const outcome = logoUploadDialog();
choose(file);
await settle();
expect(mocks.prepareLogo).toHaveBeenCalledExactlyOnceWith(file);
fill({ displayName: 'Kenso' });
send();
await expect(outcome).resolves.toBe(result);
expect(mocks.withLogin).toHaveBeenCalledOnce();
expect(mocks.uploadPreparedLogo).toHaveBeenCalledExactlyOnceWith(p, { name: 'Beispielbank', displayName: 'Kenso', mono: false });
});
});
describe('Logo-Dialog: Schließen', () => {
it('„Abbrechen“ schließt ohne zu senden und gibt die Object-URL frei', async () => {
const { outcome } = await setup();
fill();
buttonByText('Abbrechen')!.click();
await expect(outcome).resolves.toBeNull();
expect(upload).not.toHaveBeenCalled();
expect(dialogEl()).toBeNull();
expect(revoked).toEqual(['blob:logo-1']);
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
});
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen; ohne Senden ist Esc nicht verhindert', async () => {
const first = open();
const esc = new Event('cancel', { cancelable: true });
dialogEl()!.dispatchEvent(esc);
expect(esc.defaultPrevented).toBe(false);
dialogEl()!.dispatchEvent(new Event('close'));
await expect(first).resolves.toBeNull();
expect(dialogEl()).toBeNull();
const second = open();
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect(second).resolves.toBeNull();
});
it('ein Markieren, das außerhalb endet, schließt nicht (Drücken im Formular, Loslassen auf dem Hintergrund)', () => {
void open();
input('name').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
});
it('das Abbrechen-Signal schließt den Dialog, auch während des Sendens; eine spätere Antwort bleibt folgenlos', async () => {
const controller = new AbortController();
let finish!: (l: CommunityLogo) => void;
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
const { outcome } = await setup({ signal: controller.signal });
fill({ displayName: 'Kenso' });
send();
controller.abort();
await expect(outcome).resolves.toBeNull();
expect(dialogEl()).toBeNull();
expect(revoked).toEqual(['blob:logo-1']);
finish(logo());
await settle();
expect(dialogEl()).toBeNull();
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
});
it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => {
const controller = new AbortController();
let fail!: (e: Error) => void;
upload.mockReturnValue(new Promise((_, reject) => (fail = reject)));
const { outcome } = await setup({ signal: controller.signal });
fill();
send();
controller.abort();
await outcome;
fail(new ApiError(503, 'weg'));
await settle();
expect(dialogEl()).toBeNull();
});
it('ein schon abgebrochenes Signal öffnet gar nichts', async () => {
const controller = new AbortController();
controller.abort();
await expect(open({ signal: controller.signal })).resolves.toBeNull();
expect(dialogEl()).toBeNull();
});
it('der Fokus kehrt zum auslösenden Knopf zurück', async () => {
const opener = document.createElement('button');
document.body.append(opener);
opener.focus();
const outcome = open();
buttonByText('Abbrechen')!.click();
await outcome;
expect(document.activeElement).toBe(opener);
});
});
+435
View File
@@ -0,0 +1,435 @@
// Dialog „Logo hochladen“ (Spec § 4): Datei wählen, im Browser verarbeiten (prepare-logo.ts), auf dunklem und hellem Grund ansehen,
// Name, Anzeigename und Pflicht-Häkchen angeben, hochladen (upload-logo.ts). Eigenes <dialog> wie publish-dialog.ts; alle Texte
// gehen als Text in den DOM, nie als HTML.
import '../account/account.css';
import '../community/community.css';
import './logos.css';
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name';
import { errorMessage } from '../account/errors';
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
import { TONE_COLOR } from '../card/config';
import { ApiError, apiErrorText } from '../community/api';
import { CONTROL_CHARACTERS, DISPLAY_NAME_MAX } from '../community/publish-dialog';
import { el } from '../editor/dom';
import { msg, t } from '../i18n/i18n';
import type { CommunityLogo } from './logos';
import {
LOGO_ACCEPT,
LOGO_EMPTY_MESSAGE,
LOGO_PIXELS_MESSAGE,
LOGO_READ_MESSAGE,
LOGO_SIZE_MESSAGE,
LOGO_SMALL_MESSAGE,
LOGO_TYPE_MESSAGE,
prepareLogo,
validateLogoFile,
type PreparedLogo,
} from './prepare-logo';
import { uploadPreparedLogo, type LogoForm } from './upload-logo';
/** Wortlaut aus Spec § 4. */
export const LOGO_CONSENT_TEXT = msg(
'Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.',
);
/** Grenze wie der Dienst (deploy/api/logos.js): Länge in UTF-16-Einheiten, nach dem Trimmen. Der Anzeigename: wie beim Veröffentlichen. */
const NAME_MAX = 40;
/** Die Meldungen von `prepareLogo` und `validateLogoFile`: Sie kommen mit fertig übersetztem Text im Fehler an. */
const PREPARE_MESSAGES = [
LOGO_TYPE_MESSAGE,
LOGO_SIZE_MESSAGE,
LOGO_PIXELS_MESSAGE,
LOGO_READ_MESSAGE,
LOGO_EMPTY_MESSAGE,
LOGO_SMALL_MESSAGE,
];
export interface LogoFormValues {
/** Gibt es ein fertig verarbeitetes Logo? */
hasFile: boolean;
name: string;
displayName: string;
consent: boolean;
}
export type LogoField = 'file' | 'name' | 'displayName' | 'consent';
/**
* Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) oder null, wenn alles passt.
* Der Dienst prüft die Grenzen noch einmal.
*/
export function validateLogoForm(values: LogoFormValues): { field: LogoField; message: string } | null {
if (!values.hasFile) return { field: 'file', message: t('Bitte wähle eine Datei.') };
const name = values.name.trim();
if (name === '') return { field: 'name', message: t('Bitte gib einen Namen an.') };
if (CONTROL_CHARACTERS.test(name)) return { field: 'name', message: t('Der Name enthält ungültige Zeichen.') };
if (name.length > NAME_MAX) {
return { field: 'name', message: t('Der Name ist zu lang (höchstens {max} Zeichen).', { max: NAME_MAX }) };
}
const displayName = values.displayName.trim();
if (CONTROL_CHARACTERS.test(displayName)) {
return { field: 'displayName', message: t('Der Anzeigename enthält ungültige Zeichen.') };
}
if (displayName.length > DISPLAY_NAME_MAX) {
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: t('Bitte bestätige, dass du das Logo teilen darfst.') };
return null;
}
/**
* Der Vorschlag für den Namen: der Dateiname ohne Endung, auf 40 Zeichen gekürzt (nie mitten in einem Zeichen aus zwei
* UTF-16-Einheiten).
*/
function suggestedName(fileName: string): string {
let name = '';
for (const char of fileName.replace(/\.[^.]*$/, '').trim()) {
if (name.length + char.length > NAME_MAX) break;
name += char;
}
return name.trim();
}
/**
* Der Grund zu einem Fehler beim Verarbeiten oder Hochladen, in der Sprache der Seite. Meldungen des Dienstes (über ihren
* Schlüssel) und die fertigen Texte von `prepareLogo` erscheinen im Wortlaut; alles andere übersetzt `errorMessage`.
*/
function failureReason(err: unknown): string {
if (err instanceof ApiError) return apiErrorText(err);
if (err instanceof Error && PREPARE_MESSAGES.some((message) => t(message) === err.message)) return err.message;
return errorMessage(err, 'save');
}
/** Eine Vorschau auf dunklem oder hellem Grund: das PNG als Bild, einfarbig als Maske in der Farbe der Kartendetails. */
function previewSurface(side: 'dark' | 'light', url: string, mono: boolean): HTMLElement {
const dark = side === 'dark';
const label = dark ? t('Logo-Vorschau auf dunklem Grund') : t('Logo-Vorschau auf hellem Grund');
let logo: HTMLElement;
if (mono) {
// Nur die Deckkraft zählt (Maske auf dieselbe URL, siehe logos.css). Gefüllt wird wie auf der Karte: Weiß auf dunklem,
// #111111 auf hellem Grund.
logo = el('span', { class: 'logo-preview-mask', role: 'img', 'aria-label': label });
logo.style.setProperty('--logo-url', `url("${url}")`);
logo.style.setProperty('background-color', TONE_COLOR[dark ? 'light' : 'dark']);
} else {
logo = el('img', { class: 'logo-preview-img', src: url, alt: label });
}
return el(
'figure',
{ class: `logo-preview logo-preview-${side}` },
el('div', { class: 'logo-preview-surface' }, logo),
el('figcaption', { class: 'logo-preview-caption' }, dark ? t('Dunkel') : t('Hell')),
);
}
export interface LogoDialogOptions {
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
signal?: AbortSignal;
/** Verarbeitet die gewählte Datei zum fertigen Logo; wirft einen Fehler mit übersetztem Text. Standard: `prepareLogo`. */
prepare?: (file: File) => Promise<PreparedLogo>;
/** Lädt das Logo hoch und liefert das neue Logo; wirft bei Fehler. Standard: `uploadPreparedLogo` mit Anmeldung bei Bedarf. */
upload?: (prepared: PreparedLogo, form: LogoForm) => Promise<CommunityLogo>;
}
/**
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem hochgeladenen Logo, sonst mit null (abgebrochen). Fehler
* erscheinen im Dialog; er bleibt dann für einen neuen Versuch offen. Während des Hochladens lässt er sich nicht schließen
* (Abbrechen gesperrt, Esc und Hintergrund wirkungslos), damit niemand glaubt, etwas abgebrochen zu haben, das dann doch
* hochgeladen wird; nur `signal` schließt immer.
*/
export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<CommunityLogo | null> {
const prepare = opts.prepare ?? prepareLogo;
const upload = opts.upload ?? ((prepared, form) => withLogin(() => uploadPreparedLogo(prepared, form)));
return new Promise<CommunityLogo | null>((resolve, reject) => {
if (opts.signal?.aborted) {
resolve(null);
return;
}
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let result: CommunityLogo | null = null;
let closed = false;
/** Das Hochladen läuft. */
let busy = false;
/** Die gewählte Datei wird gerade verarbeitet. */
let preparing = false;
/** Zählt die Dateiwahlen: eine neue Wahl macht die Verarbeitung der früheren hinfällig. */
let selection = 0;
/** Der Name im Feld stammt vom Nutzer, nicht aus dem Dateinamen. */
let nameTyped = false;
let prepared: PreparedLogo | null = null;
/** Object-URL des fertigen PNGs für beide Vorschauen. */
let previewUrl: string | null = null;
const text = (name: string, extra: Record<string, string> = {}) =>
el('input', { class: 'auth-input', type: 'text', name, id: `logo-upload-${name}`, autocomplete: 'off', ...extra });
const fileInput = el('input', {
class: 'auth-input logo-file',
type: 'file',
name: 'file',
id: 'logo-upload-file',
accept: LOGO_ACCEPT,
'aria-required': 'true',
'aria-describedby': 'logo-upload-file-hint',
});
const monoInput = el('input', {
type: 'checkbox',
name: 'mono',
id: 'logo-upload-mono',
'aria-describedby': 'logo-upload-mono-hint',
});
const nameInput = text('name', { 'aria-required': 'true' });
const displayInput = text('displayName', { placeholder: t('Anonym'), 'aria-describedby': 'logo-upload-displayName-hint' });
displayInput.value = rememberedDisplayName(); // der zuletzt benutzte Name, auch aus dem Veröffentlichen-Dialog
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'logo-upload-consent' });
const inputs: Record<LogoField, HTMLInputElement> = {
file: fileInput,
name: nameInput,
displayName: displayInput,
consent: consentInput,
};
const field = (label: string, control: HTMLInputElement, hint?: string) =>
el(
'div',
{ class: 'auth-field' },
el('label', { class: 'auth-label', for: control.id }, label),
control,
hint ? el('p', { class: 'auth-hint', id: `${control.id}-hint` }, hint) : '',
);
const previewsEl = el('div', { class: 'logo-previews', role: 'group', 'aria-label': t('Vorschau') });
const fields = el(
'div',
{ class: 'publish-fields' },
field(t('Datei (Pflicht)'), fileInput, t('PNG, JPG, WebP oder SVG, höchstens 5 MB und 40 Megapixel.')),
previewsEl,
// Gleich unter der Vorschau, weil es sie verändert.
el(
'div',
{ class: 'logo-mono' },
el('label', { class: 'publish-consent' }, monoInput, el('span', {}, t('Einfarbig – passt sich Hell/Dunkel an'))),
el('p', { class: 'auth-hint', id: 'logo-upload-mono-hint' }, t('Nur wählbar, wenn das Logo transparente Bereiche hat.')),
),
field(t('Name (Pflicht)'), nameInput),
field(
t('Anzeigename (optional)'),
displayInput,
t('Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.'),
),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(LOGO_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' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Hochladen'));
const actions = el('div', { class: 'report-actions' }, cancel, send);
// novalidate: die Prüfung läuft selbst, mit Texten der Seite statt der des Browsers.
const form = el('form', { class: 'report-form', novalidate: true }, fields, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog logo-dialog', 'aria-labelledby': 'logo-upload-heading' },
// Eigene ID für die Überschrift: `logo-upload-<name>` gehört den Feldern.
el('div', { class: 'report-card' }, el('h2', { id: 'logo-upload-heading', class: 'report-title' }, t('Logo hochladen')), form),
);
/** Einfarbig gilt nur für ein Logo mit transparenten Bereichen (wie `uploadPreparedLogo`). */
const isMono = () => prepared !== null && prepared.hasTransparency && monoInput.checked;
const releasePreview = () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;
};
const finish = () => {
if (closed) return;
closed = true;
opts.signal?.removeEventListener('abort', finish);
releasePreview();
if (dialog.open) dialog.close();
dialog.remove();
if (opener?.isConnected) opener.focus();
resolve(result);
};
/** Öffnet den Dialog wieder, falls ihn der Browser geschlossen hat (Esc trotz verhindertem `cancel`). */
function reopen(): boolean {
if (dialog.open) return true;
try {
dialog.showModal();
return true;
} catch {
return false;
}
}
/** Sperren, Statustext und „Einfarbig“ passend zum Zustand (Hochladen läuft, Datei wird verarbeitet, Logo fertig). */
function refresh(): void {
form.setAttribute('aria-busy', String(busy));
for (const control of [fileInput, nameInput, displayInput, consentInput]) control.disabled = busy;
monoInput.disabled = busy || !prepared?.hasTransparency;
send.disabled = busy || preparing;
cancel.disabled = busy;
let status = '';
if (busy) status = t('Wird hochgeladen …');
else if (preparing) status = t('Logo wird verarbeitet …');
statusEl.textContent = status;
}
function renderPreviews(): void {
if (!prepared || !previewUrl) {
previewsEl.replaceChildren();
return;
}
const mono = isMono();
previewsEl.replaceChildren(previewSurface('dark', previewUrl, mono), previewSurface('light', previewUrl, mono));
}
/** Setzt das fertige Logo (oder keins): neue Object-URL für die Vorschau, die alte wird freigegeben, „Einfarbig“ beginnt aus. */
function setPrepared(next: PreparedLogo | null): void {
releasePreview();
prepared = next;
previewUrl = next ? URL.createObjectURL(next.png) : null;
monoInput.checked = false;
renderPreviews();
refresh();
}
function clearInvalid(): void {
for (const control of Object.values(inputs)) control.removeAttribute('aria-invalid');
}
/** Den Namen aus dem Dateinamen vorbelegen, solange der Nutzer keinen eigenen eingetippt hat. */
function suggestName(fileName: string): void {
if (nameTyped && nameInput.value.trim() !== '') return;
nameInput.value = suggestedName(fileName);
nameTyped = false;
}
async function onFile(): Promise<void> {
const file = fileInput.files?.[0];
const ticket = ++selection;
preparing = false;
setPrepared(null);
errorEl.textContent = '';
fileInput.removeAttribute('aria-invalid');
if (!file) return;
const check = validateLogoFile(file);
if (!check.ok) {
fileInput.value = ''; // „keine Datei gewählt“ stimmt dann mit dem Zustand überein
errorEl.textContent = check.message;
return;
}
preparing = true;
refresh();
let next: PreparedLogo;
try {
next = await prepare(file);
} catch (err) {
if (closed || ticket !== selection) return;
preparing = false;
fileInput.value = '';
errorEl.textContent = failureReason(err);
refresh();
return;
}
if (closed || ticket !== selection) return;
preparing = false;
setPrepared(next);
suggestName(file.name);
}
async function submit(): Promise<void> {
clearInvalid();
errorEl.textContent = '';
const problem = validateLogoForm({
hasFile: prepared !== null,
name: nameInput.value,
displayName: displayInput.value,
consent: consentInput.checked,
});
if (problem) {
errorEl.textContent = problem.message;
inputs[problem.field].setAttribute('aria-invalid', 'true');
inputs[problem.field].focus();
return;
}
const logoForm: LogoForm = { name: nameInput.value.trim(), displayName: displayInput.value.trim(), mono: isMono() };
busy = true; // vor dem ersten await: ein zweiter Klick findet den Dialog schon gesperrt
refresh();
let logo: CommunityLogo;
try {
logo = await upload(prepared!, logoForm); // `validateLogoForm` hat „keine Datei“ schon abgefangen
} catch (err) {
if (closed) return;
busy = false;
refresh();
errorEl.textContent =
err instanceof LoginCancelled ? t(LOGIN_NEEDED) : t('Hochladen fehlgeschlagen – {reason}', { reason: failureReason(err) });
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
return;
}
if (closed) return;
rememberDisplayName(logoForm.displayName);
result = logo;
finish();
}
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy || preparing || closed) return;
void submit();
});
fileInput.addEventListener('change', () => void onFile());
monoInput.addEventListener('change', renderPreviews);
nameInput.addEventListener('input', () => {
nameTyped = true;
});
cancel.addEventListener('click', finish);
// Esc löst `cancel` aus (abbrechbar) und danach `close`; mitten im Hochladen darf der Dialog nicht zugehen.
dialog.addEventListener('cancel', (event) => {
if (busy) event.preventDefault();
});
// Schließt der Browser den Dialog trotzdem mitten im Hochladen, wird er wieder geöffnet; aufgelöst wird erst mit dem echten Ergebnis.
dialog.addEventListener('close', () => {
if (busy && !closed) {
reopen();
return;
}
finish();
});
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
let pressedOnBackdrop = false;
dialog.addEventListener('mousedown', (event) => {
pressedOnBackdrop = event.target === dialog;
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog && pressedOnBackdrop && !busy) finish();
pressedOnBackdrop = false;
});
opts.signal?.addEventListener('abort', finish);
refresh();
document.body.append(dialog);
try {
dialog.showModal();
} catch (err) {
closed = true;
opts.signal?.removeEventListener('abort', finish);
dialog.remove();
reject(err);
return;
}
fileInput.focus();
});
}
+118
View File
@@ -0,0 +1,118 @@
/* Community-Logos. Dialog „Logo hochladen“: Hülle, Felder, Pflicht-Häkchen, Meldungen und Knöpfe kommen aus community.css
(Melden-/Veröffentlichen-Dialog) und account.css; hier steht nur, was neu ist: das Datei-Feld und die Vorschau. */
/* Etwas breiter als der Veröffentlichen-Dialog (480 px): zwei Vorschauen nebeneinander. Mit zwei Klassen, damit die Breite
nicht von der Reihenfolge der Stylesheets abhängt. */
.report-dialog.logo-dialog {
width: min(520px, calc(100vw - 32px));
}
/* Datei-Feld: Rahmen und Fokus wie ein Textfeld (.auth-input), der Knopf darin wie die übrigen Knöpfe */
.auth-input.logo-file {
padding: 6px 8px;
cursor: pointer;
}
.logo-file::file-selector-button {
min-height: 32px;
margin: 0 12px 0 0;
padding: 0 14px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.logo-file:hover:not(:disabled)::file-selector-button {
border-color: var(--sel-line);
background: var(--panel-2);
}
.logo-file:disabled {
opacity: 0.55;
cursor: not-allowed;
}
/* Vorschau: dasselbe Logo einmal auf dunklem und einmal auf hellem Grund (nebeneinander, solange es ein Logo gibt) */
.logo-previews {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.logo-previews:empty {
display: none;
}
.logo-preview {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
margin: 0; /* <figure> hat sonst einen Außenabstand */
}
.logo-preview-surface {
display: flex;
align-items: center;
justify-content: center;
height: 96px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
}
/* Die Karte hängt nicht vom Theme ab: dunkler Kartengrund wie im Renderer (#1B2738), heller Grund Weiß. */
.logo-preview-dark .logo-preview-surface {
background: #1b2738;
}
.logo-preview-light .logo-preview-surface {
background: #ffffff;
}
.logo-preview-img,
.logo-preview-mask {
display: block;
width: 100%;
height: 100%;
}
.logo-preview-img {
object-fit: contain;
}
/* Einfarbig: nur die Deckkraft des PNGs zählt (Maske auf dessen Object-URL, `--logo-url` setzt der Dialog), die Farbe kommt
als Hintergrund (Weiß auf dunklem, #111111 auf hellem Grund, ebenfalls vom Dialog gesetzt). */
.logo-preview-mask {
-webkit-mask-image: var(--logo-url);
mask-image: var(--logo-url);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: contain;
mask-size: contain;
}
.logo-preview-caption {
color: var(--muted);
font-size: 0.85rem;
text-align: center;
}
/* „Einfarbig“: das Häkchen im Stil des Pflicht-Häkchens; gesperrt (noch keine Datei oder keine transparenten Bereiche) blasser */
.logo-mono .auth-hint {
margin: 6px 0 0 4px;
}
.logo-mono .publish-consent:has(input:disabled) {
opacity: 0.55;
cursor: not-allowed;
}