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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user