feat(editor): Community-Logos unten rechts mit Herzen und Sortierung

Task 8: der Bereich „Logo unten rechts“ in der Optionsspalte des Editors (Spec § 3 und § 5).

- logos/logo-picker.ts (neu): createLogoPicker baut Kopf (Überschrift, Plakette „Community“, „+ Hochladen“, Satz zur Community), den
  Umschalter „Beliebt“/„Neu“ (Admins zusätzlich „Gemeldet“/„Ausgeblendet“, je eine Ansicht mit sort und filter) und das Raster:
  erste Kachel „Kein Logo“, je Logo eine Kachel mit Bild, Name, „von <Anzeigename>“ bzw. „von Anonym“ und einem eigenen Herz-Knopf
  neben dem Auswahlknopf. „Mehr laden“ hängt die nächste Seite an (Duplikate nur einmal), Leer-Text und Ladefehler mit „Nochmal
  versuchen“ (wiederholt genau den gescheiterten Schritt); nur die jüngste Antwort einer Ansicht zählt. Die Auswahl gehört dem
  Editor: ein Klick meldet onSelect, markiert wird über select(); ein Logo außerhalb der Liste wird nach dem Laden der ersten Seite
  per getLogo geholt und als zweite Kachel angeheftet (steht es in der Liste, gibt es keine Anfrage). selected() liefert die Zeile
  für die Infozeile in Task 9. Herzen: optimistisch mit Rücksprung und Fehlertext (role="alert"); solange eine Anfrage für ein
  Logo läuft, bleiben weitere Klicks auf sein Herz wirkungslos; Gäste öffnen erst den Login (abgebrochen: nichts passiert, kein
  Fehlertext); die Herzen einer Seite kommen mit genau deren IDs, beim Anmelden für alle gezeigten Logos, beim Abmelden aus; eine
  späte Antwort überschreibt kein inzwischen selbst geschaltetes Herz. „Hochladen“ meldet Gäste zuerst an, öffnet den Dialog (mit
  Signal, das beim Verlassen der Seite schließt), setzt das neue Logo als erste Kachel, ruft rememberLogo und onSelect(id).
- logos/logo-image.ts (neu): das PNG einer Kachel laden, auf die Pixelgrenze prüfen (MAX_LOGO_PIXELS, so wie checkGalleryImage die
  Galerie-Bilder prüft; darüber, nicht lesbar oder Ladefehler: neutraler Platzhalter statt Absturz) und zeigen: mehrfarbig als <img>,
  einfarbig als weiße Maske, immer auf dem dunklen Kartengrund. Die Object-URL gibt die zurückgegebene Funktion frei; eine Datei, die
  erst danach eintrifft, wird nicht mehr angezeigt.
- logos/logo-asset.ts: MAX_LOGO_PIXELS wird exportiert (eine Quelle für Renderer und Kacheln).
- logos/logos.css: Kopf, Umschalter, Raster (ab 1024 px zwei Spalten, sonst auto-fill ab 140 px), Kacheln mit Bildfläche auf #1B2738,
  Herz (gefüllt, wenn gedrückt), Leer- und Fehlerzeilen; Bild und Maske teilen sich die Regeln mit der Dialog-Vorschau.
- editor/editor-page.ts: der Picker sitzt in options.logoSlot, die Wahl setzt config.logo, sync ruft picker.select(config.logo), das
  Verlassen der Seite ruft dispose. Übernommen aus dem Review von Task 5: failedKey blockiert ein Logo nicht mehr für immer, eine
  andere Wahl (Logo, Details, Marke) hebt die Sperre auf; derselbe Schlüssel wird weiter nicht im Kreis neu versucht.
- editor/dom.ts: Symbole heart und plus. i18n/en.ts: englische Texte zu allen neuen Beschriftungen.
- Tests: logo-picker.test.ts (Aufbau, Sortierung, Mehr laden, Auswahl und angeheftetes Logo, Herzen mit Rennen und Login, Leer und
  Ladefehler, Hochladen, Kachelbilder, dispose, Englisch), logo-image.test.ts, editor-page.test.ts (Verdrahtung, Regressionstest zu
  failedKey). Vitest 51 Dateien, 1666 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 22:40:37 +00:00
1 parent e22919f4dc
commit 4cf9fe192d
10 files changed
+2279 -10

No files matched your search

