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

+85
View File
@@ -454,3 +454,88 @@ describe('Meine Karten: Löschen', () => {
expect(store.get().saved).toEqual(saved);
});
});
describe('Meine Karten: Seite wird verlassen', () => {
const savedRef = (id: string, name: string) => ({
id,
name,
imageFileId: `img-${id}`,
previewFileId: `prev-${id}`,
updatedAt: '2026-10-04T12:00:00.000Z',
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('ein offener Löschen-Dialog wird geschlossen, nichts wird gelöscht', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
unmount?.();
unmount = null;
expect(dialog()).toBeNull();
await new Promise((r) => setTimeout(r, 0));
expect(mocks.remove).not.toHaveBeenCalled();
});
it('Umbenennen, das erst nach dem Verlassen fertig wird, stellt den Editor-Zustand trotzdem richtig', async () => {
store.set({ saved: savedRef('a', 'Alpha'), cardName: 'Alpha' });
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: (c: SavedCard) => void;
mocks.rename.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Umbenennen').click();
const input = tileFor('a').querySelector<HTMLInputElement>('input')!;
input.value = 'Neu';
submit(input.closest('form')!);
await vi.waitFor(() => expect(mocks.rename).toHaveBeenCalled());
unmount?.();
unmount = null;
finish(card('a', 'Neu', { updatedAt: '2026-10-06T08:00:00.000Z' }));
await vi.waitFor(() => expect(store.get().cardName).toBe('Neu'));
expect(store.get().saved).toMatchObject({ id: 'a', name: 'Neu' });
});
it('Löschen, das erst nach dem Verlassen fertig wird, löst den Editor trotzdem von der Karte', async () => {
store.set({ saved: savedRef('a', 'Alpha'), savedConfig: structuredClone(DEFAULT_CONFIG) });
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: () => void;
mocks.remove.mockReturnValue(new Promise<void>((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(mocks.remove).toHaveBeenCalled());
unmount?.();
unmount = null;
finish();
await vi.waitFor(() => expect(store.get().saved).toBeNull());
expect(store.get().savedConfig).toBeNull();
});
it('eine zweite Vorschau derselben Datei legt keine zweite URL an (sonst bliebe die erste ungenutzt)', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
const pending: Array<(b: Blob) => void> = [];
mocks.loadPreview.mockImplementation(() => new Promise<Blob>((r) => pending.push(r)));
mount();
await vi.waitFor(() => expect(pending).toHaveLength(1));
// Abmelden und wieder anmelden lädt die Liste neu, während die erste Vorschau noch unterwegs ist.
for (const fn of mocks.authListeners) fn(null);
for (const fn of mocks.authListeners) fn(user);
await vi.waitFor(() => expect(pending).toHaveLength(2));
pending[0](new Blob(['p'], { type: 'image/webp' }));
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
pending[1](new Blob(['p'], { type: 'image/webp' }));
await new Promise((r) => setTimeout(r, 0));
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
unmount?.();
unmount = null;
expect(revoked).toEqual(['blob:preview-1']);
});
});
+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();
+1 -50
View File
@@ -1,4 +1,4 @@
/* Meine Karten: Kachelraster und Löschen-Dialog. Knöpfe und Eingabefelder kommen aus account.css (.auth-*). */
/* Meine Karten: Kachelraster. Knöpfe und Eingabefelder kommen aus account.css (.auth-*). */
.cards-page {
--card-radius: 3.711% / 5.882%; /* 57 px von 1536 x 969, wie in der Editor-Vorschau */
@@ -23,16 +23,6 @@
text-decoration: none;
}
.cards-btn-danger {
border-color: var(--danger);
color: #ff9a9d;
}
.cards-btn-danger:hover:not(:disabled) {
border-color: var(--danger);
background: rgb(229 72 77 / 0.16);
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
@@ -116,42 +106,3 @@
.card-rename-input {
flex: 1 1 100%;
}
/* Löschen bestätigen */
.confirm-dialog {
width: min(400px, calc(100vw - 32px));
padding: 0;
border: 1px solid var(--line-strong);
border-radius: 20px;
background: var(--panel);
color: var(--text);
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
}
.confirm-dialog::backdrop {
background: rgb(6 11 20 / 0.72);
}
.confirm-card {
padding: 24px;
}
.confirm-title {
margin: 0 0 8px;
font-size: 1.25rem;
line-height: 1.25;
overflow-wrap: anywhere;
}
.confirm-text {
margin: 0 0 20px;
color: var(--muted);
}
.confirm-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}