diff --git a/web/src/account/cards.test.ts b/web/src/account/cards.test.ts new file mode 100644 index 0000000..aab5a03 --- /dev/null +++ b/web/src/account/cards.test.ts @@ -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 => ({ + ...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 => ({ + 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(); + let fileCounter = 0; + const store = new Map(rows.map((r) => [r.$id, r])); + + const counts = new Map(); + 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 => ({ + name: 'Mein Urlaub', + config: config(), + image: png(), + imageChanged: true, + preview: webp(), + previous: null, + ...patch, +}); + +const service = (fake: ReturnType, 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 => ({ + $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('Bad Gateway', { 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('hallo', { 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' }); + }); + }); +}); diff --git a/web/src/account/cards.ts b/web/src/account/cards.ts new file mode 100644 index 0000000..cef2cfa --- /dev/null +++ b/web/src/account/cards.ts @@ -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; + fetchFile(fileId: string): Promise; + createRow(data: CardRowData, permissions: string[]): Promise; + updateRow(id: string, data: Partial): Promise; + getRow(id: string): Promise; + deleteRow(id: string): Promise; + listRows(): Promise; +} + +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 = { '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 { + await Promise.allSettled(fileIds.map((id) => api.deleteFile(id))); +} + +export function createCardsService(api: CardsApi, userId: () => string) { + async function save(input: SaveInput): Promise { + // 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 => { + 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 { + return (await api.listRows()).map(toSaved); + }, + + async get(id: string): Promise { + // 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 { + return toSaved(await api.updateRow(id, { name: normalizeName(name) })); + }, + + async remove(card: SavedCardRef): Promise { + await api.deleteRow(card.id); + await deleteQuietly(api, [card.imageFileId, card.previewFileId]); + }, + + loadImage(card: SavedCardRef): Promise { + return api.fetchFile(card.imageFileId); + }, + + loadPreview(card: SavedCardRef): Promise { + return api.fetchFile(card.previewFileId); + }, + }; +} + +export type CardsService = ReturnType; + +/** Lädt eine Bilddatei mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */ +async function fetchFile(fileId: string): Promise { + // 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({ ...target, rowId: ID.unique(), data, permissions }), + // Beim Ändern bleiben die Rechte der Zeile unangetastet. + updateRow: (id, data) => tables.updateRow({ ...target, rowId: id, data }), + getRow: (id) => tables.getRow({ ...target, rowId: id }), + deleteRow: async (id) => { + await tables.deleteRow({ ...target, rowId: id }); + }, + listRows: async () => { + const list = await tables.listRows({ + ...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; +} diff --git a/web/src/account/with-login.test.ts b/web/src/account/with-login.test.ts new file mode 100644 index 0000000..3975fa8 --- /dev/null +++ b/web/src/account/with-login.test.ts @@ -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(); + }); +}); diff --git a/web/src/account/with-login.ts b/web/src/account/with-login.ts new file mode 100644 index 0000000..6b3986c --- /dev/null +++ b/web/src/account/with-login.ts @@ -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( + fn: () => Promise, + requireLoginFn: typeof requireLogin = requireLogin, + refreshUser: () => Promise = getCurrentUser, +): Promise { + 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(); +} diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts new file mode 100644 index 0000000..109e992 --- /dev/null +++ b/web/src/editor/editor-page.test.ts @@ -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()), + 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 => ({ + 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 = '
'; + 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('.btn-save')!; +const replaceButton = () => [...root.querySelectorAll('button')].find((b) => b.textContent === 'Bild ersetzen') as HTMLButtonElement; +const fileInput = () => root.querySelector('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((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((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((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=)', () => { + 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((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((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')); + }); +}); diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts index c9b55ec..90119af 100644 --- a/web/src/editor/editor-page.ts +++ b/web/src/editor/editor-page.ts @@ -1,26 +1,49 @@ // Editor-Seite: setzt Vorlagen, Vorschau und Optionen zusammen und verbindet sie mit dem Store. import './editor.css'; +import { cardsService } from '../account/cards'; +import { getCurrentUser } from '../account/auth'; +import { 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 { panBy, zoomTo } from '../card/crop'; +import { clampCrop, panBy, zoomTo } from '../card/crop'; 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 { el } from './dom'; import { createOptionsPanel } from './options-panel'; 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 { decodeImage, validateImageFile } from './upload'; +import { READ_MESSAGE, decodeImage, validateImageFile } from './upload'; const LOGO_ERROR = 'Die Logos konnten nicht geladen 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; /** 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}`; -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; const patchConfig = (patch: Partial) => 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) }); }, 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'); @@ -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. let fileToken = 0; async function handleFile(file: File): Promise { + // Während des Speicherns bleibt das Bild, das gerade gespeichert wird. + if (store.get().saving) return; const token = ++fileToken; const check = validateImageFile(file); if (!check.ok) { preview.setError(check.message); return; } + // Wer selbst ein Bild wählt, will die Karte, die noch lädt, nicht mehr. + cancelCardLoad(); try { const image = await decodeImage(file); 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.'); } } - 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. async function download(): Promise { @@ -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 | 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 { + // 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=): nur die zuletzt angeforderte zählt. + let loadToken = 0; + function cancelCardLoad(): void { + loadToken += 1; + preview.setNotice(null); + } + + async function openSavedCard(id: string): Promise { + 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). let logos: (Logos & { key: string }) | null = null; let pendingKey = ''; @@ -149,7 +288,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSa function sync(s: EditorState): void { templates.select(s.config.template); options.select(s.config); - preview.sync({ hasImage: s.image !== null, zoom: s.config.crop.zoom, saving: s.saving, exporting }); + preview.sync({ hasImage: s.image !== null, zoom: s.config.crop.zoom, saving: s.saving, saved: savedFlash, exporting }); } function onState(s: EditorState): void { @@ -166,8 +305,13 @@ export function mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSa const unsubscribe = store.subscribe(onState); 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 () => { disposed = true; + clearTimeout(flashTimer); unsubscribe(); if (frame) cancelAnimationFrame(frame); frame = 0; diff --git a/web/src/editor/editor.css b/web/src/editor/editor.css index 84f7fe2..ec5d108 100644 --- a/web/src/editor/editor.css +++ b/web/src/editor/editor.css @@ -387,6 +387,10 @@ color: #f2c66d; } +.msg-notice { + color: var(--muted); +} + .actions { display: flex; flex-wrap: wrap; @@ -438,6 +442,10 @@ background: var(--primary-hover); } +.btn-save { + min-width: 9.5em; /* „Speichern“, „Speichert …“ und „Gespeichert“ sollen den Knopf nicht springen lassen */ +} + .btn-large { min-height: 52px; padding: 0 28px; diff --git a/web/src/editor/preview.ts b/web/src/editor/preview.ts index 577b93d..081c6b8 100644 --- a/web/src/editor/preview.ts +++ b/web/src/editor/preview.ts @@ -17,6 +17,8 @@ export interface PreviewView { hasImage: boolean; zoom: number; saving: boolean; + /** true für kurze Zeit nach erfolgreichem Speichern (Knopf zeigt „Gespeichert“). */ + saved: boolean; exporting: boolean; } @@ -27,9 +29,14 @@ export interface Preview { setDragging(active: boolean): void; setError(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 SAVE_LABEL = 'Speichern'; +const SAVING_LABEL = 'Speichert …'; +const SAVED_LABEL = 'Gespeichert'; const KEY_STEP = 24; const KEY_STEP_BIG = 96; const KEY_ZOOM_STEP = 0.1; @@ -185,13 +192,14 @@ export function createPreview(handlers: PreviewHandlers): Preview { // Meldungen const errorBox = el('p', { class: 'msg msg-error', role: 'alert' }); const warningBox = el('p', { class: 'msg msg-warning', role: 'status' }); + const noticeBox = el('p', { class: 'msg msg-notice', role: 'status' }); // Knopfleiste const replaceButton = el('button', { type: 'button', class: 'btn btn-ghost' }, 'Bild ersetzen'); replaceButton.addEventListener('click', openPicker); const downloadButton = el('button', { type: 'button', class: 'btn btn-primary' }, 'Herunterladen'); 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()); const actions = el( '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 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); 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)} %`; setDisabled(downloadButton, !view.hasImage || view.exporting, 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)); saveButton.setAttribute('aria-busy', String(view.saving)); }, @@ -239,5 +251,8 @@ export function createPreview(handlers: PreviewHandlers): Preview { setWarning(message) { setMessage(warningBox, message); }, + setNotice(message) { + setMessage(noticeBox, message); + }, }; } diff --git a/web/src/editor/save.test.ts b/web/src/editor/save.test.ts new file mode 100644 index 0000000..e310403 --- /dev/null +++ b/web/src/editor/save.test.ts @@ -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((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function setup(overrides: Partial[0]> = {}) { + const statuses: Array<[SaveStatus, string | undefined]> = []; + const deps = { + getUser: vi.fn(async (): Promise => user), + requireLogin: vi.fn(async () => true), + refreshUser: vi.fn(async (): Promise => 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'); + }); +}); diff --git a/web/src/editor/save.ts b/web/src/editor/save.ts new file mode 100644 index 0000000..dab5b11 --- /dev/null +++ b/web/src/editor/save.ts @@ -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; + /** Zeigt den Login-Dialog; true, wenn sich der Nutzer angemeldet hat. */ + requireLogin(): Promise; + doSave(): Promise; + onStatus(status: SaveStatus, message?: string): void; + /** Holt den Anmeldestatus nach einem 401 neu (Standard: getCurrentUser). */ + refreshUser?(): Promise; +} + +const FAILED = 'Speichern fehlgeschlagen – '; + +/** Liefert die Funktion hinter dem Speichern-Knopf; ein laufendes Speichern blockiert jeden weiteren Aufruf. */ +export function createSaveAction(deps: SaveDeps): () => Promise { + 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; + } + }; +} diff --git a/web/src/main.ts b/web/src/main.ts index 2777128..d0c5e29 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -3,6 +3,7 @@ import './styles.css'; import { mountAccountMenu } from './account/account-menu'; import { getCurrentUser } from './account/auth'; import { startRouter } from './pages/router'; +import { mountCardsPage } from './pages/cards-page'; import { mountResetPage } from './pages/reset-page'; import { mountEditor } from './editor/editor-page'; import { createStore, initialEditorState } from './editor/state'; @@ -24,14 +25,10 @@ if (accountSlot) { startRouter((route, root) => { if (route === 'editor') { - // Speichern wird mit den Konten (spätere Aufgabe) angebunden; bis dahin passiert nichts. - return mountEditor(root, editorStore, { onSave: () => {} }); + return mountEditor(root, editorStore); } if (route === 'reset') { return mountResetPage(root); } - // Platzhalter, bis die Karten-Seite aus der Folgeaufgabe angebunden ist. - const heading = document.createElement('h1'); - heading.textContent = 'Meine Karten'; - root.replaceChildren(heading); + return mountCardsPage(root, editorStore); }); diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts new file mode 100644 index 0000000..01cf7ba --- /dev/null +++ b/web/src/pages/cards-page.test.ts @@ -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()), + saveBlobAs: mocks.saveBlobAs, +})); +vi.mock('../account/cards', async (importOriginal) => ({ + ...(await importOriginal()), + 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 => ({ + 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; +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 = '
'; + 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('.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('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('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((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-.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('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('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('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); + }); +}); diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts new file mode 100644 index 0000000..f126ea0 --- /dev/null +++ b/web/src/pages/cards-page.ts @@ -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 { + return new Promise((resolve, reject) => { + const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen'); + const remove = el('button', { type: 'button', class: 'auth-btn cards-btn-danger' }, 'Löschen'); + const dialog = el( + 'dialog', + { class: 'confirm-dialog', 'aria-labelledby': 'confirm-title' }, + el( + 'div', + { class: 'confirm-card' }, + el('h2', { id: 'confirm-title', class: 'confirm-title' }, `Karte „${name}“ löschen?`), + el('p', { class: 'confirm-text' }, 'Die Karte und ihr Bild werden endgültig gelöscht.'), + el('div', { class: 'confirm-actions' }, cancel, remove), + ), + ); + + let answered = false; + const finish = (ok: boolean) => { + if (answered) return; + answered = true; + if (dialog.open) dialog.close(); + dialog.remove(); + resolve(ok); + }; + cancel.addEventListener('click', () => finish(false)); + remove.addEventListener('click', () => finish(true)); + // Esc schließt nativ und löst `close` aus. + dialog.addEventListener('close', () => finish(false)); + // Hintergrund = Klick auf das selbst (die Karte füllt es ganz aus). + dialog.addEventListener('click', (event) => { + if (event.target === dialog) finish(false); + }); + + document.body.append(dialog); + try { + dialog.showModal(); + } catch (err) { + answered = true; + dialog.remove(); + reject(err); + return; + } + cancel.focus(); // das Ungefährliche zuerst + }); +} + +export function mountCardsPage(root: HTMLElement, store: EditorStore): () => void { + const service = cardsService(); + let disposed = false; + 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(); + const previewFailed = new Set(); + const previewQueued = new Set(); + const thumbSlots = new Map(); + 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 = 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 { + 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('.card-tile')].find((t) => t.dataset.card === focusId); + tile?.querySelector(`[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, busyButton?: HTMLButtonElement, busyText?: string): Promise { + 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('.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 { + 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(); + }; +} diff --git a/web/src/pages/cards.css b/web/src/pages/cards.css new file mode 100644 index 0000000..ba3ac47 --- /dev/null +++ b/web/src/pages/cards.css @@ -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; +}