diff --git a/web/src/community/auto-publish.ts b/web/src/community/auto-publish.ts new file mode 100644 index 0000000..0d74eea --- /dev/null +++ b/web/src/community/auto-publish.ts @@ -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; + } + } +} diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts index c44c2a2..e3ae1ef 100644 --- a/web/src/editor/editor-page.test.ts +++ b/web/src/editor/editor-page.test.ts @@ -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'); diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts index 7a67e09..f7b45a3 100644 --- a/web/src/editor/editor-page.ts +++ b/web/src/editor/editor-page.ts @@ -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 | 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({ diff --git a/web/src/editor/preview.ts b/web/src/editor/preview.ts index abfc72c..2be2c71 100644 --- a/web/src/editor/preview.ts +++ b/web/src/editor/preview.ts @@ -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); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 8c9b14d..5946f06 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -209,11 +209,11 @@ export const EN: Readonly> = { '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> = { '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.', }; diff --git a/web/src/i18n/english-ui.test.ts b/web/src/i18n/english-ui.test.ts index f5ca823..c3eea9c 100644 --- a/web/src/i18n/english-ui.test.ts +++ b/web/src/i18n/english-ui.test.ts @@ -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 diff --git a/web/src/pages/bulk-upload.ts b/web/src/pages/bulk-upload.ts new file mode 100644 index 0000000..5a88276 --- /dev/null +++ b/web/src/pages/bulk-upload.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([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 { + 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 { + 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(); + }); +} diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts index a13afde..4b98f29 100644 --- a/web/src/pages/cards-page.test.ts +++ b/web/src/pages/cards-page.test.ts @@ -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, diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts index d8ec912..81195fa 100644 --- a/web/src/pages/cards-page.ts +++ b/web/src/pages/cards-page.ts @@ -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 { + 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 = new Set()): void { for (const [fileId, url] of previewUrls) { diff --git a/web/src/pages/cards.css b/web/src/pages/cards.css index a9aad21..b3b734e 100644 --- a/web/src/pages/cards.css +++ b/web/src/pages/cards.css @@ -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; }