+2
View File
@@ -36,6 +36,8 @@ const ICONS = {
'<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>' + '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>' +
'<path d="M9 18c-4.51 2-5-2-7-2"/>', '<path d="M9 18c-4.51 2-5-2-7-2"/>',
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>', globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
} as const; } as const;
export type IconName = keyof typeof ICONS; export type IconName = keyof typeof ICONS;
+102 -1
View File
@@ -1,5 +1,5 @@
// Speichern und Öffnen im Editor (Karten-Dienst, Anmeldung und Renderer sind ersetzt). // 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 { User } from '../account/auth';
import type { SavedCard } from '../account/cards'; import type { SavedCard } from '../account/cards';
import { DEFAULT_CONFIG } from '../card/config'; import { DEFAULT_CONFIG } from '../card/config';
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
saveBlobAs: vi.fn(), saveBlobAs: vi.fn(),
getGalleryCard: vi.fn(), getGalleryCard: vi.fn(),
loadGalleryImage: vi.fn(), loadGalleryImage: vi.fn(),
createLogoPicker: vi.fn(),
logoutListeners: new Set<() => void>(), logoutListeners: new Set<() => void>(),
})); }));
@@ -51,6 +52,8 @@ vi.mock('../community/gallery', () => ({
getGalleryCard: mocks.getGalleryCard, getGalleryCard: mocks.getGalleryCard,
loadGalleryImage: mocks.loadGalleryImage, 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) => ({ vi.mock('./upload', async (importOriginal) => ({
...(await importOriginal<typeof import('./upload')>()), ...(await importOriginal<typeof import('./upload')>()),
decodeImage: mocks.decodeImage, decodeImage: mocks.decodeImage,
@@ -75,6 +78,9 @@ const savedCard = (patch: Partial<SavedCard> = {}): SavedCard => ({
let root: HTMLElement; let root: HTMLElement;
let store: EditorStore; let store: EditorStore;
let unmount: (() => void) | null; 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(() => { beforeEach(() => {
for (const fn of Object.values(mocks)) if (typeof fn === 'function') fn.mockReset(); for (const fn of Object.values(mocks)) if (typeof fn === 'function') fn.mockReset();
@@ -89,6 +95,11 @@ beforeEach(() => {
mocks.requireLogin.mockResolvedValue(true); mocks.requireLogin.mockResolvedValue(true);
mocks.loadCardAssets.mockResolvedValue({ image: null, logo: null, brand: {}, logoMissing: false }); mocks.loadCardAssets.mockResolvedValue({ image: null, logo: null, brand: {}, logoMissing: false });
mocks.exportCard.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' })); 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; unmount = null;
}); });
@@ -1113,6 +1124,28 @@ describe('Community-Logo auf der Karte', () => {
expect(mocks.loadCardAssets).toHaveBeenCalledTimes(attempts); 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 () => { 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'); history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: 'gone' } })); 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<HTMLButtonElement>('.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=<id>)', () => { describe('Vorlage übernehmen (/gestalten?vorlage=<id>)', () => {
const openTemplate = (id: string) => history.replaceState(null, '', `/gestalten?vorlage=${id}`); const openTemplate = (id: string) => history.replaceState(null, '', `/gestalten?vorlage=${id}`);
const galleryCard = (patch: Record<string, unknown> = {}) => ({ const galleryCard = (patch: Record<string, unknown> = {}) => ({
+9 -1
View File
@@ -12,6 +12,7 @@ import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage }
import { getGalleryCard, loadGalleryImage } from '../community/gallery'; import { getGalleryCard, loadGalleryImage } from '../community/gallery';
import { t } from '../i18n/i18n'; import { t } from '../i18n/i18n';
import { LOGO_MISSING } from '../logos/logo-asset'; import { LOGO_MISSING } from '../logos/logo-asset';
import { createLogoPicker } from '../logos/logo-picker';
import { dropCardParam, dropTemplateParam } from './card-url'; import { dropCardParam, dropTemplateParam } from './card-url';
import { confirmDialog } from './confirm-dialog'; import { confirmDialog } from './confirm-dialog';
import { downloadFileName, pngSizeWarning, saveBlobAs } from './download'; import { downloadFileName, pngSizeWarning, saveBlobAs } from './download';
@@ -71,6 +72,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
onDetails: (details: DetailsTone) => patchConfig({ details }), onDetails: (details: DetailsTone) => patchConfig({ details }),
onBrand: (brand: BrandId) => patchConfig({ brand }), 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; 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). // 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 // 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 logos: (Logos & { key: string }) | null = null;
let pendingKey = ''; let pendingKey = '';
let failedKey = ''; let failedKey = '';
@@ -396,6 +401,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
function ensureLogos(config: CardConfig): void { function ensureLogos(config: CardConfig): void {
const key = logoKey(config); const key = logoKey(config);
if (logos?.key === key || pendingKey === key || failedKey === key) return; if (logos?.key === key || pendingKey === key || failedKey === key) return;
failedKey = '';
pendingKey = key; pendingKey = key;
const token = ++logoToken; const token = ++logoToken;
loadCardAssets(config, null).then( loadCardAssets(config, null).then(
@@ -442,6 +448,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
function sync(s: EditorState): void { function sync(s: EditorState): void {
templates.select(s.config.template); templates.select(s.config.template);
options.select(s.config); 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 }); 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); clearTimeout(flashTimer);
offLogout(); offLogout();
unsubscribe(); unsubscribe();
logoPicker.dispose();
if (frame) cancelAnimationFrame(frame); if (frame) cancelAnimationFrame(frame);
frame = 0; frame = 0;
}; };
+17
View File
@@ -74,6 +74,23 @@ export const EN: Readonly<Record<string, string>> = {
'Der Anzeigename ist zu lang (höchstens {max} Zeichen).': 'The display name is too long (at most {max} characters).', '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.', '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. // 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.', 'Bitte melde dich an.': 'Please sign in.',
'Nicht gefunden.': 'Not found.', 'Nicht gefunden.': 'Not found.',
+3 -2
View File
@@ -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 * 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; type Decoded = LogoAsset;
+224
View File
@@ -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> = {}): 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<Blob>>();
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 <img> (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 <img>', async () => {
showLogoImage(art, logo({ mono: true }), load);
await flush();
expect(art.querySelector('img')).toBeNull();
const mask = art.querySelector<HTMLElement>('.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<Blob>((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);
});
});
+70
View File
@@ -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<void> {
return new Promise<void>((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<Blob>): () => 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;
};
}
File diff suppressed because it is too large. Load diff
+489
View File
@@ -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<boolean>;
/** 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<CommunityLogo | null>;
}
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<string, { label: string; sort: LogoSort; filter: LogoFilter; admin: boolean }>;
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<string, CommunityLogo>();
const tiles = new Map<string, Tile>();
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<string>();
/** Logos mit laufender Herz-Anfrage (oder offenem Login-Dialog): weitere Klicks auf ihr Herz bleiben ohne Wirkung. */
const pending = new Set<string>();
/** Wann ich ein Herz zuletzt selbst geschaltet habe (Stand der Uhr): eine ältere Antwort von `myHearts` überschreibt es nicht. */
const touched = new Map<string, number>();
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<View, HTMLButtonElement>();
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<void> {
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<void> {
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<void> {
const me = deps.userId();
if (me === null || ids.length === 0) return;
const started = ++clock;
const mine = new Set<string>();
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<void> {
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<void> {
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();
},
};
}
+251 -6
View File
@@ -1,5 +1,6 @@
/* Community-Logos. Dialog „Logo hochladen“: Hülle, Felder, Pflicht-Häkchen, Meldungen und Knöpfe kommen aus community.css /* 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 /* 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. */ nicht von der Reihenfolge der Stylesheets abhängt. */
@@ -77,19 +78,23 @@
} }
.logo-preview-img, .logo-preview-img,
.logo-preview-mask { .logo-preview-mask,
.logo-tile-img,
.logo-tile-mask {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.logo-preview-img { .logo-preview-img,
.logo-tile-img {
object-fit: contain; object-fit: contain;
} }
/* Einfarbig: nur die Deckkraft des PNGs zählt (Maske auf dessen Object-URL, `--logo-url` setzt der Dialog), die Farbe kommt /* Einfarbig: nur die Deckkraft des PNGs zählt (Maske auf dessen Object-URL, `--logo-url` setzt der Dialog bzw. die Kachel), die
als Hintergrund (Weiß auf dunklem, #111111 auf hellem Grund, ebenfalls vom Dialog gesetzt). */ Farbe kommt als Hintergrund (Weiß auf dunklem, #111111 auf hellem Grund im Dialog; Weiß in der Kachel, ebenfalls gesetzt). */
.logo-preview-mask { .logo-preview-mask,
.logo-tile-mask {
-webkit-mask-image: var(--logo-url); -webkit-mask-image: var(--logo-url);
mask-image: var(--logo-url); mask-image: var(--logo-url);
-webkit-mask-repeat: no-repeat; -webkit-mask-repeat: no-repeat;
@@ -116,3 +121,243 @@
opacity: 0.55; opacity: 0.55;
cursor: not-allowed; 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;
}
}