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