fix(cards): Editor an Karte und Konto koppeln (Neue Karte, Abmelden, Kontowechsel)

- Editor zeigt „Bearbeitet: <Name>“, solange eine gespeicherte Karte gebunden ist,
  und bietet „Neue Karte“ (setzt zurück, bricht Laden ab, entfernt ?karte=;
  fragt bei ungespeicherten Änderungen per eigenem Dialog „Verwerfen?“)
- EditorState.savedConfig als Vergleichsbasis für ungespeicherte Änderungen
- bindEditorToAuth: Abmelden leert den Editor, Kontowechsel A -> B entkoppelt die
  Karte (Entwurf bleibt), Anmelden ohne Vorgänger und abgelaufene Sitzung behalten
  den Entwurf; auth.onLogout unterscheidet eigenes Abmelden von bemerktem Ablauf
- Speichern nimmt den Schnappschuss pro Klick (auch für die Wiederholung nach 401)
- save: row_not_found beim Update legt eine neue Karte an (inkl. Aufräumen)
- Karten-Seite: Editor-Abgleich auch nach dem Verlassen der Seite, Löschen-Dialog
  wird beim Verlassen geschlossen, keine doppelten Vorschau-URLs
- confirm-dialog als gemeinsames Modul (Löschen, Verwerfen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 09:32:19 +00:00
1 parent ba4d9f76d4
commit 38b361ad7a
20 files changed
+1038 -116

No files matched your search

+19 -53
View File
@@ -9,6 +9,7 @@ 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';
@@ -29,56 +30,10 @@ export function formatDate(iso: string): string {
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
}
/** Fragt, ob die Karte wirklich gelöscht werden soll (eigener Dialog statt window.confirm). */
function confirmDelete(name: string): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen');
const remove = el('button', { type: 'button', class: 'auth-btn cards-btn-danger' }, 'Löschen');
const dialog = el(
'dialog',
{ class: 'confirm-dialog', 'aria-labelledby': 'confirm-title' },
el(
'div',
{ class: 'confirm-card' },
el('h2', { id: 'confirm-title', class: 'confirm-title' }, `Karte „${name}“ löschen?`),
el('p', { class: 'confirm-text' }, 'Die Karte und ihr Bild werden endgültig gelöscht.'),
el('div', { class: 'confirm-actions' }, cancel, remove),
),
);
let answered = false;
const finish = (ok: boolean) => {
if (answered) return;
answered = true;
if (dialog.open) dialog.close();
dialog.remove();
resolve(ok);
};
cancel.addEventListener('click', () => finish(false));
remove.addEventListener('click', () => finish(true));
// Esc schließt nativ und löst `close` aus.
dialog.addEventListener('close', () => finish(false));
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus).
dialog.addEventListener('click', (event) => {
if (event.target === dialog) finish(false);
});
document.body.append(dialog);
try {
dialog.showModal();
} catch (err) {
answered = true;
dialog.remove();
reject(err);
return;
}
cancel.focus(); // das Ungefährliche zuerst
});
}
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[] = [];
@@ -123,7 +78,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
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;
previewUrls.set(card.previewFileId, URL.createObjectURL(blob));
// 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);
@@ -193,7 +149,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
});
}
// 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 });
if (change.removedId && s.saved.id === change.removedId) store.set({ saved: null, savedConfig: null });
}
function renderList(focusId?: string, focusAction?: string): void {
@@ -230,7 +186,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
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 cards-btn-danger');
const deleteButton = action('delete', 'Löschen', 'auth-btn auth-btn-small auth-btn-danger');
const buttons = [openButton, downloadButton, renameButton, deleteButton];
const tile = el(
@@ -284,19 +240,28 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
deleteButton.addEventListener('click', () => {
void run(async () => {
if (!(await confirmDelete(card.name))) return;
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);
syncEditor({ removedId: card.id });
renderList();
if (cards.length === 0) heading.focus();
else body.querySelector<HTMLElement>('.card-tile [data-action="open"]')?.focus();
@@ -345,10 +310,10 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
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)];
syncEditor({ renamed: updated });
renderList(updated.id, 'rename');
});
});
@@ -410,6 +375,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
return () => {
disposed = true;
disposal.abort();
offAuth();
queue.length = 0;
revokePreviews();