// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags, Anzeigename und die Pflicht-Checkbox. // 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'; 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([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 { return new Promise((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 = {}) => 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 = { 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-`), 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 { 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 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(); }); }