feat: Speichern veröffentlicht direkt, mehrere Karten auf einmal hochladen und veröffentlichen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:59:07 +00:00
1 parent 4e9659ca85
commit 1cdebb7102
10 files changed
+281 -19

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
// Direkt veröffentlichen: nach dem Speichern (Editor) und beim Mehrfach-Upload („Meine Karten“) ohne Dialog.
// Titel = Name der Karte. Ist die Karte schon veröffentlicht, wird die alte Veröffentlichung zurückgezogen und die neue Fassung
// veröffentlicht, damit die Galerie immer den gespeicherten Stand zeigt. Fehlt der Benutzername, fragt der Namensdialog einmal nach.
import type { SavedCard } from '../account/cards';
import { requireUserId } from '../account/cards';
import { usernameDialog } from '../account/username-dialog';
import { withLogin } from '../account/with-login';
import { isAlreadyPublished, isUsernameRequired, withdrawCard } from './api';
import { myPublications } from './gallery';
import { publishSavedCard, type PublishForm } from './publish';
const TITLE_MAX = 60;
export function autoPublishForm(name: string): PublishForm {
const title = name.trim().slice(0, TITLE_MAX).trim();
return { title: title === '' ? 'AirCard' : title, issuer: '', tags: '' };
}
export async function autoPublish(card: SavedCard, signal?: AbortSignal): Promise<{ id: string }> {
const form = autoPublishForm(card.name);
const publish = () => withLogin(() => publishSavedCard(card, form));
let askedName = false;
let replaced = false;
for (;;) {
try {
return await publish();
} catch (err) {
if (isUsernameRequired(err) && !askedName) {
askedName = true;
if ((await usernameDialog({ mode: 'required', signal })) === null) throw err;
continue;
}
if (isAlreadyPublished(err) && !replaced) {
replaced = true;
const old = (await myPublications(requireUserId())).get(card.id);
if (!old) throw err;
await withLogin(() => withdrawCard(old.id));
continue;
}
throw err;
}
}
}
+13 -12
View File
@@ -55,6 +55,7 @@ vi.mock('../community/gallery', () => ({
loadGalleryImage: mocks.loadGalleryImage,
}));
// Der Bereich „Logo unten rechts“ hat seine eigenen Tests (logos/logo-picker.test.ts); hier zählt nur die Verdrahtung.
vi.mock('../community/auto-publish', () => ({ autoPublish: vi.fn(async () => ({ id: 'g1' })) }));
vi.mock('../logos/logo-picker', () => ({ createLogoPicker: mocks.createLogoPicker }));
// Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt.
vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
@@ -143,7 +144,7 @@ describe('Speichern', () => {
saveButton().click();
expect(saveButton().disabled).toBe(true);
expect(saveButton().textContent).toBe('Speichert …');
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
expect(mocks.getCurrentUser).toHaveBeenCalled();
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), expect.anything(), {
@@ -176,10 +177,10 @@ describe('Speichern', () => {
mount();
saveButton().click();
// vi.waitFor würde die falschen Uhren selbst vorstellen; deshalb nur die Zusagen abarbeiten.
for (let i = 0; i < 50 && saveButton().textContent !== 'Gespeichert'; i += 1) await vi.advanceTimersByTimeAsync(0);
expect(saveButton().textContent).toBe('Gespeichert');
for (let i = 0; i < 50 && saveButton().textContent !== 'Veröffentlicht'; i += 1) await vi.advanceTimersByTimeAsync(0);
expect(saveButton().textContent).toBe('Veröffentlicht');
await vi.advanceTimersByTimeAsync(1999);
expect(saveButton().textContent).toBe('Gespeichert');
expect(saveButton().textContent).toBe('Veröffentlicht');
await vi.advanceTimersByTimeAsync(1);
expect(saveButton().textContent).toBe('Speichern');
});
@@ -191,7 +192,7 @@ describe('Speichern', () => {
mocks.save.mockResolvedValue(savedCard({ imageFileId: 'img0', previewFileId: 'prev9' }));
mount();
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
expect(mocks.save.mock.calls[0][0]).toMatchObject({ previous: ref, imageChanged: false });
expect(store.get().saved?.previewFileId).toBe('prev9');
});
@@ -216,7 +217,7 @@ describe('Speichern', () => {
saveButton().click();
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
finish(savedCard());
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
expect(mocks.save).toHaveBeenCalledTimes(1);
});
@@ -235,7 +236,7 @@ describe('Speichern', () => {
expect(errorText()).toBe('');
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
expect(mocks.save).toHaveBeenCalledTimes(1);
});
@@ -246,7 +247,7 @@ describe('Speichern', () => {
.mockResolvedValueOnce(savedCard());
mount();
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
expect(mocks.save).toHaveBeenCalledTimes(2);
expect(errorText()).toBe('');
@@ -264,7 +265,7 @@ describe('Speichern', () => {
expect(store.get().saved).toBeNull();
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
expect(errorText()).toBe('');
});
@@ -289,7 +290,7 @@ describe('Speichern', () => {
expect(store.get().image).toBe(img);
finish(savedCard());
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
expect(replaceButton().disabled).toBe(false);
expect(fileInput().disabled).toBe(false);
});
@@ -299,7 +300,7 @@ describe('Speichern', () => {
mocks.save.mockResolvedValue(savedCard());
mount();
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
mocks.decodeImage.mockResolvedValue(image(1000, 1000));
const file = new File(['x'], 'neu.png', { type: 'image/png' });
const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown };
@@ -995,7 +996,7 @@ describe('Speichern: Schnappschuss beim Klick', () => {
});
mount();
saveButton().click();
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
expect(mocks.save).toHaveBeenCalledTimes(2);
for (const [input] of mocks.save.mock.calls) {
expect(input.name).toBe('Beim Klick');
+13 -1
View File
@@ -9,7 +9,9 @@ import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type
import { clampCrop, panBy, zoomTo } from '../card/crop';
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage, type LoadedAssets } from '../card/render';
import { autoPublish } from '../community/auto-publish';
import { getGalleryCard, loadGalleryImage } from '../community/gallery';
import { publishFailureText } from '../community/publish-dialog';
import { msg, t } from '../i18n/i18n';
import { LOGO_MISSING, forgetLogo } from '../logos/logo-asset';
import { createLogoPicker } from '../logos/logo-picker';
@@ -222,6 +224,8 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
// Speichern: Anmeldung prüfen, Karte speichern, bei abgelaufener Sitzung einmal neu anmelden (siehe save.ts).
let savedFlash = false;
/** Gespeichert, aber das direkte Veröffentlichen ist gescheitert: der Text erscheint nach dem Speichern. */
let publishError: string | null = null;
let flashTimer: ReturnType<typeof setTimeout> | undefined;
function onSaveStatus(status: SaveStatus, message?: string): void {
@@ -233,7 +237,8 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
if (!disposed) sync(store.get());
}, SAVED_FLASH_MS);
}
if (!disposed) preview.setError(status === 'error' ? (message ?? null) : null);
if (!disposed) preview.setError(status === 'error' ? (message ?? null) : status === 'saved' ? publishError : null);
publishError = null;
store.set({ saving: status === 'saving' });
}
@@ -269,6 +274,13 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
if (now.imageBlob === imageBlob && now.saved?.id === saved?.id) {
store.set({ saved: toRef(card), savedConfig: config, imageChanged: false, cardName: card.name });
}
// Gespeichert heißt veröffentlicht: die Karte geht direkt (oder in neuer Fassung) in die Community-Galerie.
publishError = null;
try {
await autoPublish(card);
} catch (err) {
publishError = publishFailureText(err);
}
}
const saveCard = createSaveAction({
+2 -2
View File
@@ -40,7 +40,7 @@ export interface Preview {
const NO_IMAGE_HINT = msg('Erst ein Bild wählen');
const SAVE_LABEL = msg('Speichern');
const SAVING_LABEL = msg('Speichert …');
const SAVED_LABEL = msg('Gespeichert');
const SAVED_LABEL = msg('Veröffentlicht');
const KEY_STEP = 24;
const KEY_STEP_BIG = 96;
const KEY_ZOOM_STEP = 0.1;
@@ -223,7 +223,7 @@ export function createPreview(handlers: PreviewHandlers): Preview {
el('div', { class: 'action-buttons' }, replaceButton, downloadButton, saveButton),
);
const note = el('p', { class: 'note' }, icon('lock', 'note-icon'), t('Dein Bild bleibt auf deinem Gerät, bis du speicherst.'));
const note = el('p', { class: 'note' }, icon('lock', 'note-icon'), t('Speichern veröffentlicht die Karte direkt in der Community. Nur eigene Bilder, keine echten Kartendaten.'));
const inner = el('div', { class: 'preview-inner' }, boundRow, stage, zoomRow, errorBox, warningBox, noticeBox, actions, note, input);
const section = el('section', { class: 'editor-center', 'aria-label': t('Kartenvorschau') }, inner);
+10 -2
View File
@@ -209,11 +209,11 @@ export const EN: Readonly<Record<string, string>> = {
'Herunterladen': 'Download',
'Speichern': 'Save',
'Speichert …': 'Saving …',
'Gespeichert': 'Saved',
'Erst ein Bild wählen': 'Choose an image first',
'Neue Karte': 'New card',
'Bearbeitet: {name}': 'Editing: {name}',
'Dein Bild bleibt auf deinem Gerät, bis du speicherst.': 'Your image stays on your device until you save.',
'Speichern veröffentlicht die Karte direkt in der Community. Nur eigene Bilder, keine echten Kartendaten.':
'Saving publishes the card to the community right away. Only your own images, no real card details.',
// Editor, Meldungen und Rückfragen (editor/editor-page.ts, editor/upload.ts, editor/download.ts, editor/save.ts, card/render.ts)
'Bitte zuerst ein Bild wählen.': 'Please choose an image first.',
@@ -384,4 +384,12 @@ export const EN: Readonly<Record<string, string>> = {
'Etwas ist schiefgelaufen. Bitte nochmal versuchen.': 'Something went wrong. Please try again.',
'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.':
'Token sign-in is turned off on the server – please let the site owner know.',
// Mehrere Karten hochladen (pages/bulk-upload.ts, pages/cards-page.ts)
'Mehrere Karten hochladen': 'Upload several cards',
'Name der Karte': 'Card name',
'Alle veröffentlichen': 'Publish all',
'Gib jeder Karte einen Namen. Alle werden gespeichert und direkt in der Community veröffentlicht.':
'Give each card a name. All of them are saved and published to the community right away.',
'Bitte gib jeder Karte einen Namen.': 'Please give every card a name.',
};
+2 -1
View File
@@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({
getGalleryCard: vi.fn(),
}));
vi.mock('../community/auto-publish', () => ({ autoPublish: vi.fn(async () => ({ id: 'g1' })) }));
vi.mock('../account/appwrite', () => ({
account: mocks.acct,
client: {},
@@ -324,7 +325,7 @@ describe('Editor auf Englisch', () => {
await vi.waitFor(() => expect(errorText()).toBe('Saving failed – Can’t reach the server.'));
mocks.saveCard.mockResolvedValueOnce({ id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x', config: store.get().config });
button('Save').click();
await vi.waitFor(() => expect(button('Saved')).toBeDefined());
await vi.waitFor(() => expect(button('Published')).toBeDefined());
expect(root.querySelector('.bound-label')!.textContent).toBe('Editing: Urlaub');
// Download: Logos nicht ladbar, dann ein PNG über der Wallet-Grenze, dann ein Export-Fehler mit deutscher Meldung aus card/render.ts
+175
View File
@@ -0,0 +1,175 @@
// „Mehrere Karten hochladen“ auf /karten: mehrere Bilder wählen, je einen Namen vergeben, alle auf einmal speichern und veröffentlichen.
// Jede Karte wird mit der Vorlage „Nur Bild“ gespeichert (das Bild ist schon das fertige Kartendesign) und direkt veröffentlicht.
import '../account/account.css';
import '../community/community.css';
import './cards.css';
import { cardsService } from '../account/cards';
import { DEFAULT_CONFIG, type CardConfig } from '../card/config';
import { clampCrop } from '../card/crop';
import { PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
import { exportCard, loadCardAssets } from '../card/render';
import { autoPublish } from '../community/auto-publish';
import { CONSENT_TEXT, publishFailureText } from '../community/publish-dialog';
import { el } from '../editor/dom';
import { ACCEPTED_TYPES, SIZE_MESSAGE, TYPE_MESSAGE, decodeImage, validateImageFile } from '../editor/upload';
import { t } from '../i18n/i18n';
const MAX_FILES = 20;
/** Fehler der Dateiprüfung (deutsch markiert, angezeigt mit t()). */
const FILE_MESSAGES = new Set<string>([TYPE_MESSAGE, SIZE_MESSAGE]);
interface Row {
file: File;
url: string;
name: HTMLInputElement;
status: HTMLElement;
done: boolean;
}
function baseName(file: File): string {
return file.name.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim().slice(0, 60) || t('Meine Karte');
}
async function saveAndPublish(file: File, name: string): Promise<void> {
const check = validateImageFile(file);
if (!check.ok) throw new Error(check.message);
const image = await decodeImage(file);
const config: CardConfig = { ...DEFAULT_CONFIG, template: 'image', crop: clampCrop(image, DEFAULT_CONFIG.crop) };
const assets = await loadCardAssets(config, image);
const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY });
const card = await cardsService().save({ name, config, image: file, imageChanged: true, preview, previous: null });
await autoPublish(card);
}
/** Öffnet die Dateiauswahl und danach den Dialog. Löst auf, wenn er geschlossen ist; `changed`: mindestens eine Karte gespeichert. */
export function bulkUpload(signal?: AbortSignal): Promise<{ changed: boolean }> {
return new Promise((resolve) => {
const picker = el('input', { type: 'file', accept: ACCEPTED_TYPES.join(','), multiple: true, hidden: true });
picker.addEventListener('change', () => {
const files = [...(picker.files ?? [])].slice(0, MAX_FILES);
picker.remove();
if (files.length === 0) resolve({ changed: false });
else void openDialog(files, signal).then(resolve);
});
picker.addEventListener('cancel', () => {
picker.remove();
resolve({ changed: false });
});
document.body.append(picker);
picker.click();
});
}
function openDialog(files: File[], signal?: AbortSignal): Promise<{ changed: boolean }> {
return new Promise((resolve) => {
let busy = false;
let changed = false;
const rows: Row[] = files.map((file, i) => {
const name = el('input', {
class: 'auth-input',
type: 'text',
id: `bulk-name-${i}`,
maxlength: '60',
autocomplete: 'off',
'aria-label': t('Name der Karte'),
});
name.value = baseName(file);
return { file, url: URL.createObjectURL(file), name, status: el('span', { class: 'bulk-status', role: 'status' }), done: false };
});
const list = el(
'ul',
{ class: 'bulk-list' },
...rows.map((r) =>
el('li', { class: 'bulk-row' }, el('img', { class: 'bulk-thumb', src: r.url, alt: '' }), el('div', { class: 'bulk-fields' }, r.name, r.status)),
),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Alle veröffentlichen'));
const form = el(
'form',
{ class: 'report-form', novalidate: true },
el('p', { class: 'report-text' }, t('Gib jeder Karte einen Namen. Alle werden gespeichert und direkt in der Community veröffentlicht.')),
list,
el('p', { class: 'auth-hint' }, t(CONSENT_TEXT)),
errorEl,
el('div', { class: 'report-actions' }, cancel, send),
);
const dialog = el(
'dialog',
{ class: 'report-dialog publish-dialog bulk-dialog', 'aria-labelledby': 'bulk-heading' },
el('div', { class: 'report-card' }, el('h2', { id: 'bulk-heading', class: 'report-title' }, t('Mehrere Karten hochladen')), form),
);
let closed = false;
const finish = () => {
if (closed || busy) return;
closed = true;
signal?.removeEventListener('abort', finish);
if (dialog.open) dialog.close();
dialog.remove();
for (const r of rows) URL.revokeObjectURL(r.url);
resolve({ changed });
};
async function run(): Promise<void> {
const empty = rows.find((r) => !r.done && r.name.value.trim() === '');
if (empty) {
errorEl.textContent = t('Bitte gib jeder Karte einen Namen.');
empty.name.focus();
return;
}
errorEl.textContent = '';
busy = true;
send.disabled = true;
cancel.disabled = true;
let failed = 0;
for (const r of rows) {
if (r.done) continue;
r.name.disabled = true;
r.status.className = 'bulk-status';
r.status.textContent = t('Wird veröffentlicht …');
try {
await saveAndPublish(r.file, r.name.value.trim());
r.done = true;
changed = true;
r.status.className = 'bulk-status bulk-ok';
r.status.textContent = t('Veröffentlicht');
} catch (err) {
failed += 1;
r.name.disabled = false;
r.status.className = 'bulk-status bulk-error';
r.status.textContent = err instanceof Error && FILE_MESSAGES.has(err.message) ? t(err.message) : publishFailureText(err);
}
}
busy = false;
cancel.disabled = false;
if (failed === 0) {
cancel.textContent = t('Schließen');
send.remove();
cancel.focus();
} else {
send.disabled = false;
send.textContent = t('Nochmal versuchen');
}
}
form.addEventListener('submit', (event) => {
event.preventDefault();
if (!busy) void run();
});
cancel.addEventListener('click', finish);
dialog.addEventListener('cancel', (event) => {
event.preventDefault();
finish();
});
signal?.addEventListener('abort', () => {
busy = false;
finish();
});
document.body.append(dialog);
dialog.showModal();
rows[0]?.name.focus();
});
}
+1
View File
@@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({
authListeners: new Set<(user: User | null) => void>(),
}));
vi.mock('./bulk-upload', () => ({ bulkUpload: vi.fn(async () => ({ changed: false })) }));
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({
getCurrentUser: mocks.getCurrentUser,
+11 -1
View File
@@ -23,6 +23,7 @@ import type { EditorStore } from '../editor/state';
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload';
import { msg, t } from '../i18n/i18n';
import { forgetLogo } from '../logos/logo-asset';
import { bulkUpload } from './bulk-upload';
import { formatDate } from './format';
import { navigate } from './router';
@@ -79,7 +80,16 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
const heading = el('h1', { tabindex: '-1' }, t('Meine Karten'));
const body = el('section', { class: 'cards-page', 'aria-label': t('Gespeicherte Karten') });
root.replaceChildren(heading, body);
const bulkButton = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Mehrere Karten hochladen'));
bulkButton.addEventListener('click', () => void startBulk());
root.replaceChildren(heading, el('p', { class: 'cards-bulk' }, bulkButton), body);
async function startBulk(): Promise<void> {
if (!(await getCurrentUser()) && !(await requireLogin('login'))) return;
if (disposed || !(await requireUsername(disposal.signal)) || disposed) return;
const { changed } = await bulkUpload(disposal.signal);
if (changed && !disposed) void load();
}
function revokePreviews(keep: ReadonlySet<string> = new Set()): void {
for (const [fileId, url] of previewUrls) {
+11
View File
@@ -146,3 +146,14 @@
.card-rename-input {
flex: 1 1 100%;
}
/* Mehrere Karten hochladen (bulk-upload.ts) */
.cards-bulk { margin: 0 0 16px; }
.bulk-dialog .report-card { max-width: 560px; }
.bulk-list { list-style: none; margin: 12px 0; padding: 0; display: grid; gap: 10px; max-height: 55vh; overflow-y: auto; }
.bulk-row { display: flex; gap: 12px; align-items: center; }
.bulk-thumb { width: 96px; aspect-ratio: 1536 / 969; object-fit: cover; border-radius: 8px; flex: none; background: rgba(127, 127, 127, 0.15); }
.bulk-fields { flex: 1; min-width: 0; display: grid; gap: 4px; }
.bulk-status { font-size: 0.85rem; opacity: 0.8; min-height: 1.1em; }
.bulk-ok { color: #1a8f4c; opacity: 1; }
.bulk-error { color: #d23c3c; opacity: 1; }