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:
1 parent
ba4d9f76d4
commit
38b361ad7a
20 files changed
+1038
-116
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user