From 4cf9fe192db859dab15105ac42152094a869a6e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:40:29 +0000 Subject: [PATCH] feat(editor): Community-Logos unten rechts mit Herzen und Sortierung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 8: der Bereich „Logo unten rechts“ in der Optionsspalte des Editors (Spec § 3 und § 5). - logos/logo-picker.ts (neu): createLogoPicker baut Kopf (Überschrift, Plakette „Community“, „+ Hochladen“, Satz zur Community), den Umschalter „Beliebt“/„Neu“ (Admins zusätzlich „Gemeldet“/„Ausgeblendet“, je eine Ansicht mit sort und filter) und das Raster: erste Kachel „Kein Logo“, je Logo eine Kachel mit Bild, Name, „von “ bzw. „von Anonym“ und einem eigenen Herz-Knopf neben dem Auswahlknopf. „Mehr laden“ hängt die nächste Seite an (Duplikate nur einmal), Leer-Text und Ladefehler mit „Nochmal versuchen“ (wiederholt genau den gescheiterten Schritt); nur die jüngste Antwort einer Ansicht zählt. Die Auswahl gehört dem Editor: ein Klick meldet onSelect, markiert wird über select(); ein Logo außerhalb der Liste wird nach dem Laden der ersten Seite per getLogo geholt und als zweite Kachel angeheftet (steht es in der Liste, gibt es keine Anfrage). selected() liefert die Zeile für die Infozeile in Task 9. Herzen: optimistisch mit Rücksprung und Fehlertext (role="alert"); solange eine Anfrage für ein Logo läuft, bleiben weitere Klicks auf sein Herz wirkungslos; Gäste öffnen erst den Login (abgebrochen: nichts passiert, kein Fehlertext); die Herzen einer Seite kommen mit genau deren IDs, beim Anmelden für alle gezeigten Logos, beim Abmelden aus; eine späte Antwort überschreibt kein inzwischen selbst geschaltetes Herz. „Hochladen“ meldet Gäste zuerst an, öffnet den Dialog (mit Signal, das beim Verlassen der Seite schließt), setzt das neue Logo als erste Kachel, ruft rememberLogo und onSelect(id). - logos/logo-image.ts (neu): das PNG einer Kachel laden, auf die Pixelgrenze prüfen (MAX_LOGO_PIXELS, so wie checkGalleryImage die Galerie-Bilder prüft; darüber, nicht lesbar oder Ladefehler: neutraler Platzhalter statt Absturz) und zeigen: mehrfarbig als , einfarbig als weiße Maske, immer auf dem dunklen Kartengrund. Die Object-URL gibt die zurückgegebene Funktion frei; eine Datei, die erst danach eintrifft, wird nicht mehr angezeigt. - logos/logo-asset.ts: MAX_LOGO_PIXELS wird exportiert (eine Quelle für Renderer und Kacheln). - logos/logos.css: Kopf, Umschalter, Raster (ab 1024 px zwei Spalten, sonst auto-fill ab 140 px), Kacheln mit Bildfläche auf #1B2738, Herz (gefüllt, wenn gedrückt), Leer- und Fehlerzeilen; Bild und Maske teilen sich die Regeln mit der Dialog-Vorschau. - editor/editor-page.ts: der Picker sitzt in options.logoSlot, die Wahl setzt config.logo, sync ruft picker.select(config.logo), das Verlassen der Seite ruft dispose. Übernommen aus dem Review von Task 5: failedKey blockiert ein Logo nicht mehr für immer, eine andere Wahl (Logo, Details, Marke) hebt die Sperre auf; derselbe Schlüssel wird weiter nicht im Kreis neu versucht. - editor/dom.ts: Symbole heart und plus. i18n/en.ts: englische Texte zu allen neuen Beschriftungen. - Tests: logo-picker.test.ts (Aufbau, Sortierung, Mehr laden, Auswahl und angeheftetes Logo, Herzen mit Rennen und Login, Leer und Ladefehler, Hochladen, Kachelbilder, dispose, Englisch), logo-image.test.ts, editor-page.test.ts (Verdrahtung, Regressionstest zu failedKey). Vitest 51 Dateien, 1666 Tests grün, tsc und Build sauber. Co-Authored-By: Claude Opus 5.5 --- web/src/editor/dom.ts | 2 + web/src/editor/editor-page.test.ts | 103 ++- web/src/editor/editor-page.ts | 10 +- web/src/i18n/en.ts | 17 + web/src/logos/logo-asset.ts | 5 +- web/src/logos/logo-image.test.ts | 224 ++++++ web/src/logos/logo-image.ts | 70 ++ web/src/logos/logo-picker.test.ts | 1112 ++++++++++++++++++++++++++++ web/src/logos/logo-picker.ts | 489 ++++++++++++ web/src/logos/logos.css | 257 ++++++- 10 files changed, 2279 insertions(+), 10 deletions(-) create mode 100644 web/src/logos/logo-image.test.ts create mode 100644 web/src/logos/logo-image.ts create mode 100644 web/src/logos/logo-picker.test.ts create mode 100644 web/src/logos/logo-picker.ts diff --git a/web/src/editor/dom.ts b/web/src/editor/dom.ts index 0109732..d7a774f 100644 --- a/web/src/editor/dom.ts +++ b/web/src/editor/dom.ts @@ -36,6 +36,8 @@ const ICONS = { '' + '', globe: '', + heart: '', + plus: '', } as const; export type IconName = keyof typeof ICONS; diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts index 5160284..02b5361 100644 --- a/web/src/editor/editor-page.test.ts +++ b/web/src/editor/editor-page.test.ts @@ -1,5 +1,5 @@ // Speichern und Öffnen im Editor (Karten-Dienst, Anmeldung und Renderer sind ersetzt). -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import type { User } from '../account/auth'; import type { SavedCard } from '../account/cards'; import { DEFAULT_CONFIG } from '../card/config'; @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ saveBlobAs: vi.fn(), getGalleryCard: vi.fn(), loadGalleryImage: vi.fn(), + createLogoPicker: vi.fn(), logoutListeners: new Set<() => void>(), })); @@ -51,6 +52,8 @@ vi.mock('../community/gallery', () => ({ getGalleryCard: mocks.getGalleryCard, loadGalleryImage: mocks.loadGalleryImage, })); +// Der Bereich „Logo unten rechts“ hat seine eigenen Tests (logos/logo-picker.test.ts); hier zählt nur die Verdrahtung. +vi.mock('../logos/logo-picker', () => ({ createLogoPicker: mocks.createLogoPicker })); vi.mock('./upload', async (importOriginal) => ({ ...(await importOriginal()), decodeImage: mocks.decodeImage, @@ -75,6 +78,9 @@ const savedCard = (patch: Partial = {}): SavedCard => ({ let root: HTMLElement; let store: EditorStore; let unmount: (() => void) | null; +/** Der Attrappen-Picker und die Funktionen, die der Editor ihm mitgegeben hat. */ +let picker: { el: HTMLElement; select: Mock; selected: Mock; dispose: Mock }; +let pickerHandlers: { onSelect(id: string | null): void }; beforeEach(() => { for (const fn of Object.values(mocks)) if (typeof fn === 'function') fn.mockReset(); @@ -89,6 +95,11 @@ beforeEach(() => { mocks.requireLogin.mockResolvedValue(true); mocks.loadCardAssets.mockResolvedValue({ image: null, logo: null, brand: {}, logoMissing: false }); mocks.exportCard.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' })); + picker = { el: document.createElement('div'), select: vi.fn(), selected: vi.fn(() => null), dispose: vi.fn() }; + mocks.createLogoPicker.mockImplementation((handlers: typeof pickerHandlers) => { + pickerHandlers = handlers; + return picker; + }); unmount = null; }); @@ -1113,6 +1124,28 @@ describe('Community-Logo auf der Karte', () => { expect(mocks.loadCardAssets).toHaveBeenCalledTimes(attempts); }); + it('ein einmal gescheiterter Ladevorgang blockiert das Logo nicht für immer: A scheitert, B lädt, A wird erneut gewählt → A wird neu geladen und gezeichnet', async () => { + let attemptsForA = 0; + mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => { + if (config.logo === 'A' && ++attemptsForA === 1) throw new TypeError('Failed to fetch'); + return FOUND; + }); + store.set({ config: withLogo('A') }); + mount(); + await vi.waitFor(() => expect(errorText()).toBe('Die Logos konnten nicht geladen werden.')); + expect(mocks.drawCard).not.toHaveBeenCalled(); + + store.set({ config: withLogo('B') }); + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); + expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'B')).toBe(true); + + mocks.drawCard.mockClear(); + store.set({ config: withLogo('A') }); + await vi.waitFor(() => expect(attemptsForA).toBe(2)); + await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); + expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'A')).toBe(true); + }); + 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' } })); @@ -1203,6 +1236,74 @@ describe('Community-Logo auf der Karte', () => { }); }); +describe('Logo-Auswahl („Logo unten rechts“)', () => { + it('der Picker wird einmal erzeugt und sitzt im Platz oben in der Optionsspalte (logoSlot)', () => { + mount(); + expect(mocks.createLogoPicker).toHaveBeenCalledTimes(1); + const slot = root.querySelector('.editor-logo-slot')!; + expect(slot.contains(picker.el)).toBe(true); + expect(slot.parentElement!.classList.contains('editor-options')).toBe(true); + }); + + it('eine Wahl im Picker setzt config.logo, „Kein Logo“ setzt es zurück; der Rest der Konfiguration bleibt', () => { + store.set({ config: { ...store.get().config, template: 'classic', details: 'dark' } }); + mount(); + pickerHandlers.onSelect('abc123'); + expect(store.get().config).toMatchObject({ logo: 'abc123', template: 'classic', details: 'dark' }); + pickerHandlers.onSelect(null); + expect(store.get().config).toMatchObject({ logo: null, template: 'classic', details: 'dark' }); + }); + + it('die Wahl lädt die Karten-Bilder mit dem neuen Logo (der Editor zeichnet sie neu)', async () => { + mount(); + await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1)); + pickerHandlers.onSelect('abc123'); + await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2)); + expect(mocks.loadCardAssets.mock.calls[1][0]).toMatchObject({ logo: 'abc123' }); + }); + + it('sync: der Picker bekommt config.logo beim Aufbau und bei jeder Änderung', () => { + store.set({ config: { ...store.get().config, logo: 'abc123' } }); + mount(); + expect(picker.select).toHaveBeenLastCalledWith('abc123'); + store.set({ config: { ...store.get().config, logo: 'def456' } }); + expect(picker.select).toHaveBeenLastCalledWith('def456'); + store.set({ config: { ...store.get().config, logo: null } }); + expect(picker.select).toHaveBeenLastCalledWith(null); + }); + + it('eine geöffnete gespeicherte Karte: der Picker bekommt ihr Logo', async () => { + history.replaceState(null, '', '/gestalten?karte=k1'); + mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), logo: 'gespeichert' } })); + mocks.loadImage.mockResolvedValue(png()); + mocks.decodeImage.mockResolvedValue(image()); + mount(); + await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); + expect(picker.select).toHaveBeenLastCalledWith('gespeichert'); + }); + + it('„Neue Karte“ setzt den Editor zurück: der Picker bekommt wieder null', () => { + store.set({ + config: { ...store.get().config, logo: 'abc123' }, + saved: { id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' }, + savedConfig: { ...store.get().config, logo: 'abc123' }, + cardName: 'Urlaub', + }); + mount(); + expect(picker.select).toHaveBeenLastCalledWith('abc123'); + root.querySelector('.bound-row button')!.click(); + expect(picker.select).toHaveBeenLastCalledWith(null); + }); + + it('die Seite zu verlassen räumt den Picker genau einmal auf (dispose)', () => { + mount(); + expect(picker.dispose).not.toHaveBeenCalled(); + unmount!(); + unmount = null; + expect(picker.dispose).toHaveBeenCalledTimes(1); + }); +}); + describe('Vorlage übernehmen (/gestalten?vorlage=)', () => { const openTemplate = (id: string) => history.replaceState(null, '', `/gestalten?vorlage=${id}`); const galleryCard = (patch: Record = {}) => ({ diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts index c9ab8d6..524e532 100644 --- a/web/src/editor/editor-page.ts +++ b/web/src/editor/editor-page.ts @@ -12,6 +12,7 @@ import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } import { getGalleryCard, loadGalleryImage } from '../community/gallery'; import { t } from '../i18n/i18n'; import { LOGO_MISSING } from '../logos/logo-asset'; +import { createLogoPicker } from '../logos/logo-picker'; import { dropCardParam, dropTemplateParam } from './card-url'; import { confirmDialog } from './confirm-dialog'; import { downloadFileName, pngSizeWarning, saveBlobAs } from './download'; @@ -71,6 +72,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { onDetails: (details: DetailsTone) => patchConfig({ details }), onBrand: (brand: BrandId) => patchConfig({ brand }), }); + // „Logo unten rechts“: die Wahl setzt config.logo, markiert wird sie über sync (picker.select). + const logoPicker = createLogoPicker({ onSelect: (logo) => patchConfig({ logo }) }); + options.logoSlot.append(logoPicker.el); let exporting = false; @@ -388,7 +392,8 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { // Logos und Schrift: werden nur bei Wechsel von Community-Logo, Details oder Marke geladen (Logos sind zwischengespeichert). // Fehlt das gewählte Community-Logo, wird die Karte ohne es gezeichnet und der Hinweis gezeigt, solange das so bleibt; ein echter - // Ladefehler (Netz, Server) zeigt dagegen LOGO_ERROR, und derselbe Schlüssel wird nicht noch einmal versucht. + // Ladefehler (Netz, Server) zeigt dagegen LOGO_ERROR, und derselbe Schlüssel wird nicht im Kreis neu versucht – erst eine andere + // Wahl (Logo, Details, Marke) hebt die Sperre auf, damit sich ein Logo nach einem Netzfehler wieder wählen lässt. let logos: (Logos & { key: string }) | null = null; let pendingKey = ''; let failedKey = ''; @@ -396,6 +401,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { function ensureLogos(config: CardConfig): void { const key = logoKey(config); if (logos?.key === key || pendingKey === key || failedKey === key) return; + failedKey = ''; pendingKey = key; const token = ++logoToken; loadCardAssets(config, null).then( @@ -442,6 +448,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { function sync(s: EditorState): void { templates.select(s.config.template); options.select(s.config); + logoPicker.select(s.config.logo); preview.sync({ hasImage: s.image !== null, zoom: s.config.crop.zoom, saving: s.saving, saved: savedFlash, boundName: s.saved?.name ?? null, exporting }); } @@ -481,6 +488,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { clearTimeout(flashTimer); offLogout(); unsubscribe(); + logoPicker.dispose(); if (frame) cancelAnimationFrame(frame); frame = 0; }; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index c83e140..52c5056 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -74,6 +74,23 @@ export const EN: Readonly> = { 'Der Anzeigename ist zu lang (höchstens {max} Zeichen).': 'The display name is too long (at most {max} characters).', 'Bitte bestätige, dass du das Logo teilen darfst.': 'Please confirm that you’re allowed to share the logo.', + // Bereich „Logo unten rechts“ im Editor (logos/logo-picker.ts). „Community“, „Hochladen“ und „Anonym“ stehen weiter oben. + 'Logo unten rechts': 'Bottom-right logo', + 'Logos werden von der Community hochgeladen.': 'Logos are uploaded by the community.', + 'Sortierung': 'Sort order', + 'Beliebt': 'Popular', + 'Neu': 'New', + 'Gemeldet': 'Reported', + 'Ausgeblendet': 'Hidden', + 'Kein Logo': 'No logo', + 'von {name}': 'by {name}', + 'Herz für „{name}“: {count}': 'Heart for “{name}”: {count}', + 'Noch keine Community-Logos – lade das erste hoch.': 'No community logos yet – upload the first one.', + 'Keine Logos in dieser Ansicht.': 'No logos in this view.', + 'Die Logos konnten nicht geladen werden.': 'The logos couldn’t be loaded.', + 'Nochmal versuchen': 'Try again', + 'Mehr laden': 'Load more', + // 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-asset.ts b/web/src/logos/logo-asset.ts index 5bc03aa..2d9b8ed 100644 --- a/web/src/logos/logo-asset.ts +++ b/web/src/logos/logo-asset.ts @@ -12,9 +12,10 @@ export const LOGO_MISSING = msg('Das gewählte Logo ist nicht mehr verfügbar.') /** * Obergrenze für Breite × Höhe eines geladenen Logos. Der Server nimmt höchstens 720 × 250 an (rund 180 000 Pixel); alles - * Größere kann nur eine veränderte Datei sein. Es gilt als nicht verfügbar und wird weder in einen Canvas noch auf die Karte gezeichnet. + * Größere kann nur eine veränderte Datei sein. Es gilt als nicht verfügbar und wird weder in einen Canvas noch auf die Karte gezeichnet + * (und auch nicht als Kachel im Logo-Raster, siehe logo-picker.ts). */ -const MAX_LOGO_PIXELS = 1_000_000; +export const MAX_LOGO_PIXELS = 1_000_000; type Decoded = LogoAsset; diff --git a/web/src/logos/logo-image.test.ts b/web/src/logos/logo-image.test.ts new file mode 100644 index 0000000..b3e4256 --- /dev/null +++ b/web/src/logos/logo-image.test.ts @@ -0,0 +1,224 @@ +// Kachelbild eines Community-Logos: laden, Pixelgrenze, Platzhalter und Object-URL. Datei und `Image` sind ersetzt. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// `logo-asset` (Quelle der Pixelgrenze) hängt an Appwrite und Anmeldung. +vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); +vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} })); + +import { MAX_LOGO_PIXELS } from './logo-asset'; +import { showLogoImage } from './logo-image'; +import type { CommunityLogo } from './logos'; + +const logo = (patch: Partial = {}): CommunityLogo => ({ + id: 'l1', + name: 'Beispielbank', + displayName: 'Kenso', + fileId: 'l1', + ownerId: 'u1', + mono: false, + likeCount: 0, + hidden: false, + reportCount: 0, + createdAt: '2026-10-04T12:00:00.000Z', + ...patch, +}); + +const flush = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); +}; + +/** Was das ersetzte `Image` meldet. Mit `hold` bleibt die Prüfung offen, bis der Test `held` auslöst. */ +let image: { width: number; height: number; fails: boolean; hold: boolean }; +let held: Array<() => void>; +let created: string[]; +let revoked: string[]; + +class FakeImage { + naturalWidth = 0; + naturalHeight = 0; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + set src(_value: string) { + const settle = () => { + if (image.fails) { + this.onerror?.(); + return; + } + this.naturalWidth = image.width; + this.naturalHeight = image.height; + this.onload?.(); + }; + if (image.hold) held.push(settle); + else queueMicrotask(settle); + } +} + +const load = vi.fn<(row: CommunityLogo) => Promise>(); +let art: HTMLElement; + +beforeEach(() => { + image = { width: 200, height: 80, fails: false, hold: false }; + held = []; + created = []; + revoked = []; + load.mockReset().mockResolvedValue(new Blob(['png'])); + URL.createObjectURL = vi.fn(() => { + created.push(`blob:logo-${created.length + 1}`); + return created[created.length - 1]; + }); + URL.revokeObjectURL = vi.fn((url: string) => { + revoked.push(url); + }); + vi.stubGlobal('Image', FakeImage); + art = document.createElement('span'); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const state = () => art.getAttribute('data-state'); + +describe('showLogoImage: Anzeige', () => { + it('lädt die Datei des Logos; erst „loading“, danach „ready“', async () => { + const row = logo(); + showLogoImage(art, row, load); + expect(state()).toBe('loading'); + expect(load).toHaveBeenCalledExactlyOnceWith(row); + await flush(); + expect(state()).toBe('ready'); + }); + + it('mehrfarbig: ein dekoratives (alt leer, nicht ziehbar) auf die Object-URL des PNGs', async () => { + showLogoImage(art, logo(), load); + await flush(); + const img = art.querySelector('img')!; + expect(img.getAttribute('src')).toBe(created[0]); + expect(img.getAttribute('alt')).toBe(''); + expect(img.getAttribute('draggable')).toBe('false'); + expect(art.children).toHaveLength(1); + }); + + it('einfarbig: ein Element mit Maske auf die Object-URL, gefüllt in Weiß, ohne ', async () => { + showLogoImage(art, logo({ mono: true }), load); + await flush(); + expect(art.querySelector('img')).toBeNull(); + const mask = art.querySelector('.logo-tile-mask')!; + expect(mask.style.getPropertyValue('--logo-url')).toBe(`url("${created[0]}")`); + expect(mask.style.getPropertyValue('background-color')).toBe('#FFFFFF'); + expect(mask.getAttribute('aria-hidden')).toBe('true'); + }); + + it('die Object-URL bleibt, solange das Bild steht (der Aufrufer gibt sie über die Rückgabe frei)', async () => { + showLogoImage(art, logo(), load); + await flush(); + expect(created).toHaveLength(1); + expect(revoked).toEqual([]); + }); +}); + +describe('showLogoImage: Pixelgrenze und Platzhalter', () => { + it('Grenze: 1 000 000 Pixel (die Datei ist höchstens 720 × 250 groß)', () => { + expect(MAX_LOGO_PIXELS).toBe(1_000_000); + }); + + it('genau 1 000 000 Pixel gehen durch', async () => { + image = { ...image, width: 1000, height: 1000 }; + showLogoImage(art, logo(), load); + await flush(); + expect(state()).toBe('ready'); + expect(art.querySelector('img')).not.toBeNull(); + }); + + it.each([ + ['1000 × 1001', 1000, 1001], + ['60 000 × 60 000 (Dekompressionsbombe)', 60_000, 60_000], + ['Maße 0 (kein echtes Bild)', 0, 0], + ])('%s: nicht angezeigt (Platzhalter), die URL wird freigegeben', async (_name, width, height) => { + image = { ...image, width, height }; + showLogoImage(art, logo(), load); + await flush(); + expect(state()).toBe('unusable'); + expect(art.children).toHaveLength(0); + expect(revoked).toEqual(created); + expect(created).toHaveLength(1); + }); + + it('auch ein einfarbiges Logo über der Grenze wird nicht angezeigt (keine Maske)', async () => { + image = { ...image, width: 5000, height: 5000 }; + showLogoImage(art, logo({ mono: true }), load); + await flush(); + expect(state()).toBe('unusable'); + expect(art.querySelector('.logo-tile-mask')).toBeNull(); + expect(revoked).toEqual(created); + }); + + it('nicht dekodierbar: Platzhalter, URL freigegeben', async () => { + image = { ...image, fails: true }; + showLogoImage(art, logo(), load); + await flush(); + expect(state()).toBe('unusable'); + expect(revoked).toEqual(created); + }); + + it('die Datei lässt sich nicht laden (404, Netz): Platzhalter, es wird keine URL angelegt, nichts wirft', async () => { + load.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); + showLogoImage(art, logo(), load); + await flush(); + expect(state()).toBe('unusable'); + expect(created).toEqual([]); + load.mockRejectedValue(new TypeError('Failed to fetch')); + showLogoImage(art, logo(), load); + await flush(); + expect(created).toEqual([]); + }); +}); + +describe('showLogoImage: freigeben', () => { + it('die Rückgabe gibt die angezeigte URL genau einmal frei; ein zweiter Aufruf schadet nicht', async () => { + const release = showLogoImage(art, logo(), load); + await flush(); + release(); + expect(revoked).toEqual([created[0]]); + release(); + expect(revoked).toEqual([created[0]]); + }); + + it('freigegeben, bevor die Datei ankommt: es wird nichts angelegt und nichts angezeigt', async () => { + let arrive!: (blob: Blob) => void; + load.mockReturnValue(new Promise((resolve) => (arrive = resolve))); + const release = showLogoImage(art, logo(), load); + release(); + arrive(new Blob(['spät'])); + await flush(); + expect(created).toEqual([]); + expect(art.children).toHaveLength(0); + expect(state()).toBe('loading'); // die Fläche gehört niemandem mehr + }); + + it('freigegeben während der Bildprüfung: nichts angezeigt, die schon angelegte URL wird freigegeben', async () => { + image = { ...image, hold: true }; + const release = showLogoImage(art, logo(), load); + await flush(); + expect(created).toHaveLength(1); // Datei da, Prüfung offen + release(); + expect(revoked).toEqual([]); // noch gehört die URL keinem angezeigten Bild + for (const settle of held) settle(); + await flush(); + expect(revoked).toEqual(created); + expect(art.children).toHaveLength(0); + expect(state()).toBe('loading'); + }); + + it('eine abgelehnte Prüfung nach dem Freigeben macht den Platzhalter nicht mehr sichtbar', async () => { + image = { ...image, hold: true, fails: true }; + const release = showLogoImage(art, logo(), load); + await flush(); + release(); + for (const settle of held) settle(); + await flush(); + expect(state()).toBe('loading'); + expect(revoked).toEqual(created); + }); +}); diff --git a/web/src/logos/logo-image.ts b/web/src/logos/logo-image.ts new file mode 100644 index 0000000..ccc390b --- /dev/null +++ b/web/src/logos/logo-image.ts @@ -0,0 +1,70 @@ +// Das PNG eines Community-Logos als Bild einer Kachel: laden, auf die Pixelgrenze prüfen und auf der Bildfläche zeigen. Die Object-URL +// gehört dem Aufrufer, der sie über die zurückgegebene Funktion freigibt. Fremde Dateien sind ungeprüft: Bilder über der Grenze oder +// nicht lesbare zeigt es nicht (neutraler Platzhalter), es stürzt nie ab. +import { TONE_COLOR } from '../card/config'; +import { el } from '../editor/dom'; +import { MAX_LOGO_PIXELS } from './logo-asset'; +import type { CommunityLogo } from './logos'; + +/** + * Lädt die Object-URL in ein `Image` und lehnt ab, was sich nicht lesen lässt oder mehr als `MAX_LOGO_PIXELS` Pixel hat (so wie + * `checkGalleryImage` die Galerie-Bilder prüft): ein winziges, stark komprimiertes PNG kann sonst Gigabytes an Pixeln enthalten + * und den Tab einfrieren. Die Maße sind nach dem Laden bekannt, das Bild ist aber noch nicht dekodiert. + */ +function checkImage(url: string): Promise { + return new Promise((resolve, reject) => { + const probe = new Image(); + probe.onload = () => { + const { naturalWidth: w, naturalHeight: h } = probe; + if (w > 0 && h > 0 && w * h <= MAX_LOGO_PIXELS) resolve(); + else reject(new Error('Das Logo ist zu groß.')); + }; + probe.onerror = () => reject(new Error('Das Logo konnte nicht gelesen werden.')); + probe.src = url; + }); +} + +/** Einfarbiges Logo: nur die Deckkraft des PNGs zählt (Maske auf dessen Object-URL), gefüllt in Weiß wie auf der dunklen Karte. */ +function maskOf(url: string): HTMLElement { + const mask = el('span', { class: 'logo-tile-mask', 'aria-hidden': 'true' }); + mask.style.setProperty('--logo-url', `url("${url}")`); + mask.style.setProperty('background-color', TONE_COLOR.light); + return mask; +} + +/** + * Zeigt das Logo auf `art` (dekorativ: mehrfarbig als Bild, einfarbig als weiße Maske) und setzt dort `data-state`: `loading`, dann + * `ready` oder `unusable` (Ladefehler, nicht lesbar, zu groß: die Fläche bleibt leer, der Platzhalter kommt aus dem Stylesheet). + * `load` holt die Datei. Die zurückgegebene Funktion gibt die Object-URL frei; eine Datei, die erst danach eintrifft, wird nicht mehr + * angezeigt und ihre URL sofort wieder freigegeben. + */ +export function showLogoImage(art: HTMLElement, logo: CommunityLogo, load: (logo: CommunityLogo) => Promise): () => void { + let shown: string | null = null; + let released = false; + art.setAttribute('data-state', 'loading'); + + void (async () => { + let url: string | null = null; + try { + const blob = await load(logo); + if (released) return; + url = URL.createObjectURL(blob); + await checkImage(url); + if (!released) { + shown = url; + art.replaceChildren(logo.mono ? maskOf(url) : el('img', { class: 'logo-tile-img', src: url, alt: '', draggable: 'false' })); + art.setAttribute('data-state', 'ready'); + return; + } + } catch { + if (!released) art.setAttribute('data-state', 'unusable'); + } + if (url) URL.revokeObjectURL(url); // nicht angezeigt (abgelehnt oder die Kachel ist inzwischen weg) + })(); + + return () => { + released = true; + if (shown) URL.revokeObjectURL(shown); + shown = null; + }; +} diff --git a/web/src/logos/logo-picker.test.ts b/web/src/logos/logo-picker.test.ts new file mode 100644 index 0000000..a81e420 --- /dev/null +++ b/web/src/logos/logo-picker.test.ts @@ -0,0 +1,1112 @@ +// 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). +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ rememberLogo: 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: '' })); +vi.mock('../account/auth', () => ({ + currentUser: () => null, + currentUserId: () => null, + isAdmin: () => false, + onAuthChange: () => () => {}, + onLogout: () => () => {}, +})); +vi.mock('../account/login-dialog', () => ({ requireLogin: vi.fn() })); +vi.mock('./logo-dialog', () => ({ logoUploadDialog: vi.fn() })); +vi.mock('./logo-asset', async (importOriginal) => ({ + ...(await importOriginal()), + rememberLogo: mocks.rememberLogo, +})); + +import { ApiError } from '../community/api'; +import { setLang } from '../i18n/i18n'; +import { createLogoPicker, type LogoPicker, type LogoPickerDeps } from './logo-picker'; +import type { CommunityLogo } from './logos'; + +const logo = (id: string, patch: Partial = {}): CommunityLogo => ({ + id, + name: `Logo ${id.toUpperCase()}`, + displayName: 'Kenso', + fileId: id, + ownerId: 'u9', + mono: false, + likeCount: 0, + hidden: false, + reportCount: 0, + createdAt: '2026-10-04T12:00:00.000Z', + ...patch, +}); + +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 user: string | null; +let admin: boolean; +const authListeners = new Set<() => void>(); +const emitAuth = () => { + for (const fn of [...authListeners]) fn(); +}; + +const fx = { + listLogos: vi.fn(), + getLogo: vi.fn(), + myHearts: vi.fn(), + setHeart: vi.fn(), + loadLogoBlob: vi.fn(), + requireLogin: vi.fn(), + uploadDialog: vi.fn(), + onSelect: vi.fn<(id: string | null) => void>(), +}; + +const deps: LogoPickerDeps = { + listLogos: fx.listLogos, + getLogo: fx.getLogo, + myHearts: fx.myHearts, + setHeart: fx.setHeart, + loadLogoBlob: fx.loadLogoBlob, + userId: () => user, + isAdmin: () => admin, + requireLogin: fx.requireLogin, + onAuthChange(fn) { + authListeners.add(fn); + return () => authListeners.delete(fn); + }, + uploadDialog: fx.uploadDialog, +}; + +// Bildprüfung: `loadLogoBlob` liefert einen Blob, den die Attrappe einem Logo zuordnet; die Object-URL erbt das, und das +// ersetzte `Image` meldet daraus die Maße (oder einen Ladefehler). +const blobOwner = new WeakMap(); +const urlOwner = new Map(); +const sizes = new Map(); +const broken = new Set(); +let created: string[]; +let revoked: string[]; + +class FakeImage { + naturalWidth = 0; + naturalHeight = 0; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + set src(value: string) { + queueMicrotask(() => { + const id = urlOwner.get(value) ?? ''; + if (broken.has(id)) { + this.onerror?.(); + return; + } + [this.naturalWidth, this.naturalHeight] = sizes.get(id) ?? [200, 80]; + this.onload?.(); + }); + } +} + +let host: HTMLElement; +let picker: LogoPicker; + +beforeEach(() => { + for (const fn of [...Object.values(fx), mocks.rememberLogo]) fn.mockReset(); + user = 'u1'; + admin = false; + authListeners.clear(); + sizes.clear(); + broken.clear(); + urlOwner.clear(); + created = []; + revoked = []; + fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c')], nextCursor: null }); + fx.getLogo.mockResolvedValue(null); + fx.myHearts.mockResolvedValue(new Set()); + fx.setHeart.mockImplementation(async (_id, herz) => ({ herz, likeCount: herz ? 1 : 0 })); + fx.loadLogoBlob.mockImplementation(async (row) => { + const blob = new Blob(['png']); + blobOwner.set(blob, row.id); + return blob; + }); + fx.requireLogin.mockResolvedValue(true); + fx.uploadDialog.mockResolvedValue(null); + URL.createObjectURL = vi.fn((blob: Blob) => { + const url = `blob:logo-${created.length + 1}`; + created.push(url); + urlOwner.set(url, blobOwner.get(blob) ?? ''); + return url; + }); + URL.revokeObjectURL = vi.fn((url: string) => { + revoked.push(url); + }); + vi.stubGlobal('Image', FakeImage); + document.body.innerHTML = ''; + host = document.createElement('div'); + document.body.append(host); +}); + +afterEach(() => { + picker?.dispose(); + host.remove(); + vi.unstubAllGlobals(); +}); + +async function mount(): Promise { + picker = createLogoPicker({ onSelect: fx.onSelect }, deps); + host.append(picker.el); + await flush(); + return picker; +} + +// ---- Zugriffe auf das DOM ------------------------------------------------------------------------------------------------- + +const cells = () => [...picker.el.querySelectorAll('li.logo-cell')]; +const cellName = (cell: HTMLElement) => cell.querySelector('.logo-tile-name')?.textContent; +/** Die Namen aller Kacheln in Reihenfolge, die erste ist „Kein Logo“. */ +const names = () => cells().map(cellName); +const cell = (name: string) => cells().find((c) => cellName(c) === name)!; +const tile = (name: string) => cell(name).querySelector('button.logo-tile')!; +const heart = (name: string) => cell(name).querySelector('button.logo-heart')!; +const art = (name: string) => cell(name).querySelector('.logo-tile-art')!; +const pressedTiles = () => cells().filter((c) => c.querySelector('button.logo-tile')?.getAttribute('aria-pressed') === 'true').map(cellName); +const heartsOn = () => cells().filter((c) => c.querySelector('button.logo-heart')?.getAttribute('aria-pressed') === 'true').map(cellName); +const sortButtons = () => [...picker.el.querySelectorAll('.logo-sort button')]; +const sortButton = (label: string) => sortButtons().find((b) => b.textContent === label)!; +const pressedSort = () => sortButtons().filter((b) => b.getAttribute('aria-pressed') === 'true').map((b) => b.textContent); +const alertText = () => picker.el.querySelector('[role="alert"]')!.textContent; +const uploadButton = () => picker.el.querySelector('button.logo-upload')!; +const moreButton = () => picker.el.querySelector('button.logo-more')!; +const retryButton = () => picker.el.querySelector('button.logo-retry')!; +const emptyText = () => picker.el.querySelector('.logo-empty')!.textContent; + +describe('Aufbau', () => { + it('Kopf: Überschrift „Logo unten rechts“, Plakette „Community“, Knopf „Hochladen“ und der Satz zur Community', async () => { + await mount(); + const heading = picker.el.querySelector('h3')!; + expect(heading.textContent).toBe('Logo unten rechts'); + expect(picker.el.getAttribute('aria-labelledby')).toBe(heading.id); + expect(picker.el.querySelector('.logo-badge')!.textContent).toBe('Community'); + expect(uploadButton().type).toBe('button'); + expect(uploadButton().querySelector('svg')).not.toBeNull(); // das Plus + expect(picker.el.querySelector('.logo-picker-note')!.textContent).toBe('Logos werden von der Community hochgeladen.'); + }); + + it('Umschalter „Beliebt“ (gedrückt) und „Neu“; Nicht-Admins sehen „Gemeldet“ und „Ausgeblendet“ nicht', async () => { + await mount(); + expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu']); + expect(pressedSort()).toEqual(['Beliebt']); + }); + + it('erste Kachel „Kein Logo“, dann je Logo eine Kachel mit Name, „von “ bzw. „von Anonym“ und Herz-Knopf mit der Zahl', async () => { + fx.listLogos.mockResolvedValue({ + logos: [logo('a', { likeCount: 12 }), logo('b', { displayName: '' }), logo('c', { displayName: 'Anna', likeCount: 3 })], + nextCursor: null, + }); + await mount(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(cell('Kein Logo').querySelector('button.logo-tile')!.classList.contains('logo-tile-none')).toBe(true); + expect(cell('Kein Logo').querySelector('.logo-heart')).toBeNull(); + expect(['Logo A', 'Logo B', 'Logo C'].map((n) => cell(n).querySelector('.logo-tile-by')!.textContent)).toEqual([ + 'von Kenso', + 'von Anonym', + 'von Anna', + ]); + expect(['Logo A', 'Logo B', 'Logo C'].map((n) => heart(n).textContent)).toEqual(['12', '0', '3']); + }); + + it('der Herz-Knopf ist ein eigener Knopf neben dem Auswahlknopf, nie darin verschachtelt', async () => { + await mount(); + for (const c of cells().slice(1)) { + const select = c.querySelector('button.logo-tile')!; + const like = c.querySelector('button.logo-heart')!; + expect(select.contains(like)).toBe(false); + expect(like.contains(select)).toBe(false); + expect(like.type).toBe('button'); + expect(like.querySelector('svg')).not.toBeNull(); + expect(c.querySelector('button button')).toBeNull(); + } + }); + + it('Herz-Knopf: Zustand in aria-pressed, Beschriftung mit Name und Zahl', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 4 })], nextCursor: null }); + await mount(); + expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false'); + expect(heart('Logo A').getAttribute('aria-label')).toBe('Herz für „Logo A“: 4'); + }); + + it('Name und Anzeigename stehen nur als Text, nie als HTML', async () => { + fx.listLogos.mockResolvedValue({ + logos: [logo('a', { name: '', displayName: 'fett' })], + nextCursor: null, + }); + await mount(); + const c = cells()[1]; + expect(c.querySelector('.logo-tile-name')!.textContent).toBe(''); + expect(c.querySelector('.logo-tile-by')!.textContent).toBe('von fett'); + expect(c.querySelector('img[src="x"]')).toBeNull(); + expect(c.querySelector('b')).toBeNull(); + }); + + it('die erste Seite wird beliebt sortiert und ungefiltert geladen', async () => { + await mount(); + expect(fx.listLogos).toHaveBeenCalledExactlyOnceWith({ sort: 'popular', filter: 'all' }); + }); + + it('Englisch: Überschrift, Umschalter, Kacheln und Knöpfe sind übersetzt', async () => { + setLang('en', { persist: false }); + fx.listLogos.mockResolvedValue({ logos: [logo('a', { displayName: '' })], nextCursor: 'a' }); + await mount(); + expect(picker.el.querySelector('h3')!.textContent).toBe('Bottom-right logo'); + expect(picker.el.querySelector('.logo-picker-note')!.textContent).toBe('Logos are uploaded by the community.'); + expect(uploadButton().textContent).toBe('Upload'); + expect(sortButtons().map((b) => b.textContent)).toEqual(['Popular', 'New']); + expect(names()).toEqual(['No logo', 'Logo A']); + expect(cell('Logo A').querySelector('.logo-tile-by')!.textContent).toBe('by Anonymous'); + expect(heart('Logo A').getAttribute('aria-label')).toBe('Heart for “Logo A”: 0'); + expect(moreButton().textContent).toBe('Load more'); + }); +}); + +describe('Sortierung', () => { + it('„Neu“ lädt mit sort:new neu, der Umschalter folgt, die Kacheln der alten Liste fallen weg', async () => { + await mount(); + fx.listLogos.mockResolvedValue({ logos: [logo('z'), logo('a')], nextCursor: null }); + sortButton('Neu').click(); + expect(pressedSort()).toEqual(['Neu']); + await flush(); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'all' }); + expect(names()).toEqual(['Kein Logo', 'Logo Z', 'Logo A']); + sortButton('Beliebt').click(); + await flush(); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' }); + }); + + it('ein Klick auf die schon gewählte Ansicht lädt nichts neu', async () => { + await mount(); + sortButton('Beliebt').click(); + await flush(); + expect(fx.listLogos).toHaveBeenCalledTimes(1); + }); + + it('Admins sehen zusätzlich „Gemeldet“ und „Ausgeblendet“; sie laden mit filter:reported bzw. filter:hidden', async () => { + admin = true; + await mount(); + expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu', 'Gemeldet', 'Ausgeblendet']); + sortButton('Gemeldet').click(); + await flush(); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'reported' }); + expect(pressedSort()).toEqual(['Gemeldet']); + sortButton('Ausgeblendet').click(); + await flush(); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'new', filter: 'hidden' }); + expect(pressedSort()).toEqual(['Ausgeblendet']); + sortButton('Beliebt').click(); + await flush(); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' }); + }); + + it('eine späte Antwort der früheren Ansicht überschreibt die neuere nicht', async () => { + await mount(); + const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); + fx.listLogos.mockReturnValueOnce(slow.promise); + sortButton('Neu').click(); + fx.listLogos.mockResolvedValueOnce({ logos: [logo('p')], nextCursor: null }); + sortButton('Beliebt').click(); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo P']); + slow.resolve({ logos: [logo('q')], nextCursor: null }); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo P']); + expect(pressedSort()).toEqual(['Beliebt']); + }); + + it('verliert der Nutzer die Admin-Rolle (Abmelden), verschwinden die Admin-Knöpfe und die Liste wird wieder öffentlich geladen', async () => { + admin = true; + await mount(); + sortButton('Gemeldet').click(); + await flush(); + admin = false; + user = null; + emitAuth(); + await flush(); + expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu']); + expect(pressedSort()).toEqual(['Beliebt']); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' }); + }); + + it('wird jemand Admin (Anmeldung), erscheinen die Admin-Knöpfe, ohne dass die Liste neu geladen wird', async () => { + user = null; + await mount(); + admin = true; + user = 'u1'; + emitAuth(); + await flush(); + expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu', 'Gemeldet', 'Ausgeblendet']); + expect(fx.listLogos).toHaveBeenCalledTimes(1); + }); +}); + +describe('Mehr laden', () => { + const page2 = () => ({ logos: [logo('d'), logo('e')], nextCursor: null }); + + it('der Knopf erscheint nur, wenn es einen nextCursor gibt', async () => { + await mount(); + expect(moreButton().hidden).toBe(true); + picker.dispose(); + fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); + await mount(); + expect(moreButton().hidden).toBe(false); + expect(moreButton().textContent).toBe('Mehr laden'); + }); + + it('ein Klick hängt die nächste Seite hinten an (mit Cursor), danach verschwindet der Knopf', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' }); + await mount(); + const first = tile('Logo A'); + fx.listLogos.mockResolvedValue(page2()); + 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', 'Logo E']); + expect(tile('Logo A')).toBe(first); // die vorhandenen Kacheln bleiben + expect(moreButton().hidden).toBe(true); + }); + + it('während des Ladens ist der Knopf gesperrt, ein zweiter Klick löst keine zweite Anfrage aus', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' }); + await mount(); + const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); + fx.listLogos.mockReturnValue(slow.promise); + moreButton().click(); + expect(moreButton().disabled).toBe(true); + moreButton().click(); + moreButton().disabled = false; // selbst wenn der Klick den Knopf erreicht + moreButton().click(); + expect(fx.listLogos).toHaveBeenCalledTimes(2); + slow.resolve(page2()); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D', 'Logo E']); + }); + + it('ein Logo, das auf der nächsten Seite noch einmal vorkommt (die Rangfolge hat sich verschoben), bleibt an seinem Platz und steht nur einmal im Raster', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b'), logo('c')], nextCursor: 'c' }); + await mount(); + fx.listLogos.mockResolvedValue({ logos: [logo('b', { likeCount: 4 }), logo('d')], nextCursor: null }); + moreButton().click(); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C', 'Logo D']); + expect(heart('Logo B').textContent).toBe('4'); // die jüngere Zeile gilt + }); + + it('scheitert das Nachladen: Meldung und „Nochmal versuchen“ holen genau diese Seite erneut, die Liste bleibt', 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.'); + expect(names()).toEqual(['Kein Logo', 'Logo A']); + expect(retryButton().hidden).toBe(false); + expect(moreButton().hidden).toBe(true); + fx.listLogos.mockResolvedValue(page2()); + retryButton().click(); + await flush(); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'a' }); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D', 'Logo E']); + expect(alertText()).toBe(''); + expect(retryButton().hidden).toBe(true); + }); +}); + +describe('Auswahl', () => { + it('ein Klick auf eine Kachel meldet onSelect(id), „Kein Logo“ meldet onSelect(null)', async () => { + await mount(); + tile('Logo B').click(); + expect(fx.onSelect).toHaveBeenLastCalledWith('b'); + tile('Kein Logo').click(); + expect(fx.onSelect).toHaveBeenLastCalledWith(null); + expect(fx.onSelect).toHaveBeenCalledTimes(2); + }); + + it('ein Klick auf das Herz wählt das Logo nicht', async () => { + await mount(); + heart('Logo B').click(); + await flush(); + expect(fx.onSelect).not.toHaveBeenCalled(); + }); + + it('aria-pressed folgt select(): zuerst „Kein Logo“, dann das gewählte Logo, dann wieder keins', async () => { + await mount(); + expect(pressedTiles()).toEqual(['Kein Logo']); + picker.select('b'); + expect(pressedTiles()).toEqual(['Logo B']); + picker.select('c'); + expect(pressedTiles()).toEqual(['Logo C']); + picker.select(null); + expect(pressedTiles()).toEqual(['Kein Logo']); + }); + + it('ein Klick markiert noch nichts: die Auswahl gehört dem Aufrufer (select)', async () => { + await mount(); + tile('Logo B').click(); + expect(pressedTiles()).toEqual(['Kein Logo']); + picker.select('b'); + expect(pressedTiles()).toEqual(['Logo B']); + }); + + it('ein gewähltes Logo der Liste braucht kein getLogo; selected() liefert seine Zeile', async () => { + await mount(); + expect(picker.selected()).toBeNull(); + picker.select('b'); + await flush(); + expect(fx.getLogo).not.toHaveBeenCalled(); + expect(picker.selected()).toEqual(logo('b')); + picker.select(null); + expect(picker.selected()).toBeNull(); + }); + + it('ein Logo außerhalb der Liste wird per getLogo geholt und steht als zweite Kachel direkt nach „Kein Logo“', async () => { + fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo' })); + await mount(); + picker.select('x'); + expect(picker.selected()).toBeNull(); // noch unbekannt + await flush(); + expect(fx.getLogo).toHaveBeenCalledExactlyOnceWith('x'); + expect(names()).toEqual(['Kein Logo', 'Fremdes Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(pressedTiles()).toEqual(['Fremdes Logo']); + expect(picker.selected()).toMatchObject({ id: 'x', name: 'Fremdes Logo' }); + }); + + it('der angeheftete Eintrag hat wie jede Kachel ein Herz; angemeldet wird sein Zustand geladen', async () => { + fx.getLogo.mockResolvedValue(logo('x', { name: 'Fremdes Logo', likeCount: 5 })); + fx.myHearts.mockImplementation(async (_u, ids) => new Set(ids.filter((id) => id === 'x'))); + await mount(); + picker.select('x'); + await flush(); + expect(fx.myHearts).toHaveBeenLastCalledWith('u1', ['x']); + expect(heart('Fremdes Logo').textContent).toBe('5'); + expect(heartsOn()).toEqual(['Fremdes Logo']); + }); + + it('solange die Liste noch lädt, wird nicht nachgefragt: steht das Logo auf der ersten Seite, gibt es kein getLogo und keinen Sprung', 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(fx.getLogo).not.toHaveBeenCalled(); + expect(names()).toEqual(['Kein Logo']); + list.resolve({ logos: [logo('a'), logo('b')], nextCursor: null }); + await flush(); + expect(fx.getLogo).not.toHaveBeenCalled(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B']); + expect(pressedTiles()).toEqual(['Logo B']); + expect(picker.selected()).toEqual(logo('b')); + }); + + it('steht das Logo nicht auf der ersten Seite, wird es nach dem Laden der Liste geholt und angeheftet', async () => { + fx.getLogo.mockResolvedValue(logo('x')); + 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('x'); + await flush(); + expect(fx.getLogo).not.toHaveBeenCalled(); + list.resolve({ logos: [logo('a')], nextCursor: null }); + await flush(); + expect(fx.getLogo).toHaveBeenCalledExactlyOnceWith('x'); + expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo A']); + }); + + it('scheitert das Laden der Liste, wird das gewählte Logo trotzdem geholt und angeheftet', async () => { + fx.getLogo.mockResolvedValue(logo('x')); + fx.listLogos.mockRejectedValue(new Error('offline')); + picker = createLogoPicker({ onSelect: fx.onSelect }, deps); + host.append(picker.el); + picker.select('x'); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo X']); + }); + + it('ein Logo, das es nicht mehr gibt (getLogo → null): keine Kachel, nichts gewählt, selected() ist null, kein Fehler', async () => { + await mount(); + picker.select('weg'); + await flush(); + expect(fx.getLogo).toHaveBeenCalledWith('weg'); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(pressedTiles()).toEqual([]); // auch „Kein Logo“ nicht: die Auswahl bleibt, bis jemand ein anderes wählt + expect(picker.selected()).toBeNull(); + expect(alertText()).toBe(''); + }); + + it('scheitert getLogo (Netz), bleibt die Oberfläche heil: keine Kachel, kein Fehlertext, kein Absturz', async () => { + fx.getLogo.mockRejectedValue(new Error('offline')); + await mount(); + picker.select('x'); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(picker.selected()).toBeNull(); + expect(alertText()).toBe(''); + }); + + it('dieselbe Auswahl noch einmal (der Editor ruft select bei jeder Änderung) fragt nicht erneut nach', async () => { + fx.getLogo.mockResolvedValue(logo('x')); + await mount(); + picker.select('x'); + picker.select('x'); + await flush(); + picker.select('x'); + await flush(); + expect(fx.getLogo).toHaveBeenCalledTimes(1); + expect(names().filter((n) => n === 'Logo X')).toHaveLength(1); + }); + + it('wechselt die Auswahl, fällt der angeheftete Eintrag weg; „Kein Logo“ räumt ihn ebenfalls ab', async () => { + fx.getLogo.mockResolvedValue(logo('x')); + await mount(); + picker.select('x'); + await flush(); + expect(names()).toContain('Logo X'); + picker.select('b'); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + picker.select('x'); // schon bekannt: sofort wieder da, ohne neue Anfrage + expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo A', 'Logo B', 'Logo C']); + expect(fx.getLogo).toHaveBeenCalledTimes(1); + picker.select(null); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + }); + + it('eine späte Antwort von getLogo für eine inzwischen verlassene Auswahl wird verworfen', async () => { + await mount(); + const slow = deferred(); + fx.getLogo.mockReturnValueOnce(slow.promise); + picker.select('x'); + picker.select('b'); + slow.resolve(logo('x')); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(picker.selected()).toEqual(logo('b')); + expect(fx.myHearts).not.toHaveBeenCalledWith('u1', ['x']); // für das verlassene Logo wird nichts weiter angefragt + }); + + it('taucht das angeheftete Logo nach einem Wechsel der Sortierung in der Liste auf, steht es nicht doppelt da', async () => { + fx.getLogo.mockResolvedValue(logo('x')); + await mount(); + picker.select('x'); + await flush(); + fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('x')], nextCursor: null }); + sortButton('Neu').click(); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo X']); + expect(pressedTiles()).toEqual(['Logo X']); + }); + + it('bei einem Wechsel der Sortierung bleibt der angeheftete Eintrag stehen, solange er nicht in der neuen Liste ist', async () => { + fx.getLogo.mockResolvedValue(logo('x')); + await mount(); + picker.select('x'); + await flush(); + const pinned = tile('Logo X'); + fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null }); + sortButton('Neu').click(); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo Z']); + expect(tile('Logo X')).toBe(pinned); + }); +}); + +describe('Herzen', () => { + it('angemeldet: ein Klick schaltet sofort um (aria-pressed, Zahl + 1), ruft setHeart(id, true) und übernimmt likeCount der Antwort', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 2 })], nextCursor: null }); + const answer = deferred<{ herz: boolean; likeCount: number }>(); + fx.setHeart.mockReturnValue(answer.promise); + await mount(); + heart('Logo A').click(); + expect(heart('Logo A').getAttribute('aria-pressed')).toBe('true'); // sofort, vor der Antwort + expect(heart('Logo A').textContent).toBe('3'); + expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', true); + answer.resolve({ herz: true, likeCount: 7 }); // der Dienst hat neu gezählt + await flush(); + expect(heart('Logo A').getAttribute('aria-pressed')).toBe('true'); + expect(heart('Logo A').textContent).toBe('7'); + expect(heart('Logo A').getAttribute('aria-label')).toBe('Herz für „Logo A“: 7'); + expect(alertText()).toBe(''); + }); + + it('ein zweiter Klick nimmt das Herz zurück: setHeart(id, false), Zahl − 1', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 2 })], nextCursor: null }); + fx.myHearts.mockResolvedValue(new Set(['a'])); + await mount(); + expect(heartsOn()).toEqual(['Logo A']); + const answer = deferred<{ herz: boolean; likeCount: number }>(); + fx.setHeart.mockReturnValue(answer.promise); + heart('Logo A').click(); + expect(heart('Logo A').getAttribute('aria-pressed')).toBe('false'); + expect(heart('Logo A').textContent).toBe('1'); + expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', false); + answer.resolve({ herz: false, likeCount: 1 }); + await flush(); + expect(heartsOn()).toEqual([]); + expect(heart('Logo A').textContent).toBe('1'); + }); + + it('die Zahl wird nie negativ', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a', { likeCount: 0 })], nextCursor: null }); + fx.myHearts.mockResolvedValue(new Set(['a'])); // veraltet: Zähler 0, Herz an + fx.setHeart.mockReturnValue(new Promise(() => {})); + await mount(); + heart('Logo A').click(); + expect(heart('Logo A').textContent).toBe('0'); + }); + + it('Fehler: das Herz springt samt Zahl zurück, der Grund steht im Statusbereich (role="alert")', 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(); + heart('Logo A').click(); + 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.'); + }); + + it('Fehler anderer Art: allgemeiner Text; der Fehler verschwindet beim nächsten Versuch, der dann auch wieder möglich ist', async () => { + fx.setHeart.mockRejectedValueOnce(new Error('kaputt')); + await mount(); + heart('Logo A').click(); + await flush(); + expect(alertText()).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(''); + await flush(); + expect(fx.setHeart).toHaveBeenCalledTimes(2); + expect(heartsOn()).toEqual(['Logo A']); + }); + + it('ein zweiter Klick während der laufenden Anfrage löst keinen zweiten Aufruf aus', async () => { + const answer = deferred<{ herz: boolean; likeCount: number }>(); + fx.setHeart.mockReturnValue(answer.promise); + await mount(); + heart('Logo A').click(); + heart('Logo A').click(); + heart('Logo A').click(); + expect(fx.setHeart).toHaveBeenCalledTimes(1); + expect(heartsOn()).toEqual(['Logo A']); // und der Zustand wurde nicht zurückgeschaltet + answer.resolve({ herz: true, likeCount: 1 }); + await flush(); + heart('Logo A').click(); // danach geht es wieder (jetzt: Herz weg) + expect(fx.setHeart).toHaveBeenCalledTimes(2); + expect(fx.setHeart).toHaveBeenLastCalledWith('a', false); + }); + + it('die Sperre gilt je Logo: das Herz eines anderen Logos geht gleichzeitig', async () => { + fx.setHeart.mockReturnValue(new Promise(() => {})); + await mount(); + heart('Logo A').click(); + heart('Logo B').click(); + expect(fx.setHeart.mock.calls).toEqual([ + ['a', true], + ['b', true], + ]); + }); + + it('Gast: ein Klick öffnet den Login (requireLogin); danach wird das Herz gesetzt', async () => { + user = null; + await mount(); + expect(fx.myHearts).not.toHaveBeenCalled(); // Gäste haben keine Herzen zu laden + const login = deferred(); + fx.requireLogin.mockReturnValue(login.promise); + heart('Logo A').click(); + expect(fx.requireLogin).toHaveBeenCalledTimes(1); + expect(fx.setHeart).not.toHaveBeenCalled(); + expect(heartsOn()).toEqual([]); + user = 'u1'; + login.resolve(true); + await flush(); + expect(fx.setHeart).toHaveBeenCalledExactlyOnceWith('a', true); + expect(heartsOn()).toEqual(['Logo A']); + }); + + it('Gast bricht den Login ab: kein setHeart, das Herz bleibt aus, kein Fehlertext, und es geht danach wieder', async () => { + user = null; + fx.requireLogin.mockResolvedValue(false); + await mount(); + heart('Logo A').click(); + await flush(); + expect(fx.setHeart).not.toHaveBeenCalled(); + expect(heartsOn()).toEqual([]); + expect(heart('Logo A').textContent).toBe('0'); + expect(alertText()).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); + }); + + it('Gast: ein zweiter Klick, solange der Login-Dialog noch offen ist, öffnet keinen zweiten', async () => { + user = null; + fx.requireLogin.mockReturnValue(new Promise(() => {})); + await mount(); + heart('Logo A').click(); + heart('Logo A').click(); + expect(fx.requireLogin).toHaveBeenCalledTimes(1); + }); + + it('nach dem Laden einer Seite (angemeldet) kommen die Herzen mit genau den IDs der Seite; sie stehen an den Kacheln', async () => { + fx.myHearts.mockResolvedValue(new Set(['b'])); + await mount(); + expect(fx.myHearts).toHaveBeenCalledExactlyOnceWith('u1', ['a', 'b', 'c']); + expect(heartsOn()).toEqual(['Logo B']); + }); + + it('beim Nachladen werden die Herzen nur für die neue Seite geholt', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' }); + await mount(); + fx.listLogos.mockResolvedValue({ logos: [logo('c'), logo('d')], nextCursor: null }); + fx.myHearts.mockResolvedValue(new Set(['d'])); + moreButton().click(); + await flush(); + expect(fx.myHearts).toHaveBeenLastCalledWith('u1', ['c', 'd']); + expect(heartsOn()).toEqual(['Logo D']); + }); + + it('scheitert das Laden der Herzen, bleibt die Liste da (die Herzen sind dann aus, kein Fehlertext)', async () => { + fx.myHearts.mockRejectedValue(new Error('offline')); + await mount(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(heartsOn()).toEqual([]); + expect(alertText()).toBe(''); + }); + + it('Abmelden: alle Herzen sind aus', async () => { + fx.myHearts.mockResolvedValue(new Set(['a', 'c'])); + await mount(); + expect(heartsOn()).toEqual(['Logo A', 'Logo C']); + user = null; + emitAuth(); + expect(heartsOn()).toEqual([]); + }); + + it('Anmelden: die Herzen aller gezeigten Logos werden geladen', async () => { + user = null; + await mount(); + fx.myHearts.mockResolvedValue(new Set(['b'])); + user = 'u1'; + emitAuth(); + await flush(); + expect(fx.myHearts).toHaveBeenCalledExactlyOnceWith('u1', ['a', 'b', 'c']); + expect(heartsOn()).toEqual(['Logo B']); + }); + + it('eine späte Antwort von myHearts überschreibt kein Herz, das inzwischen geschaltet wurde', async () => { + const hearts = deferred>(); + fx.myHearts.mockReturnValue(hearts.promise); + await mount(); + heart('Logo A').click(); + await flush(); + expect(heartsOn()).toEqual(['Logo A']); + hearts.resolve(new Set(['c'])); // stammt von vor dem Klick: A kennt sie nicht, C schon + await flush(); + expect(heartsOn()).toEqual(['Logo A', 'Logo C']); + }); + + it('Herzen einer Seite für den vorigen Nutzer werden nach einem Kontowechsel nicht übernommen', async () => { + const hearts = deferred>(); + fx.myHearts.mockReturnValue(hearts.promise); + await mount(); + user = null; // abgemeldet, bevor die Antwort kommt + emitAuth(); + hearts.resolve(new Set(['a'])); + await flush(); + expect(heartsOn()).toEqual([]); + }); +}); + +describe('Leer und Ladefehler', () => { + it('leere Liste: „Noch keine Community-Logos – lade das erste hoch.“ (erst nach dem Laden; „Kein Logo“ bleibt)', async () => { + const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>(); + fx.listLogos.mockReturnValue(list.promise); + picker = createLogoPicker({ onSelect: fx.onSelect }, deps); + host.append(picker.el); + await flush(); + expect(emptyText()).toBe(''); + list.resolve({ logos: [], nextCursor: null }); + await flush(); + expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.'); + expect(names()).toEqual(['Kein Logo']); + }); + + it('der Leer-Text steht nicht, wenn es Logos gibt oder das Laden scheiterte', async () => { + await mount(); + expect(emptyText()).toBe(''); + picker.dispose(); + fx.listLogos.mockRejectedValue(new Error('offline')); + await mount(); + expect(emptyText()).toBe(''); + }); + + it('ein leerer Admin-Filter sagt das, statt zum Hochladen aufzufordern', async () => { + admin = true; + await mount(); + fx.listLogos.mockResolvedValue({ logos: [], nextCursor: null }); + sortButton('Gemeldet').click(); + await flush(); + expect(emptyText()).toBe('Keine Logos in dieser Ansicht.'); + sortButton('Beliebt').click(); + await flush(); + expect(emptyText()).toBe('Noch keine Community-Logos – lade das erste hoch.'); + }); + + it('Ladefehler: „Die Logos konnten nicht geladen werden.“ mit „Nochmal versuchen“, das neu lädt', async () => { + fx.listLogos.mockRejectedValueOnce(new Error('offline')); + await mount(); + expect(alertText()).toBe('Die Logos konnten nicht geladen werden.'); + expect(retryButton().hidden).toBe(false); + expect(retryButton().textContent).toBe('Nochmal versuchen'); + expect(names()).toEqual(['Kein Logo']); + retryButton().click(); + await flush(); + expect(fx.listLogos).toHaveBeenCalledTimes(2); + expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all' }); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(alertText()).toBe(''); + expect(retryButton().hidden).toBe(true); + }); + + it('der Statusbereich ist immer da (leer: kein Text), ohne Fehler ist „Nochmal versuchen“ verborgen', async () => { + await mount(); + expect(alertText()).toBe(''); + expect(retryButton().hidden).toBe(true); + }); + + it('Englisch: Leer-Text, Ladefehler und „Nochmal versuchen“', async () => { + setLang('en', { persist: false }); + fx.listLogos.mockResolvedValueOnce({ logos: [], nextCursor: null }); + await mount(); + expect(emptyText()).toBe('No community logos yet – upload the first one.'); + picker.dispose(); + fx.listLogos.mockRejectedValueOnce(new Error('offline')); + await mount(); + expect(alertText()).toBe('The logos couldn’t be loaded.'); + expect(retryButton().textContent).toBe('Try again'); + }); +}); + +describe('Hochladen', () => { + const fresh = () => logo('n', { name: 'Neues Logo', ownerId: 'u1' }); + + it('angemeldet: der Dialog öffnet ohne Login; das Ergebnis steht als erste Logo-Kachel, rememberLogo und onSelect(id) laufen', async () => { + fx.uploadDialog.mockResolvedValue(fresh()); + await mount(); + uploadButton().click(); + await flush(); + expect(fx.requireLogin).not.toHaveBeenCalled(); + expect(fx.uploadDialog).toHaveBeenCalledTimes(1); + expect(names()).toEqual(['Kein Logo', 'Neues Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(mocks.rememberLogo).toHaveBeenCalledExactlyOnceWith(fresh()); + expect(fx.onSelect).toHaveBeenCalledExactlyOnceWith('n'); + }); + + it('das neue Logo ist ein Logo mit Herz-Knopf (Zahl 0), und die Auswahl durch den Aufrufer markiert es ohne getLogo', async () => { + fx.uploadDialog.mockResolvedValue(fresh()); + await mount(); + uploadButton().click(); + await flush(); + expect(heart('Neues Logo').textContent).toBe('0'); + picker.select('n'); + await flush(); + expect(pressedTiles()).toEqual(['Neues Logo']); + expect(fx.getLogo).not.toHaveBeenCalled(); + expect(picker.selected()).toEqual(fresh()); + }); + + it('Gast: zuerst requireLogin, dann der Dialog', async () => { + user = null; + const order: string[] = []; + fx.requireLogin.mockImplementation(async () => { + order.push('login'); + return true; + }); + fx.uploadDialog.mockImplementation(async () => { + order.push('dialog'); + return null; + }); + await mount(); + uploadButton().click(); + await flush(); + expect(order).toEqual(['login', 'dialog']); + }); + + it('Gast bricht den Login ab: kein Dialog, nichts ändert sich, kein Fehlertext', async () => { + user = null; + fx.requireLogin.mockResolvedValue(false); + await mount(); + uploadButton().click(); + await flush(); + expect(fx.uploadDialog).not.toHaveBeenCalled(); + expect(fx.onSelect).not.toHaveBeenCalled(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(alertText()).toBe(''); + }); + + it('Dialog abgebrochen (null): keine Kachel, keine Auswahl, kein rememberLogo; der Knopf geht danach wieder', async () => { + await mount(); + uploadButton().click(); + await flush(); + expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']); + expect(fx.onSelect).not.toHaveBeenCalled(); + expect(mocks.rememberLogo).not.toHaveBeenCalled(); + uploadButton().click(); + await flush(); + expect(fx.uploadDialog).toHaveBeenCalledTimes(2); + }); + + it('ein zweiter Klick, solange der Dialog offen ist, öffnet keinen zweiten', async () => { + fx.uploadDialog.mockReturnValue(new Promise(() => {})); + await mount(); + uploadButton().click(); + uploadButton().click(); + await flush(); + expect(fx.uploadDialog).toHaveBeenCalledTimes(1); + }); + + it('der Dialog bekommt ein Signal, das beim Aufräumen der Seite ausgelöst wird; ein später Treffer wird verworfen', async () => { + const result = deferred(); + fx.uploadDialog.mockReturnValue(result.promise); + await mount(); + uploadButton().click(); + await flush(); + const signal = fx.uploadDialog.mock.calls[0][0]; + expect(signal.aborted).toBe(false); + picker.dispose(); + expect(signal.aborted).toBe(true); + result.resolve(fresh()); + await flush(); + expect(fx.onSelect).not.toHaveBeenCalled(); + expect(mocks.rememberLogo).not.toHaveBeenCalled(); + }); + + it('lässt sich der Dialog gar nicht öffnen (Fehler), steht der Grund im Statusbereich', async () => { + fx.uploadDialog.mockRejectedValue(new Error('kein ')); + await mount(); + uploadButton().click(); + await flush(); + expect(alertText()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); + }); +}); + +describe('Kachelbilder', () => { + it('jede Kachel zeigt das PNG ihres Logos: loadLogoBlob bekommt die Zeile, das trägt die Object-URL (dekorativ, alt leer)', async () => { + await mount(); + expect(fx.loadLogoBlob.mock.calls.map(([row]) => row.id)).toEqual(['a', 'b', 'c']); + const img = art('Logo A').querySelector('img')!; + expect(img.getAttribute('src')).toBe(created[0]); + expect(img.getAttribute('alt')).toBe(''); + expect(art('Logo A').getAttribute('data-state')).toBe('ready'); + }); + + it('ein einfarbiges Logo wird als Maske gezeigt (die Zeile mit mono kommt bei der Kachel an)', async () => { + fx.listLogos.mockResolvedValue({ logos: [logo('a', { mono: true })], nextCursor: null }); + await mount(); + expect(art('Logo A').querySelector('img')).toBeNull(); + expect(art('Logo A').querySelector('.logo-tile-mask')).not.toBeNull(); + }); + + it('ein unbrauchbares Bild (über der Pixelgrenze) lässt die Kachel heil: Platzhalter, Name, Herz und Auswahl funktionieren', async () => { + sizes.set('c', [60_000, 60_000]); + await mount(); + expect(art('Logo C').getAttribute('data-state')).toBe('unusable'); + expect(art('Logo C').querySelector('img')).toBeNull(); + expect(revoked).toEqual([created[2]]); + expect(art('Logo A').getAttribute('data-state')).toBe('ready'); + expect(heart('Logo C').textContent).toBe('0'); + tile('Logo C').click(); + expect(fx.onSelect).toHaveBeenCalledWith('c'); + }); + + it('Kacheln, die bei einem Wechsel der Sortierung wegfallen, geben ihre Object-URL frei; die neuen laden ihr Bild', async () => { + await mount(); + expect(created).toHaveLength(3); + fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null }); + sortButton('Neu').click(); + await flush(); + expect(revoked.sort()).toEqual([...created.slice(0, 3)].sort()); + expect(art('Logo Z').getAttribute('data-state')).toBe('ready'); + }); + + it('die Kachel des gewählten Logos behält ihr Bild beim Wechsel der Sortierung (kein Neuladen, kein Flackern)', async () => { + await mount(); + picker.select('b'); + fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null }); + sortButton('Neu').click(); + await flush(); + expect(fx.loadLogoBlob.mock.calls.filter(([row]) => row.id === 'b')).toHaveLength(1); + expect(revoked).not.toContain(created[1]); + expect(art('Logo B').querySelector('img')!.getAttribute('src')).toBe(created[1]); + }); + + it('eine Datei, die erst nach dem Wegfall der Kachel ankommt, wird nicht mehr angelegt', async () => { + const slow = deferred(); + fx.loadLogoBlob.mockReturnValueOnce(slow.promise); + await mount(); + fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null }); + sortButton('Neu').click(); + await flush(); + const before = created.length; + slow.resolve(new Blob(['spät'])); + await flush(); + expect(created).toHaveLength(before); + }); +}); + +describe('dispose', () => { + it('gibt alle Object-URLs frei und meldet sich von der Anmeldung ab', async () => { + await mount(); + expect(authListeners.size).toBe(1); + picker.dispose(); + expect(revoked.sort()).toEqual([...created].sort()); + expect(authListeners.size).toBe(0); + picker.dispose(); // ein zweiter Aufruf schadet nicht + expect(revoked).toHaveLength(created.length); + }); + + it('Antworten, die nach dem Aufräumen eintreffen, ändern nichts und werfen nichts', 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('x'); + picker.dispose(); + list.resolve({ logos: [logo('a')], nextCursor: null }); + await flush(); + expect(names()).toEqual(['Kein Logo']); + expect(created).toEqual([]); + expect(fx.getLogo).not.toHaveBeenCalled(); + expect(fx.myHearts).not.toHaveBeenCalled(); + }); + + it('eine Herz-Antwort nach dem Aufräumen wird verworfen', async () => { + const answer = deferred<{ herz: boolean; likeCount: number }>(); + fx.setHeart.mockReturnValue(answer.promise); + await mount(); + heart('Logo A').click(); + picker.dispose(); + answer.resolve({ herz: true, likeCount: 9 }); + await flush(); + expect(heart('Logo A').textContent).toBe('1'); // der Stand von vor dem Aufräumen, kein Fehler + }); +}); diff --git a/web/src/logos/logo-picker.ts b/web/src/logos/logo-picker.ts new file mode 100644 index 0000000..4e0beea --- /dev/null +++ b/web/src/logos/logo-picker.ts @@ -0,0 +1,489 @@ +// 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. +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 { el, icon } from '../editor/dom'; +import { msg, t } from '../i18n/i18n'; +import { setHeart } 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'; + +export interface LogoPickerDeps { + 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; + /** Öffnet den Dialog „Logo hochladen“; `signal` schließt ihn (Seite verlassen). Das neue Logo, null bei Abbruch. */ + uploadDialog(signal: AbortSignal): Promise; +} + +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. */ + selected(): CommunityLogo | null; + /** Gibt die Object-URLs der Kacheln frei, schließt einen offenen Upload-Dialog und beendet das Abonnement der Anmeldung. */ + dispose(): void; +} + +const LOAD_ERROR = msg('Die Logos konnten nicht geladen werden.'); + +/** + * Die Ansichten des Umschalters. „Beliebt“ und „Neu“ sind öffentlich, „Gemeldet“ und „Ausgeblendet“ nur für Admins (der Server prüft + * die Rechte; hier erscheinen die Knöpfe nur für sie). Die Admin-Ansichten zeigen die neuesten zuerst. + */ +const VIEWS = { + popular: { label: msg('Beliebt'), sort: 'popular', filter: 'all', admin: false }, + new: { label: msg('Neu'), sort: 'new', filter: 'all', admin: false }, + reported: { label: msg('Gemeldet'), sort: 'new', filter: 'reported', admin: true }, + hidden: { label: msg('Ausgeblendet'), sort: 'new', filter: 'hidden', admin: true }, +} as const satisfies Record; + +type View = keyof typeof VIEWS; + +/** `myHearts` fragt höchstens 100 Logos je Anfrage ab (logos.ts); mehr geladene Seiten gehen in mehreren Anfragen. */ +const HEARTS_PER_REQUEST = 100; + +/** Eine Kachel: Auswahlknopf (Bild, Name, Urheber) und daneben, nicht darin, der Herz-Knopf. */ +interface Tile { + logo: CommunityLogo; + cell: HTMLLIElement; + select: HTMLButtonElement; + heart: HTMLButtonElement; + count: HTMLElement; + /** Gibt das Bild frei (Object-URL); eine später eintreffende Datei wird dann nicht mehr angezeigt. */ + release(): void; +} + +function defaultDeps(): LogoPickerDeps { + return { + listLogos, + getLogo, + myHearts, + setHeart, + loadLogoBlob, + userId: currentUserId, + isAdmin: () => isAdmin(currentUser()), + requireLogin: () => requireLogin('login'), + onAuthChange, + uploadDialog: (signal) => logoUploadDialog({ signal }), + }; +} + +/** 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; + + // Ansicht und Liste + let view: View = 'popular'; + /** IDs der geladenen Liste in Anzeigereihenfolge (ohne den angehefteten Eintrag). */ + let order: string[] = []; + let nextCursor: string | null = null; + let loading = false; + /** Die erste Seite der aktuellen Ansicht ist da. */ + let loaded = false; + /** Welcher Ladevorgang zuletzt scheiterte (und mit „Nochmal versuchen“ wiederholt wird). */ + let failed: 'reset' | 'more' | null = null; + /** Zählt die Ladevorgänge: nur die jüngste Antwort zählt. */ + let loadToken = 0; + + // Zeilen, Kacheln und Auswahl + /** Jede bekannte Zeile (Liste, angehefteter Eintrag, Upload), je ID die jüngste. */ + const known = new Map(); + const tiles = new Map(); + let selectedId: string | null = null; + /** 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; + + // Herzen + /** Die Logos, die ich mit einem Herz markiert habe. */ + const hearts = new Set(); + /** Logos mit laufender Herz-Anfrage (oder offenem Login-Dialog): weitere Klicks auf ihr Herz bleiben ohne Wirkung. */ + const pending = new Set(); + /** Wann ich ein Herz zuletzt selbst geschaltet habe (Stand der Uhr): eine ältere Antwort von `myHearts` überschreibt es nicht. */ + const touched = new Map(); + let clock = 0; + + let uploading = false; + let shownAdmin: boolean | null = null; + + // ---- Aufbau --------------------------------------------------------------------------------------------------------------- + + 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(); + const noneButton = el( + 'button', + { type: 'button', class: 'tile logo-tile logo-tile-none', 'aria-pressed': 'true' }, + el('span', { class: 'logo-tile-art' }, icon('ban', 'logo-none-icon')), + el('span', { class: 'logo-tile-name' }, t('Kein Logo')), + ); + 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. + 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')); + const root = el( + 'div', + { class: 'logo-picker', role: 'group', 'aria-labelledby': 'logo-picker-heading' }, + el( + 'div', + { class: 'logo-picker-head' }, + el('h3', { id: 'logo-picker-heading', class: 'logo-picker-title' }, t('Logo unten rechts')), + el('span', { class: 'logo-badge' }, t('Community')), + uploadButton, + ), + el('p', { class: 'logo-picker-note' }, t('Logos werden von der Community hochgeladen.')), + sortBar, + grid, + emptyEl, + statusEl, + retryButton, + moreButton, + ); + + // ---- Kacheln -------------------------------------------------------------------------------------------------------------- + + /** Auswahl, Herz und Zähler einer Kachel nach dem aktuellen Stand. */ + function renderTile(tile: Tile): void { + const { logo } = tile; + tile.select.setAttribute('aria-pressed', String(logo.id === selectedId)); + tile.heart.setAttribute('aria-pressed', String(hearts.has(logo.id))); + tile.heart.setAttribute('aria-label', t('Herz für „{name}“: {count}', { name: logo.name, count: logo.likeCount })); + tile.count.textContent = String(logo.likeCount); + } + + /** Merkt die jüngste Zeile eines Logos und frischt seine Kachel 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); + } + + function createTile(logo: CommunityLogo): Tile { + const art = el('span', { class: 'logo-tile-art' }); + const select = el( + 'button', + { type: 'button', class: 'tile logo-tile', 'aria-pressed': 'false', title: logo.name }, + art, + el('span', { class: 'logo-tile-name' }, logo.name), + el('span', { class: 'logo-tile-by' }, t('von {name}', { name: logo.displayName || t('Anonym') })), + ); + const count = el('span', { class: 'logo-heart-count' }); + const heart = el('button', { type: 'button', class: 'logo-heart', 'aria-pressed': 'false' }, icon('heart'), count); + const release = showLogoImage(art, logo, deps.loadLogoBlob); // das PNG auf dem dunklen Kartengrund + const tile: Tile = { logo, cell: el('li', { class: 'logo-cell' }, select, heart), select, heart, count, release }; + select.addEventListener('click', () => handlers.onSelect(logo.id)); + heart.addEventListener('click', () => void toggleHeart(logo.id)); + tiles.set(logo.id, tile); + renderTile(tile); + return tile; + } + + /** Die Zelle für ein Logo (je Logo nur eine Kachel; sie wandert mit, wenn das Logo vom angehefteten Platz in die Liste kommt). */ + const cellOf = (logo: CommunityLogo): HTMLLIElement => (tiles.get(logo.id) ?? createTile(logo)).cell; + + function dropTile(tile: Tile): void { + tile.release(); + tile.cell.remove(); + tiles.delete(tile.logo.id); + } + + /** 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; + const wanted = row?.id ?? null; + if (wanted === pinnedId) return; + // Der bisher angeheftete Eintrag fällt weg, außer er ist inzwischen Teil der Liste (seine Kachel steht dort schon). + const old = pinnedId !== null && !order.includes(pinnedId) ? tiles.get(pinnedId) : undefined; + if (old) dropTile(old); + pinnedId = wanted; + if (row) noneCell.after(cellOf(row)); + } + + // ---- Liste ---------------------------------------------------------------------------------------------------------------- + + /** Leert die Liste für eine neue Ansicht. Die Kachel des gewählten Logos bleibt (kein neues Laden des Bildes, kein Flackern). */ + function resetList(): void { + order = []; + nextCursor = null; + loaded = false; + for (const tile of [...tiles.values()]) if (tile.logo.id !== selectedId) dropTile(tile); + } + + function addLogos(logos: readonly CommunityLogo[]): void { + const added: HTMLLIElement[] = []; + for (const logo of logos) { + setRow(logo); + if (order.includes(logo.id)) continue; // dasselbe Logo auf zwei Seiten (die Rangfolge hat sich verschoben): nur einmal + order.push(logo.id); + added.push(cellOf(logo)); + } + grid.append(...added); + syncPin(); + } + + /** Das frisch hochgeladene Logo steht als erste Logo-Kachel, gleich nach „Kein Logo“ (und dem angehefteten Eintrag). */ + function addFirst(row: CommunityLogo): void { + setRow(row); + order = [row.id, ...order.filter((id) => id !== row.id)]; + const pinned = pinnedId !== null ? tiles.get(pinnedId)?.cell : undefined; + (pinned ?? noneCell).after(cellOf(row)); + syncPin(); + renderState(); + } + + /** Laden, „Mehr laden“, Leer-Text und Fehlerknopf nach dem aktuellen Stand. */ + function renderState(): void { + grid.setAttribute('aria-busy', String(loading)); + moreButton.hidden = nextCursor === null || failed !== null; + moreButton.disabled = loading; + retryButton.hidden = failed === null; + let empty = ''; + if (loaded && order.length === 0) { + empty = VIEWS[view].admin ? t('Keine Logos in dieser Ansicht.') : t('Noch keine Community-Logos – lade das erste hoch.'); + } + emptyEl.textContent = empty; + } + + /** Lädt die erste Seite der Ansicht neu (`reset`) oder die nächste hinten an (`more`). Nur die jüngste Antwort zählt. */ + async function load(mode: 'reset' | 'more'): Promise { + if (mode === 'more' && (loading || nextCursor === null)) return; + const token = ++loadToken; + const cursor = mode === 'more' ? nextCursor : null; + loading = true; + failed = null; + statusEl.textContent = ''; + if (mode === 'reset') { + resetList(); + syncPin(); + } + renderState(); + const { sort, filter } = VIEWS[view]; + try { + const page = await deps.listLogos(cursor === null ? { sort, filter } : { sort, filter, cursor }); + if (disposed || token !== loadToken) return; + loading = false; + loaded = true; + nextCursor = page.nextCursor; + addLogos(page.logos); + void loadHearts(page.logos.map((logo) => logo.id)); + } catch { + if (disposed || token !== loadToken) return; + loading = false; + failed = mode; + statusEl.textContent = t(LOAD_ERROR); + } + renderState(); + if (mode === 'reset') void resolveSelection(); + } + + function renderSortBar(): void { + const admin = deps.isAdmin(); + if (admin === shownAdmin) return; + shownAdmin = admin; + sortButtons.clear(); + for (const id of Object.keys(VIEWS) as View[]) { + if (VIEWS[id].admin && !admin) continue; + const button = el('button', { type: 'button', class: 'logo-sort-btn' }, t(VIEWS[id].label)); + button.addEventListener('click', () => setView(id)); + sortButtons.set(id, button); + } + sortBar.replaceChildren(...sortButtons.values()); + markView(); + } + + function markView(): void { + for (const [id, button] of sortButtons) button.setAttribute('aria-pressed', String(id === view)); + } + + function setView(next: View): void { + if (next === view) return; + view = next; + markView(); + void load('reset'); + } + + // ---- Auswahl -------------------------------------------------------------------------------------------------------------- + + /** + * Kennt der Picker die Zeile des gewählten Logos nicht, wird sie geholt und das Logo angeheftet. Scheitert das (Netz) oder gibt es + * das Logo nicht mehr, bleibt die Auswahl ohne Kachel; der Editor zeigt dann seinen eigenen Hinweis. + */ + async function resolveSelection(): Promise { + const id = selectedId; + if (id === null || known.has(id) || resolvingId === id) return; + resolvingId = id; + let row: CommunityLogo | null = null; + try { + row = await deps.getLogo(id); + } catch { + // bleibt ohne Kachel + } finally { + if (resolvingId === id) resolvingId = null; + } + if (disposed || row === null || selectedId !== id) return; // inzwischen eine andere Auswahl + 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 + selectedId = id; + noneButton.setAttribute('aria-pressed', String(id === null)); + for (const tile of tiles.values()) renderTile(tile); + syncPin(); + // 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(); + } + + // ---- Herzen --------------------------------------------------------------------------------------------------------------- + + /** Setzt Herz und Zähler eines Logos (optimistisch, Antwort des Dienstes oder Rücksprung). */ + function showHeart(id: string, on: boolean, likeCount: number): void { + touched.set(id, ++clock); + if (on) hearts.add(id); + else hearts.delete(id); + const row = known.get(id); + if (row) setRow({ ...row, likeCount }); + } + + /** + * Holt, welche dieser Logos ich mit einem Herz markiert habe (Gäste: nichts). Ein Herz, das ich inzwischen selbst geschaltet habe + * oder das gerade in einer Anfrage steckt, bleibt, wie es ist: die Antwort kann von davor stammen. + */ + async function loadHearts(ids: readonly string[]): Promise { + const me = deps.userId(); + if (me === null || ids.length === 0) return; + const started = ++clock; + const mine = new Set(); + try { + for (let i = 0; i < ids.length; i += HEARTS_PER_REQUEST) { + for (const id of await deps.myHearts(me, ids.slice(i, i + HEARTS_PER_REQUEST))) mine.add(id); + } + } catch { + return; // ohne Herzen bleibt die Liste benutzbar; sie stehen dann aus + } + if (disposed || deps.userId() !== me) return; // inzwischen abgemeldet oder ein anderes Konto + for (const id of ids) { + if (pending.has(id) || (touched.get(id) ?? 0) > started) continue; + if (mine.has(id)) hearts.add(id); + else hearts.delete(id); + const tile = tiles.get(id); + if (tile) renderTile(tile); + } + } + + 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 = ''; + try { + let on = !hearts.has(id); + if (deps.userId() === null) { + if (!(await deps.requireLogin()) || disposed) return; // Anmeldung abgebrochen: es passiert nichts + on = true; // nach der Anmeldung wird das Herz gesetzt + } + const row = known.get(id); + if (!row) return; + const before = { on: hearts.has(id), likeCount: row.likeCount }; + showHeart(id, on, Math.max(0, before.likeCount + (on ? 1 : 0) - (before.on ? 1 : 0))); + try { + const answer = await deps.setHeart(id, on); + if (!disposed) showHeart(id, answer.herz, answer.likeCount); + } catch (err) { + if (disposed) return; + showHeart(id, before.on, before.likeCount); + statusEl.textContent = failureText(err); + } + } finally { + pending.delete(id); + } + } + + // ---- Hochladen ------------------------------------------------------------------------------------------------------------ + + async function upload(): Promise { + if (uploading) return; + uploading = true; + statusEl.textContent = ''; + try { + if (deps.userId() === null && !(await deps.requireLogin())) return; // Gast: zuerst anmelden; abgebrochen: es passiert nichts + if (disposed) return; + const logo = await deps.uploadDialog(abort.signal); + if (!logo || disposed) return; + rememberLogo(logo); + addFirst(logo); + handlers.onSelect(logo.id); + } catch (err) { + if (!disposed) statusEl.textContent = t('Hochladen fehlgeschlagen – {reason}', { reason: failureText(err) }); + } finally { + uploading = false; + } + } + + // ---- Verdrahtung ---------------------------------------------------------------------------------------------------------- + + noneButton.addEventListener('click', () => handlers.onSelect(null)); + uploadButton.addEventListener('click', () => void upload()); + moreButton.addEventListener('click', () => void load('more')); + retryButton.addEventListener('click', () => void load(failed ?? 'reset')); + + // Anmelden oder Abmelden: Admin-Knöpfe, Herzen. Ohne Admin-Rechte gibt es die Admin-Ansichten nicht mehr. + const offAuth = deps.onAuthChange(() => { + if (!deps.isAdmin() && VIEWS[view].admin) { + view = 'popular'; + void load('reset'); + } + renderSortBar(); + if (deps.userId() === null) { + hearts.clear(); + for (const tile of tiles.values()) renderTile(tile); + } else { + void loadHearts([...tiles.keys()]); + } + }); + + renderSortBar(); + renderState(); + void load('reset'); + + return { + el: root, + select, + selected: () => (selectedId === null ? null : (known.get(selectedId) ?? null)), + dispose() { + if (disposed) return; + disposed = true; + abort.abort(); + offAuth(); + for (const tile of tiles.values()) tile.release(); + }, + }; +} diff --git a/web/src/logos/logos.css b/web/src/logos/logos.css index 2674903..641e82b 100644 --- a/web/src/logos/logos.css +++ b/web/src/logos/logos.css @@ -1,5 +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. */ + (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. */ /* 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. */ @@ -77,19 +78,23 @@ } .logo-preview-img, -.logo-preview-mask { +.logo-preview-mask, +.logo-tile-img, +.logo-tile-mask { display: block; width: 100%; height: 100%; } -.logo-preview-img { +.logo-preview-img, +.logo-tile-img { object-fit: contain; } -/* Einfarbig: nur die Deckkraft des PNGs zählt (Maske auf dessen Object-URL, `--logo-url` setzt der Dialog), die Farbe kommt - als Hintergrund (Weiß auf dunklem, #111111 auf hellem Grund, ebenfalls vom Dialog gesetzt). */ -.logo-preview-mask { +/* Einfarbig: nur die Deckkraft des PNGs zählt (Maske auf dessen Object-URL, `--logo-url` setzt der Dialog bzw. die Kachel), die + Farbe kommt als Hintergrund (Weiß auf dunklem, #111111 auf hellem Grund im Dialog; Weiß in der Kachel, ebenfalls gesetzt). */ +.logo-preview-mask, +.logo-tile-mask { -webkit-mask-image: var(--logo-url); mask-image: var(--logo-url); -webkit-mask-repeat: no-repeat; @@ -116,3 +121,243 @@ opacity: 0.55; cursor: not-allowed; } + +/* Bereich „Logo unten rechts“ im Editor */ + +.logo-picker { + display: flex; + flex-direction: column; + gap: 12px; +} + +.logo-picker-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; +} + +/* wie `.panel-subtitle` */ +.logo-picker-title { + margin: 0; + padding-left: 4px; + font-size: 1.05rem; + font-weight: 600; + line-height: 1.2; +} + +.logo-badge { + padding: 2px 9px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel-2); + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; +} + +/* Mit zwei Klassen, damit `margin: 0` aus `.auth-btn` nicht von der Reihenfolge der Stylesheets abhängt (wie beim Dialog). */ +.auth-btn.logo-upload { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; +} + +.logo-picker-note { + margin: -4px 0 0; + padding-left: 4px; + color: var(--muted); + font-size: 0.85rem; +} + +/* Sortierung: Umschalter wie die gewählte Kachel (Rahmen und Grund der Auswahl) */ + +.logo-sort { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.logo-sort-btn { + appearance: none; + min-height: 34px; + margin: 0; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--text); + font: inherit; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.12s, border-color 0.12s; +} + +.logo-sort-btn:hover { + border-color: var(--line-strong); + background: var(--panel); +} + +.logo-sort-btn[aria-pressed='true'] { + border-color: var(--sel-line); + background: var(--sel-bg); +} + +/* Raster: ab 1024 px ist die Optionsspalte schmal (zwei Spalten). Darunter steht sie in voller Breite, auch auf dem Handy unter + 900 px: so viele Spalten, wie mit mindestens 140 px je Kachel passen. */ + +.logo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +@media (min-width: 1024px) { + .logo-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.logo-cell { + position: relative; + min-width: 0; +} + +.logo-tile { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + min-width: 0; + padding: 8px; +} + +/* Die Bildfläche steht immer auf dem dunklen Kartengrund (#1B2738), in beiden Themes: so sieht das Logo aus wie auf der Karte. */ +.logo-tile-art { + display: flex; + align-items: center; + justify-content: center; + height: 72px; + margin-bottom: 6px; + padding: 10px; + border-radius: 8px; + background: #1b2738; + color: #9db0c8; +} + +.logo-tile-none .logo-tile-art { + background: var(--panel-2); + color: var(--muted); +} + +.logo-tile-art .logo-none-icon { + width: 36px; + height: 36px; +} + +/* Bild nicht verfügbar (Ladefehler, nicht lesbar, zu groß): ein neutraler gestrichelter Platzhalter */ +.logo-tile-art[data-state='unusable']::after { + content: ''; + width: 44%; + height: 56%; + border: 2px dashed currentColor; + border-radius: 6px; + opacity: 0.45; +} + +.logo-tile-name, +.logo-tile-by { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.logo-tile-name { + padding: 0 2px; + font-size: 0.95rem; + font-weight: 700; + line-height: 1.3; +} + +.logo-tile-by { + padding: 0 2px; + color: var(--muted); + font-size: 0.8rem; + 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. */ + +.logo-heart { + appearance: none; + position: absolute; + top: 14px; + right: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 44px; + min-height: 32px; + margin: 0; + padding: 0 9px; + border: 0; + border-radius: 999px; + background: rgb(15 24 40 / 0.78); + color: #e8eef7; + font: inherit; + font-size: 0.8rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: background-color 0.12s; +} + +.logo-heart:hover { + background: rgb(15 24 40 / 0.95); +} + +.logo-heart[aria-pressed='true'] { + color: #ff7a8a; +} + +.logo-heart[aria-pressed='true'] .icon { + fill: currentColor; +} + +.logo-heart .icon { + flex: none; + font-size: 1rem; +} + +/* Leer, Fehler, „Nochmal versuchen“ und „Mehr laden“ */ + +.logo-empty { + margin: 0; + padding-left: 4px; + color: var(--muted); + font-size: 0.9rem; +} + +.logo-empty:empty { + display: none; +} + +.logo-retry, +.logo-more { + align-self: center; +} + +@media (prefers-reduced-motion: reduce) { + .logo-sort-btn, + .logo-heart { + transition: none; + } +}