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,
|
||||
}));
|
||||
// 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');
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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.',
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>(),
|
||||
}));
|
||||
|
||||
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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user