// Seite /karten („Meine Karten“): Kacheln mit Vorschau; Öffnen, Herunterladen, Umbenennen, Löschen. import '../account/account.css'; import './cards.css'; import { getCurrentUser, onAuthChange } from '../account/auth'; import { cardsService, normalizeName, type SavedCard } from '../account/cards'; import { errorMessage, errorType } from '../account/errors'; import { requireLogin } from '../account/login-dialog'; import { LoginCancelled, withLogin } from '../account/with-login'; import { clampCrop } from '../card/crop'; import { CARD_W } from '../card/geometry'; import { exportCard, loadCardAssets } from '../card/render'; import { confirmDialog } from '../editor/confirm-dialog'; import { el } from '../editor/dom'; import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download'; import type { EditorStore } from '../editor/state'; import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload'; import { navigate } from './router'; const LOADING = 'Karten werden geladen …'; const LOGGED_OUT = 'Melde dich an, um deine Karten zu sehen.'; const EMPTY = 'Noch keine Karten gespeichert.'; const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.'; const PREVIEW_PARALLEL = 4; /** `dd.mm.yyyy` in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */ export function formatDate(iso: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) return ''; const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`; } export function mountCardsPage(root: HTMLElement, store: EditorStore): () => void { const service = cardsService(); let disposed = false; const disposal = new AbortController(); // schließt einen offenen Löschen-Dialog beim Verlassen der Seite let state: 'loading' | 'loggedOut' | 'error' | 'list' = 'loading'; let loadToken = 0; let cards: SavedCard[] = []; // Vorschaubilder: Object-URLs je Vorschau-Datei, beim Verlassen der Seite (und wenn sie nicht mehr gebraucht werden) freigegeben. const previewUrls = new Map(); const previewFailed = new Set(); const previewQueued = new Set(); const thumbSlots = new Map(); const queue: SavedCard[] = []; let activeLoads = 0; const heading = el('h1', { tabindex: '-1' }, 'Meine Karten'); const body = el('section', { class: 'cards-page', 'aria-label': 'Gespeicherte Karten' }); root.replaceChildren(heading, body); function revokePreviews(keep: ReadonlySet = new Set()): void { for (const [fileId, url] of previewUrls) { if (keep.has(fileId)) continue; URL.revokeObjectURL(url); previewUrls.delete(fileId); } } // ---- Vorschaubilder laden (höchstens PREVIEW_PARALLEL gleichzeitig) ---- function fillThumb(card: SavedCard): void { const slot = thumbSlots.get(card.id); if (!slot) return; const url = previewUrls.get(card.previewFileId); if (url) { slot.replaceChildren(el('img', { src: url, alt: `Vorschau von „${card.name}“`, width: 480, height: 303 })); } else if (previewFailed.has(card.previewFileId)) { slot.replaceChildren(el('span', {}, 'Keine Vorschau')); } else { slot.replaceChildren(el('span', {}, 'Vorschau lädt …')); } } async function loadPreview(card: SavedCard): Promise { try { const blob = await withLogin(() => service.loadPreview(card)); // Die Karte kann inzwischen gelöscht oder die Liste neu geladen worden sein. if (disposed || !cards.some((c) => c.previewFileId === card.previewFileId)) return; // Schon vorhanden (z. B. durch einen früheren Lauf): nicht noch eine URL anlegen, die niemand freigibt. if (!previewUrls.has(card.previewFileId)) previewUrls.set(card.previewFileId, URL.createObjectURL(blob)); } catch { if (disposed) return; previewFailed.add(card.previewFileId); } finally { previewQueued.delete(card.previewFileId); } const current = cards.find((c) => c.previewFileId === card.previewFileId); if (current) fillThumb(current); } function pump(): void { while (!disposed && activeLoads < PREVIEW_PARALLEL && queue.length > 0) { const next = queue.shift() as SavedCard; activeLoads += 1; void loadPreview(next).finally(() => { activeLoads -= 1; pump(); }); } } function queuePreview(card: SavedCard): void { if (previewUrls.has(card.previewFileId) || previewFailed.has(card.previewFileId)) return; if (previewQueued.has(card.previewFileId)) return; previewQueued.add(card.previewFileId); queue.push(card); pump(); } // ---- Ansichten ---- function show(...nodes: Node[]): void { body.replaceChildren(...nodes); } function showStatus(text: string): void { show(el('p', { class: 'cards-msg', role: 'status' }, text)); } function showLoggedOut(): void { state = 'loggedOut'; cards = []; queue.length = 0; previewQueued.clear(); thumbSlots.clear(); revokePreviews(); const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Anmelden'); signIn.addEventListener('click', () => void requireLogin('login')); show(el('p', { class: 'cards-msg' }, LOGGED_OUT), el('p', { class: 'cards-actions' }, signIn)); } function showError(text: string): void { state = 'error'; const retry = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Nochmal versuchen'); retry.addEventListener('click', () => void load()); show(el('p', { class: 'cards-msg cards-msg-error', role: 'alert' }, text), el('p', { class: 'cards-actions' }, retry)); } /** Setzt `cards` und hält den Editor-Zustand in Einklang (er überlebt Seitenwechsel). */ function syncEditor(change: { renamed?: SavedCard; removedId?: string }): void { const s = store.get(); if (!s.saved) return; if (change.renamed && s.saved.id === change.renamed.id) { store.set({ saved: { ...s.saved, name: change.renamed.name, updatedAt: change.renamed.updatedAt }, cardName: change.renamed.name, }); } // Die Karte im Editor gibt es nicht mehr: ein späteres Speichern legt eine neue an, statt ins Leere zu schreiben. if (change.removedId && s.saved.id === change.removedId) store.set({ saved: null, savedConfig: null }); } function renderList(focusId?: string, focusAction?: string): void { state = 'list'; thumbSlots.clear(); if (cards.length === 0) { const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/gestalten', 'data-link': true }, 'Zum Editor'); show(el('p', { class: 'cards-msg' }, EMPTY), el('p', { class: 'cards-actions' }, toEditor)); return; } const grid = el('ul', { class: 'cards-grid' }); for (const card of cards) grid.append(buildTile(card)); show(grid); for (const card of cards) { fillThumb(card); queuePreview(card); } if (focusId) { const tile = [...body.querySelectorAll('.card-tile')].find((t) => t.dataset.card === focusId); tile?.querySelector(`[data-action="${focusAction ?? 'open'}"]`)?.focus(); } } // ---- Kacheln und Aktionen ---- function buildTile(card: SavedCard): HTMLElement { const thumb = el('div', { class: 'card-thumb' }); thumbSlots.set(card.id, thumb); const nameSlot = el('div', { class: 'card-name-slot' }, el('h2', { class: 'card-name' }, card.name)); const date = formatDate(card.updatedAt); const message = el('p', { class: 'card-msg', role: 'alert' }); const action = (key: string, text: string, className = 'auth-btn auth-btn-small') => el('button', { type: 'button', class: className, 'data-action': key }, text); const openButton = action('open', 'Öffnen', 'auth-btn auth-btn-small auth-btn-primary'); const downloadButton = action('download', 'Herunterladen'); const renameButton = action('rename', 'Umbenennen'); const deleteButton = action('delete', 'Löschen', 'auth-btn auth-btn-small auth-btn-danger'); const buttons = [openButton, downloadButton, renameButton, deleteButton]; const tile = el( 'li', { class: 'card-tile', 'data-card': card.id }, thumb, nameSlot, date ? el('p', { class: 'card-date' }, el('time', { datetime: card.updatedAt }, date)) : '', el('div', { class: 'card-buttons' }, ...buttons), message, ); // Eine Aktion je Kachel zur Zeit; Fehler stehen an der Kachel. async function run(task: () => Promise, busyButton?: HTMLButtonElement, busyText?: string): Promise { const label = busyButton?.textContent ?? ''; message.textContent = ''; message.classList.remove('is-warning'); for (const b of buttons) b.disabled = true; tile.setAttribute('aria-busy', 'true'); if (busyButton && busyText) busyButton.textContent = busyText; try { await task(); } catch (err) { if (!disposed) message.textContent = failureText(err); } finally { if (busyButton && busyText) busyButton.textContent = label; for (const b of buttons) b.disabled = false; tile.removeAttribute('aria-busy'); } } openButton.addEventListener('click', () => navigate(`/gestalten?karte=${encodeURIComponent(card.id)}`)); downloadButton.addEventListener('click', () => { void run(async () => { const blob = await withLogin(() => service.loadImage(card)); const image = await decodeImage(blob); const config = { ...card.config, crop: clampCrop(image, card.config.crop) }; const assets = await loadCardAssets(config, image); const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' }); saveBlobAs(png, downloadFileName(card.name, new Date())); const warning = pngSizeWarning(png.size); if (warning && !disposed) { message.textContent = warning; message.classList.add('is-warning'); } }, downloadButton, 'Wird erstellt …'); }); renameButton.addEventListener('click', () => startRename()); deleteButton.addEventListener('click', () => { void run(async () => { const confirmed = await confirmDialog( { title: `Karte „${card.name}“ löschen?`, text: 'Die Karte und ihr Bild werden endgültig gelöscht.', confirmLabel: 'Löschen', }, disposal.signal, ); if (!confirmed) return; try { await withLogin(() => service.remove(card)); } catch (err) { // Schon gelöscht (anderer Tab): das Ziel ist erreicht. if (errorType(err) !== 'row_not_found') throw err; } // Der Editor-Zustand überlebt die Seite: auch wenn sie inzwischen verlassen wurde, gibt es die Karte nicht mehr. syncEditor({ removedId: card.id }); if (disposed) return; const url = previewUrls.get(card.previewFileId); if (url) URL.revokeObjectURL(url); previewUrls.delete(card.previewFileId); cards = cards.filter((c) => c.id !== card.id); renderList(); if (cards.length === 0) heading.focus(); else body.querySelector('.card-tile [data-action="open"]')?.focus(); }); }); function startRename(): void { const input = el('input', { class: 'auth-input card-rename-input', type: 'text', value: card.name, autocomplete: 'off', 'aria-label': `Neuer Name für „${card.name}“`, }); const save = el('button', { type: 'submit', class: 'auth-btn auth-btn-small auth-btn-primary' }, 'Speichern'); const cancel = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abbrechen'); const form = el('form', { class: 'card-rename' }, input, save, cancel); const close = () => { nameSlot.replaceChildren(el('h2', { class: 'card-name' }, card.name)); renameButton.focus(); }; cancel.addEventListener('click', close); input.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; event.preventDefault(); close(); }); form.addEventListener('submit', (event) => { event.preventDefault(); if (normalizeName(input.value) === card.name) { close(); return; } input.readOnly = true; save.disabled = true; cancel.disabled = true; void run(async () => { let updated: SavedCard; try { updated = await withLogin(() => service.rename(card.id, input.value)); } catch (err) { // Das Formular bleibt offen, damit nichts getippt werden muss. input.readOnly = false; save.disabled = false; cancel.disabled = false; throw err; } syncEditor({ renamed: updated }); // auch nach dem Verlassen der Seite: sonst überschreibt Speichern den Namen if (disposed) return; // Der Server sortiert nach „zuletzt geändert“; die umbenannte Karte steht danach vorn. cards = [updated, ...cards.filter((c) => c.id !== updated.id)]; renderList(updated.id, 'rename'); }); }); nameSlot.replaceChildren(form); input.focus(); input.select(); } return tile; } function failureText(err: unknown): string { if (err instanceof LoginCancelled) return LOGIN_NEEDED; if (err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE)) return err.message; return errorMessage(err, 'load'); } // ---- Laden ---- async function load(): Promise { const token = ++loadToken; state = 'loading'; showStatus(LOADING); try { const user = await getCurrentUser(); if (disposed || token !== loadToken) return; if (!user) { showLoggedOut(); return; } const list = await withLogin(() => service.list()); if (disposed || token !== loadToken) return; cards = list; revokePreviews(new Set(list.map((c) => c.previewFileId))); previewFailed.clear(); renderList(); } catch (err) { if (disposed || token !== loadToken) return; if (err instanceof LoginCancelled) showLoggedOut(); else showError(errorMessage(err, 'load')); } } // Abmelden (auch durch eine abgelaufene Sitzung) räumt die Liste weg, Anmelden holt sie. const offAuth = onAuthChange((user) => { if (disposed) return; if (!user) { if (state !== 'loggedOut') { loadToken += 1; showLoggedOut(); } } else if (state === 'loggedOut') { void load(); } }); void load(); return () => { disposed = true; disposal.abort(); offAuth(); queue.length = 0; revokePreviews(); }; }