diff --git a/web/src/account/display-name.test.ts b/web/src/account/display-name.test.ts new file mode 100644 index 0000000..41978bb --- /dev/null +++ b/web/src/account/display-name.test.ts @@ -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(); + }); +}); diff --git a/web/src/account/display-name.ts b/web/src/account/display-name.ts new file mode 100644 index 0000000..610a5f2 --- /dev/null +++ b/web/src/account/display-name.ts @@ -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. + } +} diff --git a/web/src/community/publish-dialog.test.ts b/web/src/community/publish-dialog.test.ts index f949b70..3c4b7c7 100644 --- a/web/src/community/publish-dialog.test.ts +++ b/web/src/community/publish-dialog.test.ts @@ -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/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} })); +import { DISPLAY_NAME_KEY } from '../account/display-name'; import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; import { ApiError } from './api'; import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish'; @@ -48,10 +49,13 @@ function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise

{ submit.mockReset(); document.body.innerHTML = ''; + localStorage.clear(); // der Anzeigename wird gemerkt und im nächsten Test wieder vorbelegt }); afterEach(() => { document.body.innerHTML = ''; + localStorage.clear(); + vi.restoreAllMocks(); }); 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', () => { it('„Abbrechen“ schließt ohne zu senden', async () => { const outcome = open(); diff --git a/web/src/community/publish-dialog.ts b/web/src/community/publish-dialog.ts index 34d983e..4526635 100644 --- a/web/src/community/publish-dialog.ts +++ b/web/src/community/publish-dialog.ts @@ -2,6 +2,7 @@ // Eigenes

wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. import '../account/account.css'; import './community.css'; +import { rememberDisplayName, rememberedDisplayName } from '../account/display-name'; import { errorMessage } from '../account/errors'; import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; 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. */ const TITLE_MAX = 60; 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 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): * 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 { consent: boolean; @@ -112,6 +115,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise = { title: titleInput, @@ -240,6 +244,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise> = { '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).', + // 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. 'Bitte melde dich an.': 'Please sign in.', 'Nicht gefunden.': 'Not found.', diff --git a/web/src/logos/logo-dialog.test.ts b/web/src/logos/logo-dialog.test.ts new file mode 100644 index 0000000..ac704c6 --- /dev/null +++ b/web/src/logos/logo-dialog.test.ts @@ -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()), + withLogin: mocks.withLogin, +})); +vi.mock('./prepare-logo', async (importOriginal) => ({ + ...(await importOriginal()), + 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; +type Upload = (prepared: PreparedLogo, form: LogoForm) => Promise; +const prepare = vi.fn(); +const upload = vi.fn(); + +/** 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 => ({ + png: new Blob(['png-bytes'], { type: 'image/png' }), + width: 200, + height: 80, + hasTransparency: true, + ...patch, +}); + +const logo = (patch: Partial = {}): 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('dialog.logo-dialog'); +const input = (name: string) => dialogEl()!.querySelector(`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('.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 { + 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 }> { + 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) => 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 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('.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('.logo-preview-mask')!.style.getPropertyValue('background-color')).toBe('#FFFFFF'); + expect(light.querySelector('.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('.png')); + expect(input('name').value).toBe(''); + 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[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 – “, 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, '')); + await setup(); + fill(); + send(); + await settle(); + expect(errorText()).toBe('Hochladen fehlgeschlagen – '); + 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); + }); +}); diff --git a/web/src/logos/logo-dialog.ts b/web/src/logos/logo-dialog.ts new file mode 100644 index 0000000..da69cfc --- /dev/null +++ b/web/src/logos/logo-dialog.ts @@ -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 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; + /** 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; +} + +/** + * Ö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 { + const prepare = opts.prepare ?? prepareLogo; + const upload = opts.upload ?? ((prepared, form) => withLogin(() => uploadPreparedLogo(prepared, form))); + + return new Promise((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 = {}) => + 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 = { + 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-` 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 { + 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 { + 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 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(); + }); +} diff --git a/web/src/logos/logos.css b/web/src/logos/logos.css new file mode 100644 index 0000000..2674903 --- /dev/null +++ b/web/src/logos/logos.css @@ -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; /*
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; +}