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>
295 lines
13 KiB
TypeScript
295 lines
13 KiB
TypeScript
// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags, Anzeigename und die Pflicht-Checkbox.
|
||
// Eigenes <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML.
|
||
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';
|
||
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
|
||
import { ApiError, isAlreadyPublished } from './api';
|
||
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
|
||
|
||
/** Wortlaut aus Spec § 6. */
|
||
export const CONSENT_TEXT = 'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.';
|
||
/** Wortlaut aus Spec § 11. */
|
||
export const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
|
||
const FAILED_PREFIX = 'Veröffentlichen fehlgeschlagen – ';
|
||
const RUNNING = 'Wird veröffentlicht …';
|
||
|
||
/** 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;
|
||
/** 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).
|
||
*/
|
||
export const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
|
||
|
||
export interface PublishFormValues extends PublishForm {
|
||
consent: boolean;
|
||
}
|
||
|
||
export type PublishField = 'title' | 'issuer' | 'tags' | 'displayName' | 'consent';
|
||
|
||
/** Das erste Problem in Formularreihenfolge (mit deutschem Text) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */
|
||
export function validatePublishForm(values: PublishFormValues): { field: PublishField; message: string } | null {
|
||
const title = values.title.trim();
|
||
if (title === '') return { field: 'title', message: 'Bitte gib einen Titel ein.' };
|
||
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: 'Der Titel enthält ungültige Zeichen.' };
|
||
if (title.length > TITLE_MAX) return { field: 'title', message: `Der Titel ist zu lang (höchstens ${TITLE_MAX} Zeichen).` };
|
||
|
||
const issuer = values.issuer.trim();
|
||
if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: 'Herausgeber / Bank enthält ungültige Zeichen.' };
|
||
if (issuer.length > ISSUER_MAX) {
|
||
return { field: 'issuer', message: `Herausgeber / Bank ist zu lang (höchstens ${ISSUER_MAX} Zeichen).` };
|
||
}
|
||
|
||
const tags = normalizeTags(values.tags);
|
||
if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: 'Tags enthalten ungültige Zeichen.' };
|
||
if (tags.some((tag) => tag.length > TAG_MAX)) {
|
||
return { field: 'tags', message: `Ein Tag darf höchstens ${TAG_MAX} Zeichen lang sein.` };
|
||
}
|
||
if (tags.length > TAGS_MAX) return { field: 'tags', message: `Bitte gib höchstens ${TAGS_MAX} Tags an.` };
|
||
|
||
const displayName = values.displayName.trim();
|
||
if (CONTROL_CHARACTERS.test(displayName)) {
|
||
return { field: 'displayName', message: 'Der Anzeigename enthält ungültige Zeichen.' };
|
||
}
|
||
if (displayName.length > DISPLAY_NAME_MAX) {
|
||
return { field: 'displayName', message: `Der Anzeigename ist zu lang (höchstens ${DISPLAY_NAME_MAX} Zeichen).` };
|
||
}
|
||
|
||
if (!values.consent) return { field: 'consent', message: 'Bitte bestätige die Erklärung zum Bild.' };
|
||
return null;
|
||
}
|
||
|
||
/** Meldungen aus dem Rendern und dem Bildlesen sind schon deutsch; alles andere übersetzt `errorMessage`. */
|
||
const GERMAN_MESSAGES = new Set<string>([TOO_LARGE, NOT_RENDERED, LOGO_MISSING_PUBLISH, READ_MESSAGE, PIXELS_MESSAGE]);
|
||
|
||
/** Der Text zu einem fehlgeschlagenen Veröffentlichen (Spec § 11: „Veröffentlichen fehlgeschlagen – …“). */
|
||
export function publishFailureText(err: unknown): string {
|
||
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
|
||
if (err instanceof ApiError) return FAILED_PREFIX + err.message;
|
||
if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) return FAILED_PREFIX + err.message;
|
||
return FAILED_PREFIX + errorMessage(err, 'save');
|
||
}
|
||
|
||
/** `published`: veröffentlicht; `already`: die Karte war schon veröffentlicht (409); `cancelled`: nichts veröffentlicht oder Dialog verlassen. */
|
||
export type PublishOutcome = 'published' | 'already' | 'cancelled';
|
||
|
||
export interface PublishDialogOptions {
|
||
/** Veröffentlicht die Karte (rendern, hochladen, API); wirft bei Fehler. */
|
||
submit: (form: PublishForm) => Promise<{ id: string }>;
|
||
/** Name der privaten Karte für den Einleitungstext (als Text). */
|
||
cardName?: string;
|
||
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
|
||
signal?: AbortSignal;
|
||
}
|
||
|
||
/**
|
||
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist. Fehler erscheinen im Dialog; er bleibt dann für einen neuen
|
||
* Versuch offen. Während des Sendens lässt er sich nicht schließen (Abbrechen gesperrt, Esc und Hintergrund wirkungslos),
|
||
* damit niemand glaubt, etwas abgebrochen zu haben, das dann doch veröffentlicht wird; nur `signal` schließt immer.
|
||
*/
|
||
export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcome> {
|
||
return new Promise<PublishOutcome>((resolve, reject) => {
|
||
if (opts.signal?.aborted) {
|
||
resolve('cancelled');
|
||
return;
|
||
}
|
||
|
||
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||
let outcome: PublishOutcome = 'cancelled';
|
||
let closed = false;
|
||
let busy = false;
|
||
|
||
const text = (name: string, extra: Record<string, string> = {}) =>
|
||
el('input', { class: 'auth-input', type: 'text', name, id: `publish-${name}`, autocomplete: 'off', ...extra });
|
||
const titleInput = text('title', { 'aria-required': 'true' });
|
||
const issuerInput = text('issuer');
|
||
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' });
|
||
const nameInput = text('displayName', { placeholder: 'Anonym', 'aria-describedby': 'publish-displayName-hint' });
|
||
nameInput.value = rememberedDisplayName(); // der zuletzt benutzte Name, auch aus dem Dialog „Logo hochladen“
|
||
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
|
||
const inputs: Record<PublishField, HTMLInputElement> = {
|
||
title: titleInput,
|
||
issuer: issuerInput,
|
||
tags: tagsInput,
|
||
displayName: nameInput,
|
||
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 intro = el(
|
||
'p',
|
||
{ class: 'report-text' },
|
||
opts.cardName
|
||
? `„${opts.cardName}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.`
|
||
: 'Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.',
|
||
);
|
||
const fields = el(
|
||
'div',
|
||
{ class: 'publish-fields' },
|
||
field('Titel (Pflicht)', titleInput),
|
||
field('Herausgeber / Bank (optional)', issuerInput),
|
||
field('Tags (optional)', tagsInput, 'Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.'),
|
||
field('Anzeigename (optional)', nameInput, 'Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.'),
|
||
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, CONSENT_TEXT)),
|
||
);
|
||
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
|
||
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
|
||
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen');
|
||
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Veröffentlichen');
|
||
const actions = el('div', { class: 'report-actions' }, cancel, send);
|
||
// novalidate: die Prüfung läuft selbst, mit deutschen Texten statt der des Browsers.
|
||
const form = el('form', { class: 'report-form', novalidate: true }, intro, fields, errorEl, statusEl, actions);
|
||
const dialog = el(
|
||
'dialog',
|
||
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-heading' },
|
||
// Eigene ID für die Überschrift: `publish-title` gehört dem Titelfeld (`publish-<name>`), sonst zeigt sein Label auf die Überschrift.
|
||
el('div', { class: 'report-card' }, el('h2', { id: 'publish-heading', class: 'report-title' }, 'Karte veröffentlichen'), form),
|
||
);
|
||
|
||
const finish = () => {
|
||
if (closed) return;
|
||
closed = true;
|
||
opts.signal?.removeEventListener('abort', finish);
|
||
if (dialog.open) dialog.close();
|
||
dialog.remove();
|
||
if (opener?.isConnected) opener.focus();
|
||
resolve(outcome);
|
||
};
|
||
|
||
/** Ö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;
|
||
}
|
||
}
|
||
|
||
function setBusy(value: boolean): void {
|
||
busy = value;
|
||
form.setAttribute('aria-busy', String(value));
|
||
for (const control of Object.values(inputs)) control.disabled = value;
|
||
send.disabled = value;
|
||
cancel.disabled = value;
|
||
statusEl.textContent = value ? RUNNING : '';
|
||
}
|
||
|
||
function clearInvalid(): void {
|
||
for (const control of Object.values(inputs)) control.removeAttribute('aria-invalid');
|
||
}
|
||
|
||
function showAlready(): void {
|
||
outcome = 'already';
|
||
errorEl.textContent = '';
|
||
statusEl.textContent = ALREADY_PUBLISHED;
|
||
intro.remove();
|
||
fields.remove();
|
||
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Schließen');
|
||
close.addEventListener('click', finish);
|
||
actions.replaceChildren(close);
|
||
close.focus();
|
||
}
|
||
|
||
async function submit(): Promise<void> {
|
||
const values: PublishFormValues = {
|
||
title: titleInput.value.trim(),
|
||
issuer: issuerInput.value.trim(),
|
||
tags: tagsInput.value,
|
||
displayName: nameInput.value.trim(),
|
||
consent: consentInput.checked,
|
||
};
|
||
clearInvalid();
|
||
errorEl.textContent = '';
|
||
const problem = validatePublishForm(values);
|
||
if (problem) {
|
||
errorEl.textContent = problem.message;
|
||
inputs[problem.field].setAttribute('aria-invalid', 'true');
|
||
inputs[problem.field].focus();
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
try {
|
||
await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags, displayName: values.displayName });
|
||
} catch (err) {
|
||
if (closed) return;
|
||
if (isAlreadyPublished(err)) {
|
||
setBusy(false);
|
||
reopen();
|
||
showAlready();
|
||
return;
|
||
}
|
||
setBusy(false);
|
||
errorEl.textContent = publishFailureText(err);
|
||
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
||
return;
|
||
}
|
||
if (closed) return;
|
||
rememberDisplayName(values.displayName);
|
||
outcome = 'published';
|
||
finish();
|
||
}
|
||
|
||
form.addEventListener('submit', (event) => {
|
||
event.preventDefault();
|
||
if (busy || outcome !== 'cancelled') return;
|
||
void submit();
|
||
});
|
||
cancel.addEventListener('click', finish);
|
||
// Esc löst `cancel` aus (abbrechbar) und danach `close`; mitten im Senden darf der Dialog nicht zugehen.
|
||
dialog.addEventListener('cancel', (event) => {
|
||
if (busy) event.preventDefault();
|
||
});
|
||
// Schließt der Browser den Dialog trotzdem mitten im Senden, 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);
|
||
|
||
document.body.append(dialog);
|
||
try {
|
||
dialog.showModal();
|
||
} catch (err) {
|
||
closed = true;
|
||
opts.signal?.removeEventListener('abort', finish);
|
||
dialog.remove();
|
||
reject(err);
|
||
return;
|
||
}
|
||
titleInput.focus();
|
||
});
|
||
}
|