// Melden-Dialog für Karten (Detailseite) und Logos (Editor): Grund wählen, an den API-Dienst senden, das Ergebnis im Dialog zeigen. // Eigenes wie confirm-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 { el } from '../editor/dom'; import { msg, t } from '../i18n/i18n'; import { ApiError, apiErrorText, type ReportReason } from './api'; /** Die Gründe in der Reihenfolge und mit dem Wortlaut der Spec (§ 5), dazu der Wert für die API. */ export const REPORT_REASONS: ReadonlyArray<{ value: ReportReason; label: string }> = [ { value: 'unangemessen', label: msg('Unangemessen') }, { value: 'kartendaten', label: msg('Echte Kartendaten') }, { value: 'urheberrecht', label: msg('Urheberrecht/Marke') }, { value: 'spam', label: msg('Spam') }, { value: 'sonstiges', label: msg('Sonstiges') }, ]; /** Ein Logo hat keine Kartendaten: für Logos entfällt „Echte Kartendaten“ (Spec § 6). */ const LOGO_REASONS = REPORT_REASONS.filter((reason) => reason.value !== 'kartendaten'); export const REPORT_THANKS = msg('Danke für deine Meldung.'); /** Wortlaut aus Spec § 11. */ export const REPORT_DUPLICATE = msg('Danke, du hast diese Karte schon gemeldet.'); export const LOGO_REPORT_DUPLICATE = msg('Danke, du hast dieses Logo schon gemeldet.'); const NO_REASON = msg('Bitte wähle einen Grund.'); /** `reported`: neue Meldung angenommen; `duplicate`: dieser Melder hatte die Karte (das Logo) schon gemeldet; `cancelled`: nichts gesendet oder Ausgang unbekannt. */ export type ReportOutcome = 'reported' | 'duplicate' | 'cancelled'; /** Was gemeldet wird: eine Karte (Standard) oder ein Logo. */ export type ReportSubject = 'card' | 'logo'; export interface ReportDialogOptions { /** Sendet die Meldung (`reportCard` bzw. `reportLogo` der API). */ send: (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>; /** Titel der Karte für die Überschrift des Textes (als Text); für Logos ohne Wirkung (dort heißt es „dieses Logo“). */ cardTitle?: string; /** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */ signal?: AbortSignal; /** Karte (Standard) oder Logo: Überschrift, Einleitung, Dank bei Duplikat und die Gründe (ohne „Echte Kartendaten“) folgen daraus. */ subject?: ReportSubject; } /** * Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem Ergebnis der letzten Meldung, sonst mit `cancelled`. * Fehler des Dienstes erscheinen mit ihrem Servertext im Dialog; der Dialog bleibt dann offen für einen neuen Versuch. */ export function reportDialog(opts: ReportDialogOptions): Promise { return new Promise((resolve, reject) => { if (opts.signal?.aborted) { resolve('cancelled'); return; } const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; const logo = opts.subject === 'logo'; const choices = logo ? LOGO_REASONS : REPORT_REASONS; let outcome: ReportOutcome = 'cancelled'; let closed = false; let busy = false; const radios = choices.map(({ value }) => el('input', { type: 'radio', name: 'reason', value })); const reasons = el( 'fieldset', { class: 'report-reasons' }, el('legend', { class: 'report-legend' }, t('Grund der Meldung')), ...choices.map(({ label }, i) => el('label', { class: 'report-reason' }, radios[i], t(label))), ); const intro = el( 'p', { class: 'report-text' }, logo ? t('Warum möchtest du dieses Logo melden?') : opts.cardTitle ? t('Warum möchtest du „{title}“ melden?', { title: opts.cardTitle }) : t('Warum möchtest du diese Karte melden?'), ); const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' }); const statusEl = el('p', { class: 'auth-msg auth-msg-ok', 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('Melden')); const actions = el('div', { class: 'report-actions' }, cancel, send); const form = el('form', { class: 'report-form' }, intro, reasons, errorEl, statusEl, actions); const dialog = el( 'dialog', { class: 'report-dialog', 'aria-labelledby': 'report-title' }, el( 'div', { class: 'report-card' }, el('h2', { id: 'report-title', class: 'report-title' }, logo ? t('Logo melden') : t('Karte melden')), 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); }; function setBusy(value: boolean): void { busy = value; form.setAttribute('aria-busy', String(value)); for (const radio of radios) radio.disabled = value; send.disabled = value; } function showResult(duplicate: boolean): void { outcome = duplicate ? 'duplicate' : 'reported'; errorEl.textContent = ''; statusEl.textContent = t(duplicate ? (logo ? LOGO_REPORT_DUPLICATE : REPORT_DUPLICATE) : REPORT_THANKS); intro.remove(); reasons.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(); } async function submit(): Promise { const chosen = choices.find((_, i) => radios[i].checked); if (!chosen) { statusEl.textContent = ''; errorEl.textContent = t(NO_REASON); return; } setBusy(true); errorEl.textContent = ''; statusEl.textContent = ''; try { const res = await opts.send(chosen.value); if (closed) return; showResult(res.schonGemeldet); } catch (err) { if (closed) return; // Texte des Dienstes (auch für 429) kommen über ihren Schlüssel in der Sprache der Seite; alles andere (Netz, JWT) über errorMessage. errorEl.textContent = err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load'); setBusy(false); } } form.addEventListener('submit', (event) => { event.preventDefault(); if (busy || outcome !== 'cancelled') return; void submit(); }); cancel.addEventListener('click', finish); // Esc schließt nativ und löst `close` aus. dialog.addEventListener('close', 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) 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); } }); }