// Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6): „ · von · ♥ “ und „Melden“. Wer das Logo // hochgeladen hat, sieht dazu „Löschen“ (mit Rückfrage), Admins die Meldungen mit ihren Gründen sowie „Ausblenden“/„Einblenden“ // und „Löschen“. Zugleich die Meldezeile des Bereichs: Fehler von Herz, Hochladen und diesen Aktionen erscheinen hier, so bleibt // der Statusbereich des Pickers den Ladefehlern der Liste vorbehalten. // Die Zeilen und die Auswahl gehören dem Picker (`host`); die Infozeile liest sie und ändert sie nur über `patchRow` und `removeLogo`. // Der Aufbau steht einmal da und `update()` frischt nur Texte und Sichtbarkeit auf: ein Knopf, der gerade den Fokus hat, behält ihn. // Alle Texte gehen als Text in den DOM, nie als HTML. import '../account/account.css'; import './logos.css'; import { errorMessage } from '../account/errors'; import { ApiError, apiErrorText } from '../community/api'; import { REPORT_REASONS, type ReportDialogOptions, type ReportOutcome } from '../community/report-dialog'; import type { ConfirmOptions } from '../editor/confirm-dialog'; import { el } from '../editor/dom'; import { msg, t } from '../i18n/i18n'; import { reportsLabel } from '../pages/format'; import type { deleteLogo, reportLogo, setLogoVisibility } from './logo-api'; import { forgetLogo, LOGO_MISSING } from './logo-asset'; import type { CommunityLogo, listLogoReports, LogoReport } from './logos'; export interface LogoInfoDeps { reportLogo: typeof reportLogo; deleteLogo: typeof deleteLogo; setLogoVisibility: typeof setLogoVisibility; listLogoReports: typeof listLogoReports; /** Die Rückfrage vor dem Löschen; `signal` schließt sie (Seite verlassen). Löst mit true auf, wenn bestätigt wurde. */ confirm(opts: ConfirmOptions, signal?: AbortSignal): Promise; /** Der Melden-Dialog (für Logos: `subject: 'logo'`). */ reportDialog(opts: ReportDialogOptions): Promise; userId(): string | null; isAdmin(): boolean; } /** Was die Infozeile vom Picker braucht: Auswahl und Zeile lesen, die Zeile ändern, ein gelöschtes Logo melden. */ export interface LogoInfoHost { /** Die ID des gewählten Logos; null: kein Logo. */ selectedId(): string | null; /** Die Zeile des gewählten Logos, sobald bekannt; null: kein Logo oder (noch) unbekannt. */ selected(): CommunityLogo | null; /** Das gewählte Logo gibt es nicht (mehr): die Abfrage danach hat es nicht gefunden. */ gone(): boolean; /** Ändert Felder der bekannten Zeile von `id` (Kachel und Infozeile folgen). */ patchRow(id: string, patch: Partial): void; /** Das Logo ist gelöscht: Kachel und Zeile entfernen, steht es in der Auswahl, wird sie aufgehoben. */ removeLogo(id: string): void; } export interface LogoInfo { el: HTMLElement; /** Frischt die Anzeige nach dem Stand des Pickers auf (Auswahl, Zeile, Zähler, Anmeldung). */ update(): void; /** Zeigt einen Fehlertext in der Meldezeile; ein leerer Text löscht ihn. */ setMessage(text: string): void; } const REASONS_ERROR = msg('Die Gründe konnten nicht geladen werden.'); const DELETE_TEXT = msg( 'Das Logo, seine Herzen und Meldungen werden endgültig gelöscht. Gespeicherte Karten zeigen es danach nicht mehr. Bereits veröffentlichte Kartenbilder bleiben unverändert.', ); /** Der Grund eines Fehlers zum Anzeigen: Meldungen des Dienstes über ihren Schlüssel, alles andere über `errorMessage`. */ export function failureText(err: unknown): string { return err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load'); } /** Der Grund einer Meldung als Text: bekannte Werte mit ihrer Beschriftung, alles andere unverändert. */ function reasonLabel(reason: string): string { const known = REPORT_REASONS.find((r) => r.value === reason); return known ? t(known.label) : reason; } /** Die Gründe der Meldungen zu einem Stand (`key`: Logo-ID und Zahl der Meldungen); ein Admin holt sie einmal je Stand. */ interface Reasons { key: string; status: 'loading' | 'ready' | 'error'; list: LogoReport[]; } /** `signal` schließt offene Dialoge und beendet laufende Aktionen (Seite verlassen). */ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: AbortSignal): LogoInfo { /** Eine Aktion (Rückfrage, Dialog oder Anfrage) läuft: weitere Klicks bleiben ohne Wirkung. */ let busy = false; /** Für diese Auswahl wurde zuletzt gezeichnet: ein Wechsel räumt Meldung und Gründe ab. */ let shownId: string | null = null; let reasons: Reasons | null = null; // ---- Aufbau --------------------------------------------------------------------------------------------------------------- const nameEl = el('strong', { class: 'logo-info-name' }); const byEl = el('span', { class: 'logo-info-by' }); const heartsEl = el('span', { class: 'logo-info-hearts' }); const textEl = el('p', { class: 'logo-info-text' }, nameEl, ' · ', byEl, ' · ', heartsEl); const hiddenEl = el('span', { class: 'logo-badge logo-info-hidden' }, t('Ausgeblendet')); const reportButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-info-report' }, t('Melden')); const deleteButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small auth-btn-danger logo-info-delete' }, t('Löschen')); const visibilityButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-info-visibility' }); const actionsEl = el('div', { class: 'logo-info-actions' }, reportButton, deleteButton, visibilityButton); const reportsEl = el('p', { class: 'logo-info-reports' }); const reasonsEl = el('ul', { class: 'logo-info-reasons' }); const noteEl = el('p', { class: 'logo-info-note' }); const adminEl = el('div', { class: 'logo-info-admin' }, reportsEl, reasonsEl, noteEl); const cardEl = el('div', { class: 'logo-info-card' }, textEl, hiddenEl, actionsEl, adminEl); // Immer da (leer nehmen sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt. const goneEl = el('p', { class: 'auth-msg logo-info-gone', role: 'status' }); const messageEl = el('p', { class: 'auth-msg auth-msg-error logo-info-msg', role: 'alert' }); const root = el('div', { class: 'logo-info' }, cardEl, goneEl, messageEl); // ---- Anzeige -------------------------------------------------------------------------------------------------------------- function setMessage(text: string): void { messageEl.textContent = text; } const isOwner = (logo: CommunityLogo): boolean => { const me = deps.userId(); return me !== null && logo.ownerId !== '' && logo.ownerId === me; }; /** Löschen dürfen der Hochlader und Admins (der Dienst prüft es noch einmal). */ const mayDelete = (logo: CommunityLogo): boolean => deps.isAdmin() || isOwner(logo); /** Lädt die Gründe der Meldungen eines Logos; eine Antwort für ein inzwischen anderes Logo oder einen anderen Stand zählt nicht. */ async function loadReasons(id: string, key: string): Promise { let loaded: Reasons; try { loaded = { key, status: 'ready', list: await deps.listLogoReports(id) }; } catch { loaded = { key, status: 'error', list: [] }; } if (signal.aborted || reasons?.key !== key) return; reasons = loaded; render(); } /** Meldungen eines Admins: Anzahl aus der Zeile, Gründe nachgeladen (nur, wenn es Meldungen gibt). */ function renderReports(logo: CommunityLogo): void { reportsEl.textContent = reportsLabel(logo.reportCount); const key = `${logo.id}:${logo.reportCount}`; if (logo.reportCount > 0 && reasons?.key !== key) { reasons = { key, status: 'loading', list: [] }; void loadReasons(logo.id, key); } const current = reasons?.key === key ? reasons : null; reasonsEl.replaceChildren(...(current?.status === 'ready' ? current.list.map((r) => el('li', {}, reasonLabel(r.reason))) : [])); noteEl.textContent = current?.status === 'error' ? t(REASONS_ERROR) : ''; } function render(): void { const id = host.selectedId(); if (id !== shownId) { shownId = id; reasons = null; setMessage(''); // ein Fehler gehört zur Auswahl, bei der er auftrat } const logo = host.selected(); cardEl.hidden = logo === null; goneEl.textContent = logo === null && host.gone() ? t(LOGO_MISSING) : ''; if (logo === null) return; const admin = deps.isAdmin(); nameEl.textContent = logo.name; byEl.textContent = t('von {name}', { name: logo.displayName || t('Anonym') }); heartsEl.textContent = `♥ ${logo.likeCount}`; hiddenEl.hidden = !logo.hidden; // Ein ausgeblendetes Logo kennt der Dienst für Besucher nicht: es lässt sich nicht melden. reportButton.hidden = logo.hidden; deleteButton.hidden = !mayDelete(logo); visibilityButton.hidden = !admin; visibilityButton.textContent = logo.hidden ? t('Einblenden') : t('Ausblenden'); actionsEl.hidden = reportButton.hidden && deleteButton.hidden && visibilityButton.hidden; adminEl.hidden = !admin; if (admin) renderReports(logo); } // ---- Aktionen ------------------------------------------------------------------------------------------------------------- /** Führt eine Aktion aus: immer nur eine, die Meldezeile wird vorher geleert, ein Fehler erscheint dort als Text. */ async function run(action: () => Promise): Promise { if (busy) return; busy = true; setMessage(''); try { await action(); } catch (err) { if (!signal.aborted) setMessage(failureText(err)); } finally { busy = false; } } async function report(): Promise { const logo = host.selected(); if (!logo) return; await deps.reportDialog({ subject: 'logo', signal, send: (reason) => deps.reportLogo(logo.id, reason) }); } async function remove(): Promise { const logo = host.selected(); if (!logo || !mayDelete(logo)) return; const confirmed = await deps.confirm( { title: t('Logo „{name}“ löschen?', { name: logo.name }), text: t(DELETE_TEXT), confirmLabel: t('Löschen'), cancelLabel: t('Abbrechen'), }, signal, ); // Während der Rückfrage kann die Seite verlassen oder die Sitzung gewechselt haben: dann nichts mehr senden. if (!confirmed || signal.aborted || !mayDelete(logo)) return; try { await deps.deleteLogo(logo.id); } catch (err) { // Das Logo gibt es schon nicht mehr (die Antwort einer ersten Anfrage ging verloren und der Nutzer versucht es noch einmal, oder es // wurde anderswo gelöscht): Ziel erreicht. Nur der Schlüssel `logoGone` zählt, jedes andere 404 ist ein echter Fehler. if (!(err instanceof ApiError && err.code === 404 && err.serverCode === 'logoGone')) throw err; } forgetLogo(logo.id); // auch nach dem Verlassen der Seite: der Zwischenspeicher darf das gelöschte Logo nicht behalten if (signal.aborted) return; host.removeLogo(logo.id); render(); } async function toggleVisibility(): Promise { const logo = host.selected(); if (!logo || !deps.isAdmin()) return; const show = logo.hidden; // ausgeblendet: einblenden, sonst ausblenden await deps.setLogoVisibility(logo.id, show); // Zeile und Bilder neu holen: ein ausgeblendetes Logo gilt für die Karte als nicht verfügbar, ein eingeblendetes wieder als da. forgetLogo(logo.id); if (signal.aborted) return; // Einblenden löscht die Meldungen (Spec § 6). host.patchRow(logo.id, show ? { hidden: false, reportCount: 0 } : { hidden: true }); render(); } reportButton.addEventListener('click', () => void run(report)); deleteButton.addEventListener('click', () => void run(remove)); visibilityButton.addEventListener('click', () => void run(toggleVisibility)); render(); return { el: root, update: render, setMessage }; }