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:
1 parent
4e9659ca85
commit
1cdebb7102
10 files changed
+281
-19
No files matched your search
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ vi.mock('../community/gallery', () => ({
|
|||||||
loadGalleryImage: mocks.loadGalleryImage,
|
loadGalleryImage: mocks.loadGalleryImage,
|
||||||
}));
|
}));
|
||||||
// Der Bereich „Logo unten rechts“ hat seine eigenen Tests (logos/logo-picker.test.ts); hier zählt nur die Verdrahtung.
|
// 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 }));
|
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.
|
// 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 }));
|
vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
|
||||||
@@ -143,7 +144,7 @@ describe('Speichern', () => {
|
|||||||
saveButton().click();
|
saveButton().click();
|
||||||
expect(saveButton().disabled).toBe(true);
|
expect(saveButton().disabled).toBe(true);
|
||||||
expect(saveButton().textContent).toBe('Speichert …');
|
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.getCurrentUser).toHaveBeenCalled();
|
||||||
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), expect.anything(), {
|
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), expect.anything(), {
|
||||||
@@ -176,10 +177,10 @@ describe('Speichern', () => {
|
|||||||
mount();
|
mount();
|
||||||
saveButton().click();
|
saveButton().click();
|
||||||
// vi.waitFor würde die falschen Uhren selbst vorstellen; deshalb nur die Zusagen abarbeiten.
|
// 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);
|
for (let i = 0; i < 50 && saveButton().textContent !== 'Veröffentlicht'; i += 1) await vi.advanceTimersByTimeAsync(0);
|
||||||
expect(saveButton().textContent).toBe('Gespeichert');
|
expect(saveButton().textContent).toBe('Veröffentlicht');
|
||||||
await vi.advanceTimersByTimeAsync(1999);
|
await vi.advanceTimersByTimeAsync(1999);
|
||||||
expect(saveButton().textContent).toBe('Gespeichert');
|
expect(saveButton().textContent).toBe('Veröffentlicht');
|
||||||
await vi.advanceTimersByTimeAsync(1);
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
expect(saveButton().textContent).toBe('Speichern');
|
expect(saveButton().textContent).toBe('Speichern');
|
||||||
});
|
});
|
||||||
@@ -191,7 +192,7 @@ describe('Speichern', () => {
|
|||||||
mocks.save.mockResolvedValue(savedCard({ imageFileId: 'img0', previewFileId: 'prev9' }));
|
mocks.save.mockResolvedValue(savedCard({ imageFileId: 'img0', previewFileId: 'prev9' }));
|
||||||
mount();
|
mount();
|
||||||
saveButton().click();
|
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(mocks.save.mock.calls[0][0]).toMatchObject({ previous: ref, imageChanged: false });
|
||||||
expect(store.get().saved?.previewFileId).toBe('prev9');
|
expect(store.get().saved?.previewFileId).toBe('prev9');
|
||||||
});
|
});
|
||||||
@@ -216,7 +217,7 @@ describe('Speichern', () => {
|
|||||||
saveButton().click();
|
saveButton().click();
|
||||||
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
|
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
|
||||||
finish(savedCard());
|
finish(savedCard());
|
||||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||||
expect(mocks.save).toHaveBeenCalledTimes(1);
|
expect(mocks.save).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,7 +236,7 @@ describe('Speichern', () => {
|
|||||||
expect(errorText()).toBe('');
|
expect(errorText()).toBe('');
|
||||||
|
|
||||||
saveButton().click();
|
saveButton().click();
|
||||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||||
expect(mocks.save).toHaveBeenCalledTimes(1);
|
expect(mocks.save).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,7 +247,7 @@ describe('Speichern', () => {
|
|||||||
.mockResolvedValueOnce(savedCard());
|
.mockResolvedValueOnce(savedCard());
|
||||||
mount();
|
mount();
|
||||||
saveButton().click();
|
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.requireLogin).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.save).toHaveBeenCalledTimes(2);
|
expect(mocks.save).toHaveBeenCalledTimes(2);
|
||||||
expect(errorText()).toBe('');
|
expect(errorText()).toBe('');
|
||||||
@@ -264,7 +265,7 @@ describe('Speichern', () => {
|
|||||||
expect(store.get().saved).toBeNull();
|
expect(store.get().saved).toBeNull();
|
||||||
|
|
||||||
saveButton().click();
|
saveButton().click();
|
||||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||||
expect(errorText()).toBe('');
|
expect(errorText()).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,7 +290,7 @@ describe('Speichern', () => {
|
|||||||
expect(store.get().image).toBe(img);
|
expect(store.get().image).toBe(img);
|
||||||
|
|
||||||
finish(savedCard());
|
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(replaceButton().disabled).toBe(false);
|
||||||
expect(fileInput().disabled).toBe(false);
|
expect(fileInput().disabled).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -299,7 +300,7 @@ describe('Speichern', () => {
|
|||||||
mocks.save.mockResolvedValue(savedCard());
|
mocks.save.mockResolvedValue(savedCard());
|
||||||
mount();
|
mount();
|
||||||
saveButton().click();
|
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));
|
mocks.decodeImage.mockResolvedValue(image(1000, 1000));
|
||||||
const file = new File(['x'], 'neu.png', { type: 'image/png' });
|
const file = new File(['x'], 'neu.png', { type: 'image/png' });
|
||||||
const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown };
|
const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown };
|
||||||
@@ -995,7 +996,7 @@ describe('Speichern: Schnappschuss beim Klick', () => {
|
|||||||
});
|
});
|
||||||
mount();
|
mount();
|
||||||
saveButton().click();
|
saveButton().click();
|
||||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||||
expect(mocks.save).toHaveBeenCalledTimes(2);
|
expect(mocks.save).toHaveBeenCalledTimes(2);
|
||||||
for (const [input] of mocks.save.mock.calls) {
|
for (const [input] of mocks.save.mock.calls) {
|
||||||
expect(input.name).toBe('Beim Klick');
|
expect(input.name).toBe('Beim Klick');
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type
|
|||||||
import { clampCrop, panBy, zoomTo } from '../card/crop';
|
import { clampCrop, panBy, zoomTo } from '../card/crop';
|
||||||
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
|
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
|
||||||
import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage, type LoadedAssets } from '../card/render';
|
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 { getGalleryCard, loadGalleryImage } from '../community/gallery';
|
||||||
|
import { publishFailureText } from '../community/publish-dialog';
|
||||||
import { msg, t } from '../i18n/i18n';
|
import { msg, t } from '../i18n/i18n';
|
||||||
import { LOGO_MISSING, forgetLogo } from '../logos/logo-asset';
|
import { LOGO_MISSING, forgetLogo } from '../logos/logo-asset';
|
||||||
import { createLogoPicker } from '../logos/logo-picker';
|
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).
|
// Speichern: Anmeldung prüfen, Karte speichern, bei abgelaufener Sitzung einmal neu anmelden (siehe save.ts).
|
||||||
let savedFlash = false;
|
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;
|
let flashTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
function onSaveStatus(status: SaveStatus, message?: string): void {
|
function onSaveStatus(status: SaveStatus, message?: string): void {
|
||||||
@@ -233,7 +237,8 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
|||||||
if (!disposed) sync(store.get());
|
if (!disposed) sync(store.get());
|
||||||
}, SAVED_FLASH_MS);
|
}, 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' });
|
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) {
|
if (now.imageBlob === imageBlob && now.saved?.id === saved?.id) {
|
||||||
store.set({ saved: toRef(card), savedConfig: config, imageChanged: false, cardName: card.name });
|
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({
|
const saveCard = createSaveAction({
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface Preview {
|
|||||||
const NO_IMAGE_HINT = msg('Erst ein Bild wählen');
|
const NO_IMAGE_HINT = msg('Erst ein Bild wählen');
|
||||||
const SAVE_LABEL = msg('Speichern');
|
const SAVE_LABEL = msg('Speichern');
|
||||||
const SAVING_LABEL = msg('Speichert …');
|
const SAVING_LABEL = msg('Speichert …');
|
||||||
const SAVED_LABEL = msg('Gespeichert');
|
const SAVED_LABEL = msg('Veröffentlicht');
|
||||||
const KEY_STEP = 24;
|
const KEY_STEP = 24;
|
||||||
const KEY_STEP_BIG = 96;
|
const KEY_STEP_BIG = 96;
|
||||||
const KEY_ZOOM_STEP = 0.1;
|
const KEY_ZOOM_STEP = 0.1;
|
||||||
@@ -223,7 +223,7 @@ export function createPreview(handlers: PreviewHandlers): Preview {
|
|||||||
el('div', { class: 'action-buttons' }, replaceButton, downloadButton, saveButton),
|
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 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);
|
const section = el('section', { class: 'editor-center', 'aria-label': t('Kartenvorschau') }, inner);
|
||||||
|
|||||||
+10
-2
@@ -209,11 +209,11 @@ export const EN: Readonly<Record<string, string>> = {
|
|||||||
'Herunterladen': 'Download',
|
'Herunterladen': 'Download',
|
||||||
'Speichern': 'Save',
|
'Speichern': 'Save',
|
||||||
'Speichert …': 'Saving …',
|
'Speichert …': 'Saving …',
|
||||||
'Gespeichert': 'Saved',
|
|
||||||
'Erst ein Bild wählen': 'Choose an image first',
|
'Erst ein Bild wählen': 'Choose an image first',
|
||||||
'Neue Karte': 'New card',
|
'Neue Karte': 'New card',
|
||||||
'Bearbeitet: {name}': 'Editing: {name}',
|
'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)
|
// 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.',
|
'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.',
|
'Etwas ist schiefgelaufen. Bitte nochmal versuchen.': 'Something went wrong. Please try again.',
|
||||||
'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.':
|
'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.',
|
'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.',
|
||||||
};
|
};
|
||||||
@@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getGalleryCard: vi.fn(),
|
getGalleryCard: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../community/auto-publish', () => ({ autoPublish: vi.fn(async () => ({ id: 'g1' })) }));
|
||||||
vi.mock('../account/appwrite', () => ({
|
vi.mock('../account/appwrite', () => ({
|
||||||
account: mocks.acct,
|
account: mocks.acct,
|
||||||
client: {},
|
client: {},
|
||||||
@@ -324,7 +325,7 @@ describe('Editor auf Englisch', () => {
|
|||||||
await vi.waitFor(() => expect(errorText()).toBe('Saving failed – Can’t reach the server.'));
|
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 });
|
mocks.saveCard.mockResolvedValueOnce({ id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x', config: store.get().config });
|
||||||
button('Save').click();
|
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');
|
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
|
// Download: Logos nicht ladbar, dann ein PNG über der Wallet-Grenze, dann ein Export-Fehler mit deutscher Meldung aus card/render.ts
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
authListeners: new Set<(user: User | null) => void>(),
|
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/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||||
vi.mock('../account/auth', () => ({
|
vi.mock('../account/auth', () => ({
|
||||||
getCurrentUser: mocks.getCurrentUser,
|
getCurrentUser: mocks.getCurrentUser,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type { EditorStore } from '../editor/state';
|
|||||||
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload';
|
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload';
|
||||||
import { msg, t } from '../i18n/i18n';
|
import { msg, t } from '../i18n/i18n';
|
||||||
import { forgetLogo } from '../logos/logo-asset';
|
import { forgetLogo } from '../logos/logo-asset';
|
||||||
|
import { bulkUpload } from './bulk-upload';
|
||||||
import { formatDate } from './format';
|
import { formatDate } from './format';
|
||||||
import { navigate } from './router';
|
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 heading = el('h1', { tabindex: '-1' }, t('Meine Karten'));
|
||||||
const body = el('section', { class: 'cards-page', 'aria-label': t('Gespeicherte 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 {
|
function revokePreviews(keep: ReadonlySet<string> = new Set()): void {
|
||||||
for (const [fileId, url] of previewUrls) {
|
for (const [fileId, url] of previewUrls) {
|
||||||
|
|||||||
@@ -146,3 +146,14 @@
|
|||||||
.card-rename-input {
|
.card-rename-input {
|
||||||
flex: 1 1 100%;
|
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; }
|
||||||
Reference in new issue
Block a user