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:
ClaudeandClaude Opus 5.5 committed 2026-10-04 09:18:27 +00:00
1 parent a147d3b496
commit ba4d9f76d4
14 files changed
+2849 -16

No files matched your search

+152 -8
View File
@@ -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<CardAssets, 'network' | 'brand'>;
/** 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<CardConfig>) => store.set({ config: { ...store.get().config, ...patch } });
@@ -47,7 +70,14 @@ export function mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSa
patchConfig({ crop: zoomTo(s.image, s.config.crop, zoom) });
},
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<void> {
// 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<void> {
@@ -96,6 +130,111 @@ export function mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSa
}
}
// Speichern: Anmeldung prüfen, Karte speichern, bei abgelaufener Sitzung einmal neu anmelden (siehe save.ts).
let savedFlash = false;
let flashTimer: ReturnType<typeof setTimeout> | undefined;
function onSaveStatus(status: SaveStatus, message?: string): void {
clearTimeout(flashTimer);
savedFlash = status === 'saved';
if (savedFlash) {
flashTimer = setTimeout(() => {
savedFlash = false;
if (!disposed) sync(store.get());
}, SAVED_FLASH_MS);
}
if (!disposed) preview.setError(status === 'error' ? (message ?? null) : null);
store.set({ saving: status === 'saving' });
}
async function doSave(): Promise<void> {
// Ab hier zählt nur dieser Schnappschuss: Bild, Ausschnitt und Name, wie sie beim Klick waren.
const { config, image, imageBlob, imageChanged, cardName, saved } = store.get();
if (!image || !imageBlob) throw new Error(NO_IMAGE);
const assets = await loadCardAssets(config, image);
const previewBlob = await exportCard(config, assets, {
width: PREVIEW_WIDTH,
type: 'image/webp',
quality: PREVIEW_QUALITY,
});
const card = await cardsService().save({
name: cardName,
config,
image: imageBlob,
imageChanged,
preview: previewBlob,
previous: saved,
});
// Hat inzwischen eine andere Karte den Editor übernommen (Öffnen aus „Meine Karten“), bleibt deren Zustand unberührt.
const now = store.get();
if (now.imageBlob === imageBlob && now.saved?.id === saved?.id) {
store.set({ saved: toRef(card), imageChanged: false, cardName: card.name });
}
}
const saveCard = createSaveAction({
getUser: getCurrentUser,
requireLogin: () => requireLogin('login'),
doSave,
onStatus: onSaveStatus,
});
// Karte öffnen (/?karte=<id>): nur die zuletzt angeforderte zählt.
let loadToken = 0;
function cancelCardLoad(): void {
loadToken += 1;
preview.setNotice(null);
}
async function openSavedCard(id: string): Promise<void> {
const token = ++loadToken;
const stale = () => disposed || token !== loadToken;
const service = cardsService();
preview.setError(null);
preview.setWarning(null);
preview.setNotice(LOADING_CARD);
try {
const card = await withLogin(() => service.get(id));
if (stale()) return;
// Fehlt die Bilddatei, öffnet die Karte trotzdem (ohne Bild, mit Hinweis).
let blob: Blob | null = null;
let missingImage: string | null = null;
try {
blob = await withLogin(() => service.loadImage(card));
} catch (err) {
if (errorType(err) !== 'storage_file_not_found') throw err;
missingImage = errorMessage(err, 'load');
}
if (stale()) return;
const image: CardImage | null = blob ? await decodeImage(blob) : null;
if (stale()) return;
store.set({
config: image ? { ...card.config, crop: clampCrop(image, card.config.crop) } : card.config,
image,
imageBlob: blob,
imageChanged: false,
saved: toRef(card),
cardName: card.name,
});
preview.setWarning(missingImage);
} catch (err) {
if (stale()) return;
if (err instanceof LoginCancelled) {
preview.setWarning(LOGIN_TO_OPEN);
} else if (errorType(err) === 'row_not_found') {
// Die Karte gibt es nicht (für dieses Konto): kein halb übernommener Zustand, sondern ein leerer Editor.
store.set(initialEditorState());
preview.setError(errorMessage(err, 'load'));
} else {
preview.setError(err instanceof Error && err.message === READ_MESSAGE ? READ_MESSAGE : errorMessage(err, 'load'));
}
} finally {
if (!stale()) preview.setNotice(null);
}
}
// Logos und Schrift: werden nur bei Wechsel von Netzwerk, Details oder Logo geladen (Logos sind zwischengespeichert).
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;