feat(community): Datenschicht für Galerie, API und Veröffentlichen

- gallery.ts: Galerie aus Appwrite lesen (Abfragen für Liste, Suche, Cursor, Admin-Filter),
  getGalleryCard (404 und ungültige ID ergeben null), eigene Veröffentlichungen, Meldungen, Bilder
- api.ts: Aufrufe von aircard-api mit JWT (Cache je Nutzer, 10 Minuten, geleert bei Auth-Änderung,
  genau eine Wiederholung bei 401), ApiError mit code, Netz-/Proxy-Fehler als 503
- publish.ts: rendern, zwei Uploads in gallery-images, API; bei Fehler genau die zwei Dateien
  dieses Versuchs löschen; normalizeTags
- cards.ts: eigenständiges fetchFile(bucketId, fileId); CardsApi.fetchFile bleibt Hülle
- appwrite.ts: IDS für gallery, reports, gallery-images

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 14:39:30 +00:00
1 parent 7bbe0a9fe6
commit 89c86fd578
9 files changed
+1576 -7

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
// Eine gespeicherte Karte in der Community-Galerie veröffentlichen: rendern, zwei Dateien hochladen, API rufen.
// Scheitert danach irgendetwas, löscht der Client genau die zwei Dateien dieses Versuchs wieder.
import { ID, Permission, Role } from 'appwrite';
import { IDS, storage } from '../account/appwrite';
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards';
import { CARD_W } from '../card/geometry';
import { clampCrop } from '../card/crop';
import { exportCard, loadCardAssets } from '../card/render';
import { decodeImage } from '../editor/upload';
import { publishCard, type PublishInput } from './api';
/** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
export const PNG_MAX_BYTES = 5_242_880;
const PREVIEW_WIDTH = 480;
const PREVIEW_QUALITY = 0.85;
const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).';
const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.';
const GALLERY_TYPES = ['image/png', 'image/webp'];
export interface PublishForm {
title: string;
issuer: string;
/** Rohe Eingabe, Komma-getrennt; wird mit `normalizeTags` zur Liste. */
tags: string;
displayName: string;
}
/** Alles, was `publishSavedCard` von außen braucht (im Test ersetzbar, ohne Netz und Canvas). */
export interface PublishDeps {
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */
userId(): string;
/** Das Originalbild der privaten Karte. */
loadImage(card: SavedCard): Promise<Blob>;
/** Rendert Kartenbild (PNG, 1536 px) und Vorschau (WebP, 480 px). */
render(card: SavedCard, image: Blob): Promise<{ png: Blob; preview: Blob }>;
createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
deleteFile(fileId: string): Promise<void>;
publishCard(input: PublishInput): Promise<{ id: string }>;
}
/** Wie der Server: an Kommas trennen, trimmen, klein schreiben, Leere und Doppelte entfernen. Grenzen prüft der Server. */
export function normalizeTags(input: string): string[] {
const tags: string[] = [];
for (const part of input.split(',')) {
const tag = part.trim().toLowerCase();
if (tag !== '' && !tags.includes(tag)) tags.push(tag);
}
return tags;
}
async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> {
const image = await decodeImage(imageBlob);
const config = { ...card.config, crop: clampCrop(image, card.config.crop) };
const assets = await loadCardAssets(config, image);
const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' });
const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY });
return { png, preview };
}
function defaultDeps(): PublishDeps {
const service = cardsService();
return {
userId: requireUserId,
loadImage: (card) => service.loadImage(card),
render: renderForGallery,
// Immer eine frische ID: aufgeräumt wird nur, was dieser Versuch selbst angelegt hat.
createFile: (file, permissions) =>
storage.createFile({ bucketId: IDS.galleryBucket, fileId: ID.unique(), file, permissions }),
deleteFile: async (fileId) => {
await storage.deleteFile({ bucketId: IDS.galleryBucket, fileId });
},
publishCard,
};
}
/** Räumt auf, soweit es geht; ein Fehler (auch 404) darf den eigentlichen Fehler nie überdecken. */
async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise<void> {
await Promise.allSettled(fileIds.map((id) => deps.deleteFile(id)));
}
/**
* Veröffentlicht die Karte: Bild laden, rendern, PNG und Vorschau in den Galerie-Bucket laden, API rufen.
* Schlägt nach dem ersten Upload etwas fehl (zweiter Upload, API, auch 409), werden die in diesem Versuch
* hochgeladenen Dateien (und nur diese) gelöscht und der Fehler weitergeworfen.
*/
export async function publishSavedCard(
card: SavedCard,
form: PublishForm,
deps: PublishDeps = defaultDeps(),
): Promise<{ id: string }> {
// Ohne Nutzer wirft das hier, bevor etwas geladen oder gerendert wird.
const userId = deps.userId();
const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))];
const source = await deps.loadImage(card);
const { png, preview } = await deps.render(card, source);
// Alles vorab prüfen: ein unzulässiges oder zu großes Bild darf keine halben Uploads hinterlassen.
if (png.type !== 'image/png' || !GALLERY_TYPES.includes(preview.type)) throw new Error(NOT_RENDERED);
if (png.size > PNG_MAX_BYTES || preview.size > PNG_MAX_BYTES) throw new Error(TOO_LARGE);
const imageFile = toUploadFile(png, 'image');
// Der Name folgt dem echten Typ: Browser ohne WebP-Export liefern für „webp“ ein PNG.
const previewFile = toUploadFile(preview, 'preview');
const uploaded: string[] = [];
try {
uploaded.push((await deps.createFile(imageFile, permissions)).$id);
uploaded.push((await deps.createFile(previewFile, permissions)).$id);
return await deps.publishCard({
title: form.title.trim(),
issuer: form.issuer.trim(),
tags: normalizeTags(form.tags),
displayName: form.displayName.trim(),
imageFileId: uploaded[0],
previewFileId: uploaded[1],
sourceCardId: card.id,
});
} catch (err) {
await deleteQuietly(deps, uploaded);
throw err;
}
}