// Bereich „Logo unten rechts“ im Editor (Spec § 3, § 5 und § 6): Raster der Community-Logos mit Sortierung, Auswahl, Herzen und dem // Knopf „Hochladen“, darunter die Infozeile zum gewählten Logo (logo-info.ts: Melden, Löschen, Admin-Aktionen). Die Daten kommen // über `deps` (Standard: logos.ts, logo-api.ts, Anmeldung, Dialoge); alle Texte gehen als Text in den DOM, nie als HTML. Die Auswahl // gehört dem Editor (`config.logo`): ein Klick meldet nur `onSelect`, markiert wird über `select()`. Fehler von Herz und Hochladen // zeigt die Infozeile; der Statusbereich des Pickers ist den Ladefehlern der Liste vorbehalten. import '../account/account.css'; import './logos.css'; import { currentUser, currentUserId, isAdmin, onAuthChange } from '../account/auth'; import { requireLogin } from '../account/login-dialog'; import { reportDialog } from '../community/report-dialog'; import { confirmDialog } from '../editor/confirm-dialog'; import { el, icon } from '../editor/dom'; import { msg, t } from '../i18n/i18n'; import { deleteLogo, reportLogo, setHeart, setLogoVisibility } from './logo-api'; import { rememberLogo } from './logo-asset'; import { logoUploadDialog } from './logo-dialog'; import { showLogoImage } from './logo-image'; import { createLogoInfo, failureText, type LogoInfoDeps } from './logo-info'; import { getLogo, listLogoReports, listLogos, loadLogoBlob, myHearts, type CommunityLogo, type LogoFilter, type LogoSort } from './logos'; export interface LogoPickerDeps extends LogoInfoDeps { listLogos: typeof listLogos; getLogo: typeof getLogo; myHearts: typeof myHearts; setHeart: typeof setHeart; loadLogoBlob: typeof loadLogoBlob; 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; 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.'); /** * Das Logo, hinter dem „Mehr laden“ ansetzt (der Cursor), gibt es nicht mehr, weil jemand es gelöscht oder ausgeblendet hat: Appwrite * antwortet mit 400 `general_cursor_not_found`. Ein Wiederholen mit demselben Cursor scheitert dann immer wieder. */ function isCursorGone(err: unknown): boolean { return typeof err === 'object' && err !== null && (err as { type?: unknown }).type === 'general_cursor_not_found'; } /** * 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 }), reportLogo, deleteLogo, setLogoVisibility, listLogoReports, confirm: (opts, signal) => confirmDialog(opts, signal), reportDialog, }; } 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; /** Das gewählte Logo, nach dem `getLogo` nichts fand: es gibt es nicht (mehr). */ let goneId: string | null = null; // Herzen /** Die Logos, die ich mit einem Herz markiert habe. */ 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 --------------------------------------------------------------------------------------------------------------- /** Die Zeile des gewählten Logos, sobald bekannt. */ const selectedRow = (): CommunityLogo | null => (selectedId === null ? null : (known.get(selectedId) ?? null)); const info = createLogoInfo( { selectedId: () => selectedId, selected: selectedRow, gone: () => selectedId !== null && goneId === selectedId, patchRow(id, patch) { const row = known.get(id); if (row) setRow({ ...row, ...patch }); }, removeLogo, }, deps, abort.signal, ); const uploadButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-upload' }, icon('plus'), t('Hochladen')); const sortBar = el('div', { class: 'logo-sort', role: 'group', 'aria-label': t('Sortierung') }); const sortButtons = new Map(); 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' }); // Ladefehler der Liste. Immer da (leer nimmt sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt. const statusEl = el('p', { class: 'auth-msg auth-msg-error logo-status', role: 'alert' }); const retryButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-retry', hidden: true }, t('Nochmal versuchen')); const moreButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-more', hidden: true }, t('Mehr laden')); 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, info.el, ); // ---- 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 (und, wenn es gewählt ist, die Infozeile) auf. */ function setRow(row: CommunityLogo): void { known.set(row.id, row); const tile = tiles.get(row.id); if (tile) { tile.logo = row; renderTile(tile); } if (row.id === selectedId) info.update(); } 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 Logo ist gelöscht: Kachel und Zeile fallen weg; war es gewählt, hebt der Editor die Auswahl auf (`onSelect(null)`). */ function removeLogo(id: string): void { const tile = tiles.get(id); if (tile) dropTile(tile); known.delete(id); order = order.filter((other) => other !== id); // „Mehr laden“ setzt hinter dem letzten geladenen Logo an (Cursor); die Abfrage braucht ein Logo, das es noch gibt. if (nextCursor === id) nextCursor = order[order.length - 1] ?? null; hearts.delete(id); // eine später vergebene gleiche ID erbt das Herz nicht if (pinnedId === id) pinnedId = null; renderState(); // aus der letzten Kachel wird der Leer-Text if (selectedId === id) { // Der Fokus lag auf „Löschen“, das mit dem Logo verschwindet: er geht auf „Kein Logo“, die neue Auswahl. const focused = info.el.contains(document.activeElement); handlers.onSelect(null); if (focused) noneButton.focus(); } info.update(); } /** Das gewählte Logo steht direkt nach „Kein Logo“, solange es nicht in der geladenen Liste ist (und seine Zeile bekannt ist). */ function syncPin(): void { const row = selectedId !== null && !order.includes(selectedId) ? known.get(selectedId) : undefined; 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 (err) { if (disposed || token !== loadToken) return; loading = false; if (mode === 'more' && isCursorGone(err)) { // Die Liste hat sich unter dem Cursor verändert: statt eines Fehlers, den „Nochmal versuchen“ nicht beheben kann, von vorn laden. void load('reset'); return; } 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; let answered = false; try { row = await deps.getLogo(id); answered = true; } catch { // bleibt ohne Kachel; ein Netzfehler heißt nicht, dass es das Logo nicht mehr gibt } finally { if (resolvingId === id) resolvingId = null; } if (disposed || selectedId !== id) return; // inzwischen eine andere Auswahl if (row === null) { if (answered) { goneId = id; info.update(); } return; } setRow(row); syncPin(); void loadHearts([id]); } function select(id: string | null): void { if (disposed || id === selectedId) return; // der Editor ruft das bei jeder Änderung seines Zustands auf selectedId = id; goneId = null; noneButton.setAttribute('aria-pressed', String(id === null)); for (const tile of tiles.values()) renderTile(tile); syncPin(); info.update(); // Erst wenn die Liste da ist: steht das Logo darin, braucht es weder eine Anfrage noch einen angehefteten Eintrag. if (loaded || failed !== null) void resolveSelection(); } // ---- 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); info.setMessage(''); try { let on = !hearts.has(id); if (deps.userId() === null) { let loggedIn: boolean; try { loggedIn = await deps.requireLogin(); } catch (err) { if (!disposed) info.setMessage(failureText(err)); // der Login-Dialog ließ sich nicht öffnen return; } if (!loggedIn || disposed) return; // Anmeldung abgebrochen: es passiert nichts on = true; // nach der Anmeldung wird das Herz gesetzt } const row = known.get(id); 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); info.setMessage(failureText(err)); } } finally { pending.delete(id); } } // ---- Hochladen ------------------------------------------------------------------------------------------------------------ async function upload(): Promise { if (uploading) return; uploading = true; info.setMessage(''); 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) info.setMessage(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(); info.update(); // Hochlader und Admins sehen mehr Knöpfe 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: selectedRow, dispose() { if (disposed) return; disposed = true; abort.abort(); offAuth(); for (const tile of tiles.values()) tile.release(); }, }; }