Files
AirCardIOS/web/src/logos/logo-picker.ts
T
ClaudeandClaude Opus 5.5 ec4e80c62c fix(logos): „Mehr laden“ beginnt von vorn, wenn der Cursor nicht mehr existiert
Schlussreview F2. Hat jemand das Logo gelöscht oder ausgeblendet, hinter dem „Mehr laden“ ansetzt (der Cursor), antwortet Appwrite mit
400 `general_cursor_not_found`. „Nochmal versuchen“ wiederholte dann denselben Cursor und scheiterte immer wieder.

- Dieser Fehlertyp beim Nachladen löst jetzt `load('reset')` aus: die erste Seite der aktuellen Ansicht (gleiche Sortierung, gleicher Filter)
  wird neu geladen, die Kachel des gewählten Logos bleibt stehen. Kein Fehlertext, kein „Nochmal versuchen“ für den toten Cursor.
- Jeder andere Fehler beim Nachladen und jeder Fehler beim Laden der ersten Seite bleibt wie bisher (Meldung und „Nochmal versuchen“);
  die erste Seite hat keinen Cursor, ein Neuladen im Kreis ist ausgeschlossen.
- Tests mit einem Fehler {code: 400, type: 'general_cursor_not_found'}: Neuladen von Seite 1 mit Anfragefolge, Ansicht „Gemeldet“ bleibt,
  gewählte Kachel bleibt, Neuladen scheitert selbst, anderer Fehlertyp, Fehlertyp beim ersten Laden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:12:59 +00:00

566 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<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; 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<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 }),
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<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;
/** 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<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 ---------------------------------------------------------------------------------------------------------------
/** 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<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' });
// 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<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 (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<void> {
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<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);
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<void> {
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();
},
};
}