feat(cards): Karten speichern und Meine Karten
- account/cards.ts: Kartendienst (neu/bestehend speichern mit Aufräumen halber Uploads, Umbenennen, Löschen, Laden) über ein schmales CardsApi-Interface, Appwrite-Anschluss mit 22.4.1-Objektparametern, fetchFile über client.prepareRequest (Projekt-Header + Cookie, kein getFileView) - account/with-login.ts: bei 401 Nutzer neu prüfen, einmal anmelden, wiederholen - editor/save.ts: Speichern-Ablauf (ein Lauf zur Zeit, 401 -> Login -> einmal wiederholen, deutsche Fehlermeldung) - Editor: Speichern mit Schnappschuss und 480-px-Vorschau, Karte öffnen über ?karte=<id>, Bildwechsel während des Speicherns gesperrt - pages/cards-page.ts: /karten mit Kacheln, Öffnen, Herunterladen, Umbenennen, Löschen (eigener Dialog), Abmelde- und Leer-Ansicht - Tests für Dienst, withLogin, Speichern-Ablauf, Karten-Seite und Editor-Anbindung Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a147d3b496
commit
ba4d9f76d4
14 files changed
+2849
-16
No files matched your search
@@ -0,0 +1,456 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { User } from '../account/auth';
|
||||
import type { SavedCard } from '../account/cards';
|
||||
import { DEFAULT_CONFIG } from '../card/config';
|
||||
import { createStore, initialEditorState } from '../editor/state';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
loadPreview: vi.fn(),
|
||||
loadImage: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
getCurrentUser: vi.fn(),
|
||||
requireLogin: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
decodeImage: vi.fn(),
|
||||
loadCardAssets: vi.fn(),
|
||||
exportCard: vi.fn(),
|
||||
saveBlobAs: vi.fn(),
|
||||
authListeners: new Set<(user: User | null) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||
vi.mock('../account/auth', () => ({
|
||||
getCurrentUser: mocks.getCurrentUser,
|
||||
currentUserId: () => 'u1',
|
||||
onAuthChange: (fn: (user: User | null) => void) => {
|
||||
mocks.authListeners.add(fn);
|
||||
return () => mocks.authListeners.delete(fn);
|
||||
},
|
||||
}));
|
||||
vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
||||
vi.mock('./router', () => ({ navigate: mocks.navigate }));
|
||||
vi.mock('../editor/upload', () => ({ READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.', decodeImage: mocks.decodeImage }));
|
||||
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
|
||||
vi.mock('../editor/download', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../editor/download')>()),
|
||||
saveBlobAs: mocks.saveBlobAs,
|
||||
}));
|
||||
vi.mock('../account/cards', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../account/cards')>()),
|
||||
cardsService: () => ({
|
||||
list: mocks.list,
|
||||
loadPreview: mocks.loadPreview,
|
||||
loadImage: mocks.loadImage,
|
||||
rename: mocks.rename,
|
||||
remove: mocks.remove,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { formatDate, mountCardsPage } from './cards-page';
|
||||
|
||||
const user: User = { $id: 'u1', email: 'a@b.de' };
|
||||
|
||||
const card = (id: string, name: string, patch: Partial<SavedCard> = {}): SavedCard => ({
|
||||
id,
|
||||
name,
|
||||
imageFileId: `img-${id}`,
|
||||
previewFileId: `prev-${id}`,
|
||||
updatedAt: '2026-10-04T12:00:00.000Z',
|
||||
config: structuredClone(DEFAULT_CONFIG),
|
||||
...patch,
|
||||
});
|
||||
|
||||
let root: HTMLElement;
|
||||
let unmount: (() => void) | null;
|
||||
let store: ReturnType<typeof createStore>;
|
||||
let urlCounter: number;
|
||||
const revoked: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(mocks)) if (typeof fn === 'function' && 'mockReset' in fn) fn.mockReset();
|
||||
mocks.authListeners.clear();
|
||||
revoked.length = 0;
|
||||
urlCounter = 0;
|
||||
URL.createObjectURL = vi.fn(() => `blob:preview-${++urlCounter}`);
|
||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
});
|
||||
document.body.innerHTML = '<main id="app-root"></main>';
|
||||
root = document.getElementById('app-root') as HTMLElement;
|
||||
store = createStore(initialEditorState());
|
||||
mocks.getCurrentUser.mockResolvedValue(user);
|
||||
mocks.requireLogin.mockResolvedValue(true);
|
||||
mocks.loadPreview.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' }));
|
||||
unmount = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unmount?.();
|
||||
});
|
||||
|
||||
const mount = () => {
|
||||
unmount = mountCardsPage(root, store);
|
||||
};
|
||||
const text = () => root.textContent ?? '';
|
||||
const tiles = () => [...root.querySelectorAll<HTMLElement>('.card-tile')];
|
||||
const tileFor = (id: string) => tiles().find((t) => t.dataset.card === id)!;
|
||||
const button = (scope: ParentNode, label: string) =>
|
||||
[...scope.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
|
||||
const dialog = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
|
||||
const submit = (form: HTMLFormElement) => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('schreibt dd.mm.yyyy', () => {
|
||||
expect(formatDate(new Date(2026, 9, 4, 12).toISOString())).toBe('04.10.2026');
|
||||
expect(formatDate(new Date(2026, 0, 31, 12).toISOString())).toBe('31.01.2026');
|
||||
});
|
||||
|
||||
it('gibt bei unlesbarem Datum nichts zurück', () => {
|
||||
expect(formatDate('kaputt')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meine Karten: Zustände', () => {
|
||||
it('abgemeldet: Hinweis und Anmelden-Knopf, keine Anfrage an den Server', async () => {
|
||||
mocks.getCurrentUser.mockResolvedValue(null);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(text()).toContain('Melde dich an, um deine Karten zu sehen.'));
|
||||
expect(mocks.list).not.toHaveBeenCalled();
|
||||
button(root, 'Anmelden').click();
|
||||
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
|
||||
});
|
||||
|
||||
it('nach dem Anmelden (Konto-Menü oder Knopf) wird die Liste geladen', async () => {
|
||||
mocks.getCurrentUser.mockResolvedValue(null);
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(text()).toContain('Melde dich an'));
|
||||
mocks.getCurrentUser.mockResolvedValue(user);
|
||||
for (const fn of mocks.authListeners) fn(user);
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('leer: Hinweis und Link zum Editor', async () => {
|
||||
mocks.list.mockResolvedValue([]);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
|
||||
const link = root.querySelector<HTMLAnchorElement>('a[data-link]')!;
|
||||
expect(link.getAttribute('href')).toBe('/');
|
||||
});
|
||||
|
||||
it('Fehler beim Laden: Meldung und „Nochmal versuchen“', async () => {
|
||||
mocks.list.mockRejectedValueOnce(new TypeError('offline')).mockResolvedValueOnce([card('a', 'Alpha')]);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(text()).toContain('Keine Verbindung zum Server.'));
|
||||
button(root, 'Nochmal versuchen').click();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('Sitzung läuft beim Laden ab: Anmelden, dann wird die Liste angezeigt', async () => {
|
||||
const unauthorized = Object.assign(new Error('Unauthorized'), { code: 401 });
|
||||
mocks.list.mockRejectedValueOnce(unauthorized).mockResolvedValueOnce([card('a', 'Alpha')]);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
expect(mocks.requireLogin).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('Sitzung läuft ab und der Dialog wird geschlossen: Anmelden-Ansicht', async () => {
|
||||
mocks.list.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 }));
|
||||
mocks.requireLogin.mockResolvedValue(false);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(text()).toContain('Melde dich an, um deine Karten zu sehen.'));
|
||||
});
|
||||
|
||||
it('Abmelden räumt die Liste weg und gibt die Vorschau-URLs frei', async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
|
||||
for (const fn of mocks.authListeners) fn(null);
|
||||
expect(text()).toContain('Melde dich an, um deine Karten zu sehen.');
|
||||
expect(tiles()).toHaveLength(0);
|
||||
expect(revoked).toEqual(['blob:preview-1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meine Karten: Kacheln', () => {
|
||||
it('zeigt Name, Datum und Vorschau', async () => {
|
||||
const updatedAt = new Date(2026, 9, 4, 12).toISOString();
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha', { updatedAt }), card('b', 'Beta')]);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
|
||||
expect(tileFor('a').querySelector('.card-name')!.textContent).toBe('Alpha');
|
||||
expect(tileFor('a').querySelector('.card-date')!.textContent).toBe('04.10.2026');
|
||||
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
|
||||
await vi.waitFor(() => expect(tileFor('b').querySelector('img')).not.toBeNull());
|
||||
expect(tileFor('a').querySelector('img')!.getAttribute('src')).toMatch(/^blob:preview-/);
|
||||
expect(mocks.loadPreview).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('eine Vorschau, die nicht lädt, zeigt „Keine Vorschau“, die Kachel bleibt bedienbar', async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mocks.loadPreview.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tileFor('a').textContent).toContain('Keine Vorschau'));
|
||||
expect(button(tileFor('a'), 'Öffnen').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('lädt höchstens vier Vorschauen gleichzeitig', async () => {
|
||||
const cards = Array.from({ length: 10 }, (_, i) => card(`c${i}`, `Karte ${i}`));
|
||||
mocks.list.mockResolvedValue(cards);
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
mocks.loadPreview.mockImplementation(async () => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
active -= 1;
|
||||
return new Blob(['p'], { type: 'image/webp' });
|
||||
});
|
||||
mount();
|
||||
await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(10));
|
||||
expect(peak).toBeLessThanOrEqual(4);
|
||||
expect(peak).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('beim Verlassen der Seite werden alle Vorschau-URLs freigegeben', async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(2));
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
expect([...revoked].sort()).toEqual(['blob:preview-1', 'blob:preview-2']);
|
||||
});
|
||||
|
||||
it('eine Vorschau, die erst nach dem Verlassen ankommt, bekommt keine URL', async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
let finish!: (b: Blob) => void;
|
||||
mocks.loadPreview.mockReturnValue(new Promise<Blob>((r) => (finish = r)));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(mocks.loadPreview).toHaveBeenCalled());
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
finish(new Blob(['p'], { type: 'image/webp' }));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meine Karten: Öffnen und Herunterladen', () => {
|
||||
it('Öffnen navigiert in den Editor', async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
button(tileFor('a'), 'Öffnen').click();
|
||||
expect(mocks.navigate).toHaveBeenCalledWith('/?karte=a');
|
||||
});
|
||||
|
||||
it('Herunterladen: Bild laden, als PNG rendern und unter aircard-<name>.png speichern', async () => {
|
||||
const png = new Blob(['png'], { type: 'image/png' });
|
||||
const image = { source: {}, w: 3000, h: 2000 };
|
||||
mocks.list.mockResolvedValue([card('a', 'Mein Urlaub', { config: { ...DEFAULT_CONFIG, crop: { x: 99999, y: 0, zoom: 1 } } })]);
|
||||
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' }));
|
||||
mocks.decodeImage.mockResolvedValue(image);
|
||||
mocks.loadCardAssets.mockResolvedValue({ image, network: null, brand: null });
|
||||
mocks.exportCard.mockResolvedValue(png);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
button(tileFor('a'), 'Herunterladen').click();
|
||||
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
|
||||
expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-mein-urlaub.png');
|
||||
expect(mocks.exportCard).toHaveBeenCalledWith(expect.anything(), expect.anything(), { width: 1536, type: 'image/png' });
|
||||
// Der gespeicherte Ausschnitt wird auf das Bild begrenzt, bevor gerendert wird.
|
||||
const rendered = mocks.exportCard.mock.calls[0][0];
|
||||
expect(rendered.crop.x).toBeLessThan(99999);
|
||||
await vi.waitFor(() => expect(button(tileFor('a'), 'Herunterladen').disabled).toBe(false));
|
||||
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('Herunterladen ohne Bilddatei zeigt die Meldung an der Kachel', async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
button(tileFor('a'), 'Herunterladen').click();
|
||||
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Das Bild dieser Karte fehlt.'));
|
||||
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
|
||||
expect(button(tileFor('a'), 'Herunterladen').disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meine Karten: Umbenennen', () => {
|
||||
const startRename = async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
|
||||
button(tileFor('b'), 'Umbenennen').click();
|
||||
return tileFor('b').querySelector<HTMLInputElement>('input')!;
|
||||
};
|
||||
|
||||
it('zeigt eine Eingabe mit dem Namen, Enter speichert, die Karte steht danach vorn', async () => {
|
||||
mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name.trim(), { updatedAt: '2026-10-06T08:00:00.000Z' }));
|
||||
const input = await startRename();
|
||||
expect(input.value).toBe('Beta');
|
||||
expect(document.activeElement).toBe(input);
|
||||
input.value = ' Gamma ';
|
||||
submit(input.closest('form')!);
|
||||
await vi.waitFor(() => expect(tiles().map((t) => t.dataset.card)).toEqual(['b', 'a']));
|
||||
expect(mocks.rename).toHaveBeenCalledWith('b', ' Gamma ');
|
||||
expect(tileFor('b').querySelector('.card-name')!.textContent).toBe('Gamma');
|
||||
expect(tileFor('b').querySelector('input')).toBeNull();
|
||||
});
|
||||
|
||||
it('Esc bricht ab, ohne etwas zu speichern', async () => {
|
||||
const input = await startRename();
|
||||
input.value = 'Anders';
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
||||
expect(tileFor('b').querySelector('input')).toBeNull();
|
||||
expect(tileFor('b').querySelector('.card-name')!.textContent).toBe('Beta');
|
||||
expect(mocks.rename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('„Abbrechen“ bricht ab; ein unveränderter Name löst keine Anfrage aus', async () => {
|
||||
const input = await startRename();
|
||||
button(tileFor('b'), 'Abbrechen').click();
|
||||
expect(tileFor('b').querySelector('input')).toBeNull();
|
||||
button(tileFor('b'), 'Umbenennen').click();
|
||||
const again = tileFor('b').querySelector<HTMLInputElement>('input')!;
|
||||
again.value = ' Beta ';
|
||||
submit(again.closest('form')!);
|
||||
expect(tileFor('b').querySelector('input')).toBeNull();
|
||||
expect(mocks.rename).not.toHaveBeenCalled();
|
||||
expect(input).not.toBe(again);
|
||||
});
|
||||
|
||||
it('ein Fehler lässt die Eingabe offen und zeigt die Meldung', async () => {
|
||||
mocks.rename.mockRejectedValue(new TypeError('offline'));
|
||||
const input = await startRename();
|
||||
input.value = 'Gamma';
|
||||
submit(input.closest('form')!);
|
||||
await vi.waitFor(() => expect(tileFor('b').querySelector('.card-msg')!.textContent).toBe('Keine Verbindung zum Server.'));
|
||||
const open = tileFor('b').querySelector<HTMLInputElement>('input')!;
|
||||
expect(open.value).toBe('Gamma');
|
||||
expect(open.readOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('hält die Karte im Editor-Zustand aktuell (sonst überschreibt Speichern den neuen Namen)', async () => {
|
||||
store.set({
|
||||
saved: { id: 'b', name: 'Beta', imageFileId: 'img-b', previewFileId: 'prev-b', updatedAt: '2026-10-04T12:00:00.000Z' },
|
||||
cardName: 'Beta',
|
||||
});
|
||||
mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name, { updatedAt: '2026-10-06T08:00:00.000Z' }));
|
||||
const input = await startRename();
|
||||
input.value = 'Gamma';
|
||||
submit(input.closest('form')!);
|
||||
await vi.waitFor(() => expect(store.get().cardName).toBe('Gamma'));
|
||||
expect(store.get().saved).toMatchObject({ id: 'b', name: 'Gamma', updatedAt: '2026-10-06T08:00:00.000Z' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meine Karten: Löschen', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** Klickt „Löschen“ an der Kachel (sobald der Knopf wieder bedienbar ist) und wartet auf den Dialog. */
|
||||
const openDialog = async (id: string) => {
|
||||
await vi.waitFor(() => expect(button(tileFor(id), 'Löschen').disabled).toBe(false));
|
||||
button(tileFor(id), 'Löschen').click();
|
||||
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
|
||||
await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(2));
|
||||
};
|
||||
|
||||
it('fragt in einem eigenen Dialog nach und löscht erst nach „Löschen“', async () => {
|
||||
mocks.remove.mockResolvedValue(undefined);
|
||||
const confirm = vi.fn();
|
||||
vi.stubGlobal('confirm', confirm);
|
||||
await start();
|
||||
await openDialog('b');
|
||||
expect(dialog()!.querySelector('h2')!.textContent).toBe('Karte „Beta“ löschen?');
|
||||
expect(mocks.remove).not.toHaveBeenCalled();
|
||||
button(dialog()!, 'Löschen').click();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
expect(mocks.remove).toHaveBeenCalledWith(expect.objectContaining({ id: 'b', imageFileId: 'img-b', previewFileId: 'prev-b' }));
|
||||
expect(tiles()[0].dataset.card).toBe('a');
|
||||
expect(dialog()).toBeNull();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(revoked).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('„Abbrechen“, Esc und Klick auf den Hintergrund löschen nichts', async () => {
|
||||
await start();
|
||||
await openDialog('b');
|
||||
button(dialog()!, 'Abbrechen').click();
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull());
|
||||
|
||||
await openDialog('b');
|
||||
dialog()!.dispatchEvent(new Event('close'));
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull());
|
||||
|
||||
await openDialog('b');
|
||||
dialog()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull());
|
||||
|
||||
expect(mocks.remove).not.toHaveBeenCalled();
|
||||
expect(tiles()).toHaveLength(2);
|
||||
await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false));
|
||||
});
|
||||
|
||||
it('ein Fehler beim Löschen lässt die Karte stehen und zeigt die Meldung', async () => {
|
||||
mocks.remove.mockRejectedValue(new TypeError('offline'));
|
||||
await start();
|
||||
await openDialog('b');
|
||||
button(dialog()!, 'Löschen').click();
|
||||
await vi.waitFor(() => expect(tileFor('b').querySelector('.card-msg')!.textContent).toBe('Keine Verbindung zum Server.'));
|
||||
expect(tiles()).toHaveLength(2);
|
||||
expect(button(tileFor('b'), 'Löschen').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('eine schon gelöschte Karte (row_not_found) verschwindet ohne Fehlermeldung', async () => {
|
||||
mocks.remove.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'row_not_found' }));
|
||||
await start();
|
||||
await openDialog('b');
|
||||
button(dialog()!, 'Löschen').click();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('die letzte Karte gelöscht: Leer-Ansicht', async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mocks.remove.mockResolvedValue(undefined);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
await openDialog('a');
|
||||
button(dialog()!, 'Löschen').click();
|
||||
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
|
||||
});
|
||||
|
||||
it('die im Editor geöffnete Karte wird dort „ungespeichert“, damit Speichern eine neue anlegt', async () => {
|
||||
store.set({
|
||||
saved: { id: 'b', name: 'Beta', imageFileId: 'img-b', previewFileId: 'prev-b', updatedAt: '2026-10-04T12:00:00.000Z' },
|
||||
});
|
||||
mocks.remove.mockResolvedValue(undefined);
|
||||
await start();
|
||||
await openDialog('b');
|
||||
button(dialog()!, 'Löschen').click();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
expect(store.get().saved).toBeNull();
|
||||
});
|
||||
|
||||
it('eine andere Karte im Editor bleibt unberührt', async () => {
|
||||
const saved = { id: 'a', name: 'Alpha', imageFileId: 'img-a', previewFileId: 'prev-a', updatedAt: '2026-10-04T12:00:00.000Z' };
|
||||
store.set({ saved });
|
||||
mocks.remove.mockResolvedValue(undefined);
|
||||
await start();
|
||||
await openDialog('b');
|
||||
button(dialog()!, 'Löschen').click();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
expect(store.get().saved).toEqual(saved);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
// Seite /karten („Meine Karten“): Kacheln mit Vorschau; Öffnen, Herunterladen, Umbenennen, Löschen.
|
||||
import '../account/account.css';
|
||||
import './cards.css';
|
||||
import { getCurrentUser, onAuthChange } from '../account/auth';
|
||||
import { cardsService, normalizeName, type SavedCard } from '../account/cards';
|
||||
import { errorMessage, errorType } from '../account/errors';
|
||||
import { requireLogin } from '../account/login-dialog';
|
||||
import { LoginCancelled, withLogin } from '../account/with-login';
|
||||
import { clampCrop } from '../card/crop';
|
||||
import { CARD_W } from '../card/geometry';
|
||||
import { exportCard, loadCardAssets } from '../card/render';
|
||||
import { el } from '../editor/dom';
|
||||
import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download';
|
||||
import type { EditorStore } from '../editor/state';
|
||||
import { READ_MESSAGE, decodeImage } from '../editor/upload';
|
||||
import { navigate } from './router';
|
||||
|
||||
const LOADING = 'Karten werden geladen …';
|
||||
const LOGGED_OUT = 'Melde dich an, um deine Karten zu sehen.';
|
||||
const EMPTY = 'Noch keine Karten gespeichert.';
|
||||
const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.';
|
||||
const PREVIEW_PARALLEL = 4;
|
||||
|
||||
/** `dd.mm.yyyy` in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */
|
||||
export function formatDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** Fragt, ob die Karte wirklich gelöscht werden soll (eigener Dialog statt window.confirm). */
|
||||
function confirmDelete(name: string): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen');
|
||||
const remove = el('button', { type: 'button', class: 'auth-btn cards-btn-danger' }, 'Löschen');
|
||||
const dialog = el(
|
||||
'dialog',
|
||||
{ class: 'confirm-dialog', 'aria-labelledby': 'confirm-title' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'confirm-card' },
|
||||
el('h2', { id: 'confirm-title', class: 'confirm-title' }, `Karte „${name}“ löschen?`),
|
||||
el('p', { class: 'confirm-text' }, 'Die Karte und ihr Bild werden endgültig gelöscht.'),
|
||||
el('div', { class: 'confirm-actions' }, cancel, remove),
|
||||
),
|
||||
);
|
||||
|
||||
let answered = false;
|
||||
const finish = (ok: boolean) => {
|
||||
if (answered) return;
|
||||
answered = true;
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
resolve(ok);
|
||||
};
|
||||
cancel.addEventListener('click', () => finish(false));
|
||||
remove.addEventListener('click', () => finish(true));
|
||||
// Esc schließt nativ und löst `close` aus.
|
||||
dialog.addEventListener('close', () => finish(false));
|
||||
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus).
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target === dialog) finish(false);
|
||||
});
|
||||
|
||||
document.body.append(dialog);
|
||||
try {
|
||||
dialog.showModal();
|
||||
} catch (err) {
|
||||
answered = true;
|
||||
dialog.remove();
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
cancel.focus(); // das Ungefährliche zuerst
|
||||
});
|
||||
}
|
||||
|
||||
export function mountCardsPage(root: HTMLElement, store: EditorStore): () => void {
|
||||
const service = cardsService();
|
||||
let disposed = false;
|
||||
let state: 'loading' | 'loggedOut' | 'error' | 'list' = 'loading';
|
||||
let loadToken = 0;
|
||||
let cards: SavedCard[] = [];
|
||||
|
||||
// Vorschaubilder: Object-URLs je Vorschau-Datei, beim Verlassen der Seite (und wenn sie nicht mehr gebraucht werden) freigegeben.
|
||||
const previewUrls = new Map<string, string>();
|
||||
const previewFailed = new Set<string>();
|
||||
const previewQueued = new Set<string>();
|
||||
const thumbSlots = new Map<string, HTMLElement>();
|
||||
const queue: SavedCard[] = [];
|
||||
let activeLoads = 0;
|
||||
|
||||
const heading = el('h1', { tabindex: '-1' }, 'Meine Karten');
|
||||
const body = el('section', { class: 'cards-page', 'aria-label': 'Gespeicherte Karten' });
|
||||
root.replaceChildren(heading, body);
|
||||
|
||||
function revokePreviews(keep: ReadonlySet<string> = new Set()): void {
|
||||
for (const [fileId, url] of previewUrls) {
|
||||
if (keep.has(fileId)) continue;
|
||||
URL.revokeObjectURL(url);
|
||||
previewUrls.delete(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Vorschaubilder laden (höchstens PREVIEW_PARALLEL gleichzeitig) ----
|
||||
|
||||
function fillThumb(card: SavedCard): void {
|
||||
const slot = thumbSlots.get(card.id);
|
||||
if (!slot) return;
|
||||
const url = previewUrls.get(card.previewFileId);
|
||||
if (url) {
|
||||
slot.replaceChildren(el('img', { src: url, alt: `Vorschau von „${card.name}“`, width: 480, height: 303 }));
|
||||
} else if (previewFailed.has(card.previewFileId)) {
|
||||
slot.replaceChildren(el('span', {}, 'Keine Vorschau'));
|
||||
} else {
|
||||
slot.replaceChildren(el('span', {}, 'Vorschau lädt …'));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreview(card: SavedCard): Promise<void> {
|
||||
try {
|
||||
const blob = await withLogin(() => service.loadPreview(card));
|
||||
// Die Karte kann inzwischen gelöscht oder die Liste neu geladen worden sein.
|
||||
if (disposed || !cards.some((c) => c.previewFileId === card.previewFileId)) return;
|
||||
previewUrls.set(card.previewFileId, URL.createObjectURL(blob));
|
||||
} catch {
|
||||
if (disposed) return;
|
||||
previewFailed.add(card.previewFileId);
|
||||
} finally {
|
||||
previewQueued.delete(card.previewFileId);
|
||||
}
|
||||
const current = cards.find((c) => c.previewFileId === card.previewFileId);
|
||||
if (current) fillThumb(current);
|
||||
}
|
||||
|
||||
function pump(): void {
|
||||
while (!disposed && activeLoads < PREVIEW_PARALLEL && queue.length > 0) {
|
||||
const next = queue.shift() as SavedCard;
|
||||
activeLoads += 1;
|
||||
void loadPreview(next).finally(() => {
|
||||
activeLoads -= 1;
|
||||
pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function queuePreview(card: SavedCard): void {
|
||||
if (previewUrls.has(card.previewFileId) || previewFailed.has(card.previewFileId)) return;
|
||||
if (previewQueued.has(card.previewFileId)) return;
|
||||
previewQueued.add(card.previewFileId);
|
||||
queue.push(card);
|
||||
pump();
|
||||
}
|
||||
|
||||
// ---- Ansichten ----
|
||||
|
||||
function show(...nodes: Node[]): void {
|
||||
body.replaceChildren(...nodes);
|
||||
}
|
||||
|
||||
function showStatus(text: string): void {
|
||||
show(el('p', { class: 'cards-msg', role: 'status' }, text));
|
||||
}
|
||||
|
||||
function showLoggedOut(): void {
|
||||
state = 'loggedOut';
|
||||
cards = [];
|
||||
queue.length = 0;
|
||||
previewQueued.clear();
|
||||
thumbSlots.clear();
|
||||
revokePreviews();
|
||||
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Anmelden');
|
||||
signIn.addEventListener('click', () => void requireLogin('login'));
|
||||
show(el('p', { class: 'cards-msg' }, LOGGED_OUT), el('p', { class: 'cards-actions' }, signIn));
|
||||
}
|
||||
|
||||
function showError(text: string): void {
|
||||
state = 'error';
|
||||
const retry = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Nochmal versuchen');
|
||||
retry.addEventListener('click', () => void load());
|
||||
show(el('p', { class: 'cards-msg cards-msg-error', role: 'alert' }, text), el('p', { class: 'cards-actions' }, retry));
|
||||
}
|
||||
|
||||
/** Setzt `cards` und hält den Editor-Zustand in Einklang (er überlebt Seitenwechsel). */
|
||||
function syncEditor(change: { renamed?: SavedCard; removedId?: string }): void {
|
||||
const s = store.get();
|
||||
if (!s.saved) return;
|
||||
if (change.renamed && s.saved.id === change.renamed.id) {
|
||||
store.set({
|
||||
saved: { ...s.saved, name: change.renamed.name, updatedAt: change.renamed.updatedAt },
|
||||
cardName: change.renamed.name,
|
||||
});
|
||||
}
|
||||
// Die Karte im Editor gibt es nicht mehr: ein späteres Speichern legt eine neue an, statt ins Leere zu schreiben.
|
||||
if (change.removedId && s.saved.id === change.removedId) store.set({ saved: null });
|
||||
}
|
||||
|
||||
function renderList(focusId?: string, focusAction?: string): void {
|
||||
state = 'list';
|
||||
thumbSlots.clear();
|
||||
if (cards.length === 0) {
|
||||
const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/', 'data-link': true }, 'Zum Editor');
|
||||
show(el('p', { class: 'cards-msg' }, EMPTY), el('p', { class: 'cards-actions' }, toEditor));
|
||||
return;
|
||||
}
|
||||
const grid = el('ul', { class: 'cards-grid' });
|
||||
for (const card of cards) grid.append(buildTile(card));
|
||||
show(grid);
|
||||
for (const card of cards) {
|
||||
fillThumb(card);
|
||||
queuePreview(card);
|
||||
}
|
||||
if (focusId) {
|
||||
const tile = [...body.querySelectorAll<HTMLElement>('.card-tile')].find((t) => t.dataset.card === focusId);
|
||||
tile?.querySelector<HTMLElement>(`[data-action="${focusAction ?? 'open'}"]`)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Kacheln und Aktionen ----
|
||||
|
||||
function buildTile(card: SavedCard): HTMLElement {
|
||||
const thumb = el('div', { class: 'card-thumb' });
|
||||
thumbSlots.set(card.id, thumb);
|
||||
const nameSlot = el('div', { class: 'card-name-slot' }, el('h2', { class: 'card-name' }, card.name));
|
||||
const date = formatDate(card.updatedAt);
|
||||
const message = el('p', { class: 'card-msg', role: 'alert' });
|
||||
const action = (key: string, text: string, className = 'auth-btn auth-btn-small') =>
|
||||
el('button', { type: 'button', class: className, 'data-action': key }, text);
|
||||
const openButton = action('open', 'Öffnen', 'auth-btn auth-btn-small auth-btn-primary');
|
||||
const downloadButton = action('download', 'Herunterladen');
|
||||
const renameButton = action('rename', 'Umbenennen');
|
||||
const deleteButton = action('delete', 'Löschen', 'auth-btn auth-btn-small cards-btn-danger');
|
||||
const buttons = [openButton, downloadButton, renameButton, deleteButton];
|
||||
|
||||
const tile = el(
|
||||
'li',
|
||||
{ class: 'card-tile', 'data-card': card.id },
|
||||
thumb,
|
||||
nameSlot,
|
||||
date ? el('p', { class: 'card-date' }, el('time', { datetime: card.updatedAt }, date)) : '',
|
||||
el('div', { class: 'card-buttons' }, ...buttons),
|
||||
message,
|
||||
);
|
||||
|
||||
// Eine Aktion je Kachel zur Zeit; Fehler stehen an der Kachel.
|
||||
async function run(task: () => Promise<void>, busyButton?: HTMLButtonElement, busyText?: string): Promise<void> {
|
||||
const label = busyButton?.textContent ?? '';
|
||||
message.textContent = '';
|
||||
message.classList.remove('is-warning');
|
||||
for (const b of buttons) b.disabled = true;
|
||||
tile.setAttribute('aria-busy', 'true');
|
||||
if (busyButton && busyText) busyButton.textContent = busyText;
|
||||
try {
|
||||
await task();
|
||||
} catch (err) {
|
||||
if (!disposed) message.textContent = failureText(err);
|
||||
} finally {
|
||||
if (busyButton && busyText) busyButton.textContent = label;
|
||||
for (const b of buttons) b.disabled = false;
|
||||
tile.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
|
||||
openButton.addEventListener('click', () => navigate(`/?karte=${encodeURIComponent(card.id)}`));
|
||||
|
||||
downloadButton.addEventListener('click', () => {
|
||||
void run(async () => {
|
||||
const blob = await withLogin(() => service.loadImage(card));
|
||||
const image = await decodeImage(blob);
|
||||
const config = { ...card.config, crop: clampCrop(image, card.config.crop) };
|
||||
const assets = await loadCardAssets(config, image);
|
||||
const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' });
|
||||
saveBlobAs(png, downloadFileName(card.name, new Date()));
|
||||
const warning = pngSizeWarning(png.size);
|
||||
if (warning && !disposed) {
|
||||
message.textContent = warning;
|
||||
message.classList.add('is-warning');
|
||||
}
|
||||
}, downloadButton, 'Wird erstellt …');
|
||||
});
|
||||
|
||||
renameButton.addEventListener('click', () => startRename());
|
||||
|
||||
deleteButton.addEventListener('click', () => {
|
||||
void run(async () => {
|
||||
if (!(await confirmDelete(card.name))) return;
|
||||
try {
|
||||
await withLogin(() => service.remove(card));
|
||||
} catch (err) {
|
||||
// Schon gelöscht (anderer Tab): das Ziel ist erreicht.
|
||||
if (errorType(err) !== 'row_not_found') throw err;
|
||||
}
|
||||
if (disposed) return;
|
||||
const url = previewUrls.get(card.previewFileId);
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
previewUrls.delete(card.previewFileId);
|
||||
cards = cards.filter((c) => c.id !== card.id);
|
||||
syncEditor({ removedId: card.id });
|
||||
renderList();
|
||||
if (cards.length === 0) heading.focus();
|
||||
else body.querySelector<HTMLElement>('.card-tile [data-action="open"]')?.focus();
|
||||
});
|
||||
});
|
||||
|
||||
function startRename(): void {
|
||||
const input = el('input', {
|
||||
class: 'auth-input card-rename-input',
|
||||
type: 'text',
|
||||
value: card.name,
|
||||
autocomplete: 'off',
|
||||
'aria-label': `Neuer Name für „${card.name}“`,
|
||||
});
|
||||
const save = el('button', { type: 'submit', class: 'auth-btn auth-btn-small auth-btn-primary' }, 'Speichern');
|
||||
const cancel = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abbrechen');
|
||||
const form = el('form', { class: 'card-rename' }, input, save, cancel);
|
||||
const close = () => {
|
||||
nameSlot.replaceChildren(el('h2', { class: 'card-name' }, card.name));
|
||||
renameButton.focus();
|
||||
};
|
||||
|
||||
cancel.addEventListener('click', close);
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
close();
|
||||
});
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (normalizeName(input.value) === card.name) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
input.readOnly = true;
|
||||
save.disabled = true;
|
||||
cancel.disabled = true;
|
||||
void run(async () => {
|
||||
let updated: SavedCard;
|
||||
try {
|
||||
updated = await withLogin(() => service.rename(card.id, input.value));
|
||||
} catch (err) {
|
||||
// Das Formular bleibt offen, damit nichts getippt werden muss.
|
||||
input.readOnly = false;
|
||||
save.disabled = false;
|
||||
cancel.disabled = false;
|
||||
throw err;
|
||||
}
|
||||
if (disposed) return;
|
||||
// Der Server sortiert nach „zuletzt geändert“; die umbenannte Karte steht danach vorn.
|
||||
cards = [updated, ...cards.filter((c) => c.id !== updated.id)];
|
||||
syncEditor({ renamed: updated });
|
||||
renderList(updated.id, 'rename');
|
||||
});
|
||||
});
|
||||
|
||||
nameSlot.replaceChildren(form);
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
function failureText(err: unknown): string {
|
||||
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
|
||||
if (err instanceof Error && err.message === READ_MESSAGE) return READ_MESSAGE;
|
||||
return errorMessage(err, 'load');
|
||||
}
|
||||
|
||||
// ---- Laden ----
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const token = ++loadToken;
|
||||
state = 'loading';
|
||||
showStatus(LOADING);
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (disposed || token !== loadToken) return;
|
||||
if (!user) {
|
||||
showLoggedOut();
|
||||
return;
|
||||
}
|
||||
const list = await withLogin(() => service.list());
|
||||
if (disposed || token !== loadToken) return;
|
||||
cards = list;
|
||||
revokePreviews(new Set(list.map((c) => c.previewFileId)));
|
||||
previewFailed.clear();
|
||||
renderList();
|
||||
} catch (err) {
|
||||
if (disposed || token !== loadToken) return;
|
||||
if (err instanceof LoginCancelled) showLoggedOut();
|
||||
else showError(errorMessage(err, 'load'));
|
||||
}
|
||||
}
|
||||
|
||||
// Abmelden (auch durch eine abgelaufene Sitzung) räumt die Liste weg, Anmelden holt sie.
|
||||
const offAuth = onAuthChange((user) => {
|
||||
if (disposed) return;
|
||||
if (!user) {
|
||||
if (state !== 'loggedOut') {
|
||||
loadToken += 1;
|
||||
showLoggedOut();
|
||||
}
|
||||
} else if (state === 'loggedOut') {
|
||||
void load();
|
||||
}
|
||||
});
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
offAuth();
|
||||
queue.length = 0;
|
||||
revokePreviews();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/* Meine Karten: Kachelraster und Löschen-Dialog. Knöpfe und Eingabefelder kommen aus account.css (.auth-*). */
|
||||
|
||||
.cards-page {
|
||||
--card-radius: 3.711% / 5.882%; /* 57 px von 1536 x 969, wie in der Editor-Vorschau */
|
||||
}
|
||||
|
||||
.cards-msg {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cards-msg-error {
|
||||
color: #ff9a9d;
|
||||
}
|
||||
|
||||
.cards-actions {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cards-link-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cards-btn-danger {
|
||||
border-color: var(--danger);
|
||||
color: #ff9a9d;
|
||||
}
|
||||
|
||||
.cards-btn-danger:hover:not(:disabled) {
|
||||
border-color: var(--danger);
|
||||
background: rgb(229 72 77 / 0.16);
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
|
||||
gap: 20px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.card-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.card-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1536 / 969;
|
||||
overflow: hidden;
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--panel-2);
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.card-date {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-msg {
|
||||
margin: 0;
|
||||
color: #ff9a9d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card-msg:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-msg.is-warning {
|
||||
color: #f2c66d;
|
||||
}
|
||||
|
||||
.card-rename {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-rename-input {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
/* Löschen bestätigen */
|
||||
|
||||
.confirm-dialog {
|
||||
width: min(400px, calc(100vw - 32px));
|
||||
padding: 0;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 20px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
|
||||
}
|
||||
|
||||
.confirm-dialog::backdrop {
|
||||
background: rgb(6 11 20 / 0.72);
|
||||
}
|
||||
|
||||
.confirm-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
margin: 0 0 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
Reference in new issue
Block a user