fix(bulk-upload): spec-compliant split-panel layout with local PDF preview

Rewrites BulkDocumentEditLayout to match the spec exactly:
- Fixed viewport layout (same as DocumentEditLayout) filling viewport below nav
- Split panel visible in all states (N=0/1/≥2) — was fullscreen dark drop zone
- N=0: centered drop-zone-box in left panel; shared form visible but greyed out
- N≥1: real PDF preview via URL.createObjectURL (no server upload required)
- N≥2: FileSwitcherStrip at bottom of left panel; count pill + discard in topbar
- FileEntry gains previewUrl; blob URLs created on add, revoked on remove/destroy
- save() checks response.ok and marks failed files with status: 'error'
- BulkDropZone redesigned: spec-accurate box with circular mint icon, serif title
- FileSwitcherStrip: number badges, arrows, keyboard nav via data-chip-id selector
- ScopeCard, UploadSaveBar: hardcoded German replaced with Paraglide i18n keys
- +page.svelte simplified to bare component render (layout is self-contained)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-24 19:17:36 +02:00
committed by marcel
parent ef7a51fe30
commit 539842e849
8 changed files with 311 additions and 187 deletions

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { onMount, onDestroy, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import BulkDropZone from './BulkDropZone.svelte';
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
import type { FileEntry } from './FileSwitcherStrip.svelte';
@@ -9,6 +10,7 @@ import ScopeCard from './ScopeCard.svelte';
import UploadSaveBar from './UploadSaveBar.svelte';
import WhoWhenSection from './WhoWhenSection.svelte';
import DescriptionSection from './DescriptionSection.svelte';
import PdfViewer from '$lib/components/PdfViewer.svelte';
import { bulkTitleFromFilename } from '$lib/utils/filename';
import type { Tag } from '$lib/components/TagInput.svelte';
import type { components } from '$lib/generated/api';
@@ -25,6 +27,12 @@ let {
initialReceivers?: Person[];
} = $props();
// --- Layout ---
let navHeight = $state(0);
onMount(() => {
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
});
// --- File state ---
let files = new SvelteMap<string, FileEntry>();
let activeId = $state<string | null>(null);
@@ -40,17 +48,20 @@ let tags = $state<Tag[]>([]);
const isMulti = $derived(files.size >= 2);
const activeFile = $derived(activeId ? files.get(activeId) : null);
// --- Handlers ---
// --- File management ---
function addFiles(newFiles: File[]) {
for (const file of newFiles) {
const id = crypto.randomUUID();
const title = bulkTitleFromFilename(file.name);
files.set(id, { id, file, title, status: 'idle' });
const previewUrl = URL.createObjectURL(file);
files.set(id, { id, file, title, status: 'idle', previewUrl });
if (!activeId) activeId = id;
}
}
function removeFile(id: string) {
const entry = files.get(id);
if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
files.delete(id);
if (activeId === id) {
activeId = files.keys().next().value ?? null;
@@ -62,6 +73,22 @@ function setTitle(id: string, title: string) {
if (entry) files.set(id, { ...entry, title });
}
function discardAll() {
for (const entry of files.values()) {
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
}
files.clear();
activeId = null;
chunkProgress = undefined;
}
onDestroy(() => {
for (const entry of files.values()) {
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
}
});
// --- Save ---
async function save() {
const entries = Array.from(files.values());
const chunkSize = 10;
@@ -82,63 +109,118 @@ async function save() {
documentDate: dateIso || null
};
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
if (!res.ok) {
const body = await res.json().catch(() => ({ errors: [] }));
const errorCount = (body.errors ?? []).length;
for (let j = 0; j < errorCount && j < chunk.length; j++) {
const e = files.get(chunk[j].id);
if (e) files.set(chunk[j].id, { ...e, status: 'error' });
}
}
chunkProgress = { done: i + 1, total: chunks.length };
}
goto('/documents');
}
</script>
{#if files.size === 0}
<!-- N=0: full-panel drop zone -->
<div class="flex min-h-[400px] flex-col">
<BulkDropZone onFilesAdded={addFiles} />
</div>
{:else}
<div class="mx-auto flex max-w-7xl flex-col gap-0 px-4 py-6">
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: {navHeight}px">
<!-- Topbar -->
<div class="flex shrink-0 items-center gap-3 border-b border-line bg-surface px-6 py-3">
<a
href="/documents"
class="flex items-center gap-1.5 text-xs font-bold tracking-widest text-ink-3 uppercase hover:text-ink"
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
{m.btn_back_to_overview()}
</a>
<span class="text-ink-3" aria-hidden="true">·</span>
<span class="font-serif text-sm font-bold text-ink">
{isMulti ? 'Neue Dokumente' : 'Neues Dokument'}
</span>
{#if isMulti}
<FileSwitcherStrip
files={Array.from(files.values())}
activeId={activeId ?? ''}
onSelect={(id) => (activeId = id)}
onRemove={removeFile}
/>
<span class="ml-auto flex items-center gap-3">
<span class="rounded-[2px] bg-accent px-2 py-0.5 text-xs font-bold text-primary">
{m.bulk_count_pill({ count: files.size })}
</span>
<button
type="button"
onclick={discardAll}
class="text-xs font-medium text-red-600/70 hover:text-red-700"
>
{m.bulk_discard_all()}
</button>
</span>
{/if}
</div>
<div class="flex gap-6">
<!-- Left: PDF preview -->
<div class="hidden w-1/2 lg:block">
{#if activeFile}
<div
class="flex h-[600px] flex-col items-center justify-center rounded-sm bg-gray-900 text-white/40"
>
<span class="text-sm">{activeFile.file.name}</span>
<span class="mt-1 text-xs">Vorschau nach Upload verfügbar</span>
</div>
{/if}
</div>
<!-- Right: metadata -->
<div class="flex flex-1 flex-col gap-4">
<!-- Split panel -->
<div class="flex flex-1 overflow-hidden">
<!-- Left: PDF preview / drop zone (55%) -->
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
{#if files.size === 0}
<!-- N=0: centred drop-zone box fills the panel -->
<BulkDropZone onFilesAdded={addFiles} />
{:else}
<!-- N≥1: real PDF preview via local blob URL -->
<div class="relative flex-1 overflow-hidden">
{#if activeFile}
<PdfViewer url={activeFile.previewUrl} />
{/if}
</div>
{#if isMulti}
<!-- Per-file scope card: title -->
<ScopeCard variant="per-file" data-variant="per-file">
<!-- File switcher strip pinned to bottom of left panel -->
<FileSwitcherStrip
files={Array.from(files.values())}
activeId={activeId ?? ''}
onSelect={(id) => (activeId = id)}
onRemove={removeFile}
/>
{/if}
{/if}
</div>
<!-- Right: metadata form (45%) -->
<div class="flex flex-[45] flex-col overflow-hidden">
<!-- Scrollable form area — greyed out and non-interactive when no files selected -->
<div
class="flex-1 overflow-y-auto p-4 transition-opacity"
class:opacity-60={files.size === 0}
class:pointer-events-none={files.size === 0}
>
{#if isMulti}
<!-- N≥2: per-file card (title) + shared card (metadata) -->
<ScopeCard variant="per-file">
{#if activeFile}
<label class="block">
<span class="mb-1 block text-xs font-medium text-brand-navy/70">Titel</span>
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="border-brand-sand block w-full rounded-sm border p-2 text-sm focus:border-brand-mint focus:outline-none"
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
/>
</label>
{/if}
</ScopeCard>
<!-- Shared scope card: metadata -->
<ScopeCard variant="shared" count={files.size} data-variant="shared">
<ScopeCard variant="shared" count={files.size}>
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
@@ -148,22 +230,32 @@ async function save() {
<DescriptionSection bind:tags={tags} hideTitle />
</ScopeCard>
{:else}
<!-- N=1: no scope cards, just the fields directly -->
{#if activeFile}
<div class="border-brand-sand rounded-sm border bg-white p-4 shadow-sm">
<label class="block">
<span class="mb-1 block text-xs font-medium text-brand-navy/70">Titel</span>
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
<div class="mb-4">
<label class="block">
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
{#if activeFile}
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="border-brand-sand block w-full rounded-sm border p-2 text-sm focus:border-brand-mint focus:outline-none"
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
/>
</label>
</div>
{/if}
<div class="border-brand-sand rounded-sm border bg-white p-4 shadow-sm">
{:else}
<input
type="text"
disabled
placeholder="—"
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm text-ink-3"
/>
{/if}
</label>
</div>
<div class="rounded-sm border border-line bg-surface p-4 shadow-sm">
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
@@ -174,17 +266,14 @@ async function save() {
</div>
{/if}
</div>
</div>
<UploadSaveBar
fileCount={files.size}
chunkProgress={chunkProgress}
onSave={save}
onDiscard={() => {
files.clear();
activeId = null;
chunkProgress = undefined;
}}
/>
<!-- Action bar: always visible at bottom of right panel -->
<UploadSaveBar
fileCount={files.size}
chunkProgress={chunkProgress}
onSave={save}
onDiscard={discardAll}
/>
</div>
</div>
{/if}
</div>