From 38b361ad7ad1e1c63d72d43fa1844a504a2aa4af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:32:19 +0000 Subject: [PATCH] fix(cards): Editor an Karte und Konto koppeln (Neue Karte, Abmelden, Kontowechsel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Editor zeigt „Bearbeitet: “, 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 --- web/src/account/account.css | 10 + web/src/account/auth.test.ts | 54 +++++ web/src/account/auth.ts | 13 ++ web/src/account/cards.test.ts | 52 +++++ web/src/account/cards.ts | 20 +- web/src/editor/auth-reset.test.ts | 193 ++++++++++++++++++ web/src/editor/auth-reset.ts | 45 +++++ web/src/editor/card-url.ts | 9 + web/src/editor/confirm-dialog.css | 42 ++++ web/src/editor/confirm-dialog.ts | 70 +++++++ web/src/editor/editor-page.test.ts | 315 ++++++++++++++++++++++++++++- web/src/editor/editor-page.ts | 77 ++++++- web/src/editor/editor.css | 21 ++ web/src/editor/preview.ts | 18 +- web/src/editor/state.test.ts | 1 + web/src/editor/state.ts | 3 + web/src/main.ts | 3 + web/src/pages/cards-page.test.ts | 85 ++++++++ web/src/pages/cards-page.ts | 72 ++----- web/src/pages/cards.css | 51 +---- 20 files changed, 1038 insertions(+), 116 deletions(-) create mode 100644 web/src/editor/auth-reset.test.ts create mode 100644 web/src/editor/auth-reset.ts create mode 100644 web/src/editor/card-url.ts create mode 100644 web/src/editor/confirm-dialog.css create mode 100644 web/src/editor/confirm-dialog.ts diff --git a/web/src/account/account.css b/web/src/account/account.css index f18d0f4..3a16bc0 100644 --- a/web/src/account/account.css +++ b/web/src/account/account.css @@ -33,6 +33,16 @@ background: var(--primary-hover); } +.auth-btn-danger { + border-color: var(--danger); + color: #ff9a9d; +} + +.auth-btn-danger:hover:not(:disabled) { + border-color: var(--danger); + background: rgb(229 72 77 / 0.16); +} + .auth-btn-small { min-height: 38px; padding: 0 16px; diff --git a/web/src/account/auth.test.ts b/web/src/account/auth.test.ts index 4b5eac2..6afd155 100644 --- a/web/src/account/auth.test.ts +++ b/web/src/account/auth.test.ts @@ -251,6 +251,60 @@ describe('logout', () => { }); }); +describe('onLogout', () => { + it('wird nach dem eigenen Abmelden aufgerufen, auch wenn die Sitzung schon weg war (401)', async () => { + const auth = await freshAuth(); + const fn = vi.fn(); + auth.onLogout(fn); + mocks.deleteSession.mockResolvedValueOnce({}); + await auth.logout(); + expect(fn).toHaveBeenCalledTimes(1); + mocks.deleteSession.mockRejectedValueOnce(fail(401, 'general_unauthorized_scope')); + await auth.logout(); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('wird nicht aufgerufen, wenn nur eine abgelaufene Sitzung bemerkt wird (getCurrentUser → 401)', async () => { + const auth = await freshAuth(); + mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' }); + await auth.getCurrentUser(); + const fn = vi.fn(); + const change = vi.fn(); + auth.onLogout(fn); + auth.onAuthChange(change); + mocks.get.mockRejectedValueOnce(fail(401, 'general_unauthorized_scope')); + await auth.getCurrentUser(); + expect(change).toHaveBeenCalledWith(null); + expect(fn).not.toHaveBeenCalled(); + }); + + it('wird nicht aufgerufen, wenn das Abmelden scheitert, und auch nicht beim Login mit alter Sitzung', async () => { + const auth = await freshAuth(); + const fn = vi.fn(); + auth.onLogout(fn); + mocks.deleteSession.mockRejectedValueOnce(new TypeError('Failed to fetch')); + await expect(auth.logout()).rejects.toBeInstanceOf(TypeError); + expect(fn).not.toHaveBeenCalled(); + + mocks.createEmailPasswordSession + .mockRejectedValueOnce(fail(409, 'user_session_already_exists')) + .mockResolvedValueOnce({}); + mocks.deleteSession.mockResolvedValueOnce({}); + mocks.get.mockResolvedValue({ $id: 'u2', email: 'b@b.de' }); + await auth.login('b@b.de', 'geheim123'); + expect(fn).not.toHaveBeenCalled(); + }); + + it('gibt eine Abmelde-Funktion zurück', async () => { + const auth = await freshAuth(); + const fn = vi.fn(); + auth.onLogout(fn)(); + mocks.deleteSession.mockResolvedValue({}); + await auth.logout(); + expect(fn).not.toHaveBeenCalled(); + }); +}); + describe('onAuthChange', () => { it('gibt eine Abmelde-Funktion zurück', async () => { const auth = await freshAuth(); diff --git a/web/src/account/auth.ts b/web/src/account/auth.ts index fa50ea2..0392598 100644 --- a/web/src/account/auth.ts +++ b/web/src/account/auth.ts @@ -11,6 +11,7 @@ const PASSWORD_MAX = 256; let cachedUser: User | null = null; const listeners = new Set<(user: User | null) => void>(); +const logoutListeners = new Set<() => void>(); function notify(): void { // Kopie, damit sich ein Abonnent beim Verteilen abmelden darf. @@ -91,6 +92,7 @@ export async function logout(): Promise { // 401: die Sitzung gab es schon nicht mehr – das Ziel „abgemeldet“ ist erreicht. await deleteCurrentSession(); setUser(null); + for (const fn of [...logoutListeners]) fn(); } export async function requestPasswordReset(email: string): Promise { @@ -109,6 +111,17 @@ export function onAuthChange(fn: (user: User | null) => void): () => void { }; } +/** + * Registriert eine Funktion, die nur aufgerufen wird, wenn der Nutzer sich selbst abgemeldet hat – nicht, + * wenn eine abgelaufene Sitzung bemerkt wird (dann meldet nur onAuthChange `null`). Gibt die Abmelde-Funktion zurück. + */ +export function onLogout(fn: () => void): () => void { + logoutListeners.add(fn); + return () => { + logoutListeners.delete(fn); + }; +} + /** null, wenn das Passwort passt; sonst die Meldung für den Nutzer. */ export function validatePassword(pw: string): string | null { return pw.length >= PASSWORD_MIN && pw.length <= PASSWORD_MAX diff --git a/web/src/account/cards.test.ts b/web/src/account/cards.test.ts index aab5a03..180338d 100644 --- a/web/src/account/cards.test.ts +++ b/web/src/account/cards.test.ts @@ -365,6 +365,58 @@ describe('save: bestehende Karte', () => { expect(saved.imageFileId).toBe('file1'); }); + describe('die Zeile gibt es nicht mehr (row_not_found beim Update)', () => { + const gone = () => new AppwriteException('Row not found', 404, 'row_not_found'); + + it('mit Bildwechsel: legt eine neue Karte mit den eben hochgeladenen Dateien an, räumt die alten ab', async () => { + const fake = fakeApi([existing()]); + fake.failOnce.set('updateRow', gone()); + const saved = await service(fake).save(newInput({ name: 'Neu', imageChanged: true, previous: previous() })); + + expect(fake.names()).toEqual(['createFile', 'createFile', 'updateRow', 'createRow', 'deleteFile', 'deleteFile']); + expect(fake.methods('createRow')[0].args).toEqual([ + { name: 'Neu', config: serializeConfig(config()), imageFileId: 'file1', previewFileId: 'file2' }, + OWNER, + ]); + expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['oldImage', 'oldPreview']); + expect(saved.id).toBe('row-new'); + expect(saved.imageFileId).toBe('file1'); + expect(saved.previewFileId).toBe('file2'); + }); + + it('ohne Bildwechsel: das Bild wird aus dem Editor neu hochgeladen (die alte Datei gehörte der verschwundenen Karte)', async () => { + const fake = fakeApi([existing()]); + fake.failOnce.set('updateRow', gone()); + const saved = await service(fake).save(newInput({ imageChanged: false, previous: previous() })); + + const files = fake.methods('createFile'); + expect(files).toHaveLength(2); + expect((files[0].args[0] as File).name).toBe('preview.webp'); + expect((files[1].args[0] as File).name).toBe('image.png'); + expect(files[1].args[1]).toEqual(OWNER); + expect(fake.methods('createRow')[0].args[0]).toMatchObject({ imageFileId: 'file2', previewFileId: 'file1' }); + expect(saved).toMatchObject({ id: 'row-new', imageFileId: 'file2', previewFileId: 'file1' }); + expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['oldImage', 'oldPreview']); + }); + + it('scheitert auch das Anlegen, werden alle neuen Dateien gelöscht, die alten bleiben, der Fehler kommt an', async () => { + const fake = fakeApi([existing()]); + const boom = new Error('createRow'); + fake.failOnce.set('updateRow', gone()); + fake.failOnce.set('createRow', boom); + await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toBe(boom); + expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']); + }); + + it('andere Update-Fehler legen keine neue Karte an', async () => { + const fake = fakeApi([existing()]); + const boom = new AppwriteException('Server', 500, 'general_server_error'); + fake.failOnce.set('updateRow', boom); + await expect(service(fake).save(newInput({ previous: previous() }))).rejects.toBe(boom); + expect(fake.methods('createRow')).toHaveLength(0); + }); + }); + it('wirft userId(), wird nichts hochgeladen und nichts geändert', async () => { const fake = fakeApi([existing()]); const svc = service(fake, () => { diff --git a/web/src/account/cards.ts b/web/src/account/cards.ts index cef2cfa..70720c4 100644 --- a/web/src/account/cards.ts +++ b/web/src/account/cards.ts @@ -5,6 +5,7 @@ import { parseConfig, serializeConfig, type CardConfig } from '../card/config'; import type { SavedCardRef } from '../editor/state'; import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite'; import { currentUserId } from './auth'; +import { errorType } from './errors'; export interface CardRowData { name: string; @@ -106,11 +107,25 @@ export function createCardsService(api: CardsApi, userId: () => string) { }; let row: CardRow; + // Die Zeile gab es nicht mehr (anderer Tab, anderes Konto): dann wurde eine neue Karte angelegt, die alten Dateien sind Müll. + let replacedMissingCard = false; try { const imageFileId = imageFile ? await upload(imageFile) : (prev as SavedCardRef).imageFileId; const previewFileId = await upload(previewFile); const data: CardRowData = { name, config, imageFileId, previewFileId }; - row = prev ? await api.updateRow(prev.id, data) : await api.createRow(data, permissions); + if (!prev) { + row = await api.createRow(data, permissions); + } else { + try { + row = await api.updateRow(prev.id, data); + } catch (err) { + if (errorType(err) !== 'row_not_found') throw err; + replacedMissingCard = true; + // Ohne Bildwechsel zeigt `data` auf die Datei der verschwundenen Karte; die neue Karte bekommt ein eigenes Bild. + const ownImageId = imageFile ? imageFileId : await upload(toUploadFile(input.image, 'image')); + row = await api.createRow({ ...data, imageFileId: ownImageId }, permissions); + } + } } catch (err) { await deleteQuietly(api, uploaded); throw err; @@ -118,7 +133,8 @@ export function createCardsService(api: CardsApi, userId: () => string) { if (prev) { // Die Karte zeigt schon auf die neuen Dateien; die alten sind nur noch Müll. - await deleteQuietly(api, [prev.previewFileId, ...(uploadImage ? [prev.imageFileId] : [])]); + const oldFiles = replacedMissingCard || uploadImage ? [prev.previewFileId, prev.imageFileId] : [prev.previewFileId]; + await deleteQuietly(api, oldFiles); } return toSaved(row); } diff --git a/web/src/editor/auth-reset.test.ts b/web/src/editor/auth-reset.test.ts new file mode 100644 index 0000000..3225304 --- /dev/null +++ b/web/src/editor/auth-reset.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { User } from '../account/auth'; +import { DEFAULT_CONFIG } from '../card/config'; +import { bindEditorToAuth, type AuthSource } from './auth-reset'; +import { createStore, initialEditorState, type EditorStore } from './state'; + +const userA: User = { $id: 'a', email: 'a@x.de' }; +const userB: User = { $id: 'b', email: 'b@x.de' }; + +/** Eine Anmelde-Quelle, die der Test von Hand auslöst. */ +function fakeAuth(initial: string | null = null) { + const changes = new Set<(user: User | null) => void>(); + const logouts = new Set<() => void>(); + let id = initial; + const auth: AuthSource = { + currentUserId: () => id, + onAuthChange(fn) { + changes.add(fn); + return () => changes.delete(fn); + }, + onLogout(fn) { + logouts.add(fn); + return () => logouts.delete(fn); + }, + }; + return { + auth, + /** Der Nutzer wechselt (Anmelden, Kontowechsel, abgelaufene Sitzung bemerkt). */ + change(user: User | null) { + id = user?.$id ?? null; + for (const fn of [...changes]) fn(user); + }, + /** Der Nutzer klickt „Abmelden“: erst `null`, dann das Abmelde-Signal (wie auth.logout). */ + logout() { + id = null; + for (const fn of [...changes]) fn(null); + for (const fn of [...logouts]) fn(); + }, + counts: () => [changes.size, logouts.size], + }; +} + +const ref = { id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: '2026-10-04T12:00:00.000Z' }; + +let store: EditorStore; + +/** Ein Editor mit Entwurf: Bild, geänderte Konfiguration, gespeicherte Karte. */ +function design() { + const blob = new Blob(['x'], { type: 'image/png' }); + const config = { ...structuredClone(DEFAULT_CONFIG), template: 'minimal' as const }; + store.set({ + image: { source: {} as CanvasImageSource, w: 10, h: 10 }, + imageBlob: blob, + imageChanged: false, + config, + saved: ref, + savedConfig: config, + cardName: 'Urlaub', + }); + return { blob, config }; +} + +beforeEach(() => { + store = createStore(initialEditorState()); + history.replaceState(null, '', '/'); +}); + +describe('bindEditorToAuth', () => { + it('Abmelden: der Editor wird ganz geleert und ?karte= fällt aus der Adresse', () => { + history.replaceState(null, '', '/?karte=k1'); + const fake = fakeAuth('a'); + bindEditorToAuth(store, fake.auth); + design(); + fake.logout(); + expect(store.get()).toEqual(initialEditorState()); + expect(store.get().image).toBeNull(); + expect(store.get().saved).toBeNull(); + expect(location.search).toBe(''); + expect(location.pathname).toBe('/'); + }); + + it('Abmelden: der Anfangszustand besteht aus frischen Objekten (nichts geteilt mit dem alten)', () => { + const fake = fakeAuth('a'); + bindEditorToAuth(store, fake.auth); + const { config } = design(); + fake.logout(); + expect(store.get().config).not.toBe(config); + expect(store.get().config).not.toBe(DEFAULT_CONFIG); + expect(store.get().config.crop).not.toBe(DEFAULT_CONFIG.crop); + }); + + it('Kontowechsel A → B: Entwurf bleibt, aber ohne Karte und als „Bild noch nicht gespeichert“', () => { + history.replaceState(null, '', '/?karte=k1'); + const fake = fakeAuth('a'); + bindEditorToAuth(store, fake.auth); + const { blob, config } = design(); + fake.change(userB); + const s = store.get(); + expect(s.saved).toBeNull(); + expect(s.savedConfig).toBeNull(); + expect(s.imageChanged).toBe(true); + expect(s.imageBlob).toBe(blob); + expect(s.config).toBe(config); + expect(s.image).not.toBeNull(); + expect(location.search).toBe(''); + }); + + it('Anmelden ohne Vorgänger (null → Nutzer): nichts ändert sich', () => { + const fake = fakeAuth(null); + bindEditorToAuth(store, fake.auth); + const blob = new Blob(['x'], { type: 'image/png' }); + store.set({ image: { source: {} as CanvasImageSource, w: 10, h: 10 }, imageBlob: blob, imageChanged: true }); + const before = store.get(); + fake.change(userA); + expect(store.get()).toBe(before); + expect(store.get().imageBlob).toBe(blob); + expect(store.get().saved).toBeNull(); + }); + + it('derselbe Nutzer meldet sich erneut (z. B. Benachrichtigung ohne Wechsel): nichts ändert sich', () => { + const fake = fakeAuth('a'); + bindEditorToAuth(store, fake.auth); + design(); + const before = store.get(); + fake.change(userA); + expect(store.get()).toBe(before); + }); + + it('abgelaufene Sitzung (null ohne Abmelden), dann derselbe Nutzer: Entwurf und Karte bleiben', () => { + const fake = fakeAuth('a'); + bindEditorToAuth(store, fake.auth); + design(); + const before = store.get(); + fake.change(null); + expect(store.get()).toBe(before); + fake.change(userA); + expect(store.get()).toBe(before); + expect(store.get().saved).toEqual(ref); + }); + + it('abgelaufene Sitzung, dann ein anderer Nutzer: wie A → B', () => { + const fake = fakeAuth('a'); + bindEditorToAuth(store, fake.auth); + design(); + fake.change(null); + fake.change(userB); + expect(store.get().saved).toBeNull(); + expect(store.get().imageChanged).toBe(true); + expect(store.get().image).not.toBeNull(); + }); + + it('nach dem Abmelden und neuer Anmeldung eines anderen Nutzers bleibt der Editor leer', () => { + const fake = fakeAuth('a'); + bindEditorToAuth(store, fake.auth); + design(); + fake.logout(); + fake.change(userB); + expect(store.get()).toEqual(initialEditorState()); + }); + + it('der erste Nutzer, der nach dem Seitenstart bekannt wird (Startprüfung), ändert nichts', () => { + // Beim Start ist der Cache noch leer; die Startprüfung meldet dann den Nutzer. + const fake = fakeAuth(null); + bindEditorToAuth(store, fake.auth); + const before = store.get(); + fake.change(userA); + expect(store.get()).toBe(before); + // … und ein späterer Wechsel wird erkannt. + design(); + fake.change(userB); + expect(store.get().saved).toBeNull(); + }); + + it('lässt andere Teile der Adresse stehen', () => { + history.replaceState(null, '', '/?karte=k1&x=1#h'); + const fake = fakeAuth('a'); + bindEditorToAuth(store, fake.auth); + fake.logout(); + expect(location.search).toBe('?x=1'); + expect(location.hash).toBe('#h'); + }); + + it('die Abmelde-Funktion entfernt beide Abonnements', () => { + const fake = fakeAuth('a'); + const off = bindEditorToAuth(store, fake.auth); + expect(fake.counts()).toEqual([1, 1]); + off(); + expect(fake.counts()).toEqual([0, 0]); + const spy = vi.spyOn(store, 'set'); + fake.logout(); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/editor/auth-reset.ts b/web/src/editor/auth-reset.ts new file mode 100644 index 0000000..6cd031c --- /dev/null +++ b/web/src/editor/auth-reset.ts @@ -0,0 +1,45 @@ +// Hält den Editor-Zustand (er überlebt Seitenwechsel) mit dem angemeldeten Konto in Einklang: +// eine gespeicherte Karte gehört zu genau einem Konto und darf nicht ins nächste mitwandern. +import { currentUserId, onAuthChange, onLogout, type User } from '../account/auth'; +import { dropCardParam } from './card-url'; +import { initialEditorState, type EditorStore } from './state'; + +export interface AuthSource { + onAuthChange(fn: (user: User | null) => void): () => void; + onLogout(fn: () => void): () => void; + currentUserId(): string | null; +} + +const realAuth: AuthSource = { onAuthChange, onLogout, currentUserId }; + +/** + * - Abmelden: der Editor wird ganz geleert (kein Bild des Vorgängers auf einem geteilten Gerät) und `?karte=` fällt weg. + * - Anderes Konto (A → B): die Karte gehört A; der Entwurf bleibt, wird für B aber eine neue, noch nicht gespeicherte Karte. + * - Anmelden ohne Vorgänger (null → Nutzer): nichts ändert sich – „gestalten, Speichern, anmelden“ darf den Entwurf nicht kosten. + * - Eine abgelaufene Sitzung (onAuthChange(null) ohne Abmelden) verliert den Entwurf ebenfalls nicht; wer sich danach als + * anderer Nutzer anmeldet, wird wie A → B behandelt. + * Gibt die Funktion zum Abmelden der Abonnements zurück. + */ +export function bindEditorToAuth(store: EditorStore, auth: AuthSource = realAuth): () => void { + let lastUserId = auth.currentUserId(); + + const offChange = auth.onAuthChange((user) => { + if (!user) return; + if (lastUserId !== null && lastUserId !== user.$id) { + store.set({ saved: null, savedConfig: null, imageChanged: true }); + dropCardParam(); + } + lastUserId = user.$id; + }); + + const offLogout = auth.onLogout(() => { + lastUserId = null; + store.set(initialEditorState()); + dropCardParam(); + }); + + return () => { + offChange(); + offLogout(); + }; +} diff --git a/web/src/editor/card-url.ts b/web/src/editor/card-url.ts new file mode 100644 index 0000000..4958c79 --- /dev/null +++ b/web/src/editor/card-url.ts @@ -0,0 +1,9 @@ +// Die Adresse des Editors kann eine Karte nennen (`/?karte=`). + +/** Entfernt `karte` aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen nicht dieselbe Karte öffnet. */ +export function dropCardParam(): void { + const url = new URL(location.href); + if (!url.searchParams.has('karte')) return; + url.searchParams.delete('karte'); + history.replaceState(null, '', url.pathname + url.search + url.hash); +} diff --git a/web/src/editor/confirm-dialog.css b/web/src/editor/confirm-dialog.css new file mode 100644 index 0000000..1939b10 --- /dev/null +++ b/web/src/editor/confirm-dialog.css @@ -0,0 +1,42 @@ +/* Bestätigungsdialog (Löschen, Verwerfen). Knöpfe kommen aus account.css (.auth-btn). */ + +.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-title + .confirm-actions { + margin-top: 12px; /* ohne Text unter dem Titel */ +} + +.confirm-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} diff --git a/web/src/editor/confirm-dialog.ts b/web/src/editor/confirm-dialog.ts new file mode 100644 index 0000000..d304853 --- /dev/null +++ b/web/src/editor/confirm-dialog.ts @@ -0,0 +1,70 @@ +// Bestätigungsdialog als eigenes (kein window.confirm): Löschen einer Karte, Verwerfen von Änderungen. +import '../account/account.css'; +import './confirm-dialog.css'; +import { el } from './dom'; + +export interface ConfirmOptions { + title: string; + text?: string; + confirmLabel: string; + cancelLabel?: string; +} + +/** + * Fragt nach und löst mit true auf, wenn bestätigt wurde. Abbrechen, Esc, Klick auf den Hintergrund und `signal` + * (z. B. beim Verlassen der Seite) lösen mit false auf. Fokus liegt zuerst auf „Abbrechen“. + */ +export function confirmDialog(options: ConfirmOptions, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + resolve(false); + return; + } + const cancel = el('button', { type: 'button', class: 'auth-btn' }, options.cancelLabel ?? 'Abbrechen'); + const confirm = el('button', { type: 'button', class: 'auth-btn auth-btn-danger' }, options.confirmLabel); + const dialog = el( + 'dialog', + { class: 'confirm-dialog', 'aria-labelledby': 'confirm-title' }, + el( + 'div', + { class: 'confirm-card' }, + el('h2', { id: 'confirm-title', class: 'confirm-title' }, options.title), + options.text ? el('p', { class: 'confirm-text' }, options.text) : '', + el('div', { class: 'confirm-actions' }, cancel, confirm), + ), + ); + + let answered = false; + const finish = (ok: boolean) => { + if (answered) return; + answered = true; + signal?.removeEventListener('abort', onAbort); + if (dialog.open) dialog.close(); + dialog.remove(); + resolve(ok); + }; + const onAbort = () => finish(false); + + cancel.addEventListener('click', () => finish(false)); + confirm.addEventListener('click', () => finish(true)); + // Esc schließt nativ und löst `close` aus. + dialog.addEventListener('close', () => finish(false)); + // Hintergrund = Klick auf das selbst (die Karte füllt es ganz aus). + dialog.addEventListener('click', (event) => { + if (event.target === dialog) finish(false); + }); + signal?.addEventListener('abort', onAbort); + + document.body.append(dialog); + try { + dialog.showModal(); + } catch (err) { + answered = true; + signal?.removeEventListener('abort', onAbort); + dialog.remove(); + reject(err); + return; + } + cancel.focus(); // das Ungefährliche zuerst + }); +} diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts index 109e992..184a29e 100644 --- a/web/src/editor/editor-page.test.ts +++ b/web/src/editor/editor-page.test.ts @@ -15,10 +15,19 @@ const mocks = vi.hoisted(() => ({ decodeImage: vi.fn(), loadCardAssets: vi.fn(), exportCard: vi.fn(), + logoutListeners: new Set<() => void>(), })); vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); -vi.mock('../account/auth', () => ({ getCurrentUser: mocks.getCurrentUser, currentUserId: () => 'u1', onAuthChange: () => () => {} })); +vi.mock('../account/auth', () => ({ + getCurrentUser: mocks.getCurrentUser, + currentUserId: () => 'u1', + onAuthChange: () => () => {}, + onLogout: (fn: () => void) => { + mocks.logoutListeners.add(fn); + return () => mocks.logoutListeners.delete(fn); + }, +})); vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin })); vi.mock('../account/cards', () => ({ cardsService: () => ({ get: mocks.get, save: mocks.save, loadImage: mocks.loadImage }), @@ -54,7 +63,8 @@ let store: EditorStore; let unmount: (() => void) | null; beforeEach(() => { - for (const fn of Object.values(mocks)) fn.mockReset(); + for (const fn of Object.values(mocks)) if (typeof fn === 'function') fn.mockReset(); + mocks.logoutListeners.clear(); vi.stubGlobal('requestAnimationFrame', () => 0); vi.stubGlobal('cancelAnimationFrame', () => {}); history.replaceState(null, '', '/'); @@ -451,3 +461,304 @@ describe('Karte öffnen (/?karte=)', () => { await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); }); }); + +describe('Bindung an eine gespeicherte Karte', () => { + const ref = (patch: Record = {}) => ({ + id: 'k1', + name: 'Urlaub', + imageFileId: 'img1', + previewFileId: 'prev1', + updatedAt: '2026-10-04T12:00:00.000Z', + ...patch, + }); + const boundRow = () => root.querySelector('.bound-row')!; + const boundLabel = () => root.querySelector('.bound-label')!; + const newCardButton = () => [...root.querySelectorAll('button')].find((b) => b.textContent === 'Neue Karte') as HTMLButtonElement; + const discardDialog = () => document.querySelector('dialog.confirm-dialog'); + const dialogButton = (label: string) => + [...discardDialog()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement; + + /** Ein Editor, der eine gespeicherte Karte hält (nichts Ungespeichertes). */ + function boundEditor(patch: Partial> = {}) { + const { blob, img } = withImage(); + const config = store.get().config; + store.set({ saved: ref(), savedConfig: config, cardName: 'Urlaub', imageChanged: false, ...patch }); + return { blob, img, config }; + } + + describe('Anzeige „Bearbeitet: “', () => { + it('ist ohne gespeicherte Karte verborgen', () => { + withImage(); + mount(); + expect(boundRow().hidden).toBe(true); + expect(boundLabel().textContent).toBe(''); + }); + + it('zeigt den Namen der Karte, solange `saved` gesetzt ist', () => { + boundEditor(); + mount(); + expect(boundRow().hidden).toBe(false); + expect(boundLabel().textContent).toBe('Bearbeitet: Urlaub'); + expect(newCardButton().hidden).toBe(false); + }); + + it('erscheint nach dem ersten Speichern und folgt einer Umbenennung', async () => { + withImage(); + mocks.save.mockResolvedValue(savedCard({ id: 'neu', name: 'Meine Karte' })); + mount(); + saveButton().click(); + await vi.waitFor(() => expect(boundLabel().textContent).toBe('Bearbeitet: Meine Karte')); + store.set({ saved: ref({ id: 'neu', name: 'Umbenannt' }) }); + expect(boundLabel().textContent).toBe('Bearbeitet: Umbenannt'); + }); + + it('zeigt den Namen nur als Text, nie als HTML', () => { + boundEditor({ saved: ref({ name: 'fett' }) }); + mount(); + expect(boundLabel().textContent).toBe('Bearbeitet: fett'); + expect(boundLabel().children).toHaveLength(0); + }); + + it('ist nach dem Öffnen einer Karte da', async () => { + history.replaceState(null, '', '/?karte=k1'); + mocks.get.mockResolvedValue(savedCard()); + mocks.loadImage.mockResolvedValue(png()); + mocks.decodeImage.mockResolvedValue(image()); + mount(); + expect(boundRow().hidden).toBe(true); + await vi.waitFor(() => expect(boundRow().hidden).toBe(false)); + expect(boundLabel().textContent).toBe('Bearbeitet: Urlaub'); + }); + + it('„Neue Karte“ ist während des Speicherns gesperrt', async () => { + boundEditor(); + let finish!: (c: SavedCard) => void; + mocks.save.mockReturnValue(new Promise((r) => (finish = r))); + mount(); + saveButton().click(); + await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled()); + expect(newCardButton().disabled).toBe(true); + finish(savedCard()); + await vi.waitFor(() => expect(newCardButton().disabled).toBe(false)); + }); + }); + + describe('„Neue Karte“', () => { + it('setzt den Editor zurück (frische Objekte) und entfernt ?karte= aus der Adresse', () => { + history.replaceState(null, '', '/?karte=k1'); + const { config } = boundEditor(); + mount(); + newCardButton().click(); + + expect(discardDialog()).toBeNull(); // nichts Ungespeichertes: keine Rückfrage + const s = store.get(); + expect(s).toEqual(initialEditorState()); + expect(s.saved).toBeNull(); + expect(s.savedConfig).toBeNull(); + expect(s.image).toBeNull(); + expect(s.imageBlob).toBeNull(); + expect(s.config).toEqual(DEFAULT_CONFIG); + expect(s.config).not.toBe(DEFAULT_CONFIG); + expect(s.config.crop).not.toBe(DEFAULT_CONFIG.crop); + expect(s.config).not.toBe(config); + expect(location.pathname + location.search).toBe('/'); + expect(boundRow().hidden).toBe(true); + }); + + it('lässt die Adresse unberührt, wenn sie keine Karte nennt', () => { + boundEditor(); + mount(); + newCardButton().click(); + expect(location.pathname + location.search).toBe('/'); + expect(store.get().saved).toBeNull(); + }); + + it('bricht eine noch ladende Karte ab', async () => { + history.replaceState(null, '', '/?karte=k2'); + boundEditor(); + let finish!: (c: SavedCard) => void; + mocks.get.mockReturnValue(new Promise((r) => (finish = r))); + mount(); + await vi.waitFor(() => expect(mocks.get).toHaveBeenCalled()); + newCardButton().click(); + expect(store.get().saved).toBeNull(); + finish(savedCard({ id: 'k2' })); + await new Promise((r) => setTimeout(r, 0)); + expect(mocks.loadImage).not.toHaveBeenCalled(); + expect(store.get().saved).toBeNull(); + expect(noticeText()).toBe(''); + }); + + it('verwirft kein noch dekodiertes Bild in den leeren Editor', async () => { + boundEditor(); + let decoded!: (i: CardImage) => void; + mocks.decodeImage.mockReturnValue(new Promise((r) => (decoded = r))); + mount(); + const file = new File(['x'], 'neu.png', { type: 'image/png' }); + const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown }; + drop.dataTransfer = { types: ['Files'], files: [file] }; + root.querySelector('.editor')!.dispatchEvent(drop); + await vi.waitFor(() => expect(mocks.decodeImage).toHaveBeenCalled()); + // imageChanged ist noch false (das Bild ist nicht fertig), also keine Rückfrage + newCardButton().click(); + decoded(image()); + await new Promise((r) => setTimeout(r, 0)); + expect(store.get().image).toBeNull(); + }); + + it('fragt bei einem ersetzten Bild nach; „Abbrechen“ ändert nichts', async () => { + const { blob } = boundEditor(); + store.set({ imageChanged: true }); + mount(); + newCardButton().click(); + await vi.waitFor(() => expect(discardDialog()?.open).toBe(true)); + expect(discardDialog()!.querySelector('h2')!.textContent).toBe('Ungespeicherte Änderungen verwerfen?'); + dialogButton('Abbrechen').click(); + await vi.waitFor(() => expect(discardDialog()).toBeNull()); + expect(store.get().saved?.id).toBe('k1'); + expect(store.get().imageBlob).toBe(blob); + await vi.waitFor(() => expect(newCardButton().disabled).toBe(false)); + }); + + it('fragt bei geänderter Konfiguration nach; „Verwerfen“ setzt zurück', async () => { + boundEditor(); + store.set({ config: { ...store.get().config, template: 'minimal' } }); + mount(); + newCardButton().click(); + await vi.waitFor(() => expect(discardDialog()?.open).toBe(true)); + dialogButton('Verwerfen').click(); + await vi.waitFor(() => expect(store.get().saved).toBeNull()); + expect(store.get()).toEqual(initialEditorState()); + expect(discardDialog()).toBeNull(); + }); + + it('Esc im Rückfrage-Dialog verwirft nichts', async () => { + boundEditor(); + store.set({ imageChanged: true }); + mount(); + newCardButton().click(); + await vi.waitFor(() => expect(discardDialog()?.open).toBe(true)); + discardDialog()!.dispatchEvent(new Event('close')); + await vi.waitFor(() => expect(discardDialog()).toBeNull()); + expect(store.get().saved?.id).toBe('k1'); + }); + + it('fragt nach dem Speichern nicht mehr (der gespeicherte Stand ist der aktuelle)', async () => { + withImage(); + mocks.save.mockResolvedValue(savedCard({ id: 'neu' })); + mount(); + saveButton().click(); + await vi.waitFor(() => expect(boundRow().hidden).toBe(false)); + await vi.waitFor(() => expect(newCardButton().disabled).toBe(false)); + newCardButton().click(); + expect(discardDialog()).toBeNull(); + expect(store.get().saved).toBeNull(); + }); + + it('fragt nach dem Öffnen einer Karte nicht, solange nichts geändert wurde', async () => { + history.replaceState(null, '', '/?karte=k1'); + mocks.get.mockResolvedValue(savedCard()); + mocks.loadImage.mockResolvedValue(png()); + mocks.decodeImage.mockResolvedValue(image(3000, 2000)); + mount(); + await vi.waitFor(() => expect(boundRow().hidden).toBe(false)); + newCardButton().click(); + expect(discardDialog()).toBeNull(); + expect(store.get().saved).toBeNull(); + }); + + it('nach „Neue Karte“ legt Speichern eine neue Karte an (createRow, kein updateRow)', async () => { + const actual = await vi.importActual('../account/cards'); + const calls: string[] = []; + const api = { + createFile: vi.fn(async (file: File) => { + calls.push(`createFile ${file.name}`); + return { $id: `f${calls.length}` }; + }), + deleteFile: vi.fn(async (id: string) => { + calls.push(`deleteFile ${id}`); + }), + fetchFile: vi.fn(), + createRow: vi.fn(async (data: Record) => { + calls.push('createRow'); + return { $id: 'row-neu', $updatedAt: '2026-10-05T10:00:00.000Z', ...data }; + }), + updateRow: vi.fn(async () => { + calls.push('updateRow'); + throw new Error('updateRow darf nicht aufgerufen werden'); + }), + getRow: vi.fn(), + deleteRow: vi.fn(), + listRows: vi.fn(), + }; + const service = actual.createCardsService(api as never, () => 'u1'); + mocks.save.mockImplementation((input) => service.save(input)); + + // Karte 1 ist gespeichert und im Editor; der Nutzer beginnt eine neue Karte und legt ein anderes Foto ab. + boundEditor(); + mount(); + newCardButton().click(); + mocks.decodeImage.mockResolvedValue(image(1000, 1000)); + const file = new File(['zweites foto'], 'zwei.png', { type: 'image/png' }); + const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown }; + drop.dataTransfer = { types: ['Files'], files: [file] }; + root.querySelector('.editor')!.dispatchEvent(drop); + await vi.waitFor(() => expect(store.get().image?.w).toBe(1000)); + + saveButton().click(); + await vi.waitFor(() => expect(store.get().saved?.id).toBe('row-neu')); + expect(calls).toEqual(['createFile image.png', 'createFile preview.webp', 'createRow']); + expect(api.updateRow).not.toHaveBeenCalled(); + expect(api.deleteFile).not.toHaveBeenCalled(); // das Original von Karte 1 bleibt unangetastet + }); + }); + + describe('Abmelden während des Ladens', () => { + it('eine noch ladende Karte landet nicht mehr im Editor', async () => { + history.replaceState(null, '', '/?karte=k1'); + let finish!: (c: SavedCard) => void; + mocks.get.mockReturnValue(new Promise((r) => (finish = r))); + mount(); + await vi.waitFor(() => expect(mocks.get).toHaveBeenCalled()); + for (const fn of mocks.logoutListeners) fn(); + finish(savedCard()); + await new Promise((r) => setTimeout(r, 0)); + expect(mocks.loadImage).not.toHaveBeenCalled(); + expect(store.get().saved).toBeNull(); + expect(noticeText()).toBe(''); + }); + + it('der Editor meldet sich beim Verlassen der Seite vom Abmelde-Signal ab', () => { + mount(); + expect(mocks.logoutListeners.size).toBe(1); + unmount?.(); + unmount = null; + expect(mocks.logoutListeners.size).toBe(0); + }); + }); +}); + +describe('Speichern: Schnappschuss beim Klick', () => { + it('die Wiederholung nach einem 401 speichert genau das, was angeklickt wurde', async () => { + withImage(); + store.set({ cardName: 'Beim Klick', config: { ...store.get().config, template: 'classic' } }); + mocks.save + .mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 })) + .mockResolvedValueOnce(savedCard()); + // Während der Login-Dialog offen ist, ändert der Nutzer (hinter dem Dialog) den Editor. + mocks.requireLogin.mockImplementation(async () => { + store.set({ cardName: 'Danach', config: { ...store.get().config, template: 'image' } }); + return true; + }); + mount(); + saveButton().click(); + await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert')); + expect(mocks.save).toHaveBeenCalledTimes(2); + for (const [input] of mocks.save.mock.calls) { + expect(input.name).toBe('Beim Klick'); + expect(input.config.template).toBe('classic'); + } + expect(mocks.exportCard.mock.calls[1][0].template).toBe('classic'); + }); +}); + diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts index 90119af..ecf6d0e 100644 --- a/web/src/editor/editor-page.ts +++ b/web/src/editor/editor-page.ts @@ -1,14 +1,16 @@ // Editor-Seite: setzt Vorlagen, Vorschau und Optionen zusammen und verbindet sie mit dem Store. import './editor.css'; import { cardsService } from '../account/cards'; -import { getCurrentUser } from '../account/auth'; +import { getCurrentUser, onLogout } from '../account/auth'; import { errorMessage, errorType } from '../account/errors'; import { requireLogin } from '../account/login-dialog'; import { LoginCancelled, withLogin } from '../account/with-login'; -import type { BrandId, CardConfig, DetailsTone, NetworkId, TemplateId } from '../card/config'; +import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type NetworkId, type TemplateId } from '../card/config'; import { clampCrop, panBy, zoomTo } from '../card/crop'; import { CARD_W } from '../card/geometry'; import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render'; +import { dropCardParam } from './card-url'; +import { confirmDialog } from './confirm-dialog'; import { downloadFileName, pngSizeWarning, saveBlobAs } from './download'; import { el } from './dom'; import { createOptionsPanel } from './options-panel'; @@ -43,6 +45,12 @@ function toRef(card: SavedCardRef): SavedCardRef { }; } +/** Gibt es am Editor etwas, das beim Wechsel auf eine neue Karte verloren ginge? (Nur für eine gespeicherte Karte gefragt.) */ +function hasUnsavedChanges(s: EditorState): boolean { + if (s.imageChanged) return true; + return s.savedConfig !== null && serializeConfig(s.config) !== serializeConfig(s.savedConfig); +} + export function mountEditor(root: HTMLElement, store: EditorStore): () => void { let disposed = false; @@ -76,8 +84,13 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { preview.setError(NO_IMAGE); return; } - void saveCard(); + // Der Schnappschuss gilt für alles, was dieser Klick auslöst – auch für die Wiederholung nach einem 401. + clickSnapshot = takeSnapshot(); + void saveCard().finally(() => { + clickSnapshot = null; + }); }, + onNewCard: () => void startNewCard(), }); const heading = el('h1', { class: 'sr-only' }, 'Kartenmotiv gestalten'); @@ -147,9 +160,15 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { store.set({ saving: status === 'saving' }); } - async function doSave(): Promise { - // Ab hier zählt nur dieser Schnappschuss: Bild, Ausschnitt und Name, wie sie beim Klick waren. + /** Alles, was ein Speichern braucht, wie es beim Klick war: spätere Änderungen im Editor betreffen es nicht. */ + function takeSnapshot() { const { config, image, imageBlob, imageChanged, cardName, saved } = store.get(); + return { config, image, imageBlob, imageChanged, cardName, saved }; + } + let clickSnapshot: ReturnType | null = null; + + async function doSave(): Promise { + const { config, image, imageBlob, imageChanged, cardName, saved } = clickSnapshot ?? takeSnapshot(); if (!image || !imageBlob) throw new Error(NO_IMAGE); const assets = await loadCardAssets(config, image); const previewBlob = await exportCard(config, assets, { @@ -168,7 +187,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { // Hat inzwischen eine andere Karte den Editor übernommen (Öffnen aus „Meine Karten“), bleibt deren Zustand unberührt. const now = store.get(); if (now.imageBlob === imageBlob && now.saved?.id === saved?.id) { - store.set({ saved: toRef(card), imageChanged: false, cardName: card.name }); + store.set({ saved: toRef(card), savedConfig: config, imageChanged: false, cardName: card.name }); } } @@ -210,12 +229,14 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { const image: CardImage | null = blob ? await decodeImage(blob) : null; if (stale()) return; + const config = image ? { ...card.config, crop: clampCrop(image, card.config.crop) } : card.config; store.set({ - config: image ? { ...card.config, crop: clampCrop(image, card.config.crop) } : card.config, + config, image, imageBlob: blob, imageChanged: false, saved: toRef(card), + savedConfig: config, cardName: card.name, }); preview.setWarning(missingImage); @@ -235,6 +256,39 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { } } + // „Neue Karte“: löst den Editor von der gespeicherten Karte, damit ein anderes Bild sie nicht überschreibt. + let askingToDiscard = false; + async function startNewCard(): Promise { + if (askingToDiscard || store.get().saving || !store.get().saved) return; + if (hasUnsavedChanges(store.get())) { + askingToDiscard = true; + let discard = false; + try { + discard = await confirmDialog({ + title: 'Ungespeicherte Änderungen verwerfen?', + text: 'Alles, was du seit dem letzten Speichern geändert hast, geht verloren.', + confirmLabel: 'Verwerfen', + }); + } catch { + // Dialog nicht darstellbar: dann lieber nichts verwerfen. + } finally { + askingToDiscard = false; + } + // Währenddessen kann gespeichert oder die Seite verlassen worden sein. + if (!discard || disposed || store.get().saving) return; + } + resetEditor(); + } + + function resetEditor(): void { + cancelCardLoad(); // eine noch ladende Karte soll nicht in den leeren Editor springen + fileToken += 1; // … und ein noch dekodiertes Bild auch nicht + preview.setError(null); + preview.setWarning(null); + store.set(initialEditorState()); + dropCardParam(); + } + // Logos und Schrift: werden nur bei Wechsel von Netzwerk, Details oder Logo geladen (Logos sind zwischengespeichert). let logos: (Logos & { key: string }) | null = null; let pendingKey = ''; @@ -288,7 +342,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { function sync(s: EditorState): void { templates.select(s.config.template); options.select(s.config); - preview.sync({ hasImage: s.image !== null, zoom: s.config.crop.zoom, saving: s.saving, saved: savedFlash, exporting }); + preview.sync({ hasImage: s.image !== null, zoom: s.config.crop.zoom, saving: s.saving, saved: savedFlash, boundName: s.saved?.name ?? null, exporting }); } function onState(s: EditorState): void { @@ -305,6 +359,12 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { const unsubscribe = store.subscribe(onState); onState(store.get()); + // Meldet sich der Nutzer ab, darf eine noch ladende Karte nicht mehr im (dann geleerten) Editor landen. + const offLogout = onLogout(() => { + cancelCardLoad(); + fileToken += 1; + }); + // Die Adresse wird nur beim Öffnen der Seite gelesen; hält der Editor die Karte schon, bleibt sein Stand. const cardId = new URLSearchParams(location.search).get('karte'); if (cardId && store.get().saved?.id !== cardId) void openSavedCard(cardId); @@ -312,6 +372,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { return () => { disposed = true; clearTimeout(flashTimer); + offLogout(); unsubscribe(); if (frame) cancelAnimationFrame(frame); frame = 0; diff --git a/web/src/editor/editor.css b/web/src/editor/editor.css index ec5d108..4ff9695 100644 --- a/web/src/editor/editor.css +++ b/web/src/editor/editor.css @@ -265,6 +265,27 @@ /* Mitte */ +.bound-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.bound-label { + min-width: 0; + overflow: hidden; + color: var(--muted); + font-size: 0.95rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.btn.btn-small { + min-height: 36px; + padding: 0 14px; +} + .preview-inner { position: sticky; top: 16px; diff --git a/web/src/editor/preview.ts b/web/src/editor/preview.ts index 081c6b8..fdc840b 100644 --- a/web/src/editor/preview.ts +++ b/web/src/editor/preview.ts @@ -11,6 +11,7 @@ export interface PreviewHandlers { onZoom(zoom: number): void; onDownload(): void; onSave(): void; + onNewCard(): void; } export interface PreviewView { @@ -19,6 +20,8 @@ export interface PreviewView { saving: boolean; /** true für kurze Zeit nach erfolgreichem Speichern (Knopf zeigt „Gespeichert“). */ saved: boolean; + /** Name der Karte, an die der Editor gebunden ist (Speichern ändert sie); null = neue, noch nicht gespeicherte Karte. */ + boundName: string | null; exporting: boolean; } @@ -37,6 +40,7 @@ const NO_IMAGE_HINT = 'Erst ein Bild wählen'; const SAVE_LABEL = 'Speichern'; const SAVING_LABEL = 'Speichert …'; const SAVED_LABEL = 'Gespeichert'; +const BOUND_PREFIX = 'Bearbeitet: '; const KEY_STEP = 24; const KEY_STEP_BIG = 96; const KEY_ZOOM_STEP = 0.1; @@ -169,6 +173,12 @@ export function createPreview(handlers: PreviewHandlers): Preview { const stage = el('div', { class: 'stage' }, dropzone, cardWrap); + // Bindung an eine gespeicherte Karte: Name und Ausweg „Neue Karte“ + const boundLabel = el('span', { class: 'bound-label' }); + const newCardButton = el('button', { type: 'button', class: 'btn btn-small' }, 'Neue Karte'); + newCardButton.addEventListener('click', () => handlers.onNewCard()); + const boundRow = el('div', { class: 'bound-row', hidden: true }, boundLabel, newCardButton); + // Zoom const zoomInput = el('input', { type: 'range', @@ -210,7 +220,7 @@ export function createPreview(handlers: PreviewHandlers): Preview { const note = el('p', { class: 'note' }, icon('lock', 'note-icon'), 'Dein Bild bleibt auf deinem Gerät, bis du speicherst.'); - const inner = el('div', { class: 'preview-inner' }, stage, zoomRow, errorBox, warningBox, noticeBox, actions, note, input); + const inner = el('div', { class: 'preview-inner' }, boundRow, stage, zoomRow, errorBox, warningBox, noticeBox, actions, note, input); const section = el('section', { class: 'editor-center', 'aria-label': 'Kartenvorschau' }, inner); function setDisabled(button: HTMLButtonElement, disabled: boolean, hint: string | null): void { @@ -239,6 +249,12 @@ export function createPreview(handlers: PreviewHandlers): Preview { // Ein Bildwechsel mitten im Speichern würde die gespeicherte Karte durcheinanderbringen. replaceButton.disabled = view.saving; input.disabled = view.saving; + boundRow.hidden = view.boundName === null; + // Nur als Text, nie als HTML: der Name kommt vom Nutzer. + boundLabel.textContent = view.boundName === null ? '' : BOUND_PREFIX + view.boundName; + if (view.boundName === null) boundLabel.removeAttribute('title'); + else boundLabel.setAttribute('title', BOUND_PREFIX + view.boundName); + newCardButton.disabled = view.saving; downloadButton.setAttribute('aria-busy', String(view.exporting)); saveButton.setAttribute('aria-busy', String(view.saving)); }, diff --git a/web/src/editor/state.test.ts b/web/src/editor/state.test.ts index 3119940..07dc27c 100644 --- a/web/src/editor/state.test.ts +++ b/web/src/editor/state.test.ts @@ -14,6 +14,7 @@ describe('initialEditorState', () => { expect(s.imageBlob).toBeNull(); expect(s.imageChanged).toBe(false); expect(s.saved).toBeNull(); + expect(s.savedConfig).toBeNull(); expect(s.cardName).toBe('Meine Karte'); expect(s.saving).toBe(false); }); diff --git a/web/src/editor/state.ts b/web/src/editor/state.ts index 2cbbe4e..1bb28ff 100644 --- a/web/src/editor/state.ts +++ b/web/src/editor/state.ts @@ -18,6 +18,8 @@ export interface EditorState { /** true, sobald ein neues Bild gewählt wurde, das noch nicht gespeichert ist. */ imageChanged: boolean; saved: SavedCardRef | null; + /** Die Konfiguration, wie sie zuletzt gespeichert oder geöffnet wurde (Vergleich für „Ungespeicherte Änderungen“). */ + savedConfig: CardConfig | null; cardName: string; saving: boolean; } @@ -55,6 +57,7 @@ export function initialEditorState(): EditorState { imageBlob: null, imageChanged: false, saved: null, + savedConfig: null, cardName: 'Meine Karte', saving: false, }; diff --git a/web/src/main.ts b/web/src/main.ts index d0c5e29..af97b63 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -6,10 +6,13 @@ import { startRouter } from './pages/router'; import { mountCardsPage } from './pages/cards-page'; import { mountResetPage } from './pages/reset-page'; import { mountEditor } from './editor/editor-page'; +import { bindEditorToAuth } from './editor/auth-reset'; import { createStore, initialEditorState } from './editor/state'; // Der Editor-Zustand lebt außerhalb der Seiten und überlebt Seitenwechsel. const editorStore = createStore(initialEditorState()); +// … und gehört zum angemeldeten Konto: beim Abmelden oder Kontowechsel wird er geleert bzw. entkoppelt. +bindEditorToAuth(editorStore); // Konto-Menü im Seitenkopf. Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl // (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), gilt der Nutzer als abgemeldet. diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts index 01cf7ba..0f1751c 100644 --- a/web/src/pages/cards-page.test.ts +++ b/web/src/pages/cards-page.test.ts @@ -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((r) => (finish = r))); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(1)); + button(tileFor('a'), 'Umbenennen').click(); + const input = tileFor('a').querySelector('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((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((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']); + }); +}); + diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts index f126ea0..fe4ecfa 100644 --- a/web/src/pages/cards-page.ts +++ b/web/src/pages/cards-page.ts @@ -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 { - return new Promise((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 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('.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(); diff --git a/web/src/pages/cards.css b/web/src/pages/cards.css index ba3ac47..1656fea 100644 --- a/web/src/pages/cards.css +++ b/web/src/pages/cards.css @@ -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; -}