Files
AirCardIOS/web/src/editor/editor-page.ts
T
ClaudeandClaude Opus 5.5 ba4d9f76d4 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>
2026-10-04 09:18:27 +00:00

320 lines
12 KiB
TypeScript

// 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 { clampCrop, panBy, zoomTo } from '../card/crop';
import { CARD_W } from '../card/geometry';
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 { createSaveAction, type SaveStatus } from './save';
import { initialEditorState, setImage, type EditorState, type EditorStore, type SavedCardRef } from './state';
import { createTemplatesPanel } from './templates-panel';
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}`;
/** 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 } });
const templates = createTemplatesPanel((template: TemplateId) => patchConfig({ template }));
const options = createOptionsPanel({
onNetwork: (network: NetworkId) => patchConfig({ network }),
onDetails: (details: DetailsTone) => patchConfig({ details }),
onBrand: (brand: BrandId) => patchConfig({ brand }),
});
let exporting = false;
const preview = createPreview({
onFile: (file) => void handleFile(file),
onPan(dx, dy) {
const s = store.get();
if (!s.image) return;
patchConfig({ crop: panBy(s.image, s.config.crop, dx, dy) });
},
onZoom(zoom) {
const s = store.get();
if (!s.image) return;
patchConfig({ crop: zoomTo(s.image, s.config.crop, zoom) });
},
onDownload: () => void download(),
onSave() {
if (store.get().saving) return;
if (!store.get().imageBlob) {
preview.setError(NO_IMAGE);
return;
}
void saveCard();
},
});
const heading = el('h1', { class: 'sr-only' }, 'Kartenmotiv gestalten');
const page = el('div', { class: 'editor' }, templates.el, preview.el, options.el);
root.replaceChildren(heading, page);
// 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;
preview.setError(null);
setImage(store, image, file);
} catch (error) {
if (disposed || token !== fileToken) return;
preview.setError(error instanceof Error ? error.message : 'Das Bild konnte nicht gelesen werden.');
}
}
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> {
const s = store.get();
if (!s.image || exporting) return;
exporting = true;
sync(store.get());
preview.setError(null);
preview.setWarning(null);
try {
const assets = await loadCardAssets(s.config, s.image);
const blob = await exportCard(s.config, assets, { width: CARD_W, type: 'image/png' });
saveBlobAs(blob, downloadFileName(s.cardName, new Date()));
preview.setWarning(pngSizeWarning(blob.size));
} catch (error) {
preview.setError(error instanceof Error && error.message ? error.message : EXPORT_ERROR);
} finally {
exporting = false;
if (!disposed) sync(store.get());
}
}
// 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 = '';
let failedKey = '';
let logoToken = 0;
function ensureLogos(config: CardConfig): void {
const key = logoKey(config);
if (logos?.key === key || pendingKey === key || failedKey === key) return;
pendingKey = key;
const token = ++logoToken;
loadCardAssets(config, null).then(
(assets) => {
if (disposed || token !== logoToken) return;
pendingKey = '';
logos = { network: assets.network, brand: assets.brand, key };
schedule();
},
() => {
if (disposed || token !== logoToken) return;
pendingKey = '';
failedKey = key;
preview.setError(LOGO_ERROR);
},
);
}
// Zeichnen: höchstens einmal je Animationsbild, egal wie oft sich der Zustand ändert.
let frame = 0;
function schedule(): void {
if (frame || disposed) return;
frame = requestAnimationFrame(() => {
frame = 0;
draw();
});
}
function draw(): void {
const s = store.get();
// Mit Logos eines anderen Netzwerks würde das Seitenverhältnis nicht passen; lieber auf die neuen warten.
if (!logos || logos.key !== logoKey(s.config)) return;
const assets: CardAssets = { image: s.image, network: logos.network, brand: logos.brand };
try {
if (s.image) drawCard(preview.canvas, s.config, assets);
templates.draw(s.config, assets);
} catch (error) {
preview.setError(error instanceof Error ? error.message : 'Die Vorschau konnte nicht gezeichnet werden.');
}
}
let lastImage: EditorState['image'] = null;
function sync(s: EditorState): void {
templates.select(s.config.template);
options.select(s.config);
preview.sync({ hasImage: s.image !== null, zoom: s.config.crop.zoom, saving: s.saving, saved: savedFlash, exporting });
}
function onState(s: EditorState): void {
// Der Größenhinweis gehört zum letzten Download; ein anderes Bild macht ihn hinfällig.
if (s.image !== lastImage) {
lastImage = s.image;
preview.setWarning(null);
}
sync(s);
ensureLogos(s.config);
schedule();
}
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;
};
}