feat(cards): Karten speichern und Meine Karten
- account/cards.ts: Kartendienst (neu/bestehend speichern mit Aufräumen halber Uploads, Umbenennen, Löschen, Laden) über ein schmales CardsApi-Interface, Appwrite-Anschluss mit 22.4.1-Objektparametern, fetchFile über client.prepareRequest (Projekt-Header + Cookie, kein getFileView) - account/with-login.ts: bei 401 Nutzer neu prüfen, einmal anmelden, wiederholen - editor/save.ts: Speichern-Ablauf (ein Lauf zur Zeit, 401 -> Login -> einmal wiederholen, deutsche Fehlermeldung) - Editor: Speichern mit Schnappschuss und 480-px-Vorschau, Karte öffnen über ?karte=<id>, Bildwechsel während des Speicherns gesperrt - pages/cards-page.ts: /karten mit Kacheln, Öffnen, Herunterladen, Umbenennen, Löschen (eigener Dialog), Abmelde- und Leer-Ansicht - Tests für Dienst, withLogin, Speichern-Ablauf, Karten-Seite und Editor-Anbindung Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a147d3b496
commit
ba4d9f76d4
14 files changed
+2849
-16
No files matched your search
@@ -0,0 +1,597 @@
|
|||||||
|
import { AppwriteException } from 'appwrite';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { DEFAULT_CONFIG, parseConfig, serializeConfig, type CardConfig } from '../card/config';
|
||||||
|
import type { SavedCardRef } from '../editor/state';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
prepareRequest: vi.fn(),
|
||||||
|
createRow: vi.fn(),
|
||||||
|
updateRow: vi.fn(),
|
||||||
|
getRow: vi.fn(),
|
||||||
|
deleteRow: vi.fn(),
|
||||||
|
listRows: vi.fn(),
|
||||||
|
createFile: vi.fn(),
|
||||||
|
deleteFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./appwrite', () => ({
|
||||||
|
account: {},
|
||||||
|
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
|
||||||
|
IDS: { database: 'db', table: 'cards-table', bucket: 'bucket' },
|
||||||
|
client: { prepareRequest: mocks.prepareRequest },
|
||||||
|
tables: {
|
||||||
|
createRow: mocks.createRow,
|
||||||
|
updateRow: mocks.updateRow,
|
||||||
|
getRow: mocks.getRow,
|
||||||
|
deleteRow: mocks.deleteRow,
|
||||||
|
listRows: mocks.listRows,
|
||||||
|
},
|
||||||
|
storage: { createFile: mocks.createFile, deleteFile: mocks.deleteFile },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
appwriteCardsApi,
|
||||||
|
createCardsService,
|
||||||
|
normalizeName,
|
||||||
|
ownerPermissions,
|
||||||
|
toUploadFile,
|
||||||
|
type CardRow,
|
||||||
|
type CardRowData,
|
||||||
|
type CardsApi,
|
||||||
|
type SaveInput,
|
||||||
|
} from './cards';
|
||||||
|
|
||||||
|
const NOW = '2026-10-04T10:00:00.000Z';
|
||||||
|
const OWNER = ownerPermissions('u1');
|
||||||
|
|
||||||
|
const config = (patch: Partial<CardConfig> = {}): CardConfig => ({
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
crop: { x: 12, y: -8, zoom: 1.5 },
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = (type: string) => new Blob(['x'], { type });
|
||||||
|
const png = () => blob('image/png');
|
||||||
|
const webp = () => blob('image/webp');
|
||||||
|
|
||||||
|
/** Eine bestehende Karte, wie der Editor sie als `previous` hält. */
|
||||||
|
const previous = (patch: Partial<SavedCardRef> = {}): SavedCardRef => ({
|
||||||
|
id: 'row1',
|
||||||
|
name: 'Alt',
|
||||||
|
imageFileId: 'oldImage',
|
||||||
|
previewFileId: 'oldPreview',
|
||||||
|
updatedAt: NOW,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
type Call = { method: string; args: unknown[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake-API: zeichnet Aufrufe auf. `failOnce.set('updateRow', error)` lässt den nächsten Aufruf dieser Methode scheitern,
|
||||||
|
* `failOnce.set('createFile#2', error)` den zweiten.
|
||||||
|
*/
|
||||||
|
function fakeApi(rows: CardRow[] = []) {
|
||||||
|
const calls: Call[] = [];
|
||||||
|
const failOnce = new Map<string, unknown>();
|
||||||
|
let fileCounter = 0;
|
||||||
|
const store = new Map(rows.map((r) => [r.$id, r]));
|
||||||
|
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
const enter = (method: string, ...args: unknown[]) => {
|
||||||
|
calls.push({ method, args });
|
||||||
|
const nth = (counts.get(method) ?? 0) + 1;
|
||||||
|
counts.set(method, nth);
|
||||||
|
for (const key of [method, `${method}#${nth}`]) {
|
||||||
|
if (failOnce.has(key)) {
|
||||||
|
const err = failOnce.get(key);
|
||||||
|
failOnce.delete(key);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const api: CardsApi = {
|
||||||
|
async createFile(file, permissions) {
|
||||||
|
enter('createFile', file, permissions);
|
||||||
|
fileCounter += 1;
|
||||||
|
return { $id: `file${fileCounter}` };
|
||||||
|
},
|
||||||
|
async deleteFile(fileId) {
|
||||||
|
enter('deleteFile', fileId);
|
||||||
|
},
|
||||||
|
async fetchFile(fileId) {
|
||||||
|
enter('fetchFile', fileId);
|
||||||
|
return new Blob([fileId], { type: 'image/png' });
|
||||||
|
},
|
||||||
|
async createRow(data, permissions) {
|
||||||
|
enter('createRow', data, permissions);
|
||||||
|
const row: CardRow = { $id: 'row-new', $updatedAt: NOW, ...data };
|
||||||
|
store.set(row.$id, row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
async updateRow(id, data) {
|
||||||
|
enter('updateRow', id, data);
|
||||||
|
const row = { ...store.get(id)!, ...data, $updatedAt: '2026-10-05T10:00:00.000Z' };
|
||||||
|
store.set(id, row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
async getRow(id) {
|
||||||
|
enter('getRow', id);
|
||||||
|
return store.get(id)!;
|
||||||
|
},
|
||||||
|
async deleteRow(id) {
|
||||||
|
enter('deleteRow', id);
|
||||||
|
store.delete(id);
|
||||||
|
},
|
||||||
|
async listRows() {
|
||||||
|
enter('listRows');
|
||||||
|
return [...store.values()];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const methods = (name: string) => calls.filter((c) => c.method === name);
|
||||||
|
const names = () => calls.map((c) => c.method);
|
||||||
|
return { api, calls, failOnce, methods, names };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newInput = (patch: Partial<SaveInput> = {}): SaveInput => ({
|
||||||
|
name: 'Mein Urlaub',
|
||||||
|
config: config(),
|
||||||
|
image: png(),
|
||||||
|
imageChanged: true,
|
||||||
|
preview: webp(),
|
||||||
|
previous: null,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = (fake: ReturnType<typeof fakeApi>, userId: () => string = () => 'u1') =>
|
||||||
|
createCardsService(fake.api, userId);
|
||||||
|
|
||||||
|
describe('normalizeName', () => {
|
||||||
|
it('trimmt und fasst Leerraum zusammen', () => {
|
||||||
|
expect(normalizeName(' Mein Urlaub ')).toBe('Mein Urlaub');
|
||||||
|
expect(normalizeName('a\t\nb')).toBe('a b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leer oder nur Leerraum ergibt „Meine Karte“', () => {
|
||||||
|
expect(normalizeName(' ')).toBe('Meine Karte');
|
||||||
|
expect(normalizeName('')).toBe('Meine Karte');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kürzt auf 80 Zeichen (Codepoints, nicht UTF-16-Einheiten)', () => {
|
||||||
|
expect(Array.from(normalizeName('😀'.repeat(100)))).toHaveLength(80);
|
||||||
|
expect(normalizeName('a'.repeat(81))).toHaveLength(80);
|
||||||
|
expect(normalizeName('b'.repeat(80))).toBe('b'.repeat(80));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt nach dem Kürzen kein Leerzeichen am Ende stehen', () => {
|
||||||
|
const name = `${'a'.repeat(79)} xyz`;
|
||||||
|
expect(normalizeName(name)).toBe('a'.repeat(79));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ownerPermissions', () => {
|
||||||
|
it('gibt dem Besitzer lesen, ändern und löschen', () => {
|
||||||
|
expect(ownerPermissions('abc')).toEqual(['read("user:abc")', 'update("user:abc")', 'delete("user:abc")']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toUploadFile', () => {
|
||||||
|
it('benennt die Datei nach Art und Blob-Typ', () => {
|
||||||
|
expect(toUploadFile(blob('image/png'), 'image').name).toBe('image.png');
|
||||||
|
expect(toUploadFile(blob('image/jpeg'), 'image').name).toBe('image.jpg');
|
||||||
|
expect(toUploadFile(blob('image/webp'), 'preview').name).toBe('preview.webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behält den Typ und den Inhalt', async () => {
|
||||||
|
const file = toUploadFile(new Blob(['abc'], { type: 'image/webp' }), 'preview');
|
||||||
|
expect(file).toBeInstanceOf(File);
|
||||||
|
expect(file.type).toBe('image/webp');
|
||||||
|
expect(await file.text()).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft bei einem anderen Typ', () => {
|
||||||
|
expect(() => toUploadFile(new Blob([], { type: 'image/gif' }), 'image')).toThrow();
|
||||||
|
expect(() => toUploadFile(new Blob([]), 'preview')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('save: neue Karte', () => {
|
||||||
|
it('lädt Bild und Vorschau hoch und legt die Zeile an', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
const c = config({ template: 'minimal', details: 'dark' });
|
||||||
|
const saved = await service(fake).save(newInput({ config: c, name: ' Mein Urlaub ' }));
|
||||||
|
|
||||||
|
const files = fake.methods('createFile');
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
expect((files[0].args[0] as File).name).toBe('image.png');
|
||||||
|
expect((files[1].args[0] as File).name).toBe('preview.webp');
|
||||||
|
expect(files[0].args[1]).toEqual(OWNER);
|
||||||
|
expect(files[1].args[1]).toEqual(OWNER);
|
||||||
|
|
||||||
|
const [row] = fake.methods('createRow');
|
||||||
|
expect(row.args[0]).toEqual({
|
||||||
|
name: 'Mein Urlaub',
|
||||||
|
config: serializeConfig(c),
|
||||||
|
imageFileId: 'file1',
|
||||||
|
previewFileId: 'file2',
|
||||||
|
});
|
||||||
|
expect(row.args[1]).toEqual(OWNER);
|
||||||
|
|
||||||
|
expect(saved).toEqual({
|
||||||
|
id: 'row-new',
|
||||||
|
name: 'Mein Urlaub',
|
||||||
|
imageFileId: 'file1',
|
||||||
|
previewFileId: 'file2',
|
||||||
|
updatedAt: NOW,
|
||||||
|
config: parseConfig(serializeConfig(c)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JPEG-Bild heißt image.jpg', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
await service(fake).save(newInput({ image: blob('image/jpeg') }));
|
||||||
|
expect((fake.methods('createFile')[0].args[0] as File).name).toBe('image.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Vorschau als PNG (z. B. Safari kann kein WebP) heißt preview.png', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
await service(fake).save(newInput({ preview: png() }));
|
||||||
|
expect((fake.methods('createFile')[1].args[0] as File).name).toBe('preview.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert createRow, werden beide neuen Dateien gelöscht und der Fehler kommt an', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
const boom = new Error('createRow');
|
||||||
|
fake.failOnce.set('createRow', boom);
|
||||||
|
await expect(service(fake).save(newInput())).rejects.toBe(boom);
|
||||||
|
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert die Vorschau, wird das Bild gelöscht und keine Zeile angelegt', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
const boom = new Error('preview');
|
||||||
|
fake.failOnce.set('createFile#2', boom);
|
||||||
|
await expect(service(fake).save(newInput())).rejects.toBe(boom);
|
||||||
|
expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['file1']);
|
||||||
|
expect(fake.methods('createRow')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler beim Aufräumen überdecken den Ursprungsfehler nicht', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
const boom = new Error('createRow');
|
||||||
|
fake.failOnce.set('createRow', boom);
|
||||||
|
const original = fake.api.deleteFile;
|
||||||
|
fake.api.deleteFile = async (id) => {
|
||||||
|
await original(id);
|
||||||
|
throw new Error('delete kaputt');
|
||||||
|
};
|
||||||
|
await expect(service(fake).save(newInput())).rejects.toBe(boom);
|
||||||
|
expect(fake.methods('deleteFile')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft userId(), wird nichts hochgeladen', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
const unauthorized = Object.assign(new Error('unauthorized'), { code: 401 });
|
||||||
|
const svc = service(fake, () => {
|
||||||
|
throw unauthorized;
|
||||||
|
});
|
||||||
|
await expect(svc.save(newInput())).rejects.toBe(unauthorized);
|
||||||
|
expect(fake.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein nicht erlaubter Bildtyp wirft, bevor irgendetwas hochgeladen wird', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
await expect(service(fake).save(newInput({ image: blob('image/gif') }))).rejects.toThrow();
|
||||||
|
await expect(service(fake).save(newInput({ preview: blob('image/gif') }))).rejects.toThrow();
|
||||||
|
expect(fake.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('save: bestehende Karte', () => {
|
||||||
|
const existing = (): CardRow => ({
|
||||||
|
$id: 'row1',
|
||||||
|
$updatedAt: NOW,
|
||||||
|
name: 'Alt',
|
||||||
|
config: serializeConfig(config()),
|
||||||
|
imageFileId: 'oldImage',
|
||||||
|
previewFileId: 'oldPreview',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Bildwechsel: nur neue Vorschau, Update, dann alte Vorschau löschen', async () => {
|
||||||
|
const fake = fakeApi([existing()]);
|
||||||
|
const c = config({ network: 'jcb' });
|
||||||
|
const saved = await service(fake).save(
|
||||||
|
newInput({ name: 'Neu', config: c, imageChanged: false, previous: previous() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fake.names()).toEqual(['createFile', 'updateRow', 'deleteFile']);
|
||||||
|
const [file] = fake.methods('createFile');
|
||||||
|
expect((file.args[0] as File).name).toBe('preview.webp');
|
||||||
|
expect(file.args[1]).toEqual(OWNER);
|
||||||
|
expect(fake.methods('updateRow')[0].args).toEqual([
|
||||||
|
'row1',
|
||||||
|
{ name: 'Neu', config: serializeConfig(c), imageFileId: 'oldImage', previewFileId: 'file1' },
|
||||||
|
]);
|
||||||
|
expect(fake.methods('deleteFile')[0].args).toEqual(['oldPreview']);
|
||||||
|
expect(saved.id).toBe('row1');
|
||||||
|
expect(saved.imageFileId).toBe('oldImage');
|
||||||
|
expect(saved.previewFileId).toBe('file1');
|
||||||
|
expect(saved.config.network).toBe('jcb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit Bildwechsel: zwei neue Dateien, nach dem Update werden beide alten gelöscht', async () => {
|
||||||
|
const fake = fakeApi([existing()]);
|
||||||
|
const saved = await service(fake).save(newInput({ imageChanged: true, previous: previous() }));
|
||||||
|
|
||||||
|
expect(fake.names()).toEqual(['createFile', 'createFile', 'updateRow', 'deleteFile', 'deleteFile']);
|
||||||
|
expect((fake.methods('createFile')[0].args[0] as File).name).toBe('image.png');
|
||||||
|
expect(fake.methods('updateRow')[0].args[1]).toMatchObject({ imageFileId: 'file1', previewFileId: 'file2' });
|
||||||
|
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['oldImage', 'oldPreview']);
|
||||||
|
expect(saved.imageFileId).toBe('file1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert updateRow, werden nur die neuen Dateien gelöscht', async () => {
|
||||||
|
const fake = fakeApi([existing()]);
|
||||||
|
const boom = new Error('updateRow');
|
||||||
|
fake.failOnce.set('updateRow', boom);
|
||||||
|
await expect(service(fake).save(newInput({ imageChanged: true, previous: previous() }))).rejects.toBe(boom);
|
||||||
|
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert updateRow ohne Bildwechsel, wird nur die neue Vorschau gelöscht', async () => {
|
||||||
|
const fake = fakeApi([existing()]);
|
||||||
|
fake.failOnce.set('updateRow', new Error('updateRow'));
|
||||||
|
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toThrow();
|
||||||
|
expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['file1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert das Löschen der alten Vorschau, löst save trotzdem auf', async () => {
|
||||||
|
const fake = fakeApi([existing()]);
|
||||||
|
fake.failOnce.set('deleteFile', new AppwriteException('weg', 404, 'storage_file_not_found'));
|
||||||
|
const saved = await service(fake).save(newInput({ imageChanged: false, previous: previous() }));
|
||||||
|
expect(saved.previewFileId).toBe('file1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert das Löschen des alten Bildes, wird trotzdem die alte Vorschau gelöscht und aufgelöst', async () => {
|
||||||
|
const fake = fakeApi([existing()]);
|
||||||
|
const original = fake.api.deleteFile;
|
||||||
|
fake.api.deleteFile = async (id) => {
|
||||||
|
await original(id);
|
||||||
|
throw new Error('delete kaputt');
|
||||||
|
};
|
||||||
|
const saved = await service(fake).save(newInput({ imageChanged: true, previous: previous() }));
|
||||||
|
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['oldImage', 'oldPreview']);
|
||||||
|
expect(saved.imageFileId).toBe('file1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft userId(), wird nichts hochgeladen und nichts geändert', async () => {
|
||||||
|
const fake = fakeApi([existing()]);
|
||||||
|
const svc = service(fake, () => {
|
||||||
|
throw new Error('unauthorized');
|
||||||
|
});
|
||||||
|
await expect(svc.save(newInput({ previous: previous() }))).rejects.toThrow();
|
||||||
|
expect(fake.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove', () => {
|
||||||
|
it('löscht zuerst die Zeile, dann beide Dateien', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
await service(fake).remove(previous());
|
||||||
|
expect(fake.names()[0]).toBe('deleteRow');
|
||||||
|
expect(fake.methods('deleteRow')[0].args).toEqual(['row1']);
|
||||||
|
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['oldImage', 'oldPreview']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert ein Datei-Löschen, löst remove trotzdem auf und versucht die andere Datei', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
fake.failOnce.set('deleteFile', new Error('weg'));
|
||||||
|
await expect(service(fake).remove(previous())).resolves.toBeUndefined();
|
||||||
|
expect(fake.methods('deleteFile')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert deleteRow, bleiben die Dateien liegen und der Fehler kommt an', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
const boom = new Error('deleteRow');
|
||||||
|
fake.failOnce.set('deleteRow', boom);
|
||||||
|
await expect(service(fake).remove(previous())).rejects.toBe(boom);
|
||||||
|
expect(fake.methods('deleteFile')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list, get, rename, loadImage, loadPreview', () => {
|
||||||
|
const row = (patch: Partial<CardRow> = {}): CardRow => ({
|
||||||
|
$id: 'row1',
|
||||||
|
$updatedAt: NOW,
|
||||||
|
name: 'Karte',
|
||||||
|
config: serializeConfig(config({ template: 'classic' })),
|
||||||
|
imageFileId: 'img',
|
||||||
|
previewFileId: 'prev',
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list: macht aus Zeilen SavedCards mit gelesener Konfiguration', async () => {
|
||||||
|
const fake = fakeApi([row(), row({ $id: 'row2', name: 'Zwei' })]);
|
||||||
|
const cards = await service(fake).list();
|
||||||
|
expect(cards.map((c) => c.id)).toEqual(['row1', 'row2']);
|
||||||
|
expect(cards[0]).toEqual({
|
||||||
|
id: 'row1',
|
||||||
|
name: 'Karte',
|
||||||
|
imageFileId: 'img',
|
||||||
|
previewFileId: 'prev',
|
||||||
|
updatedAt: NOW,
|
||||||
|
config: parseConfig(serializeConfig(config({ template: 'classic' }))),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list: kaputte Konfiguration wird zur Standardkonfiguration', async () => {
|
||||||
|
const fake = fakeApi([row({ config: '{kaputt' })]);
|
||||||
|
const [card] = await service(fake).list();
|
||||||
|
expect(card.config).toEqual(DEFAULT_CONFIG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get: liest eine Karte', async () => {
|
||||||
|
const fake = fakeApi([row()]);
|
||||||
|
const card = await service(fake).get('row1');
|
||||||
|
expect(card.name).toBe('Karte');
|
||||||
|
expect(card.config.template).toBe('classic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get: eine ID mit Sonderzeichen wird nie an den Server geschickt', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
for (const id of ['', '../x', 'a/b', '-abc', 'a'.repeat(37), 'a b']) {
|
||||||
|
await expect(service(fake).get(id)).rejects.toMatchObject({ code: 404, type: 'row_not_found' });
|
||||||
|
}
|
||||||
|
expect(fake.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rename: normalisiert den Namen und ändert nur ihn', async () => {
|
||||||
|
const fake = fakeApi([row()]);
|
||||||
|
const card = await service(fake).rename('row1', ' Neuer Name ');
|
||||||
|
expect(fake.methods('updateRow')[0].args).toEqual(['row1', { name: 'Neuer Name' }]);
|
||||||
|
expect(card.name).toBe('Neuer Name');
|
||||||
|
expect(card.config.template).toBe('classic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadImage und loadPreview holen die richtige Datei', async () => {
|
||||||
|
const fake = fakeApi();
|
||||||
|
const svc = service(fake);
|
||||||
|
await svc.loadImage(previous());
|
||||||
|
await svc.loadPreview(previous());
|
||||||
|
expect(fake.methods('fetchFile').map((c) => c.args[0])).toEqual(['oldImage', 'oldPreview']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('appwriteCardsApi', () => {
|
||||||
|
const data: CardRowData = { name: 'N', config: '{}', imageFileId: 'i', previewFileId: 'p' };
|
||||||
|
const apiRow = { $id: 'r1', $updatedAt: NOW, ...data };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createFile: Bucket, neue ID, Datei und Rechte', async () => {
|
||||||
|
mocks.createFile.mockResolvedValue({ $id: 'f1', name: 'x' });
|
||||||
|
const file = new File(['x'], 'image.png', { type: 'image/png' });
|
||||||
|
const res = await appwriteCardsApi().createFile(file, OWNER);
|
||||||
|
expect(res.$id).toBe('f1');
|
||||||
|
expect(mocks.createFile).toHaveBeenCalledWith({
|
||||||
|
bucketId: 'bucket',
|
||||||
|
fileId: expect.stringMatching(/^[A-Za-z0-9._-]{1,36}$/),
|
||||||
|
file,
|
||||||
|
permissions: OWNER,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteFile: Bucket und Datei-ID', async () => {
|
||||||
|
mocks.deleteFile.mockResolvedValue({});
|
||||||
|
await appwriteCardsApi().deleteFile('f1');
|
||||||
|
expect(mocks.deleteFile).toHaveBeenCalledWith({ bucketId: 'bucket', fileId: 'f1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createRow: Datenbank, Tabelle, neue ID, Daten und Rechte', async () => {
|
||||||
|
mocks.createRow.mockResolvedValue(apiRow);
|
||||||
|
const row = await appwriteCardsApi().createRow(data, OWNER);
|
||||||
|
expect(row).toEqual(apiRow);
|
||||||
|
expect(mocks.createRow).toHaveBeenCalledWith({
|
||||||
|
databaseId: 'db',
|
||||||
|
tableId: 'cards-table',
|
||||||
|
rowId: expect.stringMatching(/^[A-Za-z0-9._-]{1,36}$/),
|
||||||
|
data,
|
||||||
|
permissions: OWNER,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateRow: ohne Rechte, getRow und deleteRow mit Zeilen-ID', async () => {
|
||||||
|
mocks.updateRow.mockResolvedValue(apiRow);
|
||||||
|
mocks.getRow.mockResolvedValue(apiRow);
|
||||||
|
mocks.deleteRow.mockResolvedValue({});
|
||||||
|
const api = appwriteCardsApi();
|
||||||
|
await api.updateRow('r1', { name: 'Neu' });
|
||||||
|
expect(mocks.updateRow).toHaveBeenCalledWith({
|
||||||
|
databaseId: 'db',
|
||||||
|
tableId: 'cards-table',
|
||||||
|
rowId: 'r1',
|
||||||
|
data: { name: 'Neu' },
|
||||||
|
});
|
||||||
|
expect(await api.getRow('r1')).toEqual(apiRow);
|
||||||
|
expect(mocks.getRow).toHaveBeenCalledWith({ databaseId: 'db', tableId: 'cards-table', rowId: 'r1' });
|
||||||
|
await api.deleteRow('r1');
|
||||||
|
expect(mocks.deleteRow).toHaveBeenCalledWith({ databaseId: 'db', tableId: 'cards-table', rowId: 'r1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listRows: zuletzt geändert zuerst, höchstens 100, Ergebnis ist .rows', async () => {
|
||||||
|
mocks.listRows.mockResolvedValue({ total: 1, rows: [apiRow] });
|
||||||
|
const rows = await appwriteCardsApi().listRows();
|
||||||
|
expect(rows).toEqual([apiRow]);
|
||||||
|
const params = mocks.listRows.mock.calls[0][0];
|
||||||
|
expect(params).toMatchObject({ databaseId: 'db', tableId: 'cards-table' });
|
||||||
|
expect(params.queries).toEqual([
|
||||||
|
JSON.stringify({ method: 'orderDesc', attribute: '$updatedAt' }),
|
||||||
|
JSON.stringify({ method: 'limit', values: [100] }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchFile', () => {
|
||||||
|
const prepare = () =>
|
||||||
|
mocks.prepareRequest.mockImplementation((method: string, url: URL) => ({
|
||||||
|
uri: url.toString(),
|
||||||
|
options: { method, headers: { 'X-Appwrite-Project': 'proj' }, credentials: 'include' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('holt die Datei über prepareRequest (Projekt-Header + Cookie), nie über eine View-URL mit ?project=', async () => {
|
||||||
|
prepare();
|
||||||
|
const fetchMock = vi.fn(async () => new Response(png(), { status: 200, headers: { 'content-type': 'image/png' } }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const result = await appwriteCardsApi().fetchFile('f1');
|
||||||
|
expect(result.type).toBe('image/png');
|
||||||
|
expect(mocks.prepareRequest).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.prepareRequest.mock.calls[0][0]).toBe('GET');
|
||||||
|
expect((mocks.prepareRequest.mock.calls[0][1] as URL).toString()).toBe(
|
||||||
|
'https://aircard.test/v1/storage/buckets/bucket/files/f1/view',
|
||||||
|
);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('https://aircard.test/v1/storage/buckets/bucket/files/f1/view', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'X-Appwrite-Project': 'proj' },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maskiert die Datei-ID im Pfad', async () => {
|
||||||
|
prepare();
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } })));
|
||||||
|
await appwriteCardsApi().fetchFile('../x?y');
|
||||||
|
expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/bucket/files/..%2Fx%3Fy/view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft bei Fehlerantwort eine AppwriteException mit Status, Typ und Meldung des Servers', async () => {
|
||||||
|
prepare();
|
||||||
|
const body = JSON.stringify({ message: 'File not found', type: 'storage_file_not_found', code: 404 });
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => new Response(body, { status: 404, statusText: 'Not Found', headers: { 'content-type': 'application/json' } })),
|
||||||
|
);
|
||||||
|
const err = await appwriteCardsApi().fetchFile('f1').catch((e: unknown) => e);
|
||||||
|
expect(err).toBeInstanceOf(AppwriteException);
|
||||||
|
expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found', message: 'File not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft auch bei einer Fehlerseite ohne JSON (z. B. 502 von nginx)', async () => {
|
||||||
|
prepare();
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => new Response('<html>Bad Gateway</html>', { status: 502, statusText: 'Bad Gateway' })));
|
||||||
|
const err = await appwriteCardsApi().fetchFile('f1').catch((e: unknown) => e);
|
||||||
|
expect(err).toBeInstanceOf(AppwriteException);
|
||||||
|
expect(err).toMatchObject({ code: 502, message: 'Bad Gateway' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Antwort ohne Bildtyp ist ein Fehler', async () => {
|
||||||
|
prepare();
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => new Response('<html>hallo</html>', { status: 200, headers: { 'content-type': 'text/html' } })));
|
||||||
|
const err = await appwriteCardsApi().fetchFile('f1').catch((e: unknown) => e);
|
||||||
|
expect(err).toBeInstanceOf(AppwriteException);
|
||||||
|
expect(err).toMatchObject({ code: 502, type: 'general_unknown', message: 'Unerwartete Antwort' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// Gespeicherte Karten: Dienst (Regeln fürs Speichern, Aufräumen, Laden) und Anschluss an Appwrite.
|
||||||
|
// Der Dienst kennt nur das schmale `CardsApi`-Interface und lässt sich dadurch ohne Netzwerk testen.
|
||||||
|
import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export interface CardRowData {
|
||||||
|
name: string;
|
||||||
|
config: string;
|
||||||
|
imageFileId: string;
|
||||||
|
previewFileId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardRow extends CardRowData {
|
||||||
|
$id: string;
|
||||||
|
$updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardsApi {
|
||||||
|
createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
|
||||||
|
deleteFile(fileId: string): Promise<void>;
|
||||||
|
fetchFile(fileId: string): Promise<Blob>;
|
||||||
|
createRow(data: CardRowData, permissions: string[]): Promise<CardRow>;
|
||||||
|
updateRow(id: string, data: Partial<CardRowData>): Promise<CardRow>;
|
||||||
|
getRow(id: string): Promise<CardRow>;
|
||||||
|
deleteRow(id: string): Promise<void>;
|
||||||
|
listRows(): Promise<CardRow[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedCard extends SavedCardRef {
|
||||||
|
config: CardConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveInput {
|
||||||
|
name: string;
|
||||||
|
config: CardConfig;
|
||||||
|
image: Blob;
|
||||||
|
/** false: das Bild ist schon gespeichert und wird nicht erneut hochgeladen (nur bei `previous`). */
|
||||||
|
imageChanged: boolean;
|
||||||
|
preview: Blob;
|
||||||
|
previous: SavedCardRef | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CARD_NAME = 'Meine Karte';
|
||||||
|
const NAME_MAX = 80;
|
||||||
|
/** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */
|
||||||
|
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||||
|
|
||||||
|
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
|
||||||
|
export function normalizeName(name: string): string {
|
||||||
|
const collapsed = name.replace(/\s+/g, ' ').trim();
|
||||||
|
const cut = Array.from(collapsed).slice(0, NAME_MAX).join('').trim();
|
||||||
|
return cut || DEFAULT_CARD_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur der Besitzer darf die Zeile bzw. Datei lesen, ändern und löschen. */
|
||||||
|
export function ownerPermissions(userId: string): string[] {
|
||||||
|
const owner = Role.user(userId);
|
||||||
|
return [Permission.read(owner), Permission.update(owner), Permission.delete(owner)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXTENSIONS: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
|
||||||
|
|
||||||
|
/** Verpackt ein Bild als Datei für den Bucket; der Name folgt dem Typ (der Bucket prüft die Endung). */
|
||||||
|
export function toUploadFile(blob: Blob, base: 'image' | 'preview'): File {
|
||||||
|
const ext = EXTENSIONS[blob.type];
|
||||||
|
if (!ext) throw new Error(`Bildtyp nicht erlaubt: ${blob.type || 'unbekannt'}`);
|
||||||
|
return new File([blob], `${base}.${ext}`, { type: blob.type });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSaved(row: CardRow): SavedCard {
|
||||||
|
return {
|
||||||
|
id: row.$id,
|
||||||
|
name: row.name,
|
||||||
|
imageFileId: row.imageFileId,
|
||||||
|
previewFileId: row.previewFileId,
|
||||||
|
updatedAt: row.$updatedAt,
|
||||||
|
config: parseConfig(row.config),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Räumt auf, soweit es geht; ein Fehler (auch 404) ist hier nie ein Grund abzubrechen. */
|
||||||
|
async function deleteQuietly(api: CardsApi, fileIds: string[]): Promise<void> {
|
||||||
|
await Promise.allSettled(fileIds.map((id) => api.deleteFile(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCardsService(api: CardsApi, userId: () => string) {
|
||||||
|
async function save(input: SaveInput): Promise<SavedCard> {
|
||||||
|
// Ohne Nutzer wirft das hier, bevor etwas hochgeladen wird.
|
||||||
|
const permissions = ownerPermissions(userId());
|
||||||
|
const prev = input.previous;
|
||||||
|
const name = normalizeName(input.name);
|
||||||
|
const config = serializeConfig(input.config);
|
||||||
|
const uploadImage = prev === null || input.imageChanged;
|
||||||
|
// Typen vorab prüfen: ein unzulässiges Bild darf keine halben Uploads hinterlassen.
|
||||||
|
const imageFile = uploadImage ? toUploadFile(input.image, 'image') : null;
|
||||||
|
const previewFile = toUploadFile(input.preview, 'preview');
|
||||||
|
|
||||||
|
const uploaded: string[] = [];
|
||||||
|
const upload = async (file: File): Promise<string> => {
|
||||||
|
const created = await api.createFile(file, permissions);
|
||||||
|
uploaded.push(created.$id);
|
||||||
|
return created.$id;
|
||||||
|
};
|
||||||
|
|
||||||
|
let row: CardRow;
|
||||||
|
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);
|
||||||
|
} catch (err) {
|
||||||
|
await deleteQuietly(api, uploaded);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
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] : [])]);
|
||||||
|
}
|
||||||
|
return toSaved(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async list(): Promise<SavedCard[]> {
|
||||||
|
return (await api.listRows()).map(toSaved);
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<SavedCard> {
|
||||||
|
// Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht.
|
||||||
|
if (!ID_PATTERN.test(id)) throw new AppwriteException('Row not found', 404, 'row_not_found', '');
|
||||||
|
return toSaved(await api.getRow(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
save,
|
||||||
|
|
||||||
|
async rename(id: string, name: string): Promise<SavedCard> {
|
||||||
|
return toSaved(await api.updateRow(id, { name: normalizeName(name) }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(card: SavedCardRef): Promise<void> {
|
||||||
|
await api.deleteRow(card.id);
|
||||||
|
await deleteQuietly(api, [card.imageFileId, card.previewFileId]);
|
||||||
|
},
|
||||||
|
|
||||||
|
loadImage(card: SavedCardRef): Promise<Blob> {
|
||||||
|
return api.fetchFile(card.imageFileId);
|
||||||
|
},
|
||||||
|
|
||||||
|
loadPreview(card: SavedCardRef): Promise<Blob> {
|
||||||
|
return api.fetchFile(card.previewFileId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CardsService = ReturnType<typeof createCardsService>;
|
||||||
|
|
||||||
|
/** Lädt eine Bilddatei mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
|
||||||
|
async function fetchFile(fileId: string): Promise<Blob> {
|
||||||
|
// Bewusst kein storage.getFileView(): das hängt das Projekt als Query-Parameter an die URL und schickt keinen
|
||||||
|
// Header; so trifft die Anfrage die Traefik-Route für /v1 mit Header nicht. prepareRequest setzt Header und Cookie.
|
||||||
|
const url = new URL(`${APPWRITE_ENDPOINT}/storage/buckets/${IDS.bucket}/files/${encodeURIComponent(fileId)}/view`);
|
||||||
|
const { uri, options } = client.prepareRequest('GET', url);
|
||||||
|
const res = await fetch(uri, options);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
let body: { message?: unknown; type?: unknown } = {};
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(text);
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) body = parsed;
|
||||||
|
} catch {
|
||||||
|
// keine JSON-Antwort (z. B. Fehlerseite des Proxys): Statuszeile genügt
|
||||||
|
}
|
||||||
|
throw new AppwriteException(
|
||||||
|
typeof body.message === 'string' ? body.message : res.statusText,
|
||||||
|
res.status,
|
||||||
|
typeof body.type === 'string' ? body.type : '',
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
if (!blob.type.startsWith('image/')) throw new AppwriteException('Unerwartete Antwort', 502, 'general_unknown', '');
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableRow = Models.Row & CardRowData;
|
||||||
|
|
||||||
|
export function appwriteCardsApi(): CardsApi {
|
||||||
|
const target = { databaseId: IDS.database, tableId: IDS.table };
|
||||||
|
return {
|
||||||
|
createFile: (file, permissions) =>
|
||||||
|
storage.createFile({ bucketId: IDS.bucket, fileId: ID.unique(), file, permissions }),
|
||||||
|
deleteFile: async (fileId) => {
|
||||||
|
await storage.deleteFile({ bucketId: IDS.bucket, fileId });
|
||||||
|
},
|
||||||
|
fetchFile,
|
||||||
|
createRow: (data, permissions) =>
|
||||||
|
tables.createRow<TableRow>({ ...target, rowId: ID.unique(), data, permissions }),
|
||||||
|
// Beim Ändern bleiben die Rechte der Zeile unangetastet.
|
||||||
|
updateRow: (id, data) => tables.updateRow<TableRow>({ ...target, rowId: id, data }),
|
||||||
|
getRow: (id) => tables.getRow<TableRow>({ ...target, rowId: id }),
|
||||||
|
deleteRow: async (id) => {
|
||||||
|
await tables.deleteRow({ ...target, rowId: id });
|
||||||
|
},
|
||||||
|
listRows: async () => {
|
||||||
|
const list = await tables.listRows<TableRow>({
|
||||||
|
...target,
|
||||||
|
queries: [Query.orderDesc('$updatedAt'), Query.limit(100)],
|
||||||
|
});
|
||||||
|
return list.rows;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler, den `withLogin`/`createSaveAction` als 401 erkennen. */
|
||||||
|
export function requireUserId(): string {
|
||||||
|
const id = currentUserId();
|
||||||
|
if (!id) throw Object.assign(new Error('unauthorized'), { code: 401 });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shared: CardsService | null = null;
|
||||||
|
|
||||||
|
/** Der Kartendienst der Seite (beim ersten Gebrauch erzeugt). */
|
||||||
|
export function cardsService(): CardsService {
|
||||||
|
shared ??= createCardsService(appwriteCardsApi(), requireUserId);
|
||||||
|
return shared;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { LoginCancelled, withLogin } from './with-login';
|
||||||
|
|
||||||
|
const unauthorized = () => Object.assign(new Error('Unauthorized'), { code: 401, type: 'user_unauthorized' });
|
||||||
|
|
||||||
|
describe('withLogin', () => {
|
||||||
|
it('gibt das Ergebnis zurück, ohne den Login-Dialog zu öffnen', async () => {
|
||||||
|
const fn = vi.fn().mockResolvedValue('ok');
|
||||||
|
const requireLogin = vi.fn();
|
||||||
|
const refreshUser = vi.fn();
|
||||||
|
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ok');
|
||||||
|
expect(fn).toHaveBeenCalledOnce();
|
||||||
|
expect(requireLogin).not.toHaveBeenCalled();
|
||||||
|
expect(refreshUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bei 401: Nutzer neu prüfen, einmal anmelden, dann fn erneut aufrufen', async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
const fn = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
order.push('fn1');
|
||||||
|
throw unauthorized();
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
order.push('fn2');
|
||||||
|
return 'ergebnis';
|
||||||
|
});
|
||||||
|
const refreshUser = vi.fn(async () => {
|
||||||
|
order.push('refresh');
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const requireLogin = vi.fn(async () => {
|
||||||
|
order.push('login');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ergebnis');
|
||||||
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(requireLogin).toHaveBeenCalledOnce();
|
||||||
|
expect(order).toEqual(['fn1', 'refresh', 'login', 'fn2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bricht der Nutzer den Dialog ab, wirft withLogin LoginCancelled und ruft fn nicht erneut auf', async () => {
|
||||||
|
const fn = vi.fn().mockRejectedValue(unauthorized());
|
||||||
|
const requireLogin = vi.fn().mockResolvedValue(false);
|
||||||
|
const err = await withLogin(fn, requireLogin, vi.fn().mockResolvedValue(null)).catch((e: unknown) => e);
|
||||||
|
expect(err).toBeInstanceOf(LoginCancelled);
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(fn).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('andere Fehler werden durchgereicht, ohne Dialog und ohne zweiten Versuch', async () => {
|
||||||
|
const boom = Object.assign(new Error('Serverfehler'), { code: 500 });
|
||||||
|
const fn = vi.fn().mockRejectedValue(boom);
|
||||||
|
const requireLogin = vi.fn();
|
||||||
|
const refreshUser = vi.fn();
|
||||||
|
await expect(withLogin(fn, requireLogin, refreshUser)).rejects.toBe(boom);
|
||||||
|
expect(fn).toHaveBeenCalledOnce();
|
||||||
|
expect(requireLogin).not.toHaveBeenCalled();
|
||||||
|
expect(refreshUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401 auch beim zweiten Versuch wird weitergereicht (kein dritter Versuch, kein zweiter Dialog)', async () => {
|
||||||
|
const second = unauthorized();
|
||||||
|
const fn = vi.fn().mockRejectedValueOnce(unauthorized()).mockRejectedValueOnce(second);
|
||||||
|
const requireLogin = vi.fn().mockResolvedValue(true);
|
||||||
|
await expect(withLogin(fn, requireLogin, vi.fn().mockResolvedValue(null))).rejects.toBe(second);
|
||||||
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(requireLogin).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert die Nutzerprüfung (z. B. Netz), wird trotzdem angemeldet', async () => {
|
||||||
|
const fn = vi.fn().mockRejectedValueOnce(unauthorized()).mockResolvedValueOnce('ok');
|
||||||
|
const refreshUser = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
const requireLogin = vi.fn().mockResolvedValue(true);
|
||||||
|
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ok');
|
||||||
|
expect(requireLogin).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401) einmal neu an.
|
||||||
|
import { getCurrentUser } from './auth';
|
||||||
|
import { isUnauthorized } from './errors';
|
||||||
|
import { requireLogin } from './login-dialog';
|
||||||
|
|
||||||
|
/** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */
|
||||||
|
export class LoginCancelled extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Anmeldung abgebrochen');
|
||||||
|
this.name = 'LoginCancelled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruft `fn` auf. Antwortet der Server mit 401, wird der Anmeldestatus neu geholt (damit Konto-Menü und
|
||||||
|
* Nutzer-ID nicht veraltet sind), einmal der Login-Dialog gezeigt und `fn` danach genau einmal wiederholt.
|
||||||
|
* Schließt der Nutzer den Dialog, wirft das `LoginCancelled`; jeder andere Fehler geht unverändert durch.
|
||||||
|
*/
|
||||||
|
export async function withLogin<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
requireLoginFn: typeof requireLogin = requireLogin,
|
||||||
|
refreshUser: () => Promise<unknown> = getCurrentUser,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
if (!isUnauthorized(err)) throw err;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await refreshUser();
|
||||||
|
} catch {
|
||||||
|
// Ein Netzfehler hier darf die Anmeldung nicht verhindern; der Login-Dialog setzt den Nutzer ohnehin neu.
|
||||||
|
}
|
||||||
|
if (!(await requireLoginFn())) throw new LoginCancelled();
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
// Speichern und Öffnen im Editor (Karten-Dienst, Anmeldung und Renderer sind ersetzt).
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { User } from '../account/auth';
|
||||||
|
import type { SavedCard } from '../account/cards';
|
||||||
|
import { DEFAULT_CONFIG } from '../card/config';
|
||||||
|
import type { CardImage } from '../card/render';
|
||||||
|
import { createStore, initialEditorState, type EditorStore } from './state';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
loadImage: vi.fn(),
|
||||||
|
getCurrentUser: vi.fn(),
|
||||||
|
requireLogin: vi.fn(),
|
||||||
|
decodeImage: vi.fn(),
|
||||||
|
loadCardAssets: vi.fn(),
|
||||||
|
exportCard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||||
|
vi.mock('../account/auth', () => ({ getCurrentUser: mocks.getCurrentUser, currentUserId: () => 'u1', onAuthChange: () => () => {} }));
|
||||||
|
vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
||||||
|
vi.mock('../account/cards', () => ({
|
||||||
|
cardsService: () => ({ get: mocks.get, save: mocks.save, loadImage: mocks.loadImage }),
|
||||||
|
}));
|
||||||
|
vi.mock('../card/render', () => ({
|
||||||
|
drawCard: vi.fn(),
|
||||||
|
loadCardAssets: mocks.loadCardAssets,
|
||||||
|
exportCard: mocks.exportCard,
|
||||||
|
}));
|
||||||
|
vi.mock('./upload', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('./upload')>()),
|
||||||
|
decodeImage: mocks.decodeImage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { mountEditor } from './editor-page';
|
||||||
|
|
||||||
|
const user: User = { $id: 'u1', email: 'a@b.de' };
|
||||||
|
const image = (w = 3000, h = 2000): CardImage => ({ source: {} as CanvasImageSource, w, h });
|
||||||
|
const png = () => new Blob(['png'], { type: 'image/png' });
|
||||||
|
|
||||||
|
const savedCard = (patch: Partial<SavedCard> = {}): SavedCard => ({
|
||||||
|
id: 'k1',
|
||||||
|
name: 'Urlaub',
|
||||||
|
imageFileId: 'img1',
|
||||||
|
previewFileId: 'prev1',
|
||||||
|
updatedAt: '2026-10-04T12:00:00.000Z',
|
||||||
|
config: { ...structuredClone(DEFAULT_CONFIG), template: 'minimal', crop: { x: 99999, y: -99999, zoom: 2 } },
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: HTMLElement;
|
||||||
|
let store: EditorStore;
|
||||||
|
let unmount: (() => void) | null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||||
|
vi.stubGlobal('requestAnimationFrame', () => 0);
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', () => {});
|
||||||
|
history.replaceState(null, '', '/');
|
||||||
|
document.body.innerHTML = '<main id="app-root"></main>';
|
||||||
|
root = document.getElementById('app-root') as HTMLElement;
|
||||||
|
store = createStore(initialEditorState());
|
||||||
|
mocks.getCurrentUser.mockResolvedValue(user);
|
||||||
|
mocks.requireLogin.mockResolvedValue(true);
|
||||||
|
mocks.loadCardAssets.mockResolvedValue({ image: null, network: {}, brand: {} });
|
||||||
|
mocks.exportCard.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' }));
|
||||||
|
unmount = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unmount?.();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mount = () => {
|
||||||
|
unmount = mountEditor(root, store);
|
||||||
|
};
|
||||||
|
const saveButton = () => root.querySelector<HTMLButtonElement>('.btn-save')!;
|
||||||
|
const replaceButton = () => [...root.querySelectorAll('button')].find((b) => b.textContent === 'Bild ersetzen') as HTMLButtonElement;
|
||||||
|
const fileInput = () => root.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||||
|
const errorText = () => root.querySelector('.msg-error')!.textContent;
|
||||||
|
const warningText = () => root.querySelector('.msg-warning')!.textContent;
|
||||||
|
const noticeText = () => root.querySelector('.msg-notice')!.textContent;
|
||||||
|
|
||||||
|
/** Der Editor mit einem gewählten Bild, bereit zum Speichern. */
|
||||||
|
function withImage(blob = png(), img = image()): { blob: Blob; img: CardImage } {
|
||||||
|
store.set({ image: img, imageBlob: blob, imageChanged: true });
|
||||||
|
return { blob, img };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Speichern', () => {
|
||||||
|
it('speichert eine neue Karte mit Schnappschuss, Vorschau und Nutzer-Prüfung', async () => {
|
||||||
|
const { blob } = withImage();
|
||||||
|
store.set({ cardName: 'Mein Urlaub', config: { ...store.get().config, network: 'jcb' } });
|
||||||
|
const result = savedCard({ id: 'neu', name: 'Mein Urlaub' });
|
||||||
|
mocks.save.mockResolvedValue(result);
|
||||||
|
mount();
|
||||||
|
expect(saveButton().textContent).toBe('Speichern');
|
||||||
|
|
||||||
|
saveButton().click();
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
expect(saveButton().textContent).toBe('Speichert …');
|
||||||
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||||
|
|
||||||
|
expect(mocks.getCurrentUser).toHaveBeenCalled();
|
||||||
|
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ network: 'jcb' }), expect.anything(), {
|
||||||
|
width: 480,
|
||||||
|
type: 'image/webp',
|
||||||
|
quality: 0.85,
|
||||||
|
});
|
||||||
|
const input = mocks.save.mock.calls[0][0];
|
||||||
|
expect(input).toMatchObject({ name: 'Mein Urlaub', image: blob, imageChanged: true, previous: null });
|
||||||
|
expect(input.config.network).toBe('jcb');
|
||||||
|
expect(input.preview.type).toBe('image/webp');
|
||||||
|
|
||||||
|
const s = store.get();
|
||||||
|
expect(s.saved).toEqual({
|
||||||
|
id: 'neu',
|
||||||
|
name: 'Mein Urlaub',
|
||||||
|
imageFileId: 'img1',
|
||||||
|
previewFileId: 'prev1',
|
||||||
|
updatedAt: '2026-10-04T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(s.imageChanged).toBe(false);
|
||||||
|
expect(s.saving).toBe(false);
|
||||||
|
expect(saveButton().disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Gespeichert“ verschwindet nach zwei Sekunden', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||||
|
withImage();
|
||||||
|
mocks.save.mockResolvedValue(savedCard());
|
||||||
|
mount();
|
||||||
|
saveButton().click();
|
||||||
|
// vi.waitFor würde die falschen Uhren selbst vorstellen; deshalb nur die Zusagen abarbeiten.
|
||||||
|
for (let i = 0; i < 50 && saveButton().textContent !== 'Gespeichert'; i += 1) await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(saveButton().textContent).toBe('Gespeichert');
|
||||||
|
await vi.advanceTimersByTimeAsync(1999);
|
||||||
|
expect(saveButton().textContent).toBe('Gespeichert');
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(saveButton().textContent).toBe('Speichern');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bestehende Karte: previous und imageChanged gehen an den Dienst, danach ist imageChanged false', async () => {
|
||||||
|
withImage();
|
||||||
|
const ref = { id: 'k1', name: 'Urlaub', imageFileId: 'img0', previewFileId: 'prev0', updatedAt: '2026-10-03T12:00:00.000Z' };
|
||||||
|
store.set({ saved: ref, cardName: 'Urlaub', imageChanged: false });
|
||||||
|
mocks.save.mockResolvedValue(savedCard({ imageFileId: 'img0', previewFileId: 'prev9' }));
|
||||||
|
mount();
|
||||||
|
saveButton().click();
|
||||||
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||||
|
expect(mocks.save.mock.calls[0][0]).toMatchObject({ previous: ref, imageChanged: false });
|
||||||
|
expect(store.get().saved?.previewFileId).toBe('prev9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Bild: Meldung, kein Speichern', () => {
|
||||||
|
mount();
|
||||||
|
// Der Knopf ist ohne Bild gesperrt; der Aufruf von außen (z. B. Tastatur-Enter in einem Formular) wird trotzdem abgefangen.
|
||||||
|
saveButton().disabled = false;
|
||||||
|
saveButton().click();
|
||||||
|
expect(errorText()).toBe('Bitte zuerst ein Bild wählen.');
|
||||||
|
expect(mocks.save).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.getCurrentUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Doppelklick speichert nur einmal', async () => {
|
||||||
|
withImage();
|
||||||
|
let finish!: (c: SavedCard) => void;
|
||||||
|
mocks.save.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
|
||||||
|
mount();
|
||||||
|
saveButton().click();
|
||||||
|
saveButton().disabled = false; // selbst wenn der Klick den Knopf erreicht
|
||||||
|
saveButton().click();
|
||||||
|
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
|
||||||
|
finish(savedCard());
|
||||||
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||||
|
expect(mocks.save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nicht angemeldet: Login-Dialog, nach dem Anmelden wird gespeichert; abgebrochen: nichts', async () => {
|
||||||
|
withImage();
|
||||||
|
mocks.getCurrentUser.mockResolvedValue(null);
|
||||||
|
mocks.requireLogin.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||||
|
mocks.save.mockResolvedValue(savedCard());
|
||||||
|
mount();
|
||||||
|
|
||||||
|
saveButton().click();
|
||||||
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Speichern'));
|
||||||
|
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.save).not.toHaveBeenCalled();
|
||||||
|
expect(store.get().saving).toBe(false);
|
||||||
|
expect(errorText()).toBe('');
|
||||||
|
|
||||||
|
saveButton().click();
|
||||||
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||||
|
expect(mocks.save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Sitzung abgelaufen (401): einmal anmelden und genau einmal wiederholen', async () => {
|
||||||
|
withImage();
|
||||||
|
mocks.save
|
||||||
|
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 }))
|
||||||
|
.mockResolvedValueOnce(savedCard());
|
||||||
|
mount();
|
||||||
|
saveButton().click();
|
||||||
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||||
|
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.save).toHaveBeenCalledTimes(2);
|
||||||
|
expect(errorText()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler: Meldung mit Präfix, Editor-Zustand bleibt, erneutes Speichern geht', async () => {
|
||||||
|
const { blob } = withImage();
|
||||||
|
mocks.save.mockRejectedValueOnce(new TypeError('offline')).mockResolvedValueOnce(savedCard());
|
||||||
|
mount();
|
||||||
|
saveButton().click();
|
||||||
|
await vi.waitFor(() => expect(errorText()).toBe('Speichern fehlgeschlagen – Keine Verbindung zum Server.'));
|
||||||
|
expect(saveButton().disabled).toBe(false);
|
||||||
|
expect(saveButton().textContent).toBe('Speichern');
|
||||||
|
expect(store.get().imageBlob).toBe(blob);
|
||||||
|
expect(store.get().saved).toBeNull();
|
||||||
|
|
||||||
|
saveButton().click();
|
||||||
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||||
|
expect(errorText()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('während des Speicherns sind „Bild ersetzen“, die Dateiwahl und Ablegen gesperrt', async () => {
|
||||||
|
const { blob, img } = withImage();
|
||||||
|
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(replaceButton().disabled).toBe(true);
|
||||||
|
expect(fileInput().disabled).toBe(true);
|
||||||
|
|
||||||
|
// Ein Bild, das jetzt abgelegt wird, wird ignoriert.
|
||||||
|
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 new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(mocks.decodeImage).not.toHaveBeenCalled();
|
||||||
|
expect(store.get().imageBlob).toBe(blob);
|
||||||
|
expect(store.get().image).toBe(img);
|
||||||
|
|
||||||
|
finish(savedCard());
|
||||||
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||||
|
expect(replaceButton().disabled).toBe(false);
|
||||||
|
expect(fileInput().disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach dem Speichern darf das Bild wieder ersetzt werden und die Karte bleibt „bestehend“', async () => {
|
||||||
|
withImage();
|
||||||
|
mocks.save.mockResolvedValue(savedCard());
|
||||||
|
mount();
|
||||||
|
saveButton().click();
|
||||||
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||||
|
mocks.decodeImage.mockResolvedValue(image(1000, 1000));
|
||||||
|
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(store.get().imageChanged).toBe(true));
|
||||||
|
expect(store.get().saved?.id).toBe('k1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Speichern, das erst fertig wird, nachdem eine andere Karte geöffnet wurde, überschreibt deren Zustand nicht', async () => {
|
||||||
|
withImage();
|
||||||
|
let finish!: (c: SavedCard) => void;
|
||||||
|
mocks.save.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
|
||||||
|
mount();
|
||||||
|
saveButton().click();
|
||||||
|
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
|
||||||
|
// Eine andere Karte übernimmt den Editor (z. B. Öffnen aus „Meine Karten“).
|
||||||
|
const otherBlob = png();
|
||||||
|
const otherRef = { id: 'andere', name: 'Andere', imageFileId: 'i', previewFileId: 'p', updatedAt: '2026-10-04T12:00:00.000Z' };
|
||||||
|
store.set({ imageBlob: otherBlob, saved: otherRef, cardName: 'Andere', imageChanged: false });
|
||||||
|
finish(savedCard({ id: 'neu' }));
|
||||||
|
await vi.waitFor(() => expect(store.get().saving).toBe(false));
|
||||||
|
expect(store.get().saved).toEqual(otherRef);
|
||||||
|
expect(store.get().cardName).toBe('Andere');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Karte öffnen (/?karte=<id>)', () => {
|
||||||
|
const open = (id: string) => history.replaceState(null, '', `/?karte=${id}`);
|
||||||
|
|
||||||
|
it('lädt Karte und Bild, begrenzt den Ausschnitt und setzt den Zustand', async () => {
|
||||||
|
open('k1');
|
||||||
|
const card = savedCard();
|
||||||
|
const img = image(3000, 2000);
|
||||||
|
const blob = new Blob(['i'], { type: 'image/jpeg' });
|
||||||
|
mocks.get.mockResolvedValue(card);
|
||||||
|
mocks.loadImage.mockResolvedValue(blob);
|
||||||
|
mocks.decodeImage.mockResolvedValue(img);
|
||||||
|
mount();
|
||||||
|
expect(noticeText()).toBe('Karte wird geladen …');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||||
|
expect(mocks.get).toHaveBeenCalledWith('k1');
|
||||||
|
expect(mocks.loadImage).toHaveBeenCalledWith(card);
|
||||||
|
expect(mocks.decodeImage).toHaveBeenCalledWith(blob);
|
||||||
|
const s = store.get();
|
||||||
|
expect(s.image).toBe(img);
|
||||||
|
expect(s.imageBlob).toBe(blob);
|
||||||
|
expect(s.imageChanged).toBe(false);
|
||||||
|
expect(s.cardName).toBe('Urlaub');
|
||||||
|
expect(s.config.template).toBe('minimal');
|
||||||
|
expect(s.config.crop.zoom).toBe(2);
|
||||||
|
expect(Math.abs(s.config.crop.x)).toBeLessThan(99999);
|
||||||
|
expect(Math.abs(s.config.crop.y)).toBeLessThan(99999);
|
||||||
|
expect(s.saved).toEqual({
|
||||||
|
id: 'k1',
|
||||||
|
name: 'Urlaub',
|
||||||
|
imageFileId: 'img1',
|
||||||
|
previewFileId: 'prev1',
|
||||||
|
updatedAt: '2026-10-04T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(noticeText()).toBe('');
|
||||||
|
expect(errorText()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lädt nicht erneut, wenn der Editor die Karte schon hält', async () => {
|
||||||
|
open('k1');
|
||||||
|
store.set({ saved: { id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' } });
|
||||||
|
mount();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(mocks.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne ?karte wird nichts geladen', async () => {
|
||||||
|
mount();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(mocks.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fehlt die Bilddatei, öffnet die Karte ohne Bild mit Hinweis', async () => {
|
||||||
|
open('k1');
|
||||||
|
mocks.get.mockResolvedValue(savedCard());
|
||||||
|
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||||
|
const s = store.get();
|
||||||
|
expect(s.image).toBeNull();
|
||||||
|
expect(s.imageBlob).toBeNull();
|
||||||
|
expect(s.imageChanged).toBe(false);
|
||||||
|
expect(s.cardName).toBe('Urlaub');
|
||||||
|
expect(s.config.template).toBe('minimal');
|
||||||
|
expect(warningText()).toBe('Das Bild dieser Karte fehlt.');
|
||||||
|
expect(noticeText()).toBe('');
|
||||||
|
expect(mocks.decodeImage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Karte nicht gefunden: Meldung und leerer Editor', async () => {
|
||||||
|
open('gibtsnicht');
|
||||||
|
store.set({ image: image(), imageBlob: png(), cardName: 'Altlast' });
|
||||||
|
mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' }));
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(errorText()).toBe('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.'),
|
||||||
|
);
|
||||||
|
expect(store.get()).toEqual(initialEditorState());
|
||||||
|
expect(noticeText()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anmeldung abgebrochen: Hinweis, Editor unverändert', async () => {
|
||||||
|
open('k1');
|
||||||
|
mocks.get.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 }));
|
||||||
|
mocks.requireLogin.mockResolvedValue(false);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(warningText()).toBe('Melde dich an, um diese Karte zu öffnen.'));
|
||||||
|
expect(store.get().saved).toBeNull();
|
||||||
|
expect(noticeText()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Sitzung abgelaufen, Anmeldung klappt: Karte wird danach geöffnet', async () => {
|
||||||
|
open('k1');
|
||||||
|
mocks.get
|
||||||
|
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 }))
|
||||||
|
.mockResolvedValueOnce(savedCard());
|
||||||
|
mocks.loadImage.mockResolvedValue(png());
|
||||||
|
mocks.decodeImage.mockResolvedValue(image());
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||||
|
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anderer Fehler (Netz): Meldung, Editor unverändert', async () => {
|
||||||
|
open('k1');
|
||||||
|
mocks.get.mockRejectedValue(new TypeError('offline'));
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(errorText()).toBe('Keine Verbindung zum Server.'));
|
||||||
|
expect(store.get().saved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Bild, das sich nicht dekodieren lässt, meldet das, ohne die Karte zu übernehmen', async () => {
|
||||||
|
open('k1');
|
||||||
|
mocks.get.mockResolvedValue(savedCard());
|
||||||
|
mocks.loadImage.mockResolvedValue(png());
|
||||||
|
mocks.decodeImage.mockRejectedValue(new Error('Das Bild konnte nicht gelesen werden.'));
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(errorText()).toBe('Das Bild konnte nicht gelesen werden.'));
|
||||||
|
expect(store.get().saved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlässt der Nutzer die Seite während des Ladens, wird nichts mehr übernommen', async () => {
|
||||||
|
open('k1');
|
||||||
|
let finish!: (c: SavedCard) => void;
|
||||||
|
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
|
||||||
|
mount();
|
||||||
|
unmount?.();
|
||||||
|
unmount = null;
|
||||||
|
finish(savedCard());
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(mocks.loadImage).not.toHaveBeenCalled();
|
||||||
|
expect(store.get().saved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wählt der Nutzer während des Ladens selbst ein Bild, gewinnt dieses', async () => {
|
||||||
|
open('k1');
|
||||||
|
let finish!: (c: SavedCard) => void;
|
||||||
|
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
|
||||||
|
mocks.decodeImage.mockResolvedValue(image(500, 500));
|
||||||
|
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(store.get().image?.w).toBe(500));
|
||||||
|
finish(savedCard());
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(mocks.loadImage).not.toHaveBeenCalled();
|
||||||
|
expect(store.get().saved).toBeNull();
|
||||||
|
expect(noticeText()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine unbrauchbare Datei bricht das Laden der Karte nicht ab', async () => {
|
||||||
|
open('k1');
|
||||||
|
mocks.get.mockResolvedValue(savedCard());
|
||||||
|
mocks.loadImage.mockResolvedValue(png());
|
||||||
|
mocks.decodeImage.mockResolvedValue(image());
|
||||||
|
mount();
|
||||||
|
const file = new File(['x'], 'neu.gif', { type: 'image/gif' });
|
||||||
|
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().saved?.id).toBe('k1'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,26 +1,49 @@
|
|||||||
// Editor-Seite: setzt Vorlagen, Vorschau und Optionen zusammen und verbindet sie mit dem Store.
|
// Editor-Seite: setzt Vorlagen, Vorschau und Optionen zusammen und verbindet sie mit dem Store.
|
||||||
import './editor.css';
|
import './editor.css';
|
||||||
|
import { cardsService } from '../account/cards';
|
||||||
|
import { getCurrentUser } 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 type { BrandId, CardConfig, DetailsTone, NetworkId, TemplateId } from '../card/config';
|
||||||
import { panBy, zoomTo } from '../card/crop';
|
import { clampCrop, panBy, zoomTo } from '../card/crop';
|
||||||
import { CARD_W } from '../card/geometry';
|
import { CARD_W } from '../card/geometry';
|
||||||
import { drawCard, exportCard, loadCardAssets, type CardAssets } from '../card/render';
|
import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render';
|
||||||
import { downloadFileName, pngSizeWarning, saveBlobAs } from './download';
|
import { downloadFileName, pngSizeWarning, saveBlobAs } from './download';
|
||||||
import { el } from './dom';
|
import { el } from './dom';
|
||||||
import { createOptionsPanel } from './options-panel';
|
import { createOptionsPanel } from './options-panel';
|
||||||
import { attachFileDrop, createPreview } from './preview';
|
import { attachFileDrop, createPreview } from './preview';
|
||||||
import { setImage, type EditorState, type EditorStore } from './state';
|
import { createSaveAction, type SaveStatus } from './save';
|
||||||
|
import { initialEditorState, setImage, type EditorState, type EditorStore, type SavedCardRef } from './state';
|
||||||
import { createTemplatesPanel } from './templates-panel';
|
import { createTemplatesPanel } from './templates-panel';
|
||||||
import { decodeImage, validateImageFile } from './upload';
|
import { READ_MESSAGE, decodeImage, validateImageFile } from './upload';
|
||||||
|
|
||||||
const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.';
|
const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.';
|
||||||
const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.';
|
const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.';
|
||||||
|
const NO_IMAGE = 'Bitte zuerst ein Bild wählen.';
|
||||||
|
const LOADING_CARD = 'Karte wird geladen …';
|
||||||
|
const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.';
|
||||||
|
const PREVIEW_WIDTH = 480;
|
||||||
|
const PREVIEW_QUALITY = 0.85;
|
||||||
|
const SAVED_FLASH_MS = 2000;
|
||||||
|
|
||||||
type Logos = Pick<CardAssets, 'network' | 'brand'>;
|
type Logos = Pick<CardAssets, 'network' | 'brand'>;
|
||||||
|
|
||||||
/** Alles, was Logo-Bilder bestimmt: wechselt der Schlüssel, müssen sie neu geladen werden. */
|
/** Alles, was Logo-Bilder bestimmt: wechselt der Schlüssel, müssen sie neu geladen werden. */
|
||||||
const logoKey = (c: CardConfig) => `${c.network}|${c.details}|${c.brand}`;
|
const logoKey = (c: CardConfig) => `${c.network}|${c.details}|${c.brand}`;
|
||||||
|
|
||||||
export function mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSave(): void }): () => void {
|
/** Der Teil einer gespeicherten Karte, den der Editor-Zustand festhält (ohne Konfiguration). */
|
||||||
|
function toRef(card: SavedCardRef): SavedCardRef {
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
name: card.name,
|
||||||
|
imageFileId: card.imageFileId,
|
||||||
|
previewFileId: card.previewFileId,
|
||||||
|
updatedAt: card.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
const patchConfig = (patch: Partial<CardConfig>) => store.set({ config: { ...store.get().config, ...patch } });
|
const patchConfig = (patch: Partial<CardConfig>) => store.set({ config: { ...store.get().config, ...patch } });
|
||||||
@@ -47,7 +70,14 @@ export function mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSa
|
|||||||
patchConfig({ crop: zoomTo(s.image, s.config.crop, zoom) });
|
patchConfig({ crop: zoomTo(s.image, s.config.crop, zoom) });
|
||||||
},
|
},
|
||||||
onDownload: () => void download(),
|
onDownload: () => void download(),
|
||||||
onSave: () => hooks.onSave(),
|
onSave() {
|
||||||
|
if (store.get().saving) return;
|
||||||
|
if (!store.get().imageBlob) {
|
||||||
|
preview.setError(NO_IMAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void saveCard();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const heading = el('h1', { class: 'sr-only' }, 'Kartenmotiv gestalten');
|
const heading = el('h1', { class: 'sr-only' }, 'Kartenmotiv gestalten');
|
||||||
@@ -57,12 +87,16 @@ export function mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSa
|
|||||||
// Dateien: nur das zuletzt gewählte Bild zählt, falls ein älteres noch dekodiert wird.
|
// Dateien: nur das zuletzt gewählte Bild zählt, falls ein älteres noch dekodiert wird.
|
||||||
let fileToken = 0;
|
let fileToken = 0;
|
||||||
async function handleFile(file: File): Promise<void> {
|
async function handleFile(file: File): Promise<void> {
|
||||||
|
// Während des Speicherns bleibt das Bild, das gerade gespeichert wird.
|
||||||
|
if (store.get().saving) return;
|
||||||
const token = ++fileToken;
|
const token = ++fileToken;
|
||||||
const check = validateImageFile(file);
|
const check = validateImageFile(file);
|
||||||
if (!check.ok) {
|
if (!check.ok) {
|
||||||
preview.setError(check.message);
|
preview.setError(check.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Wer selbst ein Bild wählt, will die Karte, die noch lädt, nicht mehr.
|
||||||
|
cancelCardLoad();
|
||||||
try {
|
try {
|
||||||
const image = await decodeImage(file);
|
const image = await decodeImage(file);
|
||||||
if (disposed || token !== fileToken) return;
|
if (disposed || token !== fileToken) return;
|
||||||
@@ -73,7 +107,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSa
|
|||||||
preview.setError(error instanceof Error ? error.message : 'Das Bild konnte nicht gelesen werden.');
|
preview.setError(error instanceof Error ? error.message : 'Das Bild konnte nicht gelesen werden.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
attachFileDrop(page, (file) => void handleFile(file), preview.setDragging);
|
attachFileDrop(page, (file) => void handleFile(file), (active) => preview.setDragging(active && !store.get().saving));
|
||||||
|
|
||||||
// Download: rendert die Karte frisch in voller Größe, unabhängig von der Vorschau.
|
// Download: rendert die Karte frisch in voller Größe, unabhängig von der Vorschau.
|
||||||
async function download(): Promise<void> {
|
async function download(): Promise<void> {
|
||||||
@@ -96,6 +130,111 @@ export function mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Speichern: Anmeldung prüfen, Karte speichern, bei abgelaufener Sitzung einmal neu anmelden (siehe save.ts).
|
||||||
|
let savedFlash = false;
|
||||||
|
let flashTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
function onSaveStatus(status: SaveStatus, message?: string): void {
|
||||||
|
clearTimeout(flashTimer);
|
||||||
|
savedFlash = status === 'saved';
|
||||||
|
if (savedFlash) {
|
||||||
|
flashTimer = setTimeout(() => {
|
||||||
|
savedFlash = false;
|
||||||
|
if (!disposed) sync(store.get());
|
||||||
|
}, SAVED_FLASH_MS);
|
||||||
|
}
|
||||||
|
if (!disposed) preview.setError(status === 'error' ? (message ?? null) : null);
|
||||||
|
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.
|
||||||
|
const { config, image, imageBlob, imageChanged, cardName, saved } = store.get();
|
||||||
|
if (!image || !imageBlob) throw new Error(NO_IMAGE);
|
||||||
|
const assets = await loadCardAssets(config, image);
|
||||||
|
const previewBlob = await exportCard(config, assets, {
|
||||||
|
width: PREVIEW_WIDTH,
|
||||||
|
type: 'image/webp',
|
||||||
|
quality: PREVIEW_QUALITY,
|
||||||
|
});
|
||||||
|
const card = await cardsService().save({
|
||||||
|
name: cardName,
|
||||||
|
config,
|
||||||
|
image: imageBlob,
|
||||||
|
imageChanged,
|
||||||
|
preview: previewBlob,
|
||||||
|
previous: saved,
|
||||||
|
});
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCard = createSaveAction({
|
||||||
|
getUser: getCurrentUser,
|
||||||
|
requireLogin: () => requireLogin('login'),
|
||||||
|
doSave,
|
||||||
|
onStatus: onSaveStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Karte öffnen (/?karte=<id>): nur die zuletzt angeforderte zählt.
|
||||||
|
let loadToken = 0;
|
||||||
|
function cancelCardLoad(): void {
|
||||||
|
loadToken += 1;
|
||||||
|
preview.setNotice(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSavedCard(id: string): Promise<void> {
|
||||||
|
const token = ++loadToken;
|
||||||
|
const stale = () => disposed || token !== loadToken;
|
||||||
|
const service = cardsService();
|
||||||
|
preview.setError(null);
|
||||||
|
preview.setWarning(null);
|
||||||
|
preview.setNotice(LOADING_CARD);
|
||||||
|
try {
|
||||||
|
const card = await withLogin(() => service.get(id));
|
||||||
|
if (stale()) return;
|
||||||
|
|
||||||
|
// Fehlt die Bilddatei, öffnet die Karte trotzdem (ohne Bild, mit Hinweis).
|
||||||
|
let blob: Blob | null = null;
|
||||||
|
let missingImage: string | null = null;
|
||||||
|
try {
|
||||||
|
blob = await withLogin(() => service.loadImage(card));
|
||||||
|
} catch (err) {
|
||||||
|
if (errorType(err) !== 'storage_file_not_found') throw err;
|
||||||
|
missingImage = errorMessage(err, 'load');
|
||||||
|
}
|
||||||
|
if (stale()) return;
|
||||||
|
const image: CardImage | null = blob ? await decodeImage(blob) : null;
|
||||||
|
if (stale()) return;
|
||||||
|
|
||||||
|
store.set({
|
||||||
|
config: image ? { ...card.config, crop: clampCrop(image, card.config.crop) } : card.config,
|
||||||
|
image,
|
||||||
|
imageBlob: blob,
|
||||||
|
imageChanged: false,
|
||||||
|
saved: toRef(card),
|
||||||
|
cardName: card.name,
|
||||||
|
});
|
||||||
|
preview.setWarning(missingImage);
|
||||||
|
} catch (err) {
|
||||||
|
if (stale()) return;
|
||||||
|
if (err instanceof LoginCancelled) {
|
||||||
|
preview.setWarning(LOGIN_TO_OPEN);
|
||||||
|
} else if (errorType(err) === 'row_not_found') {
|
||||||
|
// Die Karte gibt es nicht (für dieses Konto): kein halb übernommener Zustand, sondern ein leerer Editor.
|
||||||
|
store.set(initialEditorState());
|
||||||
|
preview.setError(errorMessage(err, 'load'));
|
||||||
|
} else {
|
||||||
|
preview.setError(err instanceof Error && err.message === READ_MESSAGE ? READ_MESSAGE : errorMessage(err, 'load'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!stale()) preview.setNotice(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Logos und Schrift: werden nur bei Wechsel von Netzwerk, Details oder Logo geladen (Logos sind zwischengespeichert).
|
// Logos und Schrift: werden nur bei Wechsel von Netzwerk, Details oder Logo geladen (Logos sind zwischengespeichert).
|
||||||
let logos: (Logos & { key: string }) | null = null;
|
let logos: (Logos & { key: string }) | null = null;
|
||||||
let pendingKey = '';
|
let pendingKey = '';
|
||||||
@@ -149,7 +288,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSa
|
|||||||
function sync(s: EditorState): void {
|
function sync(s: EditorState): void {
|
||||||
templates.select(s.config.template);
|
templates.select(s.config.template);
|
||||||
options.select(s.config);
|
options.select(s.config);
|
||||||
preview.sync({ hasImage: s.image !== null, zoom: s.config.crop.zoom, saving: s.saving, exporting });
|
preview.sync({ hasImage: s.image !== null, zoom: s.config.crop.zoom, saving: s.saving, saved: savedFlash, exporting });
|
||||||
}
|
}
|
||||||
|
|
||||||
function onState(s: EditorState): void {
|
function onState(s: EditorState): void {
|
||||||
@@ -166,8 +305,13 @@ export function mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSa
|
|||||||
const unsubscribe = store.subscribe(onState);
|
const unsubscribe = store.subscribe(onState);
|
||||||
onState(store.get());
|
onState(store.get());
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
clearTimeout(flashTimer);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
if (frame) cancelAnimationFrame(frame);
|
if (frame) cancelAnimationFrame(frame);
|
||||||
frame = 0;
|
frame = 0;
|
||||||
|
|||||||
@@ -387,6 +387,10 @@
|
|||||||
color: #f2c66d;
|
color: #f2c66d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.msg-notice {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -438,6 +442,10 @@
|
|||||||
background: var(--primary-hover);
|
background: var(--primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
min-width: 9.5em; /* „Speichern“, „Speichert …“ und „Gespeichert“ sollen den Knopf nicht springen lassen */
|
||||||
|
}
|
||||||
|
|
||||||
.btn-large {
|
.btn-large {
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
padding: 0 28px;
|
padding: 0 28px;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface PreviewView {
|
|||||||
hasImage: boolean;
|
hasImage: boolean;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
/** true für kurze Zeit nach erfolgreichem Speichern (Knopf zeigt „Gespeichert“). */
|
||||||
|
saved: boolean;
|
||||||
exporting: boolean;
|
exporting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,9 +29,14 @@ export interface Preview {
|
|||||||
setDragging(active: boolean): void;
|
setDragging(active: boolean): void;
|
||||||
setError(message: string | null): void;
|
setError(message: string | null): void;
|
||||||
setWarning(message: string | null): void;
|
setWarning(message: string | null): void;
|
||||||
|
/** Neutraler Hinweis, z. B. „Karte wird geladen …“. */
|
||||||
|
setNotice(message: string | null): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_IMAGE_HINT = 'Erst ein Bild wählen';
|
const NO_IMAGE_HINT = 'Erst ein Bild wählen';
|
||||||
|
const SAVE_LABEL = 'Speichern';
|
||||||
|
const SAVING_LABEL = 'Speichert …';
|
||||||
|
const SAVED_LABEL = 'Gespeichert';
|
||||||
const KEY_STEP = 24;
|
const KEY_STEP = 24;
|
||||||
const KEY_STEP_BIG = 96;
|
const KEY_STEP_BIG = 96;
|
||||||
const KEY_ZOOM_STEP = 0.1;
|
const KEY_ZOOM_STEP = 0.1;
|
||||||
@@ -185,13 +192,14 @@ export function createPreview(handlers: PreviewHandlers): Preview {
|
|||||||
// Meldungen
|
// Meldungen
|
||||||
const errorBox = el('p', { class: 'msg msg-error', role: 'alert' });
|
const errorBox = el('p', { class: 'msg msg-error', role: 'alert' });
|
||||||
const warningBox = el('p', { class: 'msg msg-warning', role: 'status' });
|
const warningBox = el('p', { class: 'msg msg-warning', role: 'status' });
|
||||||
|
const noticeBox = el('p', { class: 'msg msg-notice', role: 'status' });
|
||||||
|
|
||||||
// Knopfleiste
|
// Knopfleiste
|
||||||
const replaceButton = el('button', { type: 'button', class: 'btn btn-ghost' }, 'Bild ersetzen');
|
const replaceButton = el('button', { type: 'button', class: 'btn btn-ghost' }, 'Bild ersetzen');
|
||||||
replaceButton.addEventListener('click', openPicker);
|
replaceButton.addEventListener('click', openPicker);
|
||||||
const downloadButton = el('button', { type: 'button', class: 'btn btn-primary' }, 'Herunterladen');
|
const downloadButton = el('button', { type: 'button', class: 'btn btn-primary' }, 'Herunterladen');
|
||||||
downloadButton.addEventListener('click', () => handlers.onDownload());
|
downloadButton.addEventListener('click', () => handlers.onDownload());
|
||||||
const saveButton = el('button', { type: 'button', class: 'btn btn-primary' }, 'Speichern');
|
const saveButton = el('button', { type: 'button', class: 'btn btn-primary btn-save' }, SAVE_LABEL);
|
||||||
saveButton.addEventListener('click', () => handlers.onSave());
|
saveButton.addEventListener('click', () => handlers.onSave());
|
||||||
const actions = el(
|
const actions = el(
|
||||||
'div',
|
'div',
|
||||||
@@ -202,7 +210,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 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, actions, note, input);
|
const inner = el('div', { class: 'preview-inner' }, stage, zoomRow, errorBox, warningBox, noticeBox, actions, note, input);
|
||||||
const section = el('section', { class: 'editor-center', 'aria-label': 'Kartenvorschau' }, inner);
|
const section = el('section', { class: 'editor-center', 'aria-label': 'Kartenvorschau' }, inner);
|
||||||
|
|
||||||
function setDisabled(button: HTMLButtonElement, disabled: boolean, hint: string | null): void {
|
function setDisabled(button: HTMLButtonElement, disabled: boolean, hint: string | null): void {
|
||||||
@@ -227,6 +235,10 @@ export function createPreview(handlers: PreviewHandlers): Preview {
|
|||||||
zoomValue.textContent = `${Math.round(view.zoom * 100)} %`;
|
zoomValue.textContent = `${Math.round(view.zoom * 100)} %`;
|
||||||
setDisabled(downloadButton, !view.hasImage || view.exporting, view.hasImage ? null : NO_IMAGE_HINT);
|
setDisabled(downloadButton, !view.hasImage || view.exporting, view.hasImage ? null : NO_IMAGE_HINT);
|
||||||
setDisabled(saveButton, !view.hasImage || view.saving, view.hasImage ? null : NO_IMAGE_HINT);
|
setDisabled(saveButton, !view.hasImage || view.saving, view.hasImage ? null : NO_IMAGE_HINT);
|
||||||
|
saveButton.textContent = view.saving ? SAVING_LABEL : view.saved ? SAVED_LABEL : SAVE_LABEL;
|
||||||
|
// Ein Bildwechsel mitten im Speichern würde die gespeicherte Karte durcheinanderbringen.
|
||||||
|
replaceButton.disabled = view.saving;
|
||||||
|
input.disabled = view.saving;
|
||||||
downloadButton.setAttribute('aria-busy', String(view.exporting));
|
downloadButton.setAttribute('aria-busy', String(view.exporting));
|
||||||
saveButton.setAttribute('aria-busy', String(view.saving));
|
saveButton.setAttribute('aria-busy', String(view.saving));
|
||||||
},
|
},
|
||||||
@@ -239,5 +251,8 @@ export function createPreview(handlers: PreviewHandlers): Preview {
|
|||||||
setWarning(message) {
|
setWarning(message) {
|
||||||
setMessage(warningBox, message);
|
setMessage(warningBox, message);
|
||||||
},
|
},
|
||||||
|
setNotice(message) {
|
||||||
|
setMessage(noticeBox, message);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { User } from '../account/auth';
|
||||||
|
import { createSaveAction, type SaveStatus } from './save';
|
||||||
|
|
||||||
|
const user: User = { $id: 'u1', email: 'a@b.de' };
|
||||||
|
const unauthorized = () => Object.assign(new Error('Unauthorized'), { code: 401, type: 'user_unauthorized' });
|
||||||
|
|
||||||
|
/** Ein Aufruf von `doSave`, den der Test von außen auflösen kann. */
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(overrides: Partial<Parameters<typeof createSaveAction>[0]> = {}) {
|
||||||
|
const statuses: Array<[SaveStatus, string | undefined]> = [];
|
||||||
|
const deps = {
|
||||||
|
getUser: vi.fn(async (): Promise<User | null> => user),
|
||||||
|
requireLogin: vi.fn(async () => true),
|
||||||
|
refreshUser: vi.fn(async (): Promise<User | null> => null),
|
||||||
|
doSave: vi.fn(async () => {}),
|
||||||
|
onStatus: vi.fn((s: SaveStatus, message?: string) => {
|
||||||
|
statuses.push([s, message]);
|
||||||
|
}),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const save = createSaveAction(deps);
|
||||||
|
const last = () => statuses[statuses.length - 1];
|
||||||
|
return { deps, save, statuses, last };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createSaveAction', () => {
|
||||||
|
it('angemeldet: speichert einmal, Status saving → saved', async () => {
|
||||||
|
const { deps, save, statuses } = setup();
|
||||||
|
await save();
|
||||||
|
expect(deps.doSave).toHaveBeenCalledOnce();
|
||||||
|
expect(deps.requireLogin).not.toHaveBeenCalled();
|
||||||
|
expect(statuses.map(([s]) => s)).toEqual(['saving', 'saved']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zwei schnelle Aufrufe speichern nur einmal', async () => {
|
||||||
|
const gate = deferred();
|
||||||
|
const { deps, save } = setup({ doSave: vi.fn(() => gate.promise) });
|
||||||
|
const first = save();
|
||||||
|
const second = save();
|
||||||
|
await second; // der zweite Aufruf kehrt sofort zurück
|
||||||
|
expect(deps.doSave).toHaveBeenCalledTimes(1);
|
||||||
|
gate.resolve();
|
||||||
|
await first;
|
||||||
|
expect(deps.doSave).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Aufruf während der Nutzerprüfung zählt auch als laufend', async () => {
|
||||||
|
const gate = deferred();
|
||||||
|
const getUser = vi.fn(async () => {
|
||||||
|
await gate.promise;
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
const { deps, save } = setup({ getUser });
|
||||||
|
const first = save();
|
||||||
|
await save();
|
||||||
|
expect(getUser).toHaveBeenCalledTimes(1);
|
||||||
|
gate.resolve();
|
||||||
|
await first;
|
||||||
|
expect(deps.doSave).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach dem Ende kann erneut gespeichert werden', async () => {
|
||||||
|
const { deps, save } = setup();
|
||||||
|
await save();
|
||||||
|
await save();
|
||||||
|
expect(deps.doSave).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach einem Fehler kann erneut gespeichert werden', async () => {
|
||||||
|
const doSave = vi.fn().mockRejectedValueOnce(new Error('kaputt')).mockResolvedValueOnce(undefined);
|
||||||
|
const { save, last } = setup({ doSave });
|
||||||
|
await save();
|
||||||
|
expect(last()?.[0]).toBe('error');
|
||||||
|
await save();
|
||||||
|
expect(last()?.[0]).toBe('saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Nutzer und abgebrochenem Login: nie doSave, letzter Status idle', async () => {
|
||||||
|
const { deps, save, last } = setup({
|
||||||
|
getUser: vi.fn(async () => null),
|
||||||
|
requireLogin: vi.fn(async () => false),
|
||||||
|
});
|
||||||
|
await save();
|
||||||
|
expect(deps.requireLogin).toHaveBeenCalledOnce();
|
||||||
|
expect(deps.doSave).not.toHaveBeenCalled();
|
||||||
|
expect(last()?.[0]).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Nutzer, nach erfolgreichem Login wird gespeichert', async () => {
|
||||||
|
const { deps, save, last } = setup({ getUser: vi.fn(async () => null) });
|
||||||
|
await save();
|
||||||
|
expect(deps.requireLogin).toHaveBeenCalledOnce();
|
||||||
|
expect(deps.doSave).toHaveBeenCalledOnce();
|
||||||
|
expect(last()?.[0]).toBe('saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doSave wirft 401, Login ok: einmal anmelden, doSave genau zweimal, Status saved', async () => {
|
||||||
|
const doSave = vi.fn().mockRejectedValueOnce(unauthorized()).mockResolvedValueOnce(undefined);
|
||||||
|
const order: string[] = [];
|
||||||
|
const { deps, save, last } = setup({
|
||||||
|
doSave,
|
||||||
|
refreshUser: vi.fn(async () => {
|
||||||
|
order.push('refresh');
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
requireLogin: vi.fn(async () => {
|
||||||
|
order.push('login');
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await save();
|
||||||
|
expect(deps.requireLogin).toHaveBeenCalledOnce();
|
||||||
|
expect(doSave).toHaveBeenCalledTimes(2);
|
||||||
|
expect(last()?.[0]).toBe('saved');
|
||||||
|
expect(order).toEqual(['refresh', 'login']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401 und abgebrochener Login: Status idle, kein zweites doSave', async () => {
|
||||||
|
const doSave = vi.fn().mockRejectedValue(unauthorized());
|
||||||
|
const { deps, save, last } = setup({ doSave, requireLogin: vi.fn(async () => false) });
|
||||||
|
await save();
|
||||||
|
expect(doSave).toHaveBeenCalledOnce();
|
||||||
|
expect(deps.requireLogin).toHaveBeenCalledOnce();
|
||||||
|
expect(last()?.[0]).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401 auch beim zweiten Versuch: Fehler, kein dritter Versuch, kein zweiter Dialog', async () => {
|
||||||
|
const doSave = vi.fn().mockRejectedValue(unauthorized());
|
||||||
|
const { deps, save, last } = setup({ doSave });
|
||||||
|
await save();
|
||||||
|
expect(doSave).toHaveBeenCalledTimes(2);
|
||||||
|
expect(deps.requireLogin).toHaveBeenCalledOnce();
|
||||||
|
expect(last()?.[0]).toBe('error');
|
||||||
|
expect(last()?.[1]).toMatch(/^Speichern fehlgeschlagen – /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert die Nutzerprüfung beim 401 (Netz), wird trotzdem angemeldet', async () => {
|
||||||
|
const doSave = vi.fn().mockRejectedValueOnce(unauthorized()).mockResolvedValueOnce(undefined);
|
||||||
|
const { deps, save, last } = setup({ doSave, refreshUser: vi.fn().mockRejectedValue(new TypeError('offline')) });
|
||||||
|
await save();
|
||||||
|
expect(deps.requireLogin).toHaveBeenCalledOnce();
|
||||||
|
expect(last()?.[0]).toBe('saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anderer Fehler: Status error mit Präfix und deutscher Meldung, kein Login', async () => {
|
||||||
|
const doSave = vi.fn().mockRejectedValue(Object.assign(new Error('Too many'), { code: 429 }));
|
||||||
|
const { deps, save, last } = setup({ doSave });
|
||||||
|
await save();
|
||||||
|
expect(deps.requireLogin).not.toHaveBeenCalled();
|
||||||
|
expect(last()).toEqual(['error', 'Speichern fehlgeschlagen – Zu viele Versuche – bitte kurz warten.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Netzfehler (TypeError) bekommt die Verbindungsmeldung', async () => {
|
||||||
|
const doSave = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
const { save, last } = setup({ doSave });
|
||||||
|
await save();
|
||||||
|
expect(last()).toEqual(['error', 'Speichern fehlgeschlagen – Keine Verbindung zum Server.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert die Nutzerprüfung selbst, wird der Fehler gemeldet und der Status ist nicht hängen geblieben', async () => {
|
||||||
|
const getUser = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
const { deps, save, last } = setup({ getUser });
|
||||||
|
await save();
|
||||||
|
expect(deps.doSave).not.toHaveBeenCalled();
|
||||||
|
expect(last()?.[0]).toBe('error');
|
||||||
|
expect(last()?.[1]).toMatch(/^Speichern fehlgeschlagen – /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft requireLogin, wird der Fehler gemeldet und danach geht Speichern wieder', async () => {
|
||||||
|
const requireLogin = vi.fn().mockRejectedValueOnce(new Error('showModal')).mockResolvedValue(true);
|
||||||
|
const { deps, save, last } = setup({ getUser: vi.fn(async () => null), requireLogin });
|
||||||
|
await save();
|
||||||
|
expect(last()?.[0]).toBe('error');
|
||||||
|
await save();
|
||||||
|
expect(deps.doSave).toHaveBeenCalledOnce();
|
||||||
|
expect(last()?.[0]).toBe('saved');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Ablauf „Speichern“: Anmeldung prüfen, speichern, bei abgelaufener Sitzung (401) einmal neu anmelden und wiederholen.
|
||||||
|
// Kennt weder DOM noch Appwrite; alles Konkrete kommt über `deps`.
|
||||||
|
import { getCurrentUser, type User } from '../account/auth';
|
||||||
|
import { errorMessage, isUnauthorized } from '../account/errors';
|
||||||
|
|
||||||
|
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
|
export interface SaveDeps {
|
||||||
|
/** Der angemeldete Nutzer laut Server (hält nebenbei den Nutzer-Cache aktuell); null = nicht angemeldet. */
|
||||||
|
getUser(): Promise<User | null>;
|
||||||
|
/** Zeigt den Login-Dialog; true, wenn sich der Nutzer angemeldet hat. */
|
||||||
|
requireLogin(): Promise<boolean>;
|
||||||
|
doSave(): Promise<void>;
|
||||||
|
onStatus(status: SaveStatus, message?: string): void;
|
||||||
|
/** Holt den Anmeldestatus nach einem 401 neu (Standard: getCurrentUser). */
|
||||||
|
refreshUser?(): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAILED = 'Speichern fehlgeschlagen – ';
|
||||||
|
|
||||||
|
/** Liefert die Funktion hinter dem Speichern-Knopf; ein laufendes Speichern blockiert jeden weiteren Aufruf. */
|
||||||
|
export function createSaveAction(deps: SaveDeps): () => Promise<void> {
|
||||||
|
const refreshUser = deps.refreshUser ?? getCurrentUser;
|
||||||
|
let running = false;
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
deps.onStatus('saving');
|
||||||
|
try {
|
||||||
|
if (!(await deps.getUser()) && !(await deps.requireLogin())) {
|
||||||
|
deps.onStatus('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deps.doSave();
|
||||||
|
} catch (err) {
|
||||||
|
if (!isUnauthorized(err)) throw err;
|
||||||
|
// Die Sitzung ist zwischen Seitenaufruf und Speichern abgelaufen.
|
||||||
|
try {
|
||||||
|
await refreshUser();
|
||||||
|
} catch {
|
||||||
|
// Ein Netzfehler hier darf die Anmeldung nicht verhindern; der Login-Dialog setzt den Nutzer ohnehin neu.
|
||||||
|
}
|
||||||
|
if (!(await deps.requireLogin())) {
|
||||||
|
deps.onStatus('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deps.doSave();
|
||||||
|
}
|
||||||
|
deps.onStatus('saved');
|
||||||
|
} catch (err) {
|
||||||
|
deps.onStatus('error', FAILED + errorMessage(err, 'save'));
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-6
@@ -3,6 +3,7 @@ import './styles.css';
|
|||||||
import { mountAccountMenu } from './account/account-menu';
|
import { mountAccountMenu } from './account/account-menu';
|
||||||
import { getCurrentUser } from './account/auth';
|
import { getCurrentUser } from './account/auth';
|
||||||
import { startRouter } from './pages/router';
|
import { startRouter } from './pages/router';
|
||||||
|
import { mountCardsPage } from './pages/cards-page';
|
||||||
import { mountResetPage } from './pages/reset-page';
|
import { mountResetPage } from './pages/reset-page';
|
||||||
import { mountEditor } from './editor/editor-page';
|
import { mountEditor } from './editor/editor-page';
|
||||||
import { createStore, initialEditorState } from './editor/state';
|
import { createStore, initialEditorState } from './editor/state';
|
||||||
@@ -24,14 +25,10 @@ if (accountSlot) {
|
|||||||
|
|
||||||
startRouter((route, root) => {
|
startRouter((route, root) => {
|
||||||
if (route === 'editor') {
|
if (route === 'editor') {
|
||||||
// Speichern wird mit den Konten (spätere Aufgabe) angebunden; bis dahin passiert nichts.
|
return mountEditor(root, editorStore);
|
||||||
return mountEditor(root, editorStore, { onSave: () => {} });
|
|
||||||
}
|
}
|
||||||
if (route === 'reset') {
|
if (route === 'reset') {
|
||||||
return mountResetPage(root);
|
return mountResetPage(root);
|
||||||
}
|
}
|
||||||
// Platzhalter, bis die Karten-Seite aus der Folgeaufgabe angebunden ist.
|
return mountCardsPage(root, editorStore);
|
||||||
const heading = document.createElement('h1');
|
|
||||||
heading.textContent = 'Meine Karten';
|
|
||||||
root.replaceChildren(heading);
|
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { User } from '../account/auth';
|
||||||
|
import type { SavedCard } from '../account/cards';
|
||||||
|
import { DEFAULT_CONFIG } from '../card/config';
|
||||||
|
import { createStore, initialEditorState } from '../editor/state';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
loadPreview: vi.fn(),
|
||||||
|
loadImage: vi.fn(),
|
||||||
|
rename: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
getCurrentUser: vi.fn(),
|
||||||
|
requireLogin: vi.fn(),
|
||||||
|
navigate: vi.fn(),
|
||||||
|
decodeImage: vi.fn(),
|
||||||
|
loadCardAssets: vi.fn(),
|
||||||
|
exportCard: vi.fn(),
|
||||||
|
saveBlobAs: vi.fn(),
|
||||||
|
authListeners: new Set<(user: User | null) => void>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||||
|
vi.mock('../account/auth', () => ({
|
||||||
|
getCurrentUser: mocks.getCurrentUser,
|
||||||
|
currentUserId: () => 'u1',
|
||||||
|
onAuthChange: (fn: (user: User | null) => void) => {
|
||||||
|
mocks.authListeners.add(fn);
|
||||||
|
return () => mocks.authListeners.delete(fn);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
||||||
|
vi.mock('./router', () => ({ navigate: mocks.navigate }));
|
||||||
|
vi.mock('../editor/upload', () => ({ READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.', decodeImage: mocks.decodeImage }));
|
||||||
|
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
|
||||||
|
vi.mock('../editor/download', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../editor/download')>()),
|
||||||
|
saveBlobAs: mocks.saveBlobAs,
|
||||||
|
}));
|
||||||
|
vi.mock('../account/cards', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../account/cards')>()),
|
||||||
|
cardsService: () => ({
|
||||||
|
list: mocks.list,
|
||||||
|
loadPreview: mocks.loadPreview,
|
||||||
|
loadImage: mocks.loadImage,
|
||||||
|
rename: mocks.rename,
|
||||||
|
remove: mocks.remove,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { formatDate, mountCardsPage } from './cards-page';
|
||||||
|
|
||||||
|
const user: User = { $id: 'u1', email: 'a@b.de' };
|
||||||
|
|
||||||
|
const card = (id: string, name: string, patch: Partial<SavedCard> = {}): SavedCard => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
imageFileId: `img-${id}`,
|
||||||
|
previewFileId: `prev-${id}`,
|
||||||
|
updatedAt: '2026-10-04T12:00:00.000Z',
|
||||||
|
config: structuredClone(DEFAULT_CONFIG),
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: HTMLElement;
|
||||||
|
let unmount: (() => void) | null;
|
||||||
|
let store: ReturnType<typeof createStore>;
|
||||||
|
let urlCounter: number;
|
||||||
|
const revoked: string[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(mocks)) if (typeof fn === 'function' && 'mockReset' in fn) fn.mockReset();
|
||||||
|
mocks.authListeners.clear();
|
||||||
|
revoked.length = 0;
|
||||||
|
urlCounter = 0;
|
||||||
|
URL.createObjectURL = vi.fn(() => `blob:preview-${++urlCounter}`);
|
||||||
|
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||||
|
revoked.push(url);
|
||||||
|
});
|
||||||
|
document.body.innerHTML = '<main id="app-root"></main>';
|
||||||
|
root = document.getElementById('app-root') as HTMLElement;
|
||||||
|
store = createStore(initialEditorState());
|
||||||
|
mocks.getCurrentUser.mockResolvedValue(user);
|
||||||
|
mocks.requireLogin.mockResolvedValue(true);
|
||||||
|
mocks.loadPreview.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' }));
|
||||||
|
unmount = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unmount?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mount = () => {
|
||||||
|
unmount = mountCardsPage(root, store);
|
||||||
|
};
|
||||||
|
const text = () => root.textContent ?? '';
|
||||||
|
const tiles = () => [...root.querySelectorAll<HTMLElement>('.card-tile')];
|
||||||
|
const tileFor = (id: string) => tiles().find((t) => t.dataset.card === id)!;
|
||||||
|
const button = (scope: ParentNode, label: string) =>
|
||||||
|
[...scope.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
|
||||||
|
const dialog = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
|
||||||
|
const submit = (form: HTMLFormElement) => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('schreibt dd.mm.yyyy', () => {
|
||||||
|
expect(formatDate(new Date(2026, 9, 4, 12).toISOString())).toBe('04.10.2026');
|
||||||
|
expect(formatDate(new Date(2026, 0, 31, 12).toISOString())).toBe('31.01.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt bei unlesbarem Datum nichts zurück', () => {
|
||||||
|
expect(formatDate('kaputt')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Meine Karten: Zustände', () => {
|
||||||
|
it('abgemeldet: Hinweis und Anmelden-Knopf, keine Anfrage an den Server', async () => {
|
||||||
|
mocks.getCurrentUser.mockResolvedValue(null);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(text()).toContain('Melde dich an, um deine Karten zu sehen.'));
|
||||||
|
expect(mocks.list).not.toHaveBeenCalled();
|
||||||
|
button(root, 'Anmelden').click();
|
||||||
|
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach dem Anmelden (Konto-Menü oder Knopf) wird die Liste geladen', async () => {
|
||||||
|
mocks.getCurrentUser.mockResolvedValue(null);
|
||||||
|
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(text()).toContain('Melde dich an'));
|
||||||
|
mocks.getCurrentUser.mockResolvedValue(user);
|
||||||
|
for (const fn of mocks.authListeners) fn(user);
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leer: Hinweis und Link zum Editor', async () => {
|
||||||
|
mocks.list.mockResolvedValue([]);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
|
||||||
|
const link = root.querySelector<HTMLAnchorElement>('a[data-link]')!;
|
||||||
|
expect(link.getAttribute('href')).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler beim Laden: Meldung und „Nochmal versuchen“', async () => {
|
||||||
|
mocks.list.mockRejectedValueOnce(new TypeError('offline')).mockResolvedValueOnce([card('a', 'Alpha')]);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(text()).toContain('Keine Verbindung zum Server.'));
|
||||||
|
button(root, 'Nochmal versuchen').click();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Sitzung läuft beim Laden ab: Anmelden, dann wird die Liste angezeigt', async () => {
|
||||||
|
const unauthorized = Object.assign(new Error('Unauthorized'), { code: 401 });
|
||||||
|
mocks.list.mockRejectedValueOnce(unauthorized).mockResolvedValueOnce([card('a', 'Alpha')]);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||||
|
expect(mocks.requireLogin).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Sitzung läuft ab und der Dialog wird geschlossen: Anmelden-Ansicht', async () => {
|
||||||
|
mocks.list.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 }));
|
||||||
|
mocks.requireLogin.mockResolvedValue(false);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(text()).toContain('Melde dich an, um deine Karten zu sehen.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Abmelden räumt die Liste weg und gibt die Vorschau-URLs frei', async () => {
|
||||||
|
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
|
||||||
|
for (const fn of mocks.authListeners) fn(null);
|
||||||
|
expect(text()).toContain('Melde dich an, um deine Karten zu sehen.');
|
||||||
|
expect(tiles()).toHaveLength(0);
|
||||||
|
expect(revoked).toEqual(['blob:preview-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Meine Karten: Kacheln', () => {
|
||||||
|
it('zeigt Name, Datum und Vorschau', async () => {
|
||||||
|
const updatedAt = new Date(2026, 9, 4, 12).toISOString();
|
||||||
|
mocks.list.mockResolvedValue([card('a', 'Alpha', { updatedAt }), card('b', 'Beta')]);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
|
||||||
|
expect(tileFor('a').querySelector('.card-name')!.textContent).toBe('Alpha');
|
||||||
|
expect(tileFor('a').querySelector('.card-date')!.textContent).toBe('04.10.2026');
|
||||||
|
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
|
||||||
|
await vi.waitFor(() => expect(tileFor('b').querySelector('img')).not.toBeNull());
|
||||||
|
expect(tileFor('a').querySelector('img')!.getAttribute('src')).toMatch(/^blob:preview-/);
|
||||||
|
expect(mocks.loadPreview).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Vorschau, die nicht lädt, zeigt „Keine Vorschau“, die Kachel bleibt bedienbar', async () => {
|
||||||
|
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||||
|
mocks.loadPreview.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(tileFor('a').textContent).toContain('Keine Vorschau'));
|
||||||
|
expect(button(tileFor('a'), 'Öffnen').disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lädt höchstens vier Vorschauen gleichzeitig', async () => {
|
||||||
|
const cards = Array.from({ length: 10 }, (_, i) => card(`c${i}`, `Karte ${i}`));
|
||||||
|
mocks.list.mockResolvedValue(cards);
|
||||||
|
let active = 0;
|
||||||
|
let peak = 0;
|
||||||
|
mocks.loadPreview.mockImplementation(async () => {
|
||||||
|
active += 1;
|
||||||
|
peak = Math.max(peak, active);
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
active -= 1;
|
||||||
|
return new Blob(['p'], { type: 'image/webp' });
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(10));
|
||||||
|
expect(peak).toBeLessThanOrEqual(4);
|
||||||
|
expect(peak).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beim Verlassen der Seite werden alle Vorschau-URLs freigegeben', async () => {
|
||||||
|
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(2));
|
||||||
|
unmount?.();
|
||||||
|
unmount = null;
|
||||||
|
expect([...revoked].sort()).toEqual(['blob:preview-1', 'blob:preview-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Vorschau, die erst nach dem Verlassen ankommt, bekommt keine URL', async () => {
|
||||||
|
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||||
|
let finish!: (b: Blob) => void;
|
||||||
|
mocks.loadPreview.mockReturnValue(new Promise<Blob>((r) => (finish = r)));
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(mocks.loadPreview).toHaveBeenCalled());
|
||||||
|
unmount?.();
|
||||||
|
unmount = null;
|
||||||
|
finish(new Blob(['p'], { type: 'image/webp' }));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Meine Karten: Öffnen und Herunterladen', () => {
|
||||||
|
it('Öffnen navigiert in den Editor', async () => {
|
||||||
|
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||||
|
button(tileFor('a'), 'Öffnen').click();
|
||||||
|
expect(mocks.navigate).toHaveBeenCalledWith('/?karte=a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Herunterladen: Bild laden, als PNG rendern und unter aircard-<name>.png speichern', async () => {
|
||||||
|
const png = new Blob(['png'], { type: 'image/png' });
|
||||||
|
const image = { source: {}, w: 3000, h: 2000 };
|
||||||
|
mocks.list.mockResolvedValue([card('a', 'Mein Urlaub', { config: { ...DEFAULT_CONFIG, crop: { x: 99999, y: 0, zoom: 1 } } })]);
|
||||||
|
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' }));
|
||||||
|
mocks.decodeImage.mockResolvedValue(image);
|
||||||
|
mocks.loadCardAssets.mockResolvedValue({ image, network: null, brand: null });
|
||||||
|
mocks.exportCard.mockResolvedValue(png);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||||
|
button(tileFor('a'), 'Herunterladen').click();
|
||||||
|
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
|
||||||
|
expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-mein-urlaub.png');
|
||||||
|
expect(mocks.exportCard).toHaveBeenCalledWith(expect.anything(), expect.anything(), { width: 1536, type: 'image/png' });
|
||||||
|
// Der gespeicherte Ausschnitt wird auf das Bild begrenzt, bevor gerendert wird.
|
||||||
|
const rendered = mocks.exportCard.mock.calls[0][0];
|
||||||
|
expect(rendered.crop.x).toBeLessThan(99999);
|
||||||
|
await vi.waitFor(() => expect(button(tileFor('a'), 'Herunterladen').disabled).toBe(false));
|
||||||
|
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Herunterladen ohne Bilddatei zeigt die Meldung an der Kachel', async () => {
|
||||||
|
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||||
|
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||||
|
button(tileFor('a'), 'Herunterladen').click();
|
||||||
|
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Das Bild dieser Karte fehlt.'));
|
||||||
|
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
|
||||||
|
expect(button(tileFor('a'), 'Herunterladen').disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Meine Karten: Umbenennen', () => {
|
||||||
|
const startRename = async () => {
|
||||||
|
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
|
||||||
|
button(tileFor('b'), 'Umbenennen').click();
|
||||||
|
return tileFor('b').querySelector<HTMLInputElement>('input')!;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('zeigt eine Eingabe mit dem Namen, Enter speichert, die Karte steht danach vorn', async () => {
|
||||||
|
mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name.trim(), { updatedAt: '2026-10-06T08:00:00.000Z' }));
|
||||||
|
const input = await startRename();
|
||||||
|
expect(input.value).toBe('Beta');
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
input.value = ' Gamma ';
|
||||||
|
submit(input.closest('form')!);
|
||||||
|
await vi.waitFor(() => expect(tiles().map((t) => t.dataset.card)).toEqual(['b', 'a']));
|
||||||
|
expect(mocks.rename).toHaveBeenCalledWith('b', ' Gamma ');
|
||||||
|
expect(tileFor('b').querySelector('.card-name')!.textContent).toBe('Gamma');
|
||||||
|
expect(tileFor('b').querySelector('input')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Esc bricht ab, ohne etwas zu speichern', async () => {
|
||||||
|
const input = await startRename();
|
||||||
|
input.value = 'Anders';
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
||||||
|
expect(tileFor('b').querySelector('input')).toBeNull();
|
||||||
|
expect(tileFor('b').querySelector('.card-name')!.textContent).toBe('Beta');
|
||||||
|
expect(mocks.rename).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Abbrechen“ bricht ab; ein unveränderter Name löst keine Anfrage aus', async () => {
|
||||||
|
const input = await startRename();
|
||||||
|
button(tileFor('b'), 'Abbrechen').click();
|
||||||
|
expect(tileFor('b').querySelector('input')).toBeNull();
|
||||||
|
button(tileFor('b'), 'Umbenennen').click();
|
||||||
|
const again = tileFor('b').querySelector<HTMLInputElement>('input')!;
|
||||||
|
again.value = ' Beta ';
|
||||||
|
submit(again.closest('form')!);
|
||||||
|
expect(tileFor('b').querySelector('input')).toBeNull();
|
||||||
|
expect(mocks.rename).not.toHaveBeenCalled();
|
||||||
|
expect(input).not.toBe(again);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Fehler lässt die Eingabe offen und zeigt die Meldung', async () => {
|
||||||
|
mocks.rename.mockRejectedValue(new TypeError('offline'));
|
||||||
|
const input = await startRename();
|
||||||
|
input.value = 'Gamma';
|
||||||
|
submit(input.closest('form')!);
|
||||||
|
await vi.waitFor(() => expect(tileFor('b').querySelector('.card-msg')!.textContent).toBe('Keine Verbindung zum Server.'));
|
||||||
|
const open = tileFor('b').querySelector<HTMLInputElement>('input')!;
|
||||||
|
expect(open.value).toBe('Gamma');
|
||||||
|
expect(open.readOnly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hält die Karte im Editor-Zustand aktuell (sonst überschreibt Speichern den neuen Namen)', async () => {
|
||||||
|
store.set({
|
||||||
|
saved: { id: 'b', name: 'Beta', imageFileId: 'img-b', previewFileId: 'prev-b', updatedAt: '2026-10-04T12:00:00.000Z' },
|
||||||
|
cardName: 'Beta',
|
||||||
|
});
|
||||||
|
mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name, { updatedAt: '2026-10-06T08:00:00.000Z' }));
|
||||||
|
const input = await startRename();
|
||||||
|
input.value = 'Gamma';
|
||||||
|
submit(input.closest('form')!);
|
||||||
|
await vi.waitFor(() => expect(store.get().cardName).toBe('Gamma'));
|
||||||
|
expect(store.get().saved).toMatchObject({ id: 'b', name: 'Gamma', updatedAt: '2026-10-06T08:00:00.000Z' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Meine Karten: Löschen', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Klickt „Löschen“ an der Kachel (sobald der Knopf wieder bedienbar ist) und wartet auf den Dialog. */
|
||||||
|
const openDialog = async (id: string) => {
|
||||||
|
await vi.waitFor(() => expect(button(tileFor(id), 'Löschen').disabled).toBe(false));
|
||||||
|
button(tileFor(id), 'Löschen').click();
|
||||||
|
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
|
||||||
|
await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(2));
|
||||||
|
};
|
||||||
|
|
||||||
|
it('fragt in einem eigenen Dialog nach und löscht erst nach „Löschen“', async () => {
|
||||||
|
mocks.remove.mockResolvedValue(undefined);
|
||||||
|
const confirm = vi.fn();
|
||||||
|
vi.stubGlobal('confirm', confirm);
|
||||||
|
await start();
|
||||||
|
await openDialog('b');
|
||||||
|
expect(dialog()!.querySelector('h2')!.textContent).toBe('Karte „Beta“ löschen?');
|
||||||
|
expect(mocks.remove).not.toHaveBeenCalled();
|
||||||
|
button(dialog()!, 'Löschen').click();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||||
|
expect(mocks.remove).toHaveBeenCalledWith(expect.objectContaining({ id: 'b', imageFileId: 'img-b', previewFileId: 'prev-b' }));
|
||||||
|
expect(tiles()[0].dataset.card).toBe('a');
|
||||||
|
expect(dialog()).toBeNull();
|
||||||
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
|
expect(revoked).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Abbrechen“, Esc und Klick auf den Hintergrund löschen nichts', async () => {
|
||||||
|
await start();
|
||||||
|
await openDialog('b');
|
||||||
|
button(dialog()!, 'Abbrechen').click();
|
||||||
|
await vi.waitFor(() => expect(dialog()).toBeNull());
|
||||||
|
|
||||||
|
await openDialog('b');
|
||||||
|
dialog()!.dispatchEvent(new Event('close'));
|
||||||
|
await vi.waitFor(() => expect(dialog()).toBeNull());
|
||||||
|
|
||||||
|
await openDialog('b');
|
||||||
|
dialog()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
await vi.waitFor(() => expect(dialog()).toBeNull());
|
||||||
|
|
||||||
|
expect(mocks.remove).not.toHaveBeenCalled();
|
||||||
|
expect(tiles()).toHaveLength(2);
|
||||||
|
await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Fehler beim Löschen lässt die Karte stehen und zeigt die Meldung', async () => {
|
||||||
|
mocks.remove.mockRejectedValue(new TypeError('offline'));
|
||||||
|
await start();
|
||||||
|
await openDialog('b');
|
||||||
|
button(dialog()!, 'Löschen').click();
|
||||||
|
await vi.waitFor(() => expect(tileFor('b').querySelector('.card-msg')!.textContent).toBe('Keine Verbindung zum Server.'));
|
||||||
|
expect(tiles()).toHaveLength(2);
|
||||||
|
expect(button(tileFor('b'), 'Löschen').disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine schon gelöschte Karte (row_not_found) verschwindet ohne Fehlermeldung', async () => {
|
||||||
|
mocks.remove.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'row_not_found' }));
|
||||||
|
await start();
|
||||||
|
await openDialog('b');
|
||||||
|
button(dialog()!, 'Löschen').click();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die letzte Karte gelöscht: Leer-Ansicht', async () => {
|
||||||
|
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||||
|
mocks.remove.mockResolvedValue(undefined);
|
||||||
|
mount();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||||
|
await openDialog('a');
|
||||||
|
button(dialog()!, 'Löschen').click();
|
||||||
|
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die im Editor geöffnete Karte wird dort „ungespeichert“, damit Speichern eine neue anlegt', async () => {
|
||||||
|
store.set({
|
||||||
|
saved: { id: 'b', name: 'Beta', imageFileId: 'img-b', previewFileId: 'prev-b', updatedAt: '2026-10-04T12:00:00.000Z' },
|
||||||
|
});
|
||||||
|
mocks.remove.mockResolvedValue(undefined);
|
||||||
|
await start();
|
||||||
|
await openDialog('b');
|
||||||
|
button(dialog()!, 'Löschen').click();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||||
|
expect(store.get().saved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine andere Karte im Editor bleibt unberührt', async () => {
|
||||||
|
const saved = { id: 'a', name: 'Alpha', imageFileId: 'img-a', previewFileId: 'prev-a', updatedAt: '2026-10-04T12:00:00.000Z' };
|
||||||
|
store.set({ saved });
|
||||||
|
mocks.remove.mockResolvedValue(undefined);
|
||||||
|
await start();
|
||||||
|
await openDialog('b');
|
||||||
|
button(dialog()!, 'Löschen').click();
|
||||||
|
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||||
|
expect(store.get().saved).toEqual(saved);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
// Seite /karten („Meine Karten“): Kacheln mit Vorschau; Öffnen, Herunterladen, Umbenennen, Löschen.
|
||||||
|
import '../account/account.css';
|
||||||
|
import './cards.css';
|
||||||
|
import { getCurrentUser, onAuthChange } from '../account/auth';
|
||||||
|
import { cardsService, normalizeName, type SavedCard } from '../account/cards';
|
||||||
|
import { errorMessage, errorType } from '../account/errors';
|
||||||
|
import { requireLogin } from '../account/login-dialog';
|
||||||
|
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 { el } from '../editor/dom';
|
||||||
|
import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download';
|
||||||
|
import type { EditorStore } from '../editor/state';
|
||||||
|
import { READ_MESSAGE, decodeImage } from '../editor/upload';
|
||||||
|
import { navigate } from './router';
|
||||||
|
|
||||||
|
const LOADING = 'Karten werden geladen …';
|
||||||
|
const LOGGED_OUT = 'Melde dich an, um deine Karten zu sehen.';
|
||||||
|
const EMPTY = 'Noch keine Karten gespeichert.';
|
||||||
|
const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.';
|
||||||
|
const PREVIEW_PARALLEL = 4;
|
||||||
|
|
||||||
|
/** `dd.mm.yyyy` in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
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;
|
||||||
|
let state: 'loading' | 'loggedOut' | 'error' | 'list' = 'loading';
|
||||||
|
let loadToken = 0;
|
||||||
|
let cards: SavedCard[] = [];
|
||||||
|
|
||||||
|
// Vorschaubilder: Object-URLs je Vorschau-Datei, beim Verlassen der Seite (und wenn sie nicht mehr gebraucht werden) freigegeben.
|
||||||
|
const previewUrls = new Map<string, string>();
|
||||||
|
const previewFailed = new Set<string>();
|
||||||
|
const previewQueued = new Set<string>();
|
||||||
|
const thumbSlots = new Map<string, HTMLElement>();
|
||||||
|
const queue: SavedCard[] = [];
|
||||||
|
let activeLoads = 0;
|
||||||
|
|
||||||
|
const heading = el('h1', { tabindex: '-1' }, 'Meine Karten');
|
||||||
|
const body = el('section', { class: 'cards-page', 'aria-label': 'Gespeicherte Karten' });
|
||||||
|
root.replaceChildren(heading, body);
|
||||||
|
|
||||||
|
function revokePreviews(keep: ReadonlySet<string> = new Set()): void {
|
||||||
|
for (const [fileId, url] of previewUrls) {
|
||||||
|
if (keep.has(fileId)) continue;
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
previewUrls.delete(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Vorschaubilder laden (höchstens PREVIEW_PARALLEL gleichzeitig) ----
|
||||||
|
|
||||||
|
function fillThumb(card: SavedCard): void {
|
||||||
|
const slot = thumbSlots.get(card.id);
|
||||||
|
if (!slot) return;
|
||||||
|
const url = previewUrls.get(card.previewFileId);
|
||||||
|
if (url) {
|
||||||
|
slot.replaceChildren(el('img', { src: url, alt: `Vorschau von „${card.name}“`, width: 480, height: 303 }));
|
||||||
|
} else if (previewFailed.has(card.previewFileId)) {
|
||||||
|
slot.replaceChildren(el('span', {}, 'Keine Vorschau'));
|
||||||
|
} else {
|
||||||
|
slot.replaceChildren(el('span', {}, 'Vorschau lädt …'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreview(card: SavedCard): Promise<void> {
|
||||||
|
try {
|
||||||
|
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));
|
||||||
|
} catch {
|
||||||
|
if (disposed) return;
|
||||||
|
previewFailed.add(card.previewFileId);
|
||||||
|
} finally {
|
||||||
|
previewQueued.delete(card.previewFileId);
|
||||||
|
}
|
||||||
|
const current = cards.find((c) => c.previewFileId === card.previewFileId);
|
||||||
|
if (current) fillThumb(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pump(): void {
|
||||||
|
while (!disposed && activeLoads < PREVIEW_PARALLEL && queue.length > 0) {
|
||||||
|
const next = queue.shift() as SavedCard;
|
||||||
|
activeLoads += 1;
|
||||||
|
void loadPreview(next).finally(() => {
|
||||||
|
activeLoads -= 1;
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePreview(card: SavedCard): void {
|
||||||
|
if (previewUrls.has(card.previewFileId) || previewFailed.has(card.previewFileId)) return;
|
||||||
|
if (previewQueued.has(card.previewFileId)) return;
|
||||||
|
previewQueued.add(card.previewFileId);
|
||||||
|
queue.push(card);
|
||||||
|
pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ansichten ----
|
||||||
|
|
||||||
|
function show(...nodes: Node[]): void {
|
||||||
|
body.replaceChildren(...nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(text: string): void {
|
||||||
|
show(el('p', { class: 'cards-msg', role: 'status' }, text));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoggedOut(): void {
|
||||||
|
state = 'loggedOut';
|
||||||
|
cards = [];
|
||||||
|
queue.length = 0;
|
||||||
|
previewQueued.clear();
|
||||||
|
thumbSlots.clear();
|
||||||
|
revokePreviews();
|
||||||
|
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Anmelden');
|
||||||
|
signIn.addEventListener('click', () => void requireLogin('login'));
|
||||||
|
show(el('p', { class: 'cards-msg' }, LOGGED_OUT), el('p', { class: 'cards-actions' }, signIn));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(text: string): void {
|
||||||
|
state = 'error';
|
||||||
|
const retry = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Nochmal versuchen');
|
||||||
|
retry.addEventListener('click', () => void load());
|
||||||
|
show(el('p', { class: 'cards-msg cards-msg-error', role: 'alert' }, text), el('p', { class: 'cards-actions' }, retry));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt `cards` und hält den Editor-Zustand in Einklang (er überlebt Seitenwechsel). */
|
||||||
|
function syncEditor(change: { renamed?: SavedCard; removedId?: string }): void {
|
||||||
|
const s = store.get();
|
||||||
|
if (!s.saved) return;
|
||||||
|
if (change.renamed && s.saved.id === change.renamed.id) {
|
||||||
|
store.set({
|
||||||
|
saved: { ...s.saved, name: change.renamed.name, updatedAt: change.renamed.updatedAt },
|
||||||
|
cardName: change.renamed.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(focusId?: string, focusAction?: string): void {
|
||||||
|
state = 'list';
|
||||||
|
thumbSlots.clear();
|
||||||
|
if (cards.length === 0) {
|
||||||
|
const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/', 'data-link': true }, 'Zum Editor');
|
||||||
|
show(el('p', { class: 'cards-msg' }, EMPTY), el('p', { class: 'cards-actions' }, toEditor));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const grid = el('ul', { class: 'cards-grid' });
|
||||||
|
for (const card of cards) grid.append(buildTile(card));
|
||||||
|
show(grid);
|
||||||
|
for (const card of cards) {
|
||||||
|
fillThumb(card);
|
||||||
|
queuePreview(card);
|
||||||
|
}
|
||||||
|
if (focusId) {
|
||||||
|
const tile = [...body.querySelectorAll<HTMLElement>('.card-tile')].find((t) => t.dataset.card === focusId);
|
||||||
|
tile?.querySelector<HTMLElement>(`[data-action="${focusAction ?? 'open'}"]`)?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Kacheln und Aktionen ----
|
||||||
|
|
||||||
|
function buildTile(card: SavedCard): HTMLElement {
|
||||||
|
const thumb = el('div', { class: 'card-thumb' });
|
||||||
|
thumbSlots.set(card.id, thumb);
|
||||||
|
const nameSlot = el('div', { class: 'card-name-slot' }, el('h2', { class: 'card-name' }, card.name));
|
||||||
|
const date = formatDate(card.updatedAt);
|
||||||
|
const message = el('p', { class: 'card-msg', role: 'alert' });
|
||||||
|
const action = (key: string, text: string, className = 'auth-btn auth-btn-small') =>
|
||||||
|
el('button', { type: 'button', class: className, 'data-action': key }, text);
|
||||||
|
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 buttons = [openButton, downloadButton, renameButton, deleteButton];
|
||||||
|
|
||||||
|
const tile = el(
|
||||||
|
'li',
|
||||||
|
{ class: 'card-tile', 'data-card': card.id },
|
||||||
|
thumb,
|
||||||
|
nameSlot,
|
||||||
|
date ? el('p', { class: 'card-date' }, el('time', { datetime: card.updatedAt }, date)) : '',
|
||||||
|
el('div', { class: 'card-buttons' }, ...buttons),
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Eine Aktion je Kachel zur Zeit; Fehler stehen an der Kachel.
|
||||||
|
async function run(task: () => Promise<void>, busyButton?: HTMLButtonElement, busyText?: string): Promise<void> {
|
||||||
|
const label = busyButton?.textContent ?? '';
|
||||||
|
message.textContent = '';
|
||||||
|
message.classList.remove('is-warning');
|
||||||
|
for (const b of buttons) b.disabled = true;
|
||||||
|
tile.setAttribute('aria-busy', 'true');
|
||||||
|
if (busyButton && busyText) busyButton.textContent = busyText;
|
||||||
|
try {
|
||||||
|
await task();
|
||||||
|
} catch (err) {
|
||||||
|
if (!disposed) message.textContent = failureText(err);
|
||||||
|
} finally {
|
||||||
|
if (busyButton && busyText) busyButton.textContent = label;
|
||||||
|
for (const b of buttons) b.disabled = false;
|
||||||
|
tile.removeAttribute('aria-busy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openButton.addEventListener('click', () => navigate(`/?karte=${encodeURIComponent(card.id)}`));
|
||||||
|
|
||||||
|
downloadButton.addEventListener('click', () => {
|
||||||
|
void run(async () => {
|
||||||
|
const blob = await withLogin(() => service.loadImage(card));
|
||||||
|
const image = await decodeImage(blob);
|
||||||
|
const config = { ...card.config, crop: clampCrop(image, card.config.crop) };
|
||||||
|
const assets = await loadCardAssets(config, image);
|
||||||
|
const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' });
|
||||||
|
saveBlobAs(png, downloadFileName(card.name, new Date()));
|
||||||
|
const warning = pngSizeWarning(png.size);
|
||||||
|
if (warning && !disposed) {
|
||||||
|
message.textContent = warning;
|
||||||
|
message.classList.add('is-warning');
|
||||||
|
}
|
||||||
|
}, downloadButton, 'Wird erstellt …');
|
||||||
|
});
|
||||||
|
|
||||||
|
renameButton.addEventListener('click', () => startRename());
|
||||||
|
|
||||||
|
deleteButton.addEventListener('click', () => {
|
||||||
|
void run(async () => {
|
||||||
|
if (!(await confirmDelete(card.name))) 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;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function startRename(): void {
|
||||||
|
const input = el('input', {
|
||||||
|
class: 'auth-input card-rename-input',
|
||||||
|
type: 'text',
|
||||||
|
value: card.name,
|
||||||
|
autocomplete: 'off',
|
||||||
|
'aria-label': `Neuer Name für „${card.name}“`,
|
||||||
|
});
|
||||||
|
const save = el('button', { type: 'submit', class: 'auth-btn auth-btn-small auth-btn-primary' }, 'Speichern');
|
||||||
|
const cancel = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abbrechen');
|
||||||
|
const form = el('form', { class: 'card-rename' }, input, save, cancel);
|
||||||
|
const close = () => {
|
||||||
|
nameSlot.replaceChildren(el('h2', { class: 'card-name' }, card.name));
|
||||||
|
renameButton.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
cancel.addEventListener('click', close);
|
||||||
|
input.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (normalizeName(input.value) === card.name) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.readOnly = true;
|
||||||
|
save.disabled = true;
|
||||||
|
cancel.disabled = true;
|
||||||
|
void run(async () => {
|
||||||
|
let updated: SavedCard;
|
||||||
|
try {
|
||||||
|
updated = await withLogin(() => service.rename(card.id, input.value));
|
||||||
|
} catch (err) {
|
||||||
|
// Das Formular bleibt offen, damit nichts getippt werden muss.
|
||||||
|
input.readOnly = false;
|
||||||
|
save.disabled = false;
|
||||||
|
cancel.disabled = false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
nameSlot.replaceChildren(form);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureText(err: unknown): string {
|
||||||
|
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
|
||||||
|
if (err instanceof Error && err.message === READ_MESSAGE) return READ_MESSAGE;
|
||||||
|
return errorMessage(err, 'load');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Laden ----
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
const token = ++loadToken;
|
||||||
|
state = 'loading';
|
||||||
|
showStatus(LOADING);
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (disposed || token !== loadToken) return;
|
||||||
|
if (!user) {
|
||||||
|
showLoggedOut();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = await withLogin(() => service.list());
|
||||||
|
if (disposed || token !== loadToken) return;
|
||||||
|
cards = list;
|
||||||
|
revokePreviews(new Set(list.map((c) => c.previewFileId)));
|
||||||
|
previewFailed.clear();
|
||||||
|
renderList();
|
||||||
|
} catch (err) {
|
||||||
|
if (disposed || token !== loadToken) return;
|
||||||
|
if (err instanceof LoginCancelled) showLoggedOut();
|
||||||
|
else showError(errorMessage(err, 'load'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abmelden (auch durch eine abgelaufene Sitzung) räumt die Liste weg, Anmelden holt sie.
|
||||||
|
const offAuth = onAuthChange((user) => {
|
||||||
|
if (disposed) return;
|
||||||
|
if (!user) {
|
||||||
|
if (state !== 'loggedOut') {
|
||||||
|
loadToken += 1;
|
||||||
|
showLoggedOut();
|
||||||
|
}
|
||||||
|
} else if (state === 'loggedOut') {
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
offAuth();
|
||||||
|
queue.length = 0;
|
||||||
|
revokePreviews();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/* Meine Karten: Kachelraster und Löschen-Dialog. 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 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-msg {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-msg-error {
|
||||||
|
color: #ff9a9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-actions {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-link-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
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));
|
||||||
|
gap: 20px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
aspect-ratio: 1536 / 969;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumb img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-date {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-msg {
|
||||||
|
margin: 0;
|
||||||
|
color: #ff9a9d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-msg:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-msg.is-warning {
|
||||||
|
color: #f2c66d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-rename {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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