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>
566 lines
23 KiB
TypeScript
566 lines
23 KiB
TypeScript
// 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();
|
||
},
|
||
};
|
||
}
|