Files
AirCardIOS/web/src/logos/logo-info.ts
T
ClaudeandClaude Opus 5.5 df1cf29d42 fix(logos): 404 „logoGone“ beim Löschen gilt als Erfolg
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>
2026-10-05 01:19:17 +00:00

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 };
}