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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
@@ -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
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Die Adresse des Editors kann eine Karte nennen (`/?karte=<id>`).
|
||||
|
||||
/** 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Bestätigungsdialog als eigenes <dialog> (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<boolean> {
|
||||
return new Promise<boolean>((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 <dialog> 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
|
||||
});
|
||||
}
|
||||
@@ -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=<id>)', () => {
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bindung an eine gespeicherte Karte', () => {
|
||||
const ref = (patch: Record<string, string> = {}) => ({
|
||||
id: 'k1',
|
||||
name: 'Urlaub',
|
||||
imageFileId: 'img1',
|
||||
previewFileId: 'prev1',
|
||||
updatedAt: '2026-10-04T12:00:00.000Z',
|
||||
...patch,
|
||||
});
|
||||
const boundRow = () => root.querySelector<HTMLElement>('.bound-row')!;
|
||||
const boundLabel = () => root.querySelector<HTMLElement>('.bound-label')!;
|
||||
const newCardButton = () => [...root.querySelectorAll('button')].find((b) => b.textContent === 'Neue Karte') as HTMLButtonElement;
|
||||
const discardDialog = () => document.querySelector<HTMLDialogElement>('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<ReturnType<typeof initialEditorState>> = {}) {
|
||||
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: <Name>“', () => {
|
||||
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: '<img src=x onerror=alert(1)><b>fett</b>' }) });
|
||||
mount();
|
||||
expect(boundLabel().textContent).toBe('Bearbeitet: <img src=x onerror=alert(1)><b>fett</b>');
|
||||
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<SavedCard>((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<SavedCard>((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<CardImage>((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<typeof import('../account/cards')>('../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<string, string>) => {
|
||||
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<SavedCard>((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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<typeof takeSnapshot> | null = null;
|
||||
|
||||
async function doSave(): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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