Files
AirCardIOS/web/src/community/publish.ts
T
ClaudeandClaude Opus 5.5 56cb67be4b feat(community): „von @name“ statt Anzeigename, Benutzername vor dem Hochladen und Veröffentlichen
Logo-Kacheln, Logo-Infozeile und die Detailseite einer Community-Karte zeigen den Benutzernamen des Kontos (Hilfsfunktion byline: Profil,
sonst alter Anzeigename, sonst „Anonym“). Die Namen kommen je geladener Seite in einer Abfrage nach, ein Fehler dabei bleibt ohne Fehlertext,
ein eigenes Umbenennen aktualisiert die eigenen Einträge sofort.

Die Felder „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ fallen weg, ebenso account/display-name.ts und der
localStorage-Schlüssel; der Client schickt displayName: ''. Vor dem Hochladen und Veröffentlichen verlangt requireUsername() einen Namen
(„Abbrechen“: es passiert nichts); antwortet der Dienst mit 403 usernameRequired, öffnet sich der Namensdialog, danach ist ein neuer Versuch möglich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:06:44 +00:00

163 lines
8.0 KiB
TypeScript

// Eine gespeicherte Karte in der Community-Galerie veröffentlichen: rendern, zwei Dateien hochladen, API rufen.
// Aufgeräumt wird nur bei eindeutigem Scheitern (Upload-Fehler, eindeutige Ablehnung der API): dann löscht der Client genau
// die zwei Dateien dieses Versuchs wieder. Bleibt der Ausgang offen, bleiben sie liegen (siehe publishSavedCard).
import { ID, Permission, Role } from 'appwrite';
import { IDS, storage } from '../account/appwrite';
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards';
import type { CardConfig } from '../card/config';
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH, layoutFor } from '../card/geometry';
import { clampCrop } from '../card/crop';
import { exportCard, loadCardAssets, type LoadedAssets } from '../card/render';
import { decodeImage } from '../editor/upload';
import { msg } from '../i18n/i18n';
import { forgetLogo } from '../logos/logo-asset';
import { isDefinitiveFailure, 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;
// Die Meldungen dieser Datei gehen deutsch markiert in den Fehler; der Veröffentlichen-Dialog zeigt sie mit t().
export const TOO_LARGE = msg('Das Kartenbild ist zu groß für die Galerie (max. 5 MB).');
export const NOT_RENDERED = msg('Das Kartenbild konnte nicht erzeugt werden.');
/** Die Karte wählt ein Logo, das gelöscht oder ausgeblendet wurde: ohne dieses Logo wird sie weder veröffentlicht noch heruntergeladen. */
export const LOGO_MISSING_PUBLISH = msg(
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
);
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;
}
/**
* Der Anzeigename, den der Client mitschickt: keiner. Seit den Benutzernamen (Spec § 5) zeigt die Detailseite den Namen des Kontos;
* der Dienst nimmt das Feld weiter an (der Altbestand trägt noch Anzeigenamen).
*/
const NO_DISPLAY_NAME = '';
/** 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;
}
/**
* Prüft nach dem Laden der Bilder einer gespeicherten Karte, ob ihr beim Rendern etwas fehlt (Veröffentlichen und Herunterladen
* aus „Meine Karten“): Gibt es das gewählte Community-Logo nicht mehr (gelöscht, ausgeblendet) und zeichnet die Vorlage es, wirft
* das LOGO_MISSING_PUBLISH. So wird nie eine andere Karte veröffentlicht oder heruntergeladen, als der Nutzer sie gestaltet hat.
* Bei „Nur Bild“ wird ein Logo nie gezeichnet: dort fehlt nichts, und es wird ganz normal gerendert.
*/
export function assertLogoAvailable(config: CardConfig, assets: LoadedAssets): void {
if (assets.logoMissing && layoutFor(config.template).logo) throw new Error(LOGO_MISSING_PUBLISH);
}
export 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) };
// Zeile und Bild des Logos neu vom Server prüfen: der Zwischenspeicher lebt so lange wie die Seite, ein inzwischen ausgeblendetes oder
// gelöschtes Logo käme sonst aus ihm in ein öffentliches Kartenbild.
if (config.logo) forgetLogo(config.logo);
const assets = await loadCardAssets(config, image);
// Lieber abbrechen, als eine Karte ohne ihr Logo zu veröffentlichen.
assertLogoAvailable(config, assets);
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.
*
* Aufräumen (nur die zwei Dateien dieses Versuchs, nie etwas anderes):
* - Scheitert ein Upload, ist noch nichts veröffentlicht: der erste Upload wird gelöscht.
* - Scheitert die API eindeutig (4xx außer 408/429, auch 409 und 403 `usernameRequired`, oder schon der JWT-Abruf), wurde nichts
* angelegt: beide Dateien werden gelöscht.
* - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API die Zeile schon
* angelegt haben. Dann bleiben die Dateien liegen: sonst zeigte eine öffentliche Karte auf gelöschte Bilder.
* (Ein Waisen-Upload ist harmlos: unverlinkt, mit Zufalls-ID, vom Nutzer jederzeit löschbar.)
* Der Fehler wird in jedem Fall 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);
} catch (err) {
// Die API wurde noch nicht gerufen: es gibt keine Zeile, die auf die Dateien zeigen könnte.
await deleteQuietly(deps, uploaded);
throw err;
}
try {
return await deps.publishCard({
title: form.title.trim(),
issuer: form.issuer.trim(),
tags: normalizeTags(form.tags),
displayName: NO_DISPLAY_NAME,
imageFileId: uploaded[0],
previewFileId: uploaded[1],
sourceCardId: card.id,
});
} catch (err) {
if (isDefinitiveFailure(err)) await deleteQuietly(deps, uploaded);
throw err;
}
}