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,230 @@
|
||||
// Gespeicherte Karten: Dienst (Regeln fürs Speichern, Aufräumen, Laden) und Anschluss an Appwrite.
|
||||
// Der Dienst kennt nur das schmale `CardsApi`-Interface und lässt sich dadurch ohne Netzwerk testen.
|
||||
import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite';
|
||||
import { parseConfig, serializeConfig, type CardConfig } from '../card/config';
|
||||
import type { SavedCardRef } from '../editor/state';
|
||||
import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite';
|
||||
import { currentUserId } from './auth';
|
||||
|
||||
export interface CardRowData {
|
||||
name: string;
|
||||
config: string;
|
||||
imageFileId: string;
|
||||
previewFileId: string;
|
||||
}
|
||||
|
||||
export interface CardRow extends CardRowData {
|
||||
$id: string;
|
||||
$updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CardsApi {
|
||||
createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
|
||||
deleteFile(fileId: string): Promise<void>;
|
||||
fetchFile(fileId: string): Promise<Blob>;
|
||||
createRow(data: CardRowData, permissions: string[]): Promise<CardRow>;
|
||||
updateRow(id: string, data: Partial<CardRowData>): Promise<CardRow>;
|
||||
getRow(id: string): Promise<CardRow>;
|
||||
deleteRow(id: string): Promise<void>;
|
||||
listRows(): Promise<CardRow[]>;
|
||||
}
|
||||
|
||||
export interface SavedCard extends SavedCardRef {
|
||||
config: CardConfig;
|
||||
}
|
||||
|
||||
export interface SaveInput {
|
||||
name: string;
|
||||
config: CardConfig;
|
||||
image: Blob;
|
||||
/** false: das Bild ist schon gespeichert und wird nicht erneut hochgeladen (nur bei `previous`). */
|
||||
imageChanged: boolean;
|
||||
preview: Blob;
|
||||
previous: SavedCardRef | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_CARD_NAME = 'Meine Karte';
|
||||
const NAME_MAX = 80;
|
||||
/** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */
|
||||
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||
|
||||
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
|
||||
export function normalizeName(name: string): string {
|
||||
const collapsed = name.replace(/\s+/g, ' ').trim();
|
||||
const cut = Array.from(collapsed).slice(0, NAME_MAX).join('').trim();
|
||||
return cut || DEFAULT_CARD_NAME;
|
||||
}
|
||||
|
||||
/** Nur der Besitzer darf die Zeile bzw. Datei lesen, ändern und löschen. */
|
||||
export function ownerPermissions(userId: string): string[] {
|
||||
const owner = Role.user(userId);
|
||||
return [Permission.read(owner), Permission.update(owner), Permission.delete(owner)];
|
||||
}
|
||||
|
||||
const EXTENSIONS: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
|
||||
|
||||
/** Verpackt ein Bild als Datei für den Bucket; der Name folgt dem Typ (der Bucket prüft die Endung). */
|
||||
export function toUploadFile(blob: Blob, base: 'image' | 'preview'): File {
|
||||
const ext = EXTENSIONS[blob.type];
|
||||
if (!ext) throw new Error(`Bildtyp nicht erlaubt: ${blob.type || 'unbekannt'}`);
|
||||
return new File([blob], `${base}.${ext}`, { type: blob.type });
|
||||
}
|
||||
|
||||
function toSaved(row: CardRow): SavedCard {
|
||||
return {
|
||||
id: row.$id,
|
||||
name: row.name,
|
||||
imageFileId: row.imageFileId,
|
||||
previewFileId: row.previewFileId,
|
||||
updatedAt: row.$updatedAt,
|
||||
config: parseConfig(row.config),
|
||||
};
|
||||
}
|
||||
|
||||
/** Räumt auf, soweit es geht; ein Fehler (auch 404) ist hier nie ein Grund abzubrechen. */
|
||||
async function deleteQuietly(api: CardsApi, fileIds: string[]): Promise<void> {
|
||||
await Promise.allSettled(fileIds.map((id) => api.deleteFile(id)));
|
||||
}
|
||||
|
||||
export function createCardsService(api: CardsApi, userId: () => string) {
|
||||
async function save(input: SaveInput): Promise<SavedCard> {
|
||||
// Ohne Nutzer wirft das hier, bevor etwas hochgeladen wird.
|
||||
const permissions = ownerPermissions(userId());
|
||||
const prev = input.previous;
|
||||
const name = normalizeName(input.name);
|
||||
const config = serializeConfig(input.config);
|
||||
const uploadImage = prev === null || input.imageChanged;
|
||||
// Typen vorab prüfen: ein unzulässiges Bild darf keine halben Uploads hinterlassen.
|
||||
const imageFile = uploadImage ? toUploadFile(input.image, 'image') : null;
|
||||
const previewFile = toUploadFile(input.preview, 'preview');
|
||||
|
||||
const uploaded: string[] = [];
|
||||
const upload = async (file: File): Promise<string> => {
|
||||
const created = await api.createFile(file, permissions);
|
||||
uploaded.push(created.$id);
|
||||
return created.$id;
|
||||
};
|
||||
|
||||
let row: CardRow;
|
||||
try {
|
||||
const imageFileId = imageFile ? await upload(imageFile) : (prev as SavedCardRef).imageFileId;
|
||||
const previewFileId = await upload(previewFile);
|
||||
const data: CardRowData = { name, config, imageFileId, previewFileId };
|
||||
row = prev ? await api.updateRow(prev.id, data) : await api.createRow(data, permissions);
|
||||
} catch (err) {
|
||||
await deleteQuietly(api, uploaded);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (prev) {
|
||||
// Die Karte zeigt schon auf die neuen Dateien; die alten sind nur noch Müll.
|
||||
await deleteQuietly(api, [prev.previewFileId, ...(uploadImage ? [prev.imageFileId] : [])]);
|
||||
}
|
||||
return toSaved(row);
|
||||
}
|
||||
|
||||
return {
|
||||
async list(): Promise<SavedCard[]> {
|
||||
return (await api.listRows()).map(toSaved);
|
||||
},
|
||||
|
||||
async get(id: string): Promise<SavedCard> {
|
||||
// Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht.
|
||||
if (!ID_PATTERN.test(id)) throw new AppwriteException('Row not found', 404, 'row_not_found', '');
|
||||
return toSaved(await api.getRow(id));
|
||||
},
|
||||
|
||||
save,
|
||||
|
||||
async rename(id: string, name: string): Promise<SavedCard> {
|
||||
return toSaved(await api.updateRow(id, { name: normalizeName(name) }));
|
||||
},
|
||||
|
||||
async remove(card: SavedCardRef): Promise<void> {
|
||||
await api.deleteRow(card.id);
|
||||
await deleteQuietly(api, [card.imageFileId, card.previewFileId]);
|
||||
},
|
||||
|
||||
loadImage(card: SavedCardRef): Promise<Blob> {
|
||||
return api.fetchFile(card.imageFileId);
|
||||
},
|
||||
|
||||
loadPreview(card: SavedCardRef): Promise<Blob> {
|
||||
return api.fetchFile(card.previewFileId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type CardsService = ReturnType<typeof createCardsService>;
|
||||
|
||||
/** Lädt eine Bilddatei mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
|
||||
async function fetchFile(fileId: string): Promise<Blob> {
|
||||
// Bewusst kein storage.getFileView(): das hängt das Projekt als Query-Parameter an die URL und schickt keinen
|
||||
// Header; so trifft die Anfrage die Traefik-Route für /v1 mit Header nicht. prepareRequest setzt Header und Cookie.
|
||||
const url = new URL(`${APPWRITE_ENDPOINT}/storage/buckets/${IDS.bucket}/files/${encodeURIComponent(fileId)}/view`);
|
||||
const { uri, options } = client.prepareRequest('GET', url);
|
||||
const res = await fetch(uri, options);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let body: { message?: unknown; type?: unknown } = {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (typeof parsed === 'object' && parsed !== null) body = parsed;
|
||||
} catch {
|
||||
// keine JSON-Antwort (z. B. Fehlerseite des Proxys): Statuszeile genügt
|
||||
}
|
||||
throw new AppwriteException(
|
||||
typeof body.message === 'string' ? body.message : res.statusText,
|
||||
res.status,
|
||||
typeof body.type === 'string' ? body.type : '',
|
||||
text,
|
||||
);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
if (!blob.type.startsWith('image/')) throw new AppwriteException('Unerwartete Antwort', 502, 'general_unknown', '');
|
||||
return blob;
|
||||
}
|
||||
|
||||
type TableRow = Models.Row & CardRowData;
|
||||
|
||||
export function appwriteCardsApi(): CardsApi {
|
||||
const target = { databaseId: IDS.database, tableId: IDS.table };
|
||||
return {
|
||||
createFile: (file, permissions) =>
|
||||
storage.createFile({ bucketId: IDS.bucket, fileId: ID.unique(), file, permissions }),
|
||||
deleteFile: async (fileId) => {
|
||||
await storage.deleteFile({ bucketId: IDS.bucket, fileId });
|
||||
},
|
||||
fetchFile,
|
||||
createRow: (data, permissions) =>
|
||||
tables.createRow<TableRow>({ ...target, rowId: ID.unique(), data, permissions }),
|
||||
// Beim Ändern bleiben die Rechte der Zeile unangetastet.
|
||||
updateRow: (id, data) => tables.updateRow<TableRow>({ ...target, rowId: id, data }),
|
||||
getRow: (id) => tables.getRow<TableRow>({ ...target, rowId: id }),
|
||||
deleteRow: async (id) => {
|
||||
await tables.deleteRow({ ...target, rowId: id });
|
||||
},
|
||||
listRows: async () => {
|
||||
const list = await tables.listRows<TableRow>({
|
||||
...target,
|
||||
queries: [Query.orderDesc('$updatedAt'), Query.limit(100)],
|
||||
});
|
||||
return list.rows;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler, den `withLogin`/`createSaveAction` als 401 erkennen. */
|
||||
export function requireUserId(): string {
|
||||
const id = currentUserId();
|
||||
if (!id) throw Object.assign(new Error('unauthorized'), { code: 401 });
|
||||
return id;
|
||||
}
|
||||
|
||||
let shared: CardsService | null = null;
|
||||
|
||||
/** Der Kartendienst der Seite (beim ersten Gebrauch erzeugt). */
|
||||
export function cardsService(): CardsService {
|
||||
shared ??= createCardsService(appwriteCardsApi(), requireUserId);
|
||||
return shared;
|
||||
}
|
||||
Reference in new issue
Block a user