Schlussreview F5. Geht die Antwort einer Löschanfrage verloren und der Nutzer klickt noch einmal auf „Löschen“, antwortet der Dienst mit 404 `logoGone` (das Logo ist ja schon weg). Die Infozeile zeigte dann „Dieses Logo ist nicht (mehr) verfügbar.“ als Fehler, die Kachel blieb stehen. - Dieser Fehler (ApiError mit Status 404 und Schlüssel `logoGone`) gilt wie ein gelungenes Löschen: forgetLogo, die Kachel verschwindet, eine Auswahl wird aufgehoben (onSelect(null)), kein Fehlertext – wie bei „Zurückziehen“ einer schon zurückgezogenen Karte. - Jeder andere Fehler bleibt einer, auch ein 404 mit anderem oder ohne Schlüssel (etwa ein Dienst, der die Anfrage nicht kennt). - Tests in logo-info.test.ts (Hochlader, Admin, Seite verlassen, Fehler, die Fehler bleiben) und im Picker mit Raster und Auswahl. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
244 lines
12 KiB
TypeScript
244 lines
12 KiB
TypeScript
// Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6): „<Name> · von <Anzeigename> · ♥ <Zahl>“ 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<boolean>;
|
|
/** Der Melden-Dialog (für Logos: `subject: 'logo'`). */
|
|
reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome>;
|
|
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<CommunityLogo>): 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<void> {
|
|
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<void>): Promise<void> {
|
|
if (busy) return;
|
|
busy = true;
|
|
setMessage('');
|
|
try {
|
|
await action();
|
|
} catch (err) {
|
|
if (!signal.aborted) setMessage(failureText(err));
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function report(): Promise<void> {
|
|
const logo = host.selected();
|
|
if (!logo) return;
|
|
await deps.reportDialog({ subject: 'logo', signal, send: (reason) => deps.reportLogo(logo.id, reason) });
|
|
}
|
|
|
|
async function remove(): Promise<void> {
|
|
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<void> {
|
|
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 };
|
|
}
|