diff --git a/docs/specs/bulk-upload-concepts.html b/docs/specs/bulk-upload-concepts.html new file mode 100644 index 00000000..bf0a2e0d --- /dev/null +++ b/docs/specs/bulk-upload-concepts.html @@ -0,0 +1,996 @@ + + +
+ + ++ Extends issue #294 (new-document split-panel) with bulk uploads. When a user drops + N files, every metadata field applies once to all of them — only the title is per-file, + pre-filled from the filename and editable inline. A single save POST creates N documents. +
+ ++ A single vertical flow: drop zone on top, then a Gilt für alle metadata card, + then stacked file cards (thumbnail · editable title · remove). No split panel, no tabs. + Scrolling down reveals all files; the save bar sticks to the bottom. +
++ Reuses the DocumentEditLayout from issue #294 and adds a horizontal file-switcher strip + under the PDF preview. Right column splits into two cards: Gilt nur für diese Datei + (title only, mint accent) and Gilt für alle N Dokumente (everything else). + When N=1 the switcher disappears and the screen is byte-identical to #294. +
++ Shared metadata sticks at the top of the page. Below, each file is a collapsed card; clicking + a card expands it to show the PDF preview + title field inline. Only one card is expanded at a + time. Scales well to 20+ files — the list stays readable, you only look at the PDFs you want + to verify. +
++ All three concepts meet the core requirement (shared metadata + per-file title + one save). + Graded against what matters for the senior audience, the responsive constraint, and the #294 + architectural commitment. +
+| Dimension | +A · Stack | +B · Split-Panel | +C · Accordion | +
|---|---|---|---|
| Reuses #294 layout | +✕ | +✓ | +✕ | +
| Single-file mode unchanged | +rewrite | +identical | +different | +
| PDF visible before save | +no | +always | +one at a time | +
| Works at 320px | +native | +via tab collapse | +native | +
| Scales to 20 files | +long scroll | +switcher scrolls | +collapsed list | +
| New Svelte components | +3 new | +1 new (switcher) | +4 new | +
| Familiar pattern | +yes | +yes (post-#294) | +new to app | +
+ Concept B treats bulk upload as a polymorphic state of the existing single-document + layout rather than a separate screen. A user who drops one file gets exactly the #294 experience. + A user who drops five gets the same screen plus a horizontal file-switcher and a two-card split + (Nur diese Datei vs. Gilt für alle). Nothing about the single-file flow changes. +
+FileSwitcherStrip) + two cards in the form.POST /api/documents/quick-upload accepts N files in one multipart).| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Count pill "N werden erstellt" | +bg-accent text-primary rounded-full px-3 py-1 text-sm font-bold |
+ 14px · 700 | +brand-mint on brand-navy | +
| "Alle verwerfen" link | +ml-auto text-sm font-bold text-red-600 hover:text-red-800 focus-visible:outline-2 focus-visible:outline-red-600 |
+ 14px / 44px target | +confirm dialog before wiping | +
| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Strip container | +flex items-center gap-1 bg-ink/95 px-2 py-2 border-t border-ink/80 |
+ height 48px | +under the PDF toolbar, on the dark panel | +
| Arrow buttons | +h-10 w-10 rounded-sm bg-white/8 text-surface/60 hover:bg-white/15 focus-visible:outline-2 |
+ 40×40 (44 w/padding) | +aria-label="Vorherige Datei" / "Nächste Datei" |
+
| File chip (inactive) | +px-3 py-2 rounded-sm bg-white/6 text-sm font-bold text-surface/55 whitespace-nowrap hover:bg-white/12 |
+ 14px / h 40px | +horizontal scroll container uses snap-x snap-mandatory |
+
| File chip (active) | +... bg-accent text-primary + aria-current="true" |
+ 14px / h 40px | +mint pill, primary text — 7.2:1 contrast passes AAA | +
| Chip number prefix | +bg-primary/25 rounded-sm px-1 mr-2 text-xs font-extrabold |
+ 12px / 800 | +"1", "2", … — for quick scanning | +
| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Card container | +bg-accent/20 border border-accent rounded-sm p-4 mb-4 |
+ padding 16px | +mint tint signals "different per file" | +
| Scope badge | +bg-primary/90 text-accent rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide |
+ 12px · 800 | +Paraglide key: bulk_only_this_file |
+
| Title input | +h-11 text-base font-semibold text-ink bg-white border border-line rounded-sm px-3 focus-visible:border-ink focus-visible:ring-2 focus-visible:ring-ink/20 |
+ 44px min-height · 16px | +pre-filled from filename without extension | +
| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Card container | +bg-surface border border-line rounded-sm p-4 mb-3 |
+ padding 16px | +neutral (no accent tint) | +
| Scope badge | +bg-accent text-primary rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide |
+ 12px · 800 | +Paraglide: bulk_shared_count ("Gilt für alle {count}") |
+
| Field grid | +grid grid-cols-1 md:grid-cols-2 gap-3 |
+ 12px gap | +single column at 320px, two at ≥ 768px | +
| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Primary save button | +h-11 px-5 bg-green-700 hover:bg-green-800 text-white font-extrabold rounded-sm text-sm focus-visible:ring-2 focus-visible:ring-green-900 |
+ 44px min · 14px | +label {count} speichern → (plural-aware Paraglide) |
+
| "Als Platzhalter" (outline) | +h-11 px-4 border border-line bg-white text-ink-3 font-bold rounded-sm text-sm |
+ 44px | +posts with metadataComplete=false for all |
+
| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Panel mode switch | +reuses DocumentEditLayout's existing tab collapse — "Vorschau / Angaben" tabs | +tab height 48px | +already shipped with #294 | +
| File switcher stays on "Vorschau" tab | +snap-x snap-mandatory overflow-x-auto |
+ h 44px | +horizontal swipe; arrow buttons removed at mobile | +
filename.replace(/\.(pdf|jpe?g|png|tiff?)$/i, '').replace(/[_-]+/g, ' ').trim(). Marks the title input as suggested until the user edits it (mint left border, same treatment as #294's filename-derived fields)./api/documents/quick-upload with N files + JSON metadata object containing shared fields + titles array. Backend maps title[i] to files[i] by index. Response splits into created[] / updated[] / errors[] — show a summary toast + inline error markers per file for the errors[] list.bg-red-600/20 text-red-800 border border-red-600), show inline error in the chip's tooltip, don't block the rest of the batch from retrying.role="status" aria-live="polite".aria-current="true" + a ▸ caret prefix so it's distinguishable for color-blind users.
+ Extends the #294 split-panel layout so the same screen covers 0 files, 1 file, or N files.
+ When the user drops multiple PDFs, each gets its own title (pre-filled from the filename, editable) while
+ every other metadata field — sender, receiver, date, location, tags, archive box — is shared across all
+ documents. A single POST to /api/documents/quick-upload creates N documents in one pass.
+
+ /documents/new is a single route with three visual states. The same DOM skeleton + renders for all three — only three pieces of chrome appear/disappear based on file count: + the PDF preview area, the file-switcher strip, and the per-file title scope card. +
+
+ When the user hits /documents/new the PDF panel is the drop target. It's not a tiny button —
+ the whole left panel is the affordance. The copy makes it explicit that the user can drop one
+ file or many; the supporting line lists accepted formats and the per-file size limit. Right panel shows
+ the shared form in a muted "waiting" state so the user sees what they'll need to fill in once files
+ land.
+
+ When exactly one file is loaded, the screen is byte-identical to the #294 DocumentEditLayout
+ single-document flow. No file-switcher strip, no "1/1" subtitle, no "Alle verwerfen" link. This is
+ the invariant that makes the bulk extension a safe ship — existing users see no new chrome for the
+ single-file case they've always used.
+
+ When N ≥ 2, three things appear: (1) a count pill in the top bar "5 werden erstellt" plus an + "Alle verwerfen" link; (2) a file-switcher strip below the PDF toolbar, with the active file in a mint pill, + inactive files in subtle pills, and any upload-errored file in red-dashed; (3) a two-card form: the mint-tinted + Nur diese Datei card on top (title only), the neutral Gilt für alle 5 card below + (everything else). +
+| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Outer panel (drop target) | +flex-1 bg-pdf-bg flex flex-col |
+ full height | +the entire left panel accepts drops, not just the inner box | +
| Inner dashed box | +border-2 border-dashed border-accent rounded-md p-9 max-w-[340px] bg-surface/50 dark:bg-white/5 |
+ padding 36px | +visual anchor for mouse drops; not the hit target | +
| Upload icon circle | +w-14 h-14 rounded-full bg-accent text-primary flex items-center justify-center text-2xl font-extrabold |
+ 56×56 | +mint on white · primary on dark (token swap is automatic) | +
| Headline | +font-serif text-base font-bold text-ink |
+ 16px · 700 | +Paraglide upload_dropzone_heading |
+
| Supporting copy | +text-sm text-ink-2 leading-relaxed max-w-prose |
+ 14px | +Paraglide upload_dropzone_body — explains title vs. shared semantics |
+
| "Dateien auswählen" CTA | +h-11 px-4 bg-primary text-primary-fg font-extrabold text-sm rounded-sm uppercase tracking-wide focus-visible:ring-2 focus-visible:ring-focus-ring |
+ 44px min · 14px | +triggers native <input type=file multiple> |
+
| Formats line | +text-xs text-ink-3 tracking-wide |
+ 12px | +Paraglide upload_dropzone_formats |
+
| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Count pill | +bg-accent text-primary dark:bg-primary dark:text-primary-fg rounded-full px-3 py-1 text-sm font-bold |
+ 14px · 700 | +text "{n} werden erstellt" · Paraglide plural | +
| Discard link | +ml-auto text-sm font-bold text-danger hover:underline focus-visible:outline-2 focus-visible:outline-danger focus-visible:outline-offset-2 min-h-[44px] flex items-center |
+ 14px · 44px tap | +fires confirm dialog; never silent | +
| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Strip container | +flex items-center gap-1 bg-pdf-ctrl border-t border-line px-2 py-2 |
+ height 48px | +sits under the PDF toolbar, on the dark PDF panel | +
| Arrow buttons (desktop only) | +hidden md:flex h-10 w-10 rounded-sm bg-ink/10 dark:bg-white/10 hover:bg-ink/20 dark:hover:bg-white/20 text-ink-3 items-center justify-center focus-visible:outline-2 focus-visible:outline-focus-ring |
+ 40×40 (with px-2 pad → 44) | +aria-label="Vorherige Datei" / "Nächste Datei" | +
| Track (scroll container) | +flex-1 flex gap-1 overflow-x-auto snap-x snap-mandatory scroll-smooth |
+ — | +mobile: gesture-swipe; desktop: arrows scroll scrollBy({left:±120}) |
+
| File chip · inactive | +snap-start shrink-0 px-3 py-2 min-h-[40px] rounded-sm bg-ink/5 dark:bg-white/8 text-sm font-bold text-ink-2 hover:bg-ink/10 focus-visible:outline-2 focus-visible:outline-focus-ring flex items-center gap-2 |
+ 14px / h 40px | +max-width 180px, title truncates with truncate |
+
| File chip · active | +same base + bg-accent text-primary dark:bg-primary dark:text-primary-fg + aria-current="true" |
+ 14px / h 40px | +caret "▸" prefix via ::before — redundant non-colour cue |
+
| File chip · error | +bg-danger/15 text-danger border border-dashed border-danger |
+ — | +tooltip = error message, ⚠ suffix, still clickable | +
| Chip number prefix | +bg-primary/20 dark:bg-primary-fg/20 rounded-sm px-1 text-xs font-extrabold |
+ 12px · 800 | +"1", "2", … | +
| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Card container | +bg-accent-bg border border-accent rounded-sm p-4 mb-4 |
+ padding 16px | +renders for N ≥ 2 only; at N = 1 title lives outside any card | +
| Scope badge | +bg-primary text-primary-fg rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide |
+ 12px · 800 | +Paraglide bulk_only_this_file |
+
| Subtitle (1 / 5 · filename) | +text-xs font-bold text-ink-2 tracking-tight truncate |
+ 12px | +filename truncates when long | +
| Title input | +h-11 w-full text-base font-semibold text-ink bg-surface border border-accent rounded-sm px-3 focus-visible:border-ink focus-visible:ring-2 focus-visible:ring-focus-ring |
+ 44px · 16px | +starts as suggested state; mint border drops to border-line on first user edit |
+
| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Card container | +bg-muted border border-line rounded-sm p-4 mb-3 |
+ padding 16px | +neutral (no accent tint) — signals "shared, not special" | +
| Scope badge | +bg-accent text-primary dark:bg-primary dark:text-primary-fg rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide |
+ 12px · 800 | +Paraglide bulk_shared_count "Gilt für alle {count}" |
+
| Field grid | +grid grid-cols-1 md:grid-cols-2 gap-3 |
+ 12px gap | +single column at ≤ 767px, two cols at ≥ 768px | +
| Disabled (empty) state | +wrap card in aria-disabled="true" opacity-60 pointer-events-none |
+ — | +only when N = 0; turns live on first file drop | +
| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Primary save · idle | +h-11 px-5 bg-green-700 hover:bg-green-800 dark:bg-green-800 dark:hover:bg-green-700 text-white font-extrabold rounded-sm text-sm focus-visible:ring-2 focus-visible:ring-green-900 |
+ 44px · 14px | +label {count} speichern → — Paraglide plural "Speichern" at 1, "N speichern" at ≥ 2 |
+
| Primary save · saving | +same + bg-green-700/90 cursor-progress relative overflow-hidden with inner progress bar absolute inset-y-0 left-0 bg-white/20 transition-[width] |
+ — | +replaces label with "Lade Datei {i} von {n}…" when > 500ms | +
| "Als Platzhalter" | +h-11 px-4 border border-line bg-surface text-ink-2 font-bold rounded-sm text-sm |
+ 44px | +posts with metadataComplete=false for all N documents |
+
| "Verwerfen" skip link | +text-sm font-bold text-ink-3 hover:text-ink min-h-[44px] flex items-center |
+ 14px · 44px | +at N ≥ 2 this is a link to "Alle verwerfen" in the top bar; hide in save bar to avoid duplicate | +
| Element | Tailwind | Px / value | Note |
|---|---|---|---|
| Tab bar | +flex h-[38px] border-b border-line — reuses DocumentEditLayout's mobile tabs |
+ 38px | +labels: "Vorschau" (with "1/N" pill) · "Angaben" | +
| Tab (active) | +flex-1 h-full flex items-center justify-center text-sm font-extrabold text-ink border-b-2 border-accent dark:border-primary uppercase tracking-wide |
+ 14px · 48px tap | +aria-selected="true" | +
| "1/N" tab pill | +ml-2 bg-accent text-primary dark:bg-primary dark:text-primary-fg rounded-full px-2 py-0.5 text-xs font-extrabold |
+ 12px | +hide at N = 1 | +
| File switcher on mobile | +arrows removed (md:flex), track full-width, snap-swipe |
+ — | +announcer fires on snap-end: "Datei 3 von 5" | +
| Key | de | en | es |
|---|---|---|---|
| upload_dropzone_heading | Eine oder mehrere Dateien ablegen | Drop one or more files here | Suelta uno o más archivos aquí |
| upload_dropzone_body | Für jede Datei wird ein eigenes Dokument erstellt. Der Titel wird aus dem Dateinamen vorausgefüllt und ist pro Datei editierbar — alle anderen Felder gelten gemeinsam. | Each file becomes its own document. The title is pre-filled from the filename and editable per file — all other fields are shared. | Cada archivo se convierte en su propio documento. El título se rellena con el nombre del archivo y se puede editar por archivo; los demás campos son compartidos. |
| upload_dropzone_cta | Dateien auswählen | Choose files | Elegir archivos |
| upload_dropzone_formats | PDF · JPEG · PNG · TIFF · max 50 MB pro Datei | PDF · JPEG · PNG · TIFF · max 50 MB per file | PDF · JPEG · PNG · TIFF · máx. 50 MB por archivo |
| bulk_count_creating | {count} werden erstellt | {count} will be created | {count} se crearán |
| bulk_discard_all | Alle verwerfen | Discard all | Descartar todo |
| bulk_discard_confirm | {count} Dateien verwerfen? | Discard {count} files? | ¿Descartar {count} archivos? |
| bulk_only_this_file | Nur diese Datei | This file only | Solo este archivo |
| bulk_only_subtitle | {index} / {count} · {filename} | {index} / {count} · {filename} | {index} / {count} · {filename} |
| bulk_shared_count | Gilt für alle {count} | Applies to all {count} | Se aplica a todos los {count} |
| bulk_save_cta | {count, plural, one {Speichern →} other {{count} speichern →}} | {count, plural, one {Save →} other {Save {count} →}} | {count, plural, one {Guardar →} other {Guardar {count} →}} |
| bulk_save_progress | Lade Datei {i} von {count}… | Uploading file {i} of {count}… | Subiendo archivo {i} de {count}… |
| bulk_save_placeholder | Als Platzhalter | Save as placeholder | Guardar como marcador |
| bulk_file_nav_prev | Vorherige Datei | Previous file | Archivo anterior |
| bulk_file_nav_next | Nächste Datei | Next file | Siguiente archivo |
| bulk_announce_count | {count} Dateien bereit zum Speichern | {count} files ready to save | {count} archivos listos para guardar |
basename.replace(/\.(pdf|jpe?g|png|tiff?)$/i, '').replace(/[_-]+/g, ' ').trim(). Input marked suggested (mint border + accent-bg) until the user edits it, then border drops to border-line.POST /api/documents/quick-upload with files[] + a JSON metadata part containing shared fields plus a titles[] array matched by index. Response splits into created[] / updated[] / errors[]; show a post-save toast + mark error chips red. Successful creates redirect to /documents (not to a single detail page — we just made N).<div role="status" aria-live="polite"> in the top bar. Fires on (a) file count change (add/remove), (b) active file switch ("Datei 3 von 5"), (c) save progress, (d) save complete.beforeunload prompts. Suppress during explicit "Alle verwerfen" / save.FileSwitcherStrip, do NOT render the count pill or "Alle verwerfen" link, do NOT render the "Nur diese Datei" card (title sits directly above shared). Any deviation breaks the invariant — covered by a component-level snapshot test.getErrorMessage(). "Retry" button on the chip re-POSTs that file alone.role="tablist" is wrong here because the chips aren't tabs for a tabbed content; use a visually-hidden "Datei auswählen" label with aria-live="polite" announcement on selection change.aria-current="true". Error chip = colour and dashed border and ⚠ suffix. Three redundant cues on both states.suggested uses ink (#012851) on accent-bg (15% mint) = ~9:1 AAA.prefers-reduced-motion: reduce via the existing global @media rule — cuts transition-duration to 0.01ms.::after + a sibling button) to avoid the "I tried to select but removed" trap.| Component | Status | Responsibility |
|---|---|---|
documents/new/+page.svelte |
+ rewrite | +State owner: files array, active index, shared metadata. Mode switches by files.length. | +
DocumentEditLayout.svelte |
+ accept props | +Takes { files, activeIndex }; emits switch + remove events. Existing props for single-file unchanged. |
+
BulkDropZone.svelte |
+ new | +Full-panel drop target for N=0 and for "add more" at N≥1. Wraps the dashed box + CTA + formats line. | +
FileSwitcherStrip.svelte |
+ new | +Horizontal chip list + arrows + aria-live region. Emits select + remove. |
+
ScopeCard.svelte |
+ new | +Wraps "Nur diese Datei" / "Gilt für alle N" with the correct badge + tint. One component, two variants. | +
UploadSaveBar.svelte |
+ extend | +Already exists for single-file. Add plural-count label + determinate progress state. | +
| Element | Tailwind / Value | Note |
|---|---|---|
| Endpoint | +POST /api/documents/quick-upload |
+ already exists — accepts List<MultipartFile> files |
+
| Request — files part | +repeated files multipart entries, one per file, in UI order |
+ backend preserves order so titles[i] matches files[i] |
+
| Request — metadata part | +JSON part named metadata containing { senderId, receiverId, documentDate, location, tags[], archiveBox, archiveFolder, metadataComplete, titles[] } |
+ backend change: add a new overload of quickUpload that reads the JSON part and applies shared fields to every created Document |
+
| Response | +{ created: DocRef[], updated: DocRef[], errors: { filename, code }[] } |
+ existing shape — no breaking change | +
webkitdirectory): expands the batch beyond what the user meant. Off by default; revisit if users ask.