- Router: Routen home, gestalten, cards, community/<id>, reset; Umleitung /?karte=<id> nach /gestalten?karte=<id>; Titel je Route (titleFor); Hash-Ziele (/#community) scrollen den Anker ins Bild, nur-Hash-Wechsel bauen die Seite nicht neu auf. - Kopfzeile: Navigation, Suche (Enter -> /?q=...#community), Spenden-Knopf, Hell/Dunkel-Schalter, Menue unter 900 px, Konto als Avatar-Kreis mit Menue. - Theme: data-theme auf <html> (Skript im <head> gegen Aufblitzen), helle Tokens, danger-/warn-/ok-text-Tokens statt fester Farben. - Spenden-Banner (schliessbar); ohne DONATE_URL bleiben Banner und Knopf weg. - User.labels und isAdmin; Platzhalter fuer Startseite und Detailseite. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
384 lines
15 KiB
TypeScript
384 lines
15 KiB
TypeScript
// 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<string, string>();
|
|
const previewFailed = new Set<string>();
|
|
const previewQueued = new Set<string>();
|
|
const thumbSlots = new Map<string, HTMLElement>();
|
|
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<string> = 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<void> {
|
|
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<HTMLElement>('.card-tile')].find((t) => t.dataset.card === focusId);
|
|
tile?.querySelector<HTMLElement>(`[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<void>, busyButton?: HTMLButtonElement, busyText?: string): Promise<void> {
|
|
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<HTMLElement>('.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<void> {
|
|
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();
|
|
};
|
|
}
|