Files
AirCardIOS/web/src/logos/logo-dialog.ts
T
ClaudeandClaude Opus 5.5 e22919f4dc 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>
2026-10-04 22:00:12 +00:00

436 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
});
}