diff --git a/web/src/community/report-dialog.test.ts b/web/src/community/report-dialog.test.ts index 0e45067..d5e5457 100644 --- a/web/src/community/report-dialog.test.ts +++ b/web/src/community/report-dialog.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} })); +import { setLang } from '../i18n/i18n'; import { ApiError, type ReportReason } from './api'; import { REPORT_REASONS, reportDialog, type ReportOutcome } from './report-dialog'; @@ -27,7 +28,7 @@ const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; -function open(opts: { cardTitle?: string; signal?: AbortSignal } = {}): Promise { +function open(opts: { cardTitle?: string; signal?: AbortSignal; subject?: 'card' | 'logo' } = {}): Promise { return reportDialog({ send, ...opts }); } @@ -239,3 +240,158 @@ describe('Melden-Dialog', () => { expect(dialogEl()).toBeNull(); }); }); + +describe('Melden-Dialog für Logos (subject: „logo“)', () => { + const reasonLabels = () => [...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent); + + it('Überschrift „Logo melden“, Einleitung nennt „dieses Logo“, vier Gründe ohne „Echte Kartendaten“', () => { + void open({ subject: 'logo' }); + expect(dialogEl()?.open).toBe(true); + expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo melden'); + expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?'); + expect(reasonLabels()).toEqual(['Unangemessen', 'Urheberrecht/Marke', 'Spam', 'Sonstiges']); + expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'urheberrecht', 'spam', 'sonstiges']); + expect(radios().some((r) => r.checked)).toBe(false); + expect(buttonByText('Abbrechen')).toBeTruthy(); + expect(buttonByText('Melden')).toBeTruthy(); + }); + + it('die Gründe der Karten bleiben unverändert (REPORT_REASONS enthält weiter „Echte Kartendaten“)', () => { + expect(REPORT_REASONS.map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']); + }); + + it('der Titel der Karte spielt für Logos keine Rolle: die Einleitung nennt immer „dieses Logo“', () => { + void open({ subject: 'logo', cardTitle: 'Urlaub' }); + expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?'); + }); + + it.each(['unangemessen', 'urheberrecht', 'spam', 'sonstiges'] as const)('Grund %s geht unverändert an send', async (value) => { + send.mockResolvedValue({ schonGemeldet: false }); + void open({ subject: 'logo' }); + choose(value); + submit(); + await settle(); + expect(send).toHaveBeenCalledExactlyOnceWith(value); + }); + + it('ohne Grund: derselbe Hinweis, nichts wird gesendet', async () => { + void open({ subject: 'logo' }); + submit(); + await settle(); + expect(errorText()).toBe('Bitte wähle einen Grund.'); + expect(send).not.toHaveBeenCalled(); + }); + + it('neue Meldung: derselbe Dank, „reported“', async () => { + send.mockResolvedValue({ schonGemeldet: false }); + const result = open({ subject: 'logo' }); + choose('spam'); + submit(); + await settle(); + expect(statusText()).toBe('Danke für deine Meldung.'); + buttonByText('Schließen')!.click(); + await expect(result).resolves.toBe('reported'); + }); + + it('doppelte Meldung: „Danke, du hast dieses Logo schon gemeldet.“ und „duplicate“', async () => { + send.mockResolvedValue({ schonGemeldet: true }); + const result = open({ subject: 'logo' }); + choose('spam'); + submit(); + await settle(); + expect(statusText()).toBe('Danke, du hast dieses Logo schon gemeldet.'); + buttonByText('Schließen')!.click(); + await expect(result).resolves.toBe('duplicate'); + }); + + it('Fehler des Dienstes erscheinen wie bei Karten im Dialog, der offen bleibt', async () => { + send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone')); + void open({ subject: 'logo' }); + choose('spam'); + submit(); + await settle(); + expect(errorText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.'); + expect(dialogEl()?.open).toBe(true); + expect(buttonByText('Melden')!.disabled).toBe(false); + }); + + it('ein Dialog mit subject „card“ ist derselbe wie ohne Angabe', () => { + void open({ subject: 'card', cardTitle: 'Urlaub' }); + expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden'); + expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du „Urlaub“ melden?'); + expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']); + }); +}); + +describe('Melden-Dialog auf Englisch', () => { + beforeEach(() => setLang('en', { persist: false })); + + it('Karte: Überschrift, Einleitung, Gründe, Knöpfe', () => { + void open({ cardTitle: 'Holiday' }); + expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report card'); + expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report “Holiday”?'); + expect(dialogEl()!.querySelector('legend')!.textContent).toBe('Reason for the report'); + expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([ + 'Inappropriate', + 'Real card data', + 'Copyright/trademark', + 'Spam', + 'Other', + ]); + expect(buttonByText('Cancel')).toBeTruthy(); + expect(buttonByText('Report')).toBeTruthy(); + // ohne Titel + dialogEl()!.remove(); + void open(); + expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this card?'); + }); + + it('Logo: Überschrift, Einleitung, vier Gründe, Dank und Duplikat', async () => { + send.mockResolvedValueOnce({ schonGemeldet: true }); + const result = open({ subject: 'logo' }); + expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report logo'); + expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this logo?'); + expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([ + 'Inappropriate', + 'Copyright/trademark', + 'Spam', + 'Other', + ]); + submit(); // ohne Grund + await settle(); + expect(errorText()).toBe('Please choose a reason.'); + choose('spam'); + submit(); + await settle(); + expect(statusText()).toBe('Thanks, you’ve already reported this logo.'); + buttonByText('Close')!.click(); + await expect(result).resolves.toBe('duplicate'); + }); + + it('Dank und Duplikat bei Karten', async () => { + send.mockResolvedValueOnce({ schonGemeldet: false }).mockResolvedValueOnce({ schonGemeldet: true }); + const first = open(); + choose('spam'); + submit(); + await settle(); + expect(statusText()).toBe('Thanks for your report.'); + buttonByText('Close')!.click(); + await first; + const second = open(); + choose('spam'); + submit(); + await settle(); + expect(statusText()).toBe('Thanks, you’ve already reported this card.'); + buttonByText('Close')!.click(); + await second; + }); + + it('Fehler des Dienstes erscheinen übersetzt, wenn der Schlüssel bekannt ist', async () => { + send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone')); + void open({ subject: 'logo' }); + choose('spam'); + submit(); + await settle(); + expect(errorText()).toBe('This logo is no longer available.'); + }); +}); diff --git a/web/src/community/report-dialog.ts b/web/src/community/report-dialog.ts index 7dacfb2..6b2e00f 100644 --- a/web/src/community/report-dialog.ts +++ b/web/src/community/report-dialog.ts @@ -1,35 +1,45 @@ -// Melden-Dialog der Detailseite: 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. +// 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 { ApiError, type ReportReason } from './api'; +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: 'Unangemessen' }, - { value: 'kartendaten', label: 'Echte Kartendaten' }, - { value: 'urheberrecht', label: 'Urheberrecht/Marke' }, - { value: 'spam', label: 'Spam' }, - { value: 'sonstiges', label: 'Sonstiges' }, + { 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') }, ]; -export const REPORT_THANKS = 'Danke für deine Meldung.'; -/** Wortlaut aus Spec § 11. */ -export const REPORT_DUPLICATE = 'Danke, du hast diese Karte schon gemeldet.'; -const NO_REASON = 'Bitte wähle einen Grund.'; +/** Ein Logo hat keine Kartendaten: für Logos entfällt „Echte Kartendaten“ (Spec § 6). */ +const LOGO_REASONS = REPORT_REASONS.filter((reason) => reason.value !== 'kartendaten'); -/** `reported`: neue Meldung angenommen; `duplicate`: dieser Melder hatte die Karte schon gemeldet; `cancelled`: nichts gesendet oder Ausgang unbekannt. */ +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` der API). */ + /** 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). */ + /** 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; } /** @@ -44,32 +54,43 @@ export function reportDialog(opts: ReportDialogOptions): Promise } 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 = REPORT_REASONS.map(({ value }) => el('input', { type: 'radio', name: 'reason', value })); + const radios = choices.map(({ value }) => el('input', { type: 'radio', name: 'reason', value })); const reasons = el( 'fieldset', { class: 'report-reasons' }, - el('legend', { class: 'report-legend' }, 'Grund der Meldung'), - ...REPORT_REASONS.map(({ label }, i) => el('label', { class: 'report-reason' }, radios[i], label)), + 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' }, - opts.cardTitle ? `Warum möchtest du „${opts.cardTitle}“ melden?` : 'Warum möchtest du diese Karte melden?', + 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' }, 'Abbrechen'); - const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Melden'); + 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' }, 'Karte melden'), form), + el( + 'div', + { class: 'report-card' }, + el('h2', { id: 'report-title', class: 'report-title' }, logo ? t('Logo melden') : t('Karte melden')), + form, + ), ); const finish = () => { @@ -92,20 +113,20 @@ export function reportDialog(opts: ReportDialogOptions): Promise function showResult(duplicate: boolean): void { outcome = duplicate ? 'duplicate' : 'reported'; errorEl.textContent = ''; - statusEl.textContent = duplicate ? REPORT_DUPLICATE : REPORT_THANKS; + 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' }, 'Schließen'); + 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 = REPORT_REASONS.find((_, i) => radios[i].checked); + const chosen = choices.find((_, i) => radios[i].checked); if (!chosen) { statusEl.textContent = ''; - errorEl.textContent = NO_REASON; + errorEl.textContent = t(NO_REASON); return; } setBusy(true); @@ -117,8 +138,8 @@ export function reportDialog(opts: ReportDialogOptions): Promise showResult(res.schonGemeldet); } catch (err) { if (closed) return; - // Der Dienst liefert deutsche Texte (auch für 429); alles andere (Netz, JWT) übersetzt errorMessage. - errorEl.textContent = err instanceof ApiError ? err.message : errorMessage(err, 'load'); + // 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); } } diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts index 02b5361..5222f34 100644 --- a/web/src/editor/editor-page.test.ts +++ b/web/src/editor/editor-page.test.ts @@ -1146,6 +1146,92 @@ describe('Community-Logo auf der Karte', () => { expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'A')).toBe(true); }); + describe('Fehler „Die Logos konnten nicht geladen werden.“ und das gemeinsame Fehlerfeld', () => { + const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.'; + /** Logo „A“ lässt sich nicht laden, alle anderen schon. */ + const failA = async (config: { logo: string | null }) => { + if (config.logo === 'A') throw new TypeError('Failed to fetch'); + return FOUND; + }; + + it('der Fehler gehört dem Logo-Zustand: lädt danach ein anderes Logo, verschwindet er', async () => { + mocks.loadCardAssets.mockImplementation(failA); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + store.set({ config: withLogo('B') }); + await vi.waitFor(() => expect(errorText()).toBe('')); + // und auch „Kein Logo“ ist ein erfolgreicher Ladevorgang + store.set({ config: withLogo('A') }); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + store.set({ config: withLogo(null) }); + await vi.waitFor(() => expect(errorText()).toBe('')); + }); + + it('ein anderer Fehler im Feld bleibt stehen, auch wenn danach ein Logo lädt', async () => { + mocks.loadCardAssets.mockImplementation(failA); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + // Ein anderer Fehler ersetzt den Logo-Fehler im Feld … + saveButton().disabled = false; + saveButton().click(); + expect(errorText()).toBe('Bitte zuerst ein Bild wählen.'); + // … und gehört nicht dem Logo-Zustand: ein erfolgreicher Ladevorgang löscht ihn nicht. + store.set({ config: withLogo('B') }); + await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2)); + await new Promise((r) => setTimeout(r, 10)); + expect(errorText()).toBe('Bitte zuerst ein Bild wählen.'); + }); + + it('ein erfolgreicher Ladevorgang ohne Logo-Fehler lässt einen fremden Fehler ebenfalls stehen', async () => { + store.set({ config: withLogo('A') }); + mocks.loadCardAssets.mockResolvedValue(FOUND); + mount(); + await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1)); + await new Promise((r) => setTimeout(r, 0)); + saveButton().disabled = false; + saveButton().click(); + expect(errorText()).toBe('Bitte zuerst ein Bild wählen.'); + store.set({ config: withLogo('B') }); + await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2)); + await new Promise((r) => setTimeout(r, 10)); + expect(errorText()).toBe('Bitte zuerst ein Bild wählen.'); + }); + + it('derselbe Text aus einem gescheiterten Download gehört dem Download und bleibt stehen', async () => { + withImage(); + store.set({ config: withLogo('da') }); + mocks.loadCardAssets.mockResolvedValueOnce(FOUND).mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValue(FOUND); + mount(); + await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1)); + await new Promise((r) => setTimeout(r, 0)); + downloadButton().click(); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + store.set({ config: withLogo('db') }); + await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(3)); + await new Promise((r) => setTimeout(r, 10)); + expect(errorText()).toBe(LOGO_ERROR); + }); + + it('eine späte Antwort für einen überholten Schlüssel löscht nichts', async () => { + let finishB!: (v: typeof FOUND) => void; + mocks.loadCardAssets.mockImplementation((config: { logo: string | null }) => { + if (config.logo === 'A') return Promise.reject(new TypeError('Failed to fetch')); + if (config.logo === 'B') return new Promise((resolve) => (finishB = resolve as (v: typeof FOUND) => void)); + return Promise.resolve(FOUND); + }); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR)); + store.set({ config: withLogo('B') }); // lädt noch … + store.set({ config: withLogo('A') }); // … da wird A wieder gewählt und scheitert erneut + finishB(FOUND); + await new Promise((r) => setTimeout(r, 10)); + expect(errorText()).toBe(LOGO_ERROR); + }); + }); + it('gespeicherte Karte mit gelöschtem Logo: öffnet ohne Fehler, zeigt den Hinweis, behält die Auswahl und lässt sich herunterladen', async () => { history.replaceState(null, '', '/gestalten?karte=k1'); mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: 'gone' } })); diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts index 524e532..d5a9f47 100644 --- a/web/src/editor/editor-page.ts +++ b/web/src/editor/editor-page.ts @@ -106,6 +106,16 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { onNewCard: () => void startNewCard(), }); + // Das Fehlerfeld der Vorschau teilen sich alle Fehler des Editors. LOGO_ERROR (ensureLogos) darf verschwinden, sobald ein Logo + // doch lädt, aber nur, solange er noch dort steht, wo ensureLogos ihn hingeschrieben hat: jeder andere Aufruf von setError + // (ein anderer Text, auch derselbe aus einem Download, oder das Leeren) nimmt ihm dieses Recht. + let logoErrorShown = false; + const setPreviewError = preview.setError; + preview.setError = (message) => { + logoErrorShown = false; + setPreviewError(message); + }; + // Der Hinweis unter der Vorschau: ein allgemeiner (Bild fehlt, PNG zu groß …) und der zum fehlenden Community-Logo (er gilt, // solange die geladenen Logos `missing` sind, siehe ensureLogos). Beide teilen sich das Feld, werden aber getrennt gehalten, // damit das Ende des einen den anderen nicht löscht. @@ -409,6 +419,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { if (disposed || token !== logoToken) return; pendingKey = ''; logos = { logo: assets.logo, brand: assets.brand, missing: assets.logoMissing, key }; + if (logoErrorShown) preview.setError(null); // der Ladefehler dieses Zustands ist erledigt, alle anderen Fehler bleiben showWarning(); schedule(); }, @@ -417,6 +428,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { pendingKey = ''; failedKey = key; preview.setError(LOGO_ERROR); + logoErrorShown = true; }, ); } diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 52c5056..f609ae6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -91,6 +91,35 @@ export const EN: Readonly> = { 'Nochmal versuchen': 'Try again', 'Mehr laden': 'Load more', + // Infozeile zum gewählten Logo (logos/logo-info.ts). „von {name}“, „Anonym“, „Ausgeblendet“ und „Melden“ stehen an anderer Stelle, + // der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ weiter oben (Logo auf der Karte). + 'Löschen': 'Delete', + 'Ausblenden': 'Hide', + 'Einblenden': 'Unhide', + 'Die Gründe konnten nicht geladen werden.': 'The reasons couldn’t be loaded.', + 'Logo „{name}“ löschen?': 'Delete logo “{name}”?', + '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.': + 'The logo, its hearts and reports will be deleted permanently. Saved cards will no longer show it. Cards that were already published stay unchanged.', + + // Melden-Dialog für Karten und Logos (community/report-dialog.ts). „Abbrechen“ steht weiter oben. + 'Melden': 'Report', + 'Karte melden': 'Report card', + 'Logo melden': 'Report logo', + 'Warum möchtest du diese Karte melden?': 'Why do you want to report this card?', + 'Warum möchtest du „{title}“ melden?': 'Why do you want to report “{title}”?', + 'Warum möchtest du dieses Logo melden?': 'Why do you want to report this logo?', + 'Grund der Meldung': 'Reason for the report', + 'Unangemessen': 'Inappropriate', + 'Echte Kartendaten': 'Real card data', + 'Urheberrecht/Marke': 'Copyright/trademark', + 'Spam': 'Spam', + 'Sonstiges': 'Other', + 'Bitte wähle einen Grund.': 'Please choose a reason.', + 'Danke für deine Meldung.': 'Thanks for your report.', + 'Danke, du hast diese Karte schon gemeldet.': 'Thanks, you’ve already reported this card.', + 'Danke, du hast dieses Logo schon gemeldet.': 'Thanks, you’ve already reported this logo.', + 'Schließen': 'Close', + // API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich. 'Bitte melde dich an.': 'Please sign in.', 'Nicht gefunden.': 'Not found.', diff --git a/web/src/logos/logo-info.test.ts b/web/src/logos/logo-info.test.ts new file mode 100644 index 0000000..e6a960a --- /dev/null +++ b/web/src/logos/logo-info.test.ts @@ -0,0 +1,709 @@ +// Infozeile zum gewählten Logo (Spec § 3 und § 6): Anzeige, Melden, Löschen und die Admin-Aktionen. Die Zeile des Logos und die Auswahl +// gehören dem Picker (hier eine Attrappe als `host`); Datenschicht, Rückfrage und Melden-Dialog sind Attrappen. Das Zusammenspiel +// mit dem echten Picker steht in logo-picker.test.ts. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ forgetLogo: vi.fn() })); + +// Die Infozeile hängt (über Melden-Dialog und API-Fehler) an Appwrite und Anmeldung; hier kommt alles über `deps`. +vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); +vi.mock('../account/auth', () => ({ + currentUser: () => null, + currentUserId: () => null, + isAdmin: () => false, + onAuthChange: () => () => {}, + onLogout: () => () => {}, +})); +vi.mock('./logo-asset', async (importOriginal) => ({ + ...(await importOriginal()), + forgetLogo: mocks.forgetLogo, +})); + +import { ApiError } from '../community/api'; +import { setLang } from '../i18n/i18n'; +import { createLogoInfo, failureText, type LogoInfo, type LogoInfoDeps, type LogoInfoHost } from './logo-info'; +import type { CommunityLogo, LogoReport } from './logos'; + +const logo = (patch: Partial = {}): CommunityLogo => ({ + id: 'a', + name: 'Logo A', + displayName: 'Kenso', + fileId: 'a', + ownerId: 'u9', + mono: false, + likeCount: 12, + hidden: false, + reportCount: 0, + createdAt: '2026-10-04T12:00:00.000Z', + ...patch, +}); + +const report = (reason: string, id = reason): LogoReport => ({ id, logoId: 'a', reason, createdAt: '2026-10-04T12:00:00.000Z' }); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const flush = async () => { + for (let i = 0; i < 50; i++) await Promise.resolve(); +}; + +// ---- Attrappen ------------------------------------------------------------------------------------------------------------ + +let selectedId: string | null; +let row: CommunityLogo | null; +let gone: boolean; +let user: string | null; +let admin: boolean; +let abort: AbortController; +let info: LogoInfo; + +const host = { + selectedId: () => selectedId, + selected: () => row, + gone: () => gone, + patchRow: vi.fn((id: string, patch: Partial) => { + if (row?.id === id) row = { ...row, ...patch }; + }), + removeLogo: vi.fn(), +} satisfies LogoInfoHost; + +const fx = { + reportLogo: vi.fn(), + deleteLogo: vi.fn(), + setLogoVisibility: vi.fn(), + listLogoReports: vi.fn(), + confirm: vi.fn(), + reportDialog: vi.fn(), +}; + +const deps: LogoInfoDeps = { ...fx, userId: () => user, isAdmin: () => admin }; + +beforeEach(() => { + for (const fn of [...Object.values(fx), mocks.forgetLogo, host.patchRow, host.removeLogo]) fn.mockReset(); + host.patchRow.mockImplementation((id, patch) => { + if (row?.id === id) row = { ...row, ...patch }; + }); + selectedId = null; + row = null; + gone = false; + user = 'u1'; + admin = false; + abort = new AbortController(); + fx.reportLogo.mockResolvedValue({ schonGemeldet: false }); + fx.deleteLogo.mockResolvedValue(undefined); + fx.setLogoVisibility.mockResolvedValue(undefined); + fx.listLogoReports.mockResolvedValue([]); + fx.confirm.mockResolvedValue(true); + fx.reportDialog.mockResolvedValue('cancelled'); + document.body.innerHTML = ''; +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +/** Baut die Infozeile; `shown` ist das gewählte Logo (null: keins). */ +async function mount(shown: CommunityLogo | null = logo()): Promise { + row = shown; + selectedId = shown?.id ?? null; + info = createLogoInfo(host, deps, abort.signal); + document.body.append(info.el); + await flush(); + return info; +} + +/** Zeigt der Picker ein anderes Logo (oder keins), meldet er es mit update(). */ +function choose(next: CommunityLogo | null): void { + row = next; + selectedId = next?.id ?? null; + info.update(); +} + +// ---- Zugriffe auf das DOM ------------------------------------------------------------------------------------------------- + +/** Sichtbar: weder selbst noch ein Vorfahre ist mit `hidden` verborgen. */ +const visible = (node: Element) => node.closest('[hidden]') === null; +const line = () => { + const text = info.el.querySelector('.logo-info-text')!; + return visible(text) ? text.textContent : ''; +}; +const goneText = () => info.el.querySelector('.logo-info-gone')!.textContent; +const message = () => info.el.querySelector('[role="alert"]')!.textContent; +const buttons = () => [...info.el.querySelectorAll('button')].filter(visible).map((b) => b.textContent); +const button = (label: string) => [...info.el.querySelectorAll('button')].find((b) => b.textContent === label && visible(b)) as HTMLButtonElement; +const reportCount = () => { + const text = info.el.querySelector('.logo-info-reports')!; + return visible(text) ? text.textContent : ''; +}; +const reasons = () => [...info.el.querySelectorAll('.logo-info-reasons li')].map((li) => li.textContent); +const reasonsNote = () => info.el.querySelector('.logo-info-note')!.textContent; +const hiddenNote = () => { + const note = info.el.querySelector('.logo-info-hidden')!; + return visible(note) ? note.textContent : ''; +}; + +describe('Anzeige', () => { + it('ohne Auswahl ist die Infozeile leer: kein Text, keine Knöpfe, keine Meldung', async () => { + await mount(null); + expect(line()).toBe(''); + expect(buttons()).toEqual([]); + expect(goneText()).toBe(''); + expect(hiddenNote()).toBe(''); + expect(message()).toBe(''); + }); + + it('gewähltes Logo: „ · von · ♥ “ und „Melden“ – auch für Gäste', async () => { + user = null; + await mount(); + expect(line()).toBe('Logo A · von Kenso · ♥ 12'); + expect(buttons()).toEqual(['Melden']); + expect(button('Melden').type).toBe('button'); + }); + + it('ohne Anzeigename steht „von Anonym“', async () => { + await mount(logo({ displayName: '' })); + expect(line()).toBe('Logo A · von Anonym · ♥ 12'); + }); + + it('Name und Anzeigename stehen nur als Text, nie als HTML', async () => { + await mount(logo({ name: '', displayName: 'fett' })); + expect(line()).toBe(' · von fett · ♥ 12'); + expect(info.el.querySelector('img')).toBeNull(); + expect(info.el.querySelector('b')).toBeNull(); + }); + + it('update() folgt der Zeile des Pickers: neue Zahl, anderes Logo, danach wieder keins', async () => { + await mount(); + row = logo({ likeCount: 13 }); + info.update(); + expect(line()).toBe('Logo A · von Kenso · ♥ 13'); + choose(logo({ id: 'b', name: 'Logo B', displayName: 'Anna', likeCount: 0 })); + expect(line()).toBe('Logo B · von Anna · ♥ 0'); + choose(null); + expect(line()).toBe(''); + expect(buttons()).toEqual([]); + }); + + it('ein Logo, dessen Zeile noch fehlt (wird gerade geholt), zeigt weder Zeile noch Hinweis', async () => { + await mount(null); + selectedId = 'x'; // gewählt, die Zeile ist noch unbekannt + info.update(); + expect(line()).toBe(''); + expect(goneText()).toBe(''); + expect(buttons()).toEqual([]); + }); + + it('gewähltes Logo nicht auffindbar: „Das gewählte Logo ist nicht mehr verfügbar.“, ohne Knöpfe', async () => { + await mount(null); + selectedId = 'x'; + gone = true; + info.update(); + expect(goneText()).toBe('Das gewählte Logo ist nicht mehr verfügbar.'); + expect(line()).toBe(''); + expect(buttons()).toEqual([]); + expect(info.el.querySelector('.logo-info-gone')!.getAttribute('role')).toBe('status'); + // Taucht die Zeile doch auf, ist der Hinweis weg. + row = logo({ id: 'x', name: 'Logo X' }); + gone = false; + info.update(); + expect(goneText()).toBe(''); + expect(line()).toBe('Logo X · von Kenso · ♥ 12'); + }); + + it('eine Zeile, die der Picker inzwischen kennt, geht dem Hinweis „nicht mehr verfügbar“ vor, auch wenn gone() noch wahr meldet', async () => { + await mount(null); + selectedId = 'x'; + gone = true; + info.update(); + expect(goneText()).toBe('Das gewählte Logo ist nicht mehr verfügbar.'); + row = logo({ id: 'x', name: 'Logo X' }); + info.update(); + expect(goneText()).toBe(''); + expect(line()).toBe('Logo X · von Kenso · ♥ 12'); + }); + + it('ohne einen sichtbaren Knopf ist auch die Knopfleiste verborgen (sonst bliebe ein leerer Abstand in der Karte)', async () => { + const actions = () => info.el.querySelector('.logo-info-actions')!; + user = 'u2'; // weder Hochlader noch Admin + await mount(logo({ hidden: true })); // melden lässt es sich nicht, ein- und ausblenden nur ein Admin + expect(buttons()).toEqual([]); + expect(actions().hidden).toBe(true); + expect(hiddenNote()).toBe('Ausgeblendet'); + user = 'u9'; // der Hochlader darf löschen + info.update(); + expect(buttons()).toEqual(['Löschen']); + expect(actions().hidden).toBe(false); + }); + + it('Englisch: Zeile, Knopf und Hinweis sind übersetzt', async () => { + setLang('en', { persist: false }); + await mount(logo({ displayName: '' })); + expect(line()).toBe('Logo A · by Anonymous · ♥ 12'); + expect(buttons()).toEqual(['Report']); + choose(null); + selectedId = 'x'; + gone = true; + info.update(); + expect(goneText()).toBe('The selected logo is no longer available.'); + }); +}); + +describe('Melden', () => { + it('öffnet den Dialog für Logos und sendet reportLogo(id, Grund) – auch als Gast', async () => { + user = null; + await mount(); + button('Melden').click(); + await flush(); + expect(fx.reportDialog).toHaveBeenCalledTimes(1); + const opts = fx.reportDialog.mock.calls[0][0]; + expect(opts.subject).toBe('logo'); + expect(opts.signal).toBe(abort.signal); + fx.reportLogo.mockResolvedValue({ schonGemeldet: true }); + await expect(opts.send('spam')).resolves.toEqual({ schonGemeldet: true }); + expect(fx.reportLogo).toHaveBeenCalledExactlyOnceWith('a', 'spam'); + }); + + it('ein zweiter Klick, solange der Dialog offen ist, öffnet keinen zweiten; danach geht es wieder', async () => { + const open = deferred<'reported'>(); + fx.reportDialog.mockReturnValue(open.promise); + await mount(); + button('Melden').click(); + button('Melden').click(); + await flush(); + expect(fx.reportDialog).toHaveBeenCalledTimes(1); + open.resolve('reported'); + await flush(); + button('Melden').click(); + expect(fx.reportDialog).toHaveBeenCalledTimes(2); + }); + + it('ein Dialog, der sich nicht öffnen lässt, zeigt einen Fehlertext; der Knopf geht danach wieder', async () => { + fx.reportDialog.mockRejectedValueOnce(new Error('kein ')); + await mount(); + button('Melden').click(); + await flush(); + expect(message()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); + button('Melden').click(); // der nächste Versuch räumt den Text ab + expect(message()).toBe(''); + await flush(); + expect(fx.reportDialog).toHaveBeenCalledTimes(2); + }); + + it('ein ausgeblendetes Logo lässt sich nicht melden (der Dienst kennt es für Besucher nicht)', async () => { + user = 'u9'; + await mount(logo({ hidden: true })); + expect(buttons()).toEqual(['Löschen']); + expect(hiddenNote()).toBe('Ausgeblendet'); + }); +}); + +describe('Löschen durch den Hochlader', () => { + it('„Löschen“ sehen nur der Hochlader und Admins; Gäste und andere Nutzer nur „Melden“', async () => { + user = 'u9'; // ownerId des Beispiel-Logos + await mount(); + expect(buttons()).toEqual(['Melden', 'Löschen']); + user = 'u2'; + info.update(); + expect(buttons()).toEqual(['Melden']); + user = null; + info.update(); + expect(buttons()).toEqual(['Melden']); + expect(reportCount()).toBe(''); + expect(button('Ausblenden')).toBeUndefined(); + }); + + it('ein Logo ohne bekannten Hochlader gehört niemandem (auch nicht einem Konto mit leerer ID)', async () => { + user = ''; + await mount(logo({ ownerId: '' })); + expect(buttons()).toEqual(['Melden']); + }); + + it('fragt mit Namen nach; bestätigt: deleteLogo, forgetLogo und removeLogo in dieser Reihenfolge', async () => { + user = 'u9'; + const order: string[] = []; + fx.confirm.mockImplementation(async () => { + order.push('confirm'); + return true; + }); + fx.deleteLogo.mockImplementation(async () => { + order.push('delete'); + }); + mocks.forgetLogo.mockImplementation(() => { + order.push('forget'); + }); + host.removeLogo.mockImplementation(() => { + order.push('remove'); + }); + await mount(); + button('Löschen').click(); + await flush(); + expect(order).toEqual(['confirm', 'delete', 'forget', 'remove']); + expect(fx.confirm).toHaveBeenCalledExactlyOnceWith( + { + title: 'Logo „Logo A“ löschen?', + text: '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.', + confirmLabel: 'Löschen', + cancelLabel: 'Abbrechen', + }, + abort.signal, + ); + expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('a'); + expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); + expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a'); + expect(message()).toBe(''); + }); + + it('abgelehnt: es passiert nichts, auch kein Fehlertext', async () => { + user = 'u9'; + fx.confirm.mockResolvedValue(false); + await mount(); + button('Löschen').click(); + await flush(); + expect(fx.deleteLogo).not.toHaveBeenCalled(); + expect(mocks.forgetLogo).not.toHaveBeenCalled(); + expect(host.removeLogo).not.toHaveBeenCalled(); + expect(message()).toBe(''); + expect(buttons()).toEqual(['Melden', 'Löschen']); + }); + + it('Fehler des Dienstes: der Text steht in der Meldezeile, nichts wird entfernt; ein neuer Versuch geht', async () => { + user = 'u9'; + fx.deleteLogo.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); + await mount(); + button('Löschen').click(); + await flush(); + expect(message()).toBe('Dazu hast du keine Berechtigung.'); + expect(mocks.forgetLogo).not.toHaveBeenCalled(); + expect(host.removeLogo).not.toHaveBeenCalled(); + button('Löschen').click(); + await flush(); + expect(message()).toBe(''); + expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a'); + }); + + it('ein Fehler anderer Art (Netz) bekommt den allgemeinen Text', async () => { + user = 'u9'; + fx.deleteLogo.mockRejectedValueOnce(new TypeError('offline')); + await mount(); + button('Löschen').click(); + await flush(); + expect(message()).toBe('Keine Verbindung zum Server.'); + }); + + it('eine Rückfrage, die sich nicht öffnen lässt, löscht nichts und sagt es', async () => { + user = 'u9'; + fx.confirm.mockRejectedValueOnce(new Error('kein ')); + await mount(); + button('Löschen').click(); + await flush(); + expect(fx.deleteLogo).not.toHaveBeenCalled(); + expect(message()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); + }); + + it('meldet sich der Nutzer während der Rückfrage ab, wird nichts gelöscht', async () => { + user = 'u9'; + const answer = deferred(); + fx.confirm.mockReturnValue(answer.promise); + await mount(); + button('Löschen').click(); + user = null; + info.update(); + answer.resolve(true); + await flush(); + expect(fx.deleteLogo).not.toHaveBeenCalled(); + expect(host.removeLogo).not.toHaveBeenCalled(); + }); + + it('solange eine Rückfrage oder Anfrage läuft, bleiben weitere Klicks ohne Wirkung', async () => { + user = 'u9'; + const request = deferred(); + fx.deleteLogo.mockReturnValue(request.promise); + await mount(); + button('Löschen').click(); + button('Löschen').click(); // die Rückfrage ist noch nicht beantwortet + await flush(); + button('Löschen').click(); // die Anfrage läuft + button('Melden').click(); + await flush(); + expect(fx.confirm).toHaveBeenCalledTimes(1); + expect(fx.deleteLogo).toHaveBeenCalledTimes(1); + expect(fx.reportDialog).not.toHaveBeenCalled(); + request.resolve(); + await flush(); + expect(host.removeLogo).toHaveBeenCalledTimes(1); + }); + + it('verlässt der Nutzer die Seite während der Rückfrage, wird nichts gelöscht', async () => { + user = 'u9'; + const answer = deferred(); + fx.confirm.mockReturnValue(answer.promise); + await mount(); + button('Löschen').click(); + abort.abort(); + answer.resolve(true); + await flush(); + expect(fx.deleteLogo).not.toHaveBeenCalled(); + }); + + it('verlässt der Nutzer die Seite während der Anfrage, vergisst der Zwischenspeicher das Logo trotzdem, die Kachel wird nicht mehr angefasst', async () => { + user = 'u9'; + const request = deferred(); + fx.deleteLogo.mockReturnValue(request.promise); + await mount(); + button('Löschen').click(); + await flush(); + abort.abort(); + request.resolve(); + await flush(); + expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); + expect(host.removeLogo).not.toHaveBeenCalled(); + }); +}); + +describe('Admin', () => { + beforeEach(() => { + admin = true; + }); + + it('Nicht-Admins sehen keine Meldungen und kein Ausblenden und fragen die Gründe nicht ab', async () => { + admin = false; + await mount(logo({ reportCount: 3 })); + expect(reportCount()).toBe(''); + expect(buttons()).toEqual(['Melden']); + expect(fx.listLogoReports).not.toHaveBeenCalled(); + }); + + it('„ Meldungen“ mit den Gründen als Text; dazu „Ausblenden“ und „Löschen“ (auch für fremde Logos)', async () => { + fx.listLogoReports.mockResolvedValue([report('spam'), report('urheberrecht'), report('Eigener Grund')]); + await mount(logo({ reportCount: 3 })); + expect(fx.listLogoReports).toHaveBeenCalledExactlyOnceWith('a'); + expect(reportCount()).toBe('3 Meldungen'); + expect(reasons()).toEqual(['Spam', 'Urheberrecht/Marke', 'Eigener Grund']); + expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); + }); + + it('„1 Meldung“ im Singular', async () => { + fx.listLogoReports.mockResolvedValue([report('unangemessen')]); + await mount(logo({ reportCount: 1 })); + expect(reportCount()).toBe('1 Meldung'); + expect(reasons()).toEqual(['Unangemessen']); + }); + + it('ohne Meldungen: „0 Meldungen“ und keine Anfrage', async () => { + await mount(logo({ reportCount: 0 })); + expect(reportCount()).toBe('0 Meldungen'); + expect(reasons()).toEqual([]); + expect(fx.listLogoReports).not.toHaveBeenCalled(); + }); + + it('die Gründe werden je Logo nur einmal geholt, auch bei vielen Aktualisierungen', async () => { + fx.listLogoReports.mockResolvedValue([report('spam')]); + await mount(logo({ reportCount: 1 })); + row = logo({ reportCount: 1, likeCount: 99 }); + info.update(); + info.update(); + await flush(); + expect(fx.listLogoReports).toHaveBeenCalledTimes(1); + expect(reasons()).toEqual(['Spam']); + choose(logo({ id: 'b', reportCount: 1 })); + await flush(); + expect(fx.listLogoReports).toHaveBeenLastCalledWith('b'); + choose(logo({ reportCount: 1 })); // zurück zu A: frisch holen + await flush(); + expect(fx.listLogoReports).toHaveBeenCalledTimes(3); + }); + + it('ändert sich die Zahl der Meldungen (die Zeile wurde neu geladen), werden die Gründe neu geholt', async () => { + fx.listLogoReports.mockResolvedValueOnce([report('spam')]); + await mount(logo({ reportCount: 1 })); + expect(reasons()).toEqual(['Spam']); + fx.listLogoReports.mockResolvedValueOnce([report('spam'), report('sonstiges')]); + row = logo({ reportCount: 2 }); + info.update(); + await flush(); + expect(reportCount()).toBe('2 Meldungen'); + expect(reasons()).toEqual(['Spam', 'Sonstiges']); + expect(fx.listLogoReports).toHaveBeenCalledTimes(2); + }); + + it('wird dasselbe Logo nach „kein Logo“ erneut gewählt, werden die Gründe frisch geholt – auch nach einem Ladefehler', async () => { + fx.listLogoReports.mockRejectedValueOnce(new Error('offline')); + await mount(logo({ reportCount: 1 })); + expect(reasonsNote()).toBe('Die Gründe konnten nicht geladen werden.'); + choose(null); + fx.listLogoReports.mockResolvedValueOnce([report('spam')]); + choose(logo({ reportCount: 1 })); + await flush(); + expect(fx.listLogoReports).toHaveBeenCalledTimes(2); + expect(reasonsNote()).toBe(''); + expect(reasons()).toEqual(['Spam']); + }); + + it('die Gründe lassen sich nicht laden: Hinweis dazu, die Zahl bleibt', async () => { + fx.listLogoReports.mockRejectedValue(new Error('offline')); + await mount(logo({ reportCount: 3 })); + expect(reportCount()).toBe('3 Meldungen'); + expect(reasons()).toEqual([]); + expect(reasonsNote()).toBe('Die Gründe konnten nicht geladen werden.'); + // Ein anderes Logo, das sich laden lässt, hat den Hinweis nicht. + fx.listLogoReports.mockResolvedValue([report('spam')]); + choose(logo({ id: 'b', reportCount: 1 })); + await flush(); + expect(reasonsNote()).toBe(''); + expect(reasons()).toEqual(['Spam']); + }); + + it('eine späte Antwort für ein inzwischen verlassenes Logo wird verworfen', async () => { + const slow = deferred(); + fx.listLogoReports.mockReturnValueOnce(slow.promise); + await mount(logo({ reportCount: 2 })); + fx.listLogoReports.mockResolvedValueOnce([report('sonstiges')]); + choose(logo({ id: 'b', reportCount: 1 })); + await flush(); + expect(reasons()).toEqual(['Sonstiges']); + slow.resolve([report('spam'), report('spam', 'zwei')]); + await flush(); + expect(reasons()).toEqual(['Sonstiges']); + }); + + it('„Ausblenden“: setLogoVisibility(id, false), forgetLogo, die Zeile wird hidden; die Anzeige folgt', async () => { + const order: string[] = []; + fx.setLogoVisibility.mockImplementation(async () => { + order.push('visibility'); + }); + mocks.forgetLogo.mockImplementation(() => { + order.push('forget'); + }); + host.patchRow.mockImplementation((id, patch) => { + order.push('patch'); + if (row?.id === id) row = { ...row, ...patch }; + }); + fx.listLogoReports.mockResolvedValue([report('spam')]); + await mount(logo({ reportCount: 1 })); + button('Ausblenden').click(); + await flush(); + expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('a', false); + expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); + expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: true }); + expect(order).toEqual(['visibility', 'forget', 'patch']); + expect(buttons()).toEqual(['Löschen', 'Einblenden']); // „Melden“ gibt es für ein ausgeblendetes Logo nicht + expect(hiddenNote()).toBe('Ausgeblendet'); + expect(reportCount()).toBe('1 Meldung'); // Ausblenden ändert die Meldungen nicht + expect(reasons()).toEqual(['Spam']); + expect(message()).toBe(''); + }); + + it('„Einblenden“: setLogoVisibility(id, true), forgetLogo; die Meldungen sind danach gelöscht (0)', async () => { + fx.listLogoReports.mockResolvedValue([report('spam'), report('sonstiges')]); + await mount(logo({ hidden: true, reportCount: 2 })); + expect(buttons()).toEqual(['Löschen', 'Einblenden']); + expect(reasons()).toEqual(['Spam', 'Sonstiges']); + button('Einblenden').click(); + await flush(); + expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('a', true); + expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); + expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: false, reportCount: 0 }); + expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); + expect(hiddenNote()).toBe(''); + expect(reportCount()).toBe('0 Meldungen'); + expect(reasons()).toEqual([]); + expect(fx.listLogoReports).toHaveBeenCalledTimes(1); // nichts mehr zu holen + }); + + it('Fehler beim Ein- oder Ausblenden: Text in der Meldezeile, die Zeile bleibt, kein forgetLogo', async () => { + fx.setLogoVisibility.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); + await mount(logo()); + button('Ausblenden').click(); + await flush(); + expect(message()).toBe('Der Dienst ist gerade nicht erreichbar.'); + expect(host.patchRow).not.toHaveBeenCalled(); + expect(mocks.forgetLogo).not.toHaveBeenCalled(); + expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); + button('Ausblenden').click(); + await flush(); + expect(message()).toBe(''); + expect(buttons()).toEqual(['Löschen', 'Einblenden']); + }); + + it('der Admin löscht wie der Hochlader, auch ein fremdes Logo', async () => { + await mount(logo({ ownerId: 'u5' })); + button('Löschen').click(); + await flush(); + expect(fx.confirm).toHaveBeenCalledTimes(1); + expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('a'); + expect(host.removeLogo).toHaveBeenCalledExactlyOnceWith('a'); + }); + + it('wird jemand Admin (Anmeldung), erscheinen die Admin-Teile nach update(); verliert er die Rolle, verschwinden sie', async () => { + admin = false; + fx.listLogoReports.mockResolvedValue([report('spam')]); + await mount(logo({ reportCount: 1 })); + expect(buttons()).toEqual(['Melden']); + admin = true; + info.update(); + await flush(); + expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); + expect(reasons()).toEqual(['Spam']); + admin = false; + info.update(); + expect(buttons()).toEqual(['Melden']); + expect(reportCount()).toBe(''); + }); + + it('Englisch: Meldungen, Gründe und Knöpfe sind übersetzt', async () => { + setLang('en', { persist: false }); + fx.listLogoReports.mockResolvedValue([report('spam')]); + await mount(logo({ reportCount: 1, ownerId: 'u1' })); + expect(buttons()).toEqual(['Report', 'Delete', 'Hide']); + expect(reasons()).toEqual(['Spam']); + row = logo({ hidden: true, reportCount: 1 }); + info.update(); + expect(buttons()).toEqual(['Delete', 'Unhide']); + expect(hiddenNote()).toBe('Hidden'); + }); +}); + +describe('Meldezeile', () => { + it('zeigt einen Text in einer Live-Region (role="alert") und löscht ihn mit einem leeren Text', async () => { + await mount(); + expect(info.el.querySelector('[role="alert"]')!.classList.contains('auth-msg-error')).toBe(true); + info.setMessage('Etwas ging schief.'); + expect(message()).toBe('Etwas ging schief.'); + info.setMessage(''); + expect(message()).toBe(''); + }); + + it('der Text steht auch ohne Auswahl (Fehler am Herz eines nicht gewählten Logos) und überlebt update()', async () => { + await mount(null); + info.setMessage('Dieses Logo ist nicht (mehr) verfügbar.'); + info.update(); + expect(message()).toBe('Dieses Logo ist nicht (mehr) verfügbar.'); + }); + + it('eine andere Auswahl löscht den Text; dieselbe Auswahl lässt ihn stehen', async () => { + await mount(); + info.setMessage('Etwas ging schief.'); + info.update(); + row = logo({ likeCount: 13 }); + info.update(); + expect(message()).toBe('Etwas ging schief.'); + choose(logo({ id: 'b', name: 'Logo B' })); + expect(message()).toBe(''); + }); +}); + +describe('failureText', () => { + it('Fehler des Dienstes über ihren Schlüssel, alles andere über errorMessage', () => { + expect(failureText(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'))).toBe('Dieses Logo ist nicht (mehr) verfügbar.'); + expect(failureText(new TypeError('offline'))).toBe('Keine Verbindung zum Server.'); + setLang('en', { persist: false }); + expect(failureText(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'))).toBe('This logo is no longer available.'); + }); +}); diff --git a/web/src/logos/logo-info.ts b/web/src/logos/logo-info.ts new file mode 100644 index 0000000..dcaa532 --- /dev/null +++ b/web/src/logos/logo-info.ts @@ -0,0 +1,237 @@ +// 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; + 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 { + 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 }; +} diff --git a/web/src/logos/logo-picker.test.ts b/web/src/logos/logo-picker.test.ts index a81e420..a52b3d6 100644 --- a/web/src/logos/logo-picker.test.ts +++ b/web/src/logos/logo-picker.test.ts @@ -1,9 +1,10 @@ -// Bereich „Logo unten rechts“ (Spec § 3 und § 5): Raster, Sortierung, Auswahl, Herzen und Hochladen. Datenschicht, Anmeldung und -// Dialog sind Attrappen; die Kachelbilder laufen über das echte logo-image.ts mit ersetztem `Image` (dessen Einzelfälle stehen in -// logo-image.test.ts). +// Bereich „Logo unten rechts“ (Spec § 3, § 5 und § 6): Raster, Sortierung, Auswahl, Herzen, Hochladen und die Infozeile zum gewählten +// Logo (Melden, Löschen, Admin). Datenschicht, Anmeldung und Dialoge sind Attrappen; die Kachelbilder laufen über das echte +// logo-image.ts mit ersetztem `Image` (dessen Einzelfälle stehen in logo-image.test.ts), die Infozeile über das echte logo-info.ts +// (dessen Einzelfälle stehen in logo-info.test.ts: hier zählt das Zusammenspiel mit Raster, Auswahl und Herzen). import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ rememberLogo: vi.fn() })); +const mocks = vi.hoisted(() => ({ rememberLogo: vi.fn(), forgetLogo: vi.fn() })); // Der Picker hängt (über die Standardwerte) an Appwrite, Anmeldung und Dialogen; hier kommt alles über `deps`. vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); @@ -19,6 +20,7 @@ vi.mock('./logo-dialog', () => ({ logoUploadDialog: vi.fn() })); vi.mock('./logo-asset', async (importOriginal) => ({ ...(await importOriginal()), rememberLogo: mocks.rememberLogo, + forgetLogo: mocks.forgetLogo, })); import { ApiError } from '../community/api'; @@ -71,6 +73,12 @@ const fx = { loadLogoBlob: vi.fn(), requireLogin: vi.fn(), uploadDialog: vi.fn(), + reportLogo: vi.fn(), + deleteLogo: vi.fn(), + setLogoVisibility: vi.fn(), + listLogoReports: vi.fn(), + confirm: vi.fn(), + reportDialog: vi.fn(), onSelect: vi.fn<(id: string | null) => void>(), }; @@ -88,6 +96,12 @@ const deps: LogoPickerDeps = { return () => authListeners.delete(fn); }, uploadDialog: fx.uploadDialog, + reportLogo: fx.reportLogo, + deleteLogo: fx.deleteLogo, + setLogoVisibility: fx.setLogoVisibility, + listLogoReports: fx.listLogoReports, + confirm: fx.confirm, + reportDialog: fx.reportDialog, }; // Bildprüfung: `loadLogoBlob` liefert einen Blob, den die Attrappe einem Logo zuordnet; die Object-URL erbt das, und das @@ -122,7 +136,7 @@ let host: HTMLElement; let picker: LogoPicker; beforeEach(() => { - for (const fn of [...Object.values(fx), mocks.rememberLogo]) fn.mockReset(); + for (const fn of [...Object.values(fx), mocks.rememberLogo, mocks.forgetLogo]) fn.mockReset(); user = 'u1'; admin = false; authListeners.clear(); @@ -142,6 +156,12 @@ beforeEach(() => { }); fx.requireLogin.mockResolvedValue(true); fx.uploadDialog.mockResolvedValue(null); + fx.reportLogo.mockResolvedValue({ schonGemeldet: false }); + fx.deleteLogo.mockResolvedValue(undefined); + fx.setLogoVisibility.mockResolvedValue(undefined); + fx.listLogoReports.mockResolvedValue([]); + fx.confirm.mockResolvedValue(true); + fx.reportDialog.mockResolvedValue('cancelled'); URL.createObjectURL = vi.fn((blob: Blob) => { const url = `blob:logo-${created.length + 1}`; created.push(url); @@ -190,6 +210,18 @@ const uploadButton = () => picker.el.querySelector('button.lo const moreButton = () => picker.el.querySelector('button.logo-more')!; const retryButton = () => picker.el.querySelector('button.logo-retry')!; const emptyText = () => picker.el.querySelector('.logo-empty')!.textContent; +// Infozeile (logo-info.ts): sichtbar ist, was weder selbst noch in einem Vorfahren mit `hidden` verborgen ist. +const infoEl = () => picker.el.querySelector('.logo-info')!; +const shown = (node: Element) => node.closest('[hidden]') === null; +const infoLine = () => { + const text = infoEl().querySelector('.logo-info-text')!; + return shown(text) ? text.textContent : ''; +}; +const infoGone = () => infoEl().querySelector('.logo-info-gone')!.textContent; +const infoMessage = () => infoEl().querySelector('[role="alert"]')!.textContent; +const infoButtons = () => [...infoEl().querySelectorAll('button')].filter(shown).map((b) => b.textContent); +const infoButton = (label: string) => + [...infoEl().querySelectorAll('button')].find((b) => b.textContent === label && shown(b)) as HTMLButtonElement; describe('Aufbau', () => { it('Kopf: Überschrift „Logo unten rechts“, Plakette „Community“, Knopf „Hochladen“ und der Satz zur Community', async () => { @@ -645,6 +677,7 @@ describe('Herzen', () => { expect(heart('Logo A').textContent).toBe('7'); expect(heart('Logo A').getAttribute('aria-label')).toBe('Herz für „Logo A“: 7'); expect(alertText()).toBe(''); + expect(infoMessage()).toBe(''); }); it('ein zweiter Klick nimmt das Herz zurück: setHeart(id, false), Zahl − 1', async () => { @@ -673,7 +706,7 @@ describe('Herzen', () => { expect(heart('Logo A').textContent).toBe('0'); }); - it('Fehler: das Herz springt samt Zahl zurück, der Grund steht im Statusbereich (role="alert")', async () => { + it('Fehler: das Herz springt samt Zahl zurück, der Grund steht in der Infozeile (role="alert"), nicht im Statusbereich der Liste', async () => { fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 2 })], nextCursor: null }); fx.setHeart.mockRejectedValue(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone')); await mount(); @@ -681,7 +714,8 @@ describe('Herzen', () => { await flush(); expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false'); expect(heart('Logo A').textContent).toBe('2'); - expect(alertText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.'); + expect(infoMessage()).toBe('Dieses Logo ist nicht (mehr) verfügbar.'); + expect(alertText()).toBe(''); }); it('Fehler anderer Art: allgemeiner Text; der Fehler verschwindet beim nächsten Versuch, der dann auch wieder möglich ist', async () => { @@ -689,10 +723,10 @@ describe('Herzen', () => { await mount(); heart('Logo A').click(); await flush(); - expect(alertText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); + expect(infoMessage()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); expect(heartsOn()).toEqual([]); heart('Logo A').click(); // die Anfrage ist zu Ende: der Knopf ist wieder frei - expect(alertText()).toBe(''); + expect(infoMessage()).toBe(''); await flush(); expect(fx.setHeart).toHaveBeenCalledTimes(2); expect(heartsOn()).toEqual(['Logo A']); @@ -752,6 +786,7 @@ describe('Herzen', () => { expect(heartsOn()).toEqual([]); expect(heart('Logo A').textContent).toBe('0'); expect(alertText()).toBe(''); + expect(infoMessage()).toBe(''); heart('Logo A').click(); // kein hängender Zustand: der nächste Klick fragt wieder nach dem Login await flush(); expect(fx.requireLogin).toHaveBeenCalledTimes(2); @@ -1000,12 +1035,13 @@ describe('Hochladen', () => { expect(mocks.rememberLogo).not.toHaveBeenCalled(); }); - it('lässt sich der Dialog gar nicht öffnen (Fehler), steht der Grund im Statusbereich', async () => { + it('lässt sich der Dialog gar nicht öffnen (Fehler), steht der Grund in der Infozeile (der Statusbereich der Liste bleibt für Ladefehler)', async () => { fx.uploadDialog.mockRejectedValue(new Error('kein ')); await mount(); uploadButton().click(); await flush(); - expect(alertText()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); + expect(infoMessage()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); + expect(alertText()).toBe(''); }); }); @@ -1099,6 +1135,18 @@ describe('dispose', () => { expect(fx.myHearts).not.toHaveBeenCalled(); }); + it('select() nach dem Aufräumen ändert nichts und fragt nichts mehr an', async () => { + await mount(); + picker.dispose(); + picker.select('b'); + picker.select('x'); + await flush(); + expect(pressedTiles()).toEqual(['Kein Logo']); + expect(fx.getLogo).not.toHaveBeenCalled(); + expect(picker.selected()).toBeNull(); + expect(infoLine()).toBe(''); + }); + it('eine Herz-Antwort nach dem Aufräumen wird verworfen', async () => { const answer = deferred<{ herz: boolean; likeCount: number }>(); fx.setHeart.mockReturnValue(answer.promise); @@ -1110,3 +1158,436 @@ describe('dispose', () => { expect(heart('Logo A').textContent).toBe('1'); // der Stand von vor dem Aufräumen, kein Fehler }); }); + +describe('Herzen: Fehler und Anmeldung', () => { + it('Gast: lässt sich der Login-Dialog nicht öffnen (requireLogin wirft), steht der Grund in der Infozeile; das Herz bleibt aus, und es geht danach wieder', async () => { + user = null; + fx.requireLogin.mockRejectedValueOnce(new Error('kein ')); + await mount(); + heart('Logo A').click(); + await flush(); + expect(fx.setHeart).not.toHaveBeenCalled(); + expect(heartsOn()).toEqual([]); + expect(infoMessage()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); + heart('Logo A').click(); // kein hängender Zustand + await flush(); + expect(fx.requireLogin).toHaveBeenCalledTimes(2); + expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', true); + expect(infoMessage()).toBe(''); + }); + + it('ein Klick auf Herz oder „Hochladen“ löscht den Ladefehler der Liste nicht: der steht allein im Statusbereich', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); + await mount(); + fx.listLogos.mockRejectedValueOnce(new Error('offline')); + moreButton().click(); + await flush(); + expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); + heart('Logo A').click(); + await flush(); + expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); + uploadButton().click(); + await flush(); + expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); + expect(retryButton().hidden).toBe(false); + }); + + it('der Herz-Fehler steht auch dann in der Infozeile, wenn das Logo gar nicht gewählt ist', async () => { + fx.setHeart.mockRejectedValue(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone')); + await mount(); + picker.select('c'); + heart('Logo A').click(); + await flush(); + expect(infoMessage()).toBe('Dieses Logo ist nicht (mehr) verfügbar.'); + expect(infoLine()).toBe('Logo C · von Kenso · ♥ 0'); + picker.select('b'); // eine andere Auswahl räumt den alten Fehler ab + expect(infoMessage()).toBe(''); + }); +}); + +describe('Infozeile', () => { + it('sitzt als Letztes im Bereich, unter dem Raster und „Mehr laden“; ohne Auswahl ist sie leer', async () => { + await mount(); + expect(picker.el.lastElementChild).toBe(infoEl()); + const grid = picker.el.querySelector('ul.logo-grid')!; + expect(grid.compareDocumentPosition(infoEl()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(infoLine()).toBe(''); + expect(infoButtons()).toEqual([]); + expect(infoMessage()).toBe(''); + }); + + it('select(id) zeigt „ · von · ♥ “ und „Melden“ – auch für Gäste; „Kein Logo“ leert sie', async () => { + user = null; + fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 7 }), logo('b', { displayName: '' })], nextCursor: null }); + await mount(); + picker.select('a'); + expect(infoLine()).toBe('Logo A · von Kenso · ♥ 7'); + expect(infoButtons()).toEqual(['Melden']); + picker.select('b'); + expect(infoLine()).toBe('Logo B · von Anonym · ♥ 0'); + picker.select(null); + expect(infoLine()).toBe(''); + expect(infoButtons()).toEqual([]); + }); + + it('der Zähler der Infozeile folgt dem Herz: sofort und mit der Antwort des Dienstes', async () => { + const answer = deferred<{ herz: boolean; likeCount: number }>(); + fx.setHeart.mockReturnValue(answer.promise); + await mount(); + picker.select('a'); + heart('Logo A').click(); + expect(infoLine()).toBe('Logo A · von Kenso · ♥ 1'); + answer.resolve({ herz: true, likeCount: 5 }); + await flush(); + expect(infoLine()).toBe('Logo A · von Kenso · ♥ 5'); + }); + + it('ein Logo außerhalb der Liste: die Zeile erscheint, sobald getLogo es geholt hat; vorher steht dort nichts', async () => { + const slow = deferred(); + fx.getLogo.mockReturnValue(slow.promise); + await mount(); + picker.select('x'); + await flush(); + expect(infoLine()).toBe(''); + expect(infoGone()).toBe(''); + slow.resolve(logo('x', { name: 'Fremdes Logo', displayName: 'Anna', likeCount: 2 })); + await flush(); + expect(infoLine()).toBe('Fremdes Logo · von Anna · ♥ 2'); + }); + + it('solange die Liste noch lädt und das Logo noch unbekannt ist, steht nichts da; danach die Zeile aus der Liste', async () => { + const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); + fx.listLogos.mockReturnValue(list.promise); + picker = createLogoPicker({ onSelect: fx.onSelect }, deps); + host.append(picker.el); + picker.select('b'); + await flush(); + expect(infoLine()).toBe(''); + expect(infoGone()).toBe(''); + list.resolve({ logos: [logo('a'), logo('b', { likeCount: 3 })], nextCursor: null }); + await flush(); + expect(infoLine()).toBe('Logo B · von Kenso · ♥ 3'); + }); + + it('ein Logo, das es nicht mehr gibt (getLogo → null): „Das gewählte Logo ist nicht mehr verfügbar.“; eine andere Wahl nimmt den Hinweis weg', async () => { + await mount(); + picker.select('weg'); + await flush(); + expect(infoGone()).toBe('Das gewählte Logo ist nicht mehr verfügbar.'); + expect(infoLine()).toBe(''); + expect(infoButtons()).toEqual([]); + picker.select('b'); + expect(infoGone()).toBe(''); + expect(infoLine()).toBe('Logo B · von Kenso · ♥ 0'); + picker.select('weg'); // noch einmal: die Antwort von vorhin gilt nicht mehr, es wird neu gefragt + expect(infoGone()).toBe(''); + await flush(); + expect(infoGone()).toBe('Das gewählte Logo ist nicht mehr verfügbar.'); + expect(fx.getLogo).toHaveBeenCalledTimes(2); + }); + + it('scheitert getLogo (Netz), sagt die Infozeile nichts: das Logo kann es ja geben', async () => { + fx.getLogo.mockRejectedValue(new Error('offline')); + await mount(); + picker.select('x'); + await flush(); + expect(infoGone()).toBe(''); + expect(infoLine()).toBe(''); + }); + + it('eine späte „gibt es nicht“-Antwort für eine inzwischen verlassene Auswahl bleibt ohne Wirkung', async () => { + const slow = deferred(); + fx.getLogo.mockReturnValueOnce(slow.promise); + await mount(); + picker.select('x'); + picker.select('b'); + slow.resolve(null); + await flush(); + expect(infoGone()).toBe(''); + expect(infoLine()).toBe('Logo B · von Kenso · ♥ 0'); + }); + + it('„Melden“ öffnet den Dialog für Logos (mit dem Signal des Pickers) und sendet reportLogo(id, Grund)', async () => { + await mount(); + picker.select('b'); + infoButton('Melden').click(); + await flush(); + expect(fx.reportDialog).toHaveBeenCalledTimes(1); + const opts = fx.reportDialog.mock.calls[0][0]; + expect(opts.subject).toBe('logo'); + await opts.send('urheberrecht'); + expect(fx.reportLogo).toHaveBeenCalledExactlyOnceWith('b', 'urheberrecht'); + expect(opts.signal!.aborted).toBe(false); + picker.dispose(); + expect(opts.signal!.aborted).toBe(true); + }); + + it('Anmelden und Abmelden: „Löschen“ erscheint für den Hochlader und verschwindet wieder', async () => { + user = null; + fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: null }); + await mount(); + picker.select('a'); + expect(infoButtons()).toEqual(['Melden']); + user = 'u1'; + emitAuth(); + await flush(); + expect(infoButtons()).toEqual(['Melden', 'Löschen']); + user = null; + emitAuth(); + expect(infoButtons()).toEqual(['Melden']); + }); + + it('Admin: Anzahl und Gründe der Meldungen, „Ausblenden“ und „Löschen“ – aus der Zeile des Pickers und listLogoReports', async () => { + admin = true; + fx.listLogos.mockResolvedValue({ logos: [logo('a', { reportCount: 2 })], nextCursor: null }); + fx.listLogoReports.mockResolvedValue([ + { id: 'r1', logoId: 'a', reason: 'spam', createdAt: '2026-10-04T12:00:00.000Z' }, + { id: 'r2', logoId: 'a', reason: 'sonstiges', createdAt: '2026-10-04T12:01:00.000Z' }, + ]); + await mount(); + picker.select('a'); + await flush(); + expect(fx.listLogoReports).toHaveBeenCalledExactlyOnceWith('a'); + expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('2 Meldungen'); + expect([...infoEl().querySelectorAll('.logo-info-reasons li')].map((li) => li.textContent)).toEqual(['Spam', 'Sonstiges']); + expect(infoButtons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); + }); + + it('Englisch: Zeile und Knöpfe sind übersetzt', async () => { + setLang('en', { persist: false }); + fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1', displayName: '' })], nextCursor: null }); + await mount(); + picker.select('a'); + expect(infoLine()).toBe('Logo A · by Anonymous · ♥ 0'); + expect(infoButtons()).toEqual(['Report', 'Delete']); + }); +}); + +describe('Löschen (mit Raster und Auswahl)', () => { + beforeEach(() => { + fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: null }); + }); + + it('bestätigt: deleteLogo, forgetLogo, die Kachel ist weg, die Auswahl wird aufgehoben (onSelect(null)); die übrigen bleiben', async () => { + await mount(); + picker.select('b'); + infoButton('Löschen').click(); + await flush(); + expect(fx.deleteLogo).toHaveBeenCalledExactlyOnceWith('b'); + expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('b'); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']); + expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith(null); + expect(picker.selected()).toBeNull(); + expect(infoLine()).toBe(''); + expect(infoGone()).toBe(''); + expect(infoMessage()).toBe(''); + expect(moreButton().hidden).toBe(true); // es gab keine weitere Seite, und das Löschen erfindet keine + expect(revoked).toContain(created[1]); // das Bild der Kachel ist freigegeben + }); + + it('der Editor übernimmt onSelect(null) mit select(null): „Kein Logo“ ist gewählt, nichts bleibt hängen', async () => { + await mount(); + picker.select('b'); + infoButton('Löschen').click(); + await flush(); + picker.select(null); + expect(pressedTiles()).toEqual(['Kein Logo']); + expect(infoLine()).toBe(''); + expect(fx.getLogo).not.toHaveBeenCalled(); + }); + + it('abgelehnt: nichts passiert', async () => { + fx.confirm.mockResolvedValue(false); + await mount(); + picker.select('b'); + infoButton('Löschen').click(); + await flush(); + expect(fx.deleteLogo).not.toHaveBeenCalled(); + expect(mocks.forgetLogo).not.toHaveBeenCalled(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(fx.onSelect).not.toHaveBeenCalled(); + expect(infoMessage()).toBe(''); + }); + + it('Fehler: der Text steht in der Infozeile; Kachel, Zeile und Auswahl bleiben', async () => { + fx.deleteLogo.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); + await mount(); + picker.select('b'); + infoButton('Löschen').click(); + await flush(); + expect(infoMessage()).toBe('Dazu hast du keine Berechtigung.'); + expect(alertText()).toBe(''); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(fx.onSelect).not.toHaveBeenCalled(); + expect(mocks.forgetLogo).not.toHaveBeenCalled(); + expect(picker.selected()).toMatchObject({ id: 'b' }); + }); + + it('Löschen sehen nur Hochlader und Admins', async () => { + await mount(); + picker.select('a'); // gehört u9 + expect(infoButtons()).toEqual(['Melden']); + picker.select('b'); // gehört u1 = ich + expect(infoButtons()).toEqual(['Melden', 'Löschen']); + }); + + it('das letzte Logo gelöscht: der Leer-Text erscheint', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: null }); + await mount(); + picker.select('a'); + infoButton('Löschen').click(); + await flush(); + expect(names()).toEqual(['Kein Logo']); + expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.'); + }); + + it('ein angeheftetes Logo (nicht in der Liste) lässt sich löschen: seine Kachel verschwindet', async () => { + fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo', ownerId: 'u1' })); + await mount(); + picker.select('x'); + await flush(); + expect(names()).toContain('Fremdes Logo'); + infoButton('Löschen').click(); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith(null); + }); + + it('hat sich die Auswahl während der Anfrage geändert, bleibt die neue Auswahl unberührt (kein onSelect)', async () => { + const request = deferred(); + fx.deleteLogo.mockReturnValue(request.promise); + await mount(); + picker.select('b'); + infoButton('Löschen').click(); + await flush(); + picker.select('c'); + request.resolve(); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo C']); + expect(fx.onSelect).not.toHaveBeenCalled(); + expect(pressedTiles()).toEqual(['Logo C']); + expect(infoLine()).toBe('Logo C · von Kenso · ♥ 0'); + }); + + it('war das gelöschte Logo der Cursor für „Mehr laden“ (die Anfrage braucht ein Logo, das es gibt), setzt das Nachladen beim Logo davor an', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c', { ownerId: 'u1' })], nextCursor: 'c' }); + await mount(); + picker.select('c'); + infoButton('Löschen').click(); + await flush(); + expect(moreButton().hidden).toBe(false); + fx.listLogos.mockResolvedValue({ logos: [logo('d')], nextCursor: null }); + moreButton().click(); + await flush(); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'b' }); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo D']); + }); + + it('ein anderes gelöschtes Logo lässt den Cursor für „Mehr laden“ stehen', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: 'c' }); + await mount(); + picker.select('b'); + infoButton('Löschen').click(); + await flush(); + fx.listLogos.mockResolvedValue({ logos: [logo('d')], nextCursor: null }); + moreButton().click(); + await flush(); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'c' }); + }); + + it('ein Logo mit derselben ID, das später auftaucht, erbt das Herz des gelöschten nicht', async () => { + fx.myHearts.mockResolvedValueOnce(new Set(['b'])); + await mount(); + expect(heartsOn()).toEqual(['Logo B']); + picker.select('b'); + infoButton('Löschen').click(); + await flush(); + picker.select(null); + fx.myHearts.mockReturnValue(new Promise(() => {})); // die Herzen der neuen Liste kommen noch + fx.listLogos.mockResolvedValue({ logos: [logo('b', { name: 'Neues B' })], nextCursor: null }); + sortButton('Neu').click(); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Neues B']); + expect(heartsOn()).toEqual([]); + }); + + it('der Fokus wandert von „Löschen“, das verschwindet, auf „Kein Logo“', async () => { + await mount(); + picker.select('b'); + infoButton('Löschen').focus(); + expect(document.activeElement).toBe(infoButton('Löschen')); + infoButton('Löschen').click(); + await flush(); + expect(document.activeElement).toBe(tile('Kein Logo')); + }); + + it('der Fokus bleibt, wo er ist, wenn er nicht in der Infozeile lag', async () => { + await mount(); + picker.select('b'); + infoButton('Löschen').click(); + tile('Logo C').focus(); + await flush(); + expect(document.activeElement).toBe(tile('Logo C')); + }); +}); + +describe('Ausblenden und Einblenden (Admin, mit Raster und Auswahl)', () => { + beforeEach(() => { + admin = true; + fx.listLogos.mockResolvedValue({ + logos: [logo('a'), logo('b', { reportCount: 3 }), logo('c', { hidden: true, reportCount: 3 })], + nextCursor: null, + }); + }); + + it('„Ausblenden“: setLogoVisibility(id, false), forgetLogo; die Zeile des Pickers ist hidden, die Kachel bleibt', async () => { + await mount(); + picker.select('b'); + infoButton('Ausblenden').click(); + await flush(); + expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('b', false); + expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('b'); + expect(picker.selected()).toMatchObject({ id: 'b', hidden: true, reportCount: 3 }); + expect(names()).toContain('Logo B'); + expect(infoButtons()).toEqual(['Löschen', 'Einblenden']); + expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('3 Meldungen'); + }); + + it('„Einblenden“: setLogoVisibility(id, true), forgetLogo; die Zeile ist sichtbar und hat 0 Meldungen', async () => { + await mount(); + picker.select('c'); + expect(infoButtons()).toEqual(['Löschen', 'Einblenden']); + infoButton('Einblenden').click(); + await flush(); + expect(fx.setLogoVisibility).toHaveBeenCalledExactlyOnceWith('c', true); + expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('c'); + expect(picker.selected()).toMatchObject({ id: 'c', hidden: false, reportCount: 0 }); + expect(infoButtons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); + expect(infoEl().querySelector('.logo-info-reports')!.textContent).toBe('0 Meldungen'); + }); + + it('Fehler: der Text steht in der Infozeile, die Zeile bleibt unverändert', async () => { + fx.setLogoVisibility.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); + await mount(); + picker.select('b'); + infoButton('Ausblenden').click(); + await flush(); + expect(infoMessage()).toBe('Der Dienst ist gerade nicht erreichbar.'); + expect(picker.selected()).toMatchObject({ id: 'b', hidden: false }); + expect(mocks.forgetLogo).not.toHaveBeenCalled(); + }); + + it('der Herz-Zähler, der sich während der Anfrage ändert, geht nicht verloren (die Zeile wird mit dem aktuellen Stand fortgeschrieben)', async () => { + const request = deferred(); + fx.setLogoVisibility.mockReturnValue(request.promise); + fx.setHeart.mockResolvedValue({ herz: true, likeCount: 9 }); + await mount(); + picker.select('b'); + infoButton('Ausblenden').click(); + heart('Logo B').click(); + await flush(); + request.resolve(); + await flush(); + expect(picker.selected()).toMatchObject({ id: 'b', hidden: true, likeCount: 9 }); + }); +}); diff --git a/web/src/logos/logo-picker.ts b/web/src/logos/logo-picker.ts index 4e0beea..ff55683 100644 --- a/web/src/logos/logo-picker.ts +++ b/web/src/logos/logo-picker.ts @@ -1,29 +1,29 @@ -// Bereich „Logo unten rechts“ im Editor (Spec § 3 und § 5): Raster der Community-Logos mit Sortierung, Auswahl, Herzen und dem -// Knopf „Hochladen“. Die Daten kommen über `deps` (Standard: logos.ts, logo-api.ts, Anmeldung, Dialog „Logo hochladen“); alle Texte -// gehen als Text in den DOM, nie als HTML. Die Auswahl gehört dem Editor (`config.logo`): ein Klick meldet nur `onSelect`, markiert -// wird über `select()`. Infozeile, Melden, Löschen und die Admin-Aktionen zum gewählten Logo kommen mit Task 9 dazu. +// Bereich „Logo unten rechts“ im Editor (Spec § 3, § 5 und § 6): Raster der Community-Logos mit Sortierung, Auswahl, Herzen und dem +// Knopf „Hochladen“, darunter die Infozeile zum gewählten Logo (logo-info.ts: Melden, Löschen, Admin-Aktionen). Die Daten kommen +// über `deps` (Standard: logos.ts, logo-api.ts, Anmeldung, Dialoge); alle Texte gehen als Text in den DOM, nie als HTML. Die Auswahl +// gehört dem Editor (`config.logo`): ein Klick meldet nur `onSelect`, markiert wird über `select()`. Fehler von Herz und Hochladen +// zeigt die Infozeile; der Statusbereich des Pickers ist den Ladefehlern der Liste vorbehalten. import '../account/account.css'; import './logos.css'; import { currentUser, currentUserId, isAdmin, onAuthChange } from '../account/auth'; -import { errorMessage } from '../account/errors'; import { requireLogin } from '../account/login-dialog'; -import { ApiError, apiErrorText } from '../community/api'; +import { reportDialog } from '../community/report-dialog'; +import { confirmDialog } from '../editor/confirm-dialog'; import { el, icon } from '../editor/dom'; import { msg, t } from '../i18n/i18n'; -import { setHeart } from './logo-api'; +import { deleteLogo, reportLogo, setHeart, setLogoVisibility } from './logo-api'; import { rememberLogo } from './logo-asset'; import { logoUploadDialog } from './logo-dialog'; import { showLogoImage } from './logo-image'; -import { getLogo, listLogos, loadLogoBlob, myHearts, type CommunityLogo, type LogoFilter, type LogoSort } from './logos'; +import { createLogoInfo, failureText, type LogoInfoDeps } from './logo-info'; +import { getLogo, listLogoReports, listLogos, loadLogoBlob, myHearts, type CommunityLogo, type LogoFilter, type LogoSort } from './logos'; -export interface LogoPickerDeps { +export interface LogoPickerDeps extends LogoInfoDeps { listLogos: typeof listLogos; getLogo: typeof getLogo; myHearts: typeof myHearts; setHeart: typeof setHeart; loadLogoBlob: typeof loadLogoBlob; - userId(): string | null; - isAdmin(): boolean; requireLogin(): Promise; /** Meldet jede Änderung des Anmeldestatus; gibt die Abmeldung zurück. */ onAuthChange(fn: () => void): () => void; @@ -35,7 +35,7 @@ export interface LogoPicker { el: HTMLElement; /** Markiert die Auswahl (null: kein Logo). Ein Logo, das nicht in der Liste steht, wird geholt und als zweite Kachel angeheftet. */ select(logoId: string | null): void; - /** Die Zeile des gewählten Logos, sobald bekannt (für die Infozeile in Task 9); null = kein Logo oder unbekannt. */ + /** Die Zeile des gewählten Logos, sobald bekannt; null = kein Logo oder unbekannt. */ selected(): CommunityLogo | null; /** Gibt die Object-URLs der Kacheln frei, schließt einen offenen Upload-Dialog und beendet das Abonnement der Anmeldung. */ dispose(): void; @@ -82,14 +82,15 @@ function defaultDeps(): LogoPickerDeps { requireLogin: () => requireLogin('login'), onAuthChange, uploadDialog: (signal) => logoUploadDialog({ signal }), + reportLogo, + deleteLogo, + setLogoVisibility, + listLogoReports, + confirm: (opts, signal) => confirmDialog(opts, signal), + reportDialog, }; } -/** Der Grund eines Fehlers zum Anzeigen: Meldungen des Dienstes über ihren Schlüssel, alles andere über `errorMessage`. */ -function failureText(err: unknown): string { - return err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load'); -} - export function createLogoPicker(handlers: { onSelect(id: string | null): void }, deps: LogoPickerDeps = defaultDeps()): LogoPicker { const abort = new AbortController(); let disposed = false; @@ -115,6 +116,8 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } /** Das gewählte Logo, wenn es nicht in der Liste steht: zweite Kachel direkt nach „Kein Logo“. */ let pinnedId: string | null = null; let resolvingId: string | null = null; + /** Das gewählte Logo, nach dem `getLogo` nichts fand: es gibt es nicht (mehr). */ + let goneId: string | null = null; // Herzen /** Die Logos, die ich mit einem Herz markiert habe. */ @@ -130,6 +133,24 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } // ---- Aufbau --------------------------------------------------------------------------------------------------------------- + /** Die Zeile des gewählten Logos, sobald bekannt. */ + const selectedRow = (): CommunityLogo | null => (selectedId === null ? null : (known.get(selectedId) ?? null)); + + const info = createLogoInfo( + { + selectedId: () => selectedId, + selected: selectedRow, + gone: () => selectedId !== null && goneId === selectedId, + patchRow(id, patch) { + const row = known.get(id); + if (row) setRow({ ...row, ...patch }); + }, + removeLogo, + }, + deps, + abort.signal, + ); + const uploadButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-upload' }, icon('plus'), t('Hochladen')); const sortBar = el('div', { class: 'logo-sort', role: 'group', 'aria-label': t('Sortierung') }); const sortButtons = new Map(); @@ -142,7 +163,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } const noneCell = el('li', { class: 'logo-cell' }, noneButton); const grid = el('ul', { class: 'logo-grid', 'aria-labelledby': 'logo-picker-heading' }, noneCell); const emptyEl = el('p', { class: 'logo-empty' }); - // Immer da (leer nimmt sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt. + // Ladefehler der Liste. Immer da (leer nimmt sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt. const statusEl = el('p', { class: 'auth-msg auth-msg-error logo-status', role: 'alert' }); const retryButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-retry', hidden: true }, t('Nochmal versuchen')); const moreButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-more', hidden: true }, t('Mehr laden')); @@ -163,6 +184,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } statusEl, retryButton, moreButton, + info.el, ); // ---- Kacheln -------------------------------------------------------------------------------------------------------------- @@ -176,13 +198,15 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } tile.count.textContent = String(logo.likeCount); } - /** Merkt die jüngste Zeile eines Logos und frischt seine Kachel auf. */ + /** Merkt die jüngste Zeile eines Logos und frischt seine Kachel (und, wenn es gewählt ist, die Infozeile) auf. */ function setRow(row: CommunityLogo): void { known.set(row.id, row); const tile = tiles.get(row.id); - if (!tile) return; - tile.logo = row; - renderTile(tile); + if (tile) { + tile.logo = row; + renderTile(tile); + } + if (row.id === selectedId) info.update(); } function createTile(logo: CommunityLogo): Tile { @@ -214,6 +238,26 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } tiles.delete(tile.logo.id); } + /** Das Logo ist gelöscht: Kachel und Zeile fallen weg; war es gewählt, hebt der Editor die Auswahl auf (`onSelect(null)`). */ + function removeLogo(id: string): void { + const tile = tiles.get(id); + if (tile) dropTile(tile); + known.delete(id); + order = order.filter((other) => other !== id); + // „Mehr laden“ setzt hinter dem letzten geladenen Logo an (Cursor); die Abfrage braucht ein Logo, das es noch gibt. + if (nextCursor === id) nextCursor = order[order.length - 1] ?? null; + hearts.delete(id); // eine später vergebene gleiche ID erbt das Herz nicht + if (pinnedId === id) pinnedId = null; + renderState(); // aus der letzten Kachel wird der Leer-Text + if (selectedId === id) { + // Der Fokus lag auf „Löschen“, das mit dem Logo verschwindet: er geht auf „Kein Logo“, die neue Auswahl. + const focused = info.el.contains(document.activeElement); + handlers.onSelect(null); + if (focused) noneButton.focus(); + } + info.update(); + } + /** Das gewählte Logo steht direkt nach „Kein Logo“, solange es nicht in der geladenen Liste ist (und seine Zeile bekannt ist). */ function syncPin(): void { const row = selectedId !== null && !order.includes(selectedId) ? known.get(selectedId) : undefined; @@ -340,25 +384,36 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } if (id === null || known.has(id) || resolvingId === id) return; resolvingId = id; let row: CommunityLogo | null = null; + let answered = false; try { row = await deps.getLogo(id); + answered = true; } catch { - // bleibt ohne Kachel + // bleibt ohne Kachel; ein Netzfehler heißt nicht, dass es das Logo nicht mehr gibt } finally { if (resolvingId === id) resolvingId = null; } - if (disposed || row === null || selectedId !== id) return; // inzwischen eine andere Auswahl + if (disposed || selectedId !== id) return; // inzwischen eine andere Auswahl + if (row === null) { + if (answered) { + goneId = id; + info.update(); + } + return; + } setRow(row); syncPin(); void loadHearts([id]); } function select(id: string | null): void { - if (id === selectedId) return; // der Editor ruft das bei jeder Änderung seines Zustands auf + if (disposed || id === selectedId) return; // der Editor ruft das bei jeder Änderung seines Zustands auf selectedId = id; + goneId = null; noneButton.setAttribute('aria-pressed', String(id === null)); for (const tile of tiles.values()) renderTile(tile); syncPin(); + info.update(); // Erst wenn die Liste da ist: steht das Logo darin, braucht es weder eine Anfrage noch einen angehefteten Eintrag. if (loaded || failed !== null) void resolveSelection(); } @@ -403,11 +458,18 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } async function toggleHeart(id: string): Promise { if (pending.has(id)) return; // eine Anfrage für dieses Herz läuft: weitere Klicks bleiben ohne Wirkung pending.add(id); - statusEl.textContent = ''; + info.setMessage(''); try { let on = !hearts.has(id); if (deps.userId() === null) { - if (!(await deps.requireLogin()) || disposed) return; // Anmeldung abgebrochen: es passiert nichts + let loggedIn: boolean; + try { + loggedIn = await deps.requireLogin(); + } catch (err) { + if (!disposed) info.setMessage(failureText(err)); // der Login-Dialog ließ sich nicht öffnen + return; + } + if (!loggedIn || disposed) return; // Anmeldung abgebrochen: es passiert nichts on = true; // nach der Anmeldung wird das Herz gesetzt } const row = known.get(id); @@ -420,7 +482,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } } catch (err) { if (disposed) return; showHeart(id, before.on, before.likeCount); - statusEl.textContent = failureText(err); + info.setMessage(failureText(err)); } } finally { pending.delete(id); @@ -432,7 +494,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } async function upload(): Promise { if (uploading) return; uploading = true; - statusEl.textContent = ''; + info.setMessage(''); try { if (deps.userId() === null && !(await deps.requireLogin())) return; // Gast: zuerst anmelden; abgebrochen: es passiert nichts if (disposed) return; @@ -442,7 +504,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } addFirst(logo); handlers.onSelect(logo.id); } catch (err) { - if (!disposed) statusEl.textContent = t('Hochladen fehlgeschlagen – {reason}', { reason: failureText(err) }); + if (!disposed) info.setMessage(t('Hochladen fehlgeschlagen – {reason}', { reason: failureText(err) })); } finally { uploading = false; } @@ -462,6 +524,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } void load('reset'); } renderSortBar(); + info.update(); // Hochlader und Admins sehen mehr Knöpfe if (deps.userId() === null) { hearts.clear(); for (const tile of tiles.values()) renderTile(tile); @@ -477,7 +540,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void } return { el: root, select, - selected: () => (selectedId === null ? null : (known.get(selectedId) ?? null)), + selected: selectedRow, dispose() { if (disposed) return; disposed = true; diff --git a/web/src/logos/logos.css b/web/src/logos/logos.css index 641e82b..a626876 100644 --- a/web/src/logos/logos.css +++ b/web/src/logos/logos.css @@ -1,6 +1,6 @@ /* Community-Logos. Dialog „Logo hochladen“: Hülle, Felder, Pflicht-Häkchen, Meldungen und Knöpfe kommen aus community.css (Melden-/Veröffentlichen-Dialog) und account.css; hier steht nur, was neu ist: das Datei-Feld und die Vorschau. - Weiter unten der Bereich „Logo unten rechts“ im Editor (Raster, Sortierung, Herzen); seine Kacheln beruhen auf `.tile` aus editor.css. */ + Weiter unten der Bereich „Logo unten rechts“ im Editor (Raster, Sortierung, Herzen, Infozeile); seine Kacheln beruhen auf `.tile` aus editor.css. */ /* Etwas breiter als der Veröffentlichen-Dialog (480 px): zwei Vorschauen nebeneinander. Mit zwei Klassen, damit die Breite nicht von der Reihenfolge der Stylesheets abhängt. */ @@ -228,11 +228,13 @@ min-width: 0; } +/* `height: 100%` füllt die Zelle (das Raster streckt sie auf die Höhe der Reihe): „Kein Logo“ ist so hoch wie sein Nachbar. */ .logo-tile { display: flex; flex-direction: column; gap: 2px; width: 100%; + height: 100%; min-width: 0; padding: 8px; } @@ -293,13 +295,20 @@ line-height: 1.3; } -/* Herz: ein eigener Knopf neben dem Auswahlknopf, über der Bildfläche (also immer auf dunklem Grund). Gedrückt: gefülltes Herz. */ +/* Name und „von …“ lassen rechts Platz für das Herz (nur Logo-Kacheln: „Kein Logo“ hat keins). */ +.logo-tile:not(.logo-tile-none) .logo-tile-name, +.logo-tile:not(.logo-tile-none) .logo-tile-by { + padding-right: 64px; +} + +/* Herz: ein eigener Knopf neben dem Auswahlknopf, am Ende der Textzeile unter der Bildfläche (liegt also nicht auf dem Logo, sondern + auf dem Grund der Kachel und folgt dem Theme), mittig neben Name und „von …“. Gedrückt: gefülltes Herz. */ .logo-heart { appearance: none; position: absolute; - top: 14px; - right: 14px; + right: 8px; + bottom: 11px; display: inline-flex; align-items: center; justify-content: center; @@ -308,24 +317,26 @@ min-height: 32px; margin: 0; padding: 0 9px; - border: 0; + border: 1px solid var(--line); border-radius: 999px; - background: rgb(15 24 40 / 0.78); - color: #e8eef7; + background: transparent; + color: var(--muted); font: inherit; font-size: 0.8rem; font-weight: 700; font-variant-numeric: tabular-nums; cursor: pointer; - transition: background-color 0.12s; + transition: background-color 0.12s, border-color 0.12s; } .logo-heart:hover { - background: rgb(15 24 40 / 0.95); + border-color: var(--line-strong); + background: var(--panel-2); } .logo-heart[aria-pressed='true'] { - color: #ff7a8a; + border-color: var(--danger-text); + color: var(--danger-text); } .logo-heart[aria-pressed='true'] .icon { @@ -355,6 +366,96 @@ align-self: center; } +/* Infozeile zum gewählten Logo (unter dem Raster): Karte mit Name, Urheber und Herzen, Knöpfen und, für Admins, den Meldungen. + Ohne Auswahl ist die Karte verborgen; die beiden Meldezeilen (Hinweis, Fehler) sind immer da. */ + +.logo-info { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* `hidden` gilt auch dort, wo ein Stil `display` setzt (und außerhalb des Editors, der `.editor [hidden]` mitbringt). */ +.logo-info [hidden] { + display: none !important; +} + +.logo-info-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); +} + +.logo-info-text { + margin: 0; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.logo-info-name { + color: var(--text); + font-size: 0.95rem; + font-weight: 700; +} + +.logo-info-hearts { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.logo-info-hidden { + align-self: flex-start; +} + +.logo-info-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.logo-info-admin { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 10px; + border-top: 1px solid var(--line); +} + +.logo-info-reports { + margin: 0; + font-size: 0.9rem; + font-weight: 700; +} + +.logo-info-reasons { + margin: 0; + padding-left: 20px; + color: var(--muted); + font-size: 0.9rem; +} + +.logo-info-reasons:empty, +.logo-info-note:empty { + display: none; +} + +.logo-info-note { + margin: 0; + color: var(--muted); + font-size: 0.85rem; +} + +.logo-info-gone { + padding-left: 4px; + color: var(--muted); +} + @media (prefers-reduced-motion: reduce) { .logo-sort-btn, .logo-heart {