// 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(); }); }