feat(logos): Infozeile mit Melden, Löschen und Admin-Aktionen

Task 9: die Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6).

- logos/logo-info.ts (neu): „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ (auch für Gäste; ein ausgeblendetes Logo lässt
  sich nicht melden). Der Hochlader sieht „Löschen“ mit Rückfrage (Titel mit Namen, Folgen im Text), Admins „<n> Meldungen“ mit den
  Gründen als Text (listLogoReports, einmal je Logo und Stand), „Ausblenden“/„Einblenden“ und „Löschen“. Gelöscht: deleteLogo,
  forgetLogo, Kachel weg, onSelect(null); ein-/ausgeblendet: setLogoVisibility, forgetLogo (ein Null-Bild bleibt nicht im Zwischen-
  speicher), die Zeile des Pickers wird fortgeschrieben (Einblenden setzt reportCount auf 0). Gewähltes Logo nicht auffindbar: „Das
  gewählte Logo ist nicht mehr verfügbar.“ (ein Netzfehler von getLogo sagt nichts). Zugleich die Meldezeile (role="alert") für Fehler
  von Herz, Hochladen und diesen Aktionen; ein Wechsel der Auswahl räumt sie ab. Immer nur eine Aktion zugleich; die Rechte werden
  nach der Rückfrage noch einmal geprüft; der Aufbau steht einmal da, update() ändert nur Texte und Sichtbarkeit (Fokus bleibt).
- logos/logo-picker.ts: hängt die Infozeile als Letztes unter das Raster (info.el). Herz- und Upload-Fehler gehen in die Infozeile,
  der Statusbereich des Pickers bleibt den Ladefehlern der Liste vorbehalten (ein Klick auf ein Herz löscht ihren Text nicht mehr).
  Aus dem Review von Task 8: requireLogin, das wirft, zeigt den Grund statt einer unbehandelten Ablehnung; select() nach dispose()
  ist wirkungslos. Löschen entfernt Kachel, Zeile, Herz und Eintrag der Liste, setzt den Cursor von „Mehr laden“ auf das Logo davor
  (Appwrite lehnt einen Cursor auf eine gelöschte Zeile ab), hebt die Auswahl über onSelect(null) auf und holt den Fokus nach
  „Kein Logo“, wenn er auf „Löschen“ lag. LogoPickerDeps erweitert um reportLogo, deleteLogo, setLogoVisibility, listLogoReports,
  confirm und reportDialog.
- community/report-dialog.ts: subject 'card' (Standard, unverändert) oder 'logo': Überschrift „Logo melden“, Einleitung „dieses
  Logo“, vier Gründe ohne „Echte Kartendaten“, Duplikat-Dank „Danke, du hast dieses Logo schon gemeldet.“. Der ganze Dialog geht
  durch t()/msg(); Fehler des Dienstes über apiErrorText.
- logos/logos.css: das Herz sitzt in der Textzeile unter der Bildfläche (nicht mehr auf dem Logo, folgt dem Theme), die Kachel füllt
  die Zelle (height: 100%), damit „Kein Logo“ so hoch ist wie sein Nachbar; Stile der Infozeile.
- editor/editor-page.ts: Aus dem Review von Task 8: der Ladefehler der Logos verschwindet, sobald ein Logo danach lädt, aber nur
  der, den der Logo-Zustand selbst ins Fehlerfeld geschrieben hat; jeder andere Text im Feld bleibt.
- i18n/en.ts: englische Texte zu allen neuen Beschriftungen und zum Melden-Dialog.
- Tests: logo-info.test.ts (neu), logo-picker.test.ts (Infozeile, Löschen, Ein-/Ausblenden, Fehler, Cursor, Fokus), report-dialog.test.ts
  (Logo und Englisch), editor-page.test.ts (Fehlerfeld). Vitest 52 Dateien, 1764 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 23:21:33 +00:00
1 parent 4cf9fe192d
commit a75be2d44c
10 files changed
+1976 -81

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
// 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;
await deps.deleteLogo(logo.id);
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 };
}