// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags und die Pflicht-Checkbox. Einen Anzeigenamen gibt // es nicht mehr: die Detailseite zeigt den Benutzernamen des Kontos (Spec § 5). Hat das Konto keinen, antwortet der Dienst mit 403 // `usernameRequired`: dann öffnet sich der Namensdialog, und mit dem gespeicherten Namen lässt es sich noch einmal versuchen. // Eigenes wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t(). import '../account/account.css'; import './community.css'; import { errorMessage } from '../account/errors'; import { usernameDialog } from '../account/username-dialog'; import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; import { el, setFieldInvalid } from '../editor/dom'; import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload'; import { msg, t } from '../i18n/i18n'; import { ApiError, apiErrorText, isAlreadyPublished, isUsernameRequired } from './api'; import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish'; /** Wortlaut aus Spec § 6. */ export const CONSENT_TEXT = msg('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 = msg('Diese Karte ist schon veröffentlicht.'); const RUNNING = msg('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 TAGS_MAX = 5; const TAG_MAX = 20; /** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */ const ERROR_ID = 'publish-error'; /** * 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 den Namen 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' | 'consent'; /** Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) 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: t('Bitte gib einen Titel ein.') }; if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: t('Der Titel enthält ungültige Zeichen.') }; if (title.length > TITLE_MAX) { return { field: 'title', message: t('Der Titel ist zu lang (höchstens {max} Zeichen).', { max: TITLE_MAX }) }; } const issuer = values.issuer.trim(); if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: t('Herausgeber / Bank enthält ungültige Zeichen.') }; if (issuer.length > ISSUER_MAX) { return { field: 'issuer', message: t('Herausgeber / Bank ist zu lang (höchstens {max} Zeichen).', { max: ISSUER_MAX }) }; } const tags = normalizeTags(values.tags); if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: t('Tags enthalten ungültige Zeichen.') }; if (tags.some((tag) => tag.length > TAG_MAX)) { return { field: 'tags', message: t('Ein Tag darf höchstens {max} Zeichen lang sein.', { max: TAG_MAX }) }; } if (tags.length > TAGS_MAX) return { field: 'tags', message: t('Bitte gib höchstens {max} Tags an.', { max: TAGS_MAX }) }; if (!values.consent) return { field: 'consent', message: t('Bitte bestätige die Erklärung zum Bild.') }; return null; } /** Meldungen aus dem Rendern und dem Bildlesen tragen den deutschen Text (msg); angezeigt werden sie mit t(). 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 in der Sprache der Seite (Spec § 11: „Veröffentlichen fehlgeschlagen – …“). * Fehler des Dienstes kommen über ihren Code (`apiErrorText`). */ export function publishFailureText(err: unknown): string { if (err instanceof LoginCancelled) return t(LOGIN_NEEDED); let reason: string; if (err instanceof ApiError) reason = apiErrorText(err); else if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) reason = t(err.message); else reason = errorMessage(err, 'save'); return t('Veröffentlichen fehlgeschlagen – {reason}', { reason }); } /** `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 consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' }); const inputs: Record = { title: titleInput, issuer: issuerInput, tags: tagsInput, 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 ? t('„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.', { name: opts.cardName }) : t('Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.'), ); const fields = el( 'div', { class: 'publish-fields' }, field(t('Titel (Pflicht)'), titleInput), field(t('Herausgeber / Bank (optional)'), issuerInput), field(t('Tags (optional)'), tagsInput, t('Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.')), el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))), ); const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, 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('Veröffentlichen')); 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 }, 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' }, t('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 ? t(RUNNING) : ''; } function clearInvalid(): void { for (const control of Object.values(inputs)) setFieldInvalid(control, ERROR_ID, false); } function showAlready(): void { outcome = 'already'; errorEl.textContent = ''; statusEl.textContent = t(ALREADY_PUBLISHED); intro.remove(); fields.remove(); const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Schließen')); close.addEventListener('click', finish); actions.replaceChildren(close); close.focus(); } /** * Der Dienst verlangt einen Benutzernamen: der Namensdialog öffnet sich über dem Dialog. Mit gespeichertem Namen ist der Hinweis erledigt * und der Fokus liegt auf „Veröffentlichen“ (erneut versuchen); bei „Abbrechen“ oder wenn der Namensdialog nicht aufgeht, bleibt der Grund stehen. */ async function askForUsername(): Promise { let saved: string | null = null; try { saved = await usernameDialog({ mode: 'required', signal: opts.signal }); } catch { // der Namensdialog lässt sich nicht öffnen: der Grund oben steht, der Nutzer kann den Namen im Konto-Menü wählen } if (closed || saved === null) return; errorEl.textContent = ''; send.focus(); } async function submit(): Promise { const values: PublishFormValues = { title: titleInput.value.trim(), issuer: issuerInput.value.trim(), tags: tagsInput.value, consent: consentInput.checked, }; clearInvalid(); errorEl.textContent = ''; const problem = validatePublishForm(values); if (problem) { errorEl.textContent = problem.message; setFieldInvalid(inputs[problem.field], ERROR_ID, true); inputs[problem.field].focus(); return; } setBusy(true); try { await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags }); } 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 if (isUsernameRequired(err)) await askForUsername(); return; } if (closed) return; 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(); }); }