- 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>
320 lines
12 KiB
TypeScript
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;
|
|
};
|
|
}
|