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:
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { goto } from '$app/navigation';
|
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 BulkDropZone from './BulkDropZone.svelte';
|
||||||
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||||
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||||
@@ -9,6 +10,7 @@ import ScopeCard from './ScopeCard.svelte';
|
|||||||
import UploadSaveBar from './UploadSaveBar.svelte';
|
import UploadSaveBar from './UploadSaveBar.svelte';
|
||||||
import WhoWhenSection from './WhoWhenSection.svelte';
|
import WhoWhenSection from './WhoWhenSection.svelte';
|
||||||
import DescriptionSection from './DescriptionSection.svelte';
|
import DescriptionSection from './DescriptionSection.svelte';
|
||||||
|
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||||
import { bulkTitleFromFilename } from '$lib/utils/filename';
|
import { bulkTitleFromFilename } from '$lib/utils/filename';
|
||||||
import type { Tag } from '$lib/components/TagInput.svelte';
|
import type { Tag } from '$lib/components/TagInput.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
@@ -25,6 +27,12 @@ let {
|
|||||||
initialReceivers?: Person[];
|
initialReceivers?: Person[];
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
// --- Layout ---
|
||||||
|
let navHeight = $state(0);
|
||||||
|
onMount(() => {
|
||||||
|
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
// --- File state ---
|
// --- File state ---
|
||||||
let files = new SvelteMap<string, FileEntry>();
|
let files = new SvelteMap<string, FileEntry>();
|
||||||
let activeId = $state<string | null>(null);
|
let activeId = $state<string | null>(null);
|
||||||
@@ -40,17 +48,20 @@ let tags = $state<Tag[]>([]);
|
|||||||
const isMulti = $derived(files.size >= 2);
|
const isMulti = $derived(files.size >= 2);
|
||||||
const activeFile = $derived(activeId ? files.get(activeId) : null);
|
const activeFile = $derived(activeId ? files.get(activeId) : null);
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- File management ---
|
||||||
function addFiles(newFiles: File[]) {
|
function addFiles(newFiles: File[]) {
|
||||||
for (const file of newFiles) {
|
for (const file of newFiles) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const title = bulkTitleFromFilename(file.name);
|
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;
|
if (!activeId) activeId = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFile(id: string) {
|
function removeFile(id: string) {
|
||||||
|
const entry = files.get(id);
|
||||||
|
if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||||
files.delete(id);
|
files.delete(id);
|
||||||
if (activeId === id) {
|
if (activeId === id) {
|
||||||
activeId = files.keys().next().value ?? null;
|
activeId = files.keys().next().value ?? null;
|
||||||
@@ -62,6 +73,22 @@ function setTitle(id: string, title: string) {
|
|||||||
if (entry) files.set(id, { ...entry, title });
|
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() {
|
async function save() {
|
||||||
const entries = Array.from(files.values());
|
const entries = Array.from(files.values());
|
||||||
const chunkSize = 10;
|
const chunkSize = 10;
|
||||||
@@ -82,63 +109,118 @@ async function save() {
|
|||||||
documentDate: dateIso || null
|
documentDate: dateIso || null
|
||||||
};
|
};
|
||||||
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
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 };
|
chunkProgress = { done: i + 1, total: chunks.length };
|
||||||
}
|
}
|
||||||
goto('/documents');
|
goto('/documents');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if files.size === 0}
|
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: {navHeight}px">
|
||||||
<!-- N=0: full-panel drop zone -->
|
<!-- Topbar -->
|
||||||
<div class="flex min-h-[400px] flex-col">
|
<div class="flex shrink-0 items-center gap-3 border-b border-line bg-surface px-6 py-3">
|
||||||
<BulkDropZone onFilesAdded={addFiles} />
|
<a
|
||||||
</div>
|
href="/documents"
|
||||||
{:else}
|
class="flex items-center gap-1.5 text-xs font-bold tracking-widest text-ink-3 uppercase hover:text-ink"
|
||||||
<div class="mx-auto flex max-w-7xl flex-col gap-0 px-4 py-6">
|
>
|
||||||
|
<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}
|
{#if isMulti}
|
||||||
<FileSwitcherStrip
|
<span class="ml-auto flex items-center gap-3">
|
||||||
files={Array.from(files.values())}
|
<span class="rounded-[2px] bg-accent px-2 py-0.5 text-xs font-bold text-primary">
|
||||||
activeId={activeId ?? ''}
|
{m.bulk_count_pill({ count: files.size })}
|
||||||
onSelect={(id) => (activeId = id)}
|
</span>
|
||||||
onRemove={removeFile}
|
<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}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-6">
|
<!-- Split panel -->
|
||||||
<!-- Left: PDF preview -->
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<div class="hidden w-1/2 lg:block">
|
<!-- Left: PDF preview / drop zone (55%) -->
|
||||||
{#if activeFile}
|
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
|
||||||
<div
|
{#if files.size === 0}
|
||||||
class="flex h-[600px] flex-col items-center justify-center rounded-sm bg-gray-900 text-white/40"
|
<!-- N=0: centred drop-zone box fills the panel -->
|
||||||
>
|
<BulkDropZone onFilesAdded={addFiles} />
|
||||||
<span class="text-sm">{activeFile.file.name}</span>
|
{:else}
|
||||||
<span class="mt-1 text-xs">Vorschau nach Upload verfügbar</span>
|
<!-- N≥1: real PDF preview via local blob URL -->
|
||||||
</div>
|
<div class="relative flex-1 overflow-hidden">
|
||||||
{/if}
|
{#if activeFile}
|
||||||
</div>
|
<PdfViewer url={activeFile.previewUrl} />
|
||||||
|
{/if}
|
||||||
<!-- Right: metadata -->
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-4">
|
|
||||||
{#if isMulti}
|
{#if isMulti}
|
||||||
<!-- Per-file scope card: title -->
|
<!-- File switcher strip pinned to bottom of left panel -->
|
||||||
<ScopeCard variant="per-file" data-variant="per-file">
|
<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}
|
{#if activeFile}
|
||||||
<label class="block">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={activeFile.title}
|
value={activeFile.title}
|
||||||
oninput={(e) =>
|
oninput={(e) =>
|
||||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
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>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
</ScopeCard>
|
</ScopeCard>
|
||||||
|
|
||||||
<!-- Shared scope card: metadata -->
|
<ScopeCard variant="shared" count={files.size}>
|
||||||
<ScopeCard variant="shared" count={files.size} data-variant="shared">
|
|
||||||
<WhoWhenSection
|
<WhoWhenSection
|
||||||
bind:senderId={senderId}
|
bind:senderId={senderId}
|
||||||
bind:selectedReceivers={selectedReceivers}
|
bind:selectedReceivers={selectedReceivers}
|
||||||
@@ -148,22 +230,32 @@ async function save() {
|
|||||||
<DescriptionSection bind:tags={tags} hideTitle />
|
<DescriptionSection bind:tags={tags} hideTitle />
|
||||||
</ScopeCard>
|
</ScopeCard>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- N=1: no scope cards, just the fields directly -->
|
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
|
||||||
{#if activeFile}
|
<div class="mb-4">
|
||||||
<div class="border-brand-sand rounded-sm border bg-white p-4 shadow-sm">
|
<label class="block">
|
||||||
<label class="block">
|
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||||
<span class="mb-1 block text-xs font-medium text-brand-navy/70">Titel</span>
|
{m.form_label_title()} <span class="text-danger">*</span>
|
||||||
|
</span>
|
||||||
|
{#if activeFile}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={activeFile.title}
|
value={activeFile.title}
|
||||||
oninput={(e) =>
|
oninput={(e) =>
|
||||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
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>
|
{:else}
|
||||||
</div>
|
<input
|
||||||
{/if}
|
type="text"
|
||||||
<div class="border-brand-sand rounded-sm border bg-white p-4 shadow-sm">
|
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
|
<WhoWhenSection
|
||||||
bind:senderId={senderId}
|
bind:senderId={senderId}
|
||||||
bind:selectedReceivers={selectedReceivers}
|
bind:selectedReceivers={selectedReceivers}
|
||||||
@@ -174,17 +266,14 @@ async function save() {
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<UploadSaveBar
|
<!-- Action bar: always visible at bottom of right panel -->
|
||||||
fileCount={files.size}
|
<UploadSaveBar
|
||||||
chunkProgress={chunkProgress}
|
fileCount={files.size}
|
||||||
onSave={save}
|
chunkProgress={chunkProgress}
|
||||||
onDiscard={() => {
|
onSave={save}
|
||||||
files.clear();
|
onDiscard={discardAll}
|
||||||
activeId = null;
|
/>
|
||||||
chunkProgress = undefined;
|
</div>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
onFilesAdded
|
onFilesAdded
|
||||||
}: {
|
}: {
|
||||||
@@ -8,30 +10,35 @@ let {
|
|||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-[300px] flex-1 flex-col items-center justify-center bg-pdf-bg">
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Dateien ablegen"
|
||||||
|
data-testid="bulk-drop-zone"
|
||||||
|
class="flex flex-1 flex-col items-center justify-center p-6"
|
||||||
|
ondragover={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = true;
|
||||||
|
}}
|
||||||
|
ondragleave={() => (isDragging = false)}
|
||||||
|
ondrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = false;
|
||||||
|
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
||||||
|
onFilesAdded(Array.from(e.dataTransfer.files));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
role="region"
|
class={[
|
||||||
aria-label="Dateien ablegen"
|
'flex w-full max-w-sm flex-col items-center gap-3 rounded-md border-2 border-dashed px-6 py-9 text-center transition-colors',
|
||||||
data-testid="bulk-drop-zone"
|
isDragging ? 'border-accent bg-accent/10' : 'border-accent/50 bg-white/[0.04]'
|
||||||
class="flex flex-col items-center gap-4 rounded-sm border border-dashed p-12 text-center transition-colors
|
].join(' ')}
|
||||||
{isDragging ? 'border-brand-mint ring-2 ring-brand-mint' : 'border-white/20'}"
|
|
||||||
ondragover={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
isDragging = true;
|
|
||||||
}}
|
|
||||||
ondragleave={() => (isDragging = false)}
|
|
||||||
ondrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
isDragging = false;
|
|
||||||
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
|
||||||
onFilesAdded(Array.from(e.dataTransfer.files));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/40">
|
<!-- Circular mint icon -->
|
||||||
|
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-accent text-primary">
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="22"
|
||||||
height="16"
|
height="22"
|
||||||
viewBox="0 0 32 32"
|
viewBox="0 0 32 32"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -42,11 +49,20 @@ let isDragging = $state(false);
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm font-medium text-white/60">PDF-Dateien hier ablegen</p>
|
|
||||||
<p class="text-xs text-white/30">oder</p>
|
<!-- Serif title -->
|
||||||
|
<p class="font-serif text-sm font-bold text-ink">{m.bulk_drop_hint()}</p>
|
||||||
|
|
||||||
|
<!-- Sub description -->
|
||||||
|
<p class="text-xs leading-relaxed text-ink-2">
|
||||||
|
Für jede Datei wird ein eigenes Dokument erstellt.<br />
|
||||||
|
<strong class="text-ink">Der Titel</strong> wird aus dem Dateinamen vorausgefüllt —
|
||||||
|
<strong class="text-ink">alle anderen Felder</strong> gelten für alle gemeinsam.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA button -->
|
||||||
<label
|
<label
|
||||||
class="flex min-h-[44px] cursor-pointer items-center rounded-sm bg-brand-navy px-4 py-1.5 text-xs font-bold tracking-widest text-white/90 uppercase"
|
class="flex min-h-[44px] cursor-pointer items-center rounded-sm bg-primary px-4 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90"
|
||||||
aria-label="Dateien auswählen"
|
|
||||||
>
|
>
|
||||||
Dateien auswählen
|
Dateien auswählen
|
||||||
<input
|
<input
|
||||||
@@ -60,5 +76,8 @@ let isDragging = $state(false);
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<!-- Format hint -->
|
||||||
|
<p class="text-[11px] text-ink-3">{m.bulk_drop_sub()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
id: string;
|
id: string;
|
||||||
file: File;
|
file: File;
|
||||||
title: string;
|
title: string;
|
||||||
status: 'idle' | 'error';
|
status: 'idle' | 'error';
|
||||||
|
previewUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -18,14 +21,22 @@ let {
|
|||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let ulEl = $state<HTMLUListElement | null>(null);
|
let trackEl = $state<HTMLDivElement | null>(null);
|
||||||
|
let listEl = $state<HTMLUListElement | null>(null);
|
||||||
|
|
||||||
|
function scrollPrev() {
|
||||||
|
trackEl?.scrollBy({ left: -120, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
function scrollNext() {
|
||||||
|
trackEl?.scrollBy({ left: 120, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!ulEl) return;
|
if (!listEl) return;
|
||||||
const node = ulEl;
|
const node = listEl;
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
const buttons = Array.from(node.querySelectorAll<HTMLElement>('[role="button"]'));
|
const buttons = Array.from(node.querySelectorAll<HTMLElement>('[data-chip-id]'));
|
||||||
if (buttons.length === 0) return;
|
if (buttons.length === 0) return;
|
||||||
|
|
||||||
const focusedIndex = buttons.indexOf(document.activeElement as HTMLElement);
|
const focusedIndex = buttons.indexOf(document.activeElement as HTMLElement);
|
||||||
@@ -43,49 +54,71 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
node.addEventListener('keydown', handleKeyDown);
|
node.addEventListener('keydown', handleKeyDown);
|
||||||
return () => {
|
return () => node.removeEventListener('keydown', handleKeyDown);
|
||||||
node.removeEventListener('keydown', handleKeyDown);
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ul
|
<div
|
||||||
bind:this={ulEl}
|
|
||||||
data-testid="file-switcher-strip"
|
data-testid="file-switcher-strip"
|
||||||
aria-live="polite"
|
class="flex h-11 shrink-0 items-center gap-1 border-t border-line bg-pdf-ctrl px-2"
|
||||||
role="list"
|
|
||||||
class="flex flex-row gap-2 overflow-x-auto px-1 py-2"
|
|
||||||
>
|
>
|
||||||
{#each files as entry (entry.id)}
|
<button
|
||||||
<li role="listitem" class="inline-flex items-center gap-0.5">
|
type="button"
|
||||||
<button
|
aria-label={m.bulk_switcher_prev()}
|
||||||
type="button"
|
onclick={scrollPrev}
|
||||||
role="button"
|
class="flex h-6 w-5 shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
tabindex="0"
|
>‹</button
|
||||||
aria-current={entry.id === activeId ? 'true' : undefined}
|
>
|
||||||
data-status={entry.status}
|
|
||||||
data-chip-id={entry.id}
|
<div bind:this={trackEl} class="flex flex-1 gap-1 overflow-x-auto" style="scrollbar-width:none">
|
||||||
onclick={() => onSelect(entry.id)}
|
<ul bind:this={listEl} aria-live="polite" role="list" class="flex flex-row gap-1 py-1">
|
||||||
class={[
|
{#each files as entry, i (entry.id)}
|
||||||
'inline-flex cursor-pointer items-center gap-1 rounded-sm border px-3 py-1.5 text-xs font-medium transition-colors',
|
<li role="listitem" class="inline-flex shrink-0 items-center">
|
||||||
entry.id === activeId
|
<button
|
||||||
? 'border-brand-mint bg-brand-mint/10 font-bold text-brand-navy'
|
type="button"
|
||||||
: 'border-brand-sand bg-white text-brand-navy',
|
tabindex="0"
|
||||||
entry.status === 'error'
|
aria-current={entry.id === activeId ? 'true' : undefined}
|
||||||
? 'border-dashed border-red-300 bg-red-50 text-red-700'
|
data-status={entry.status}
|
||||||
: ''
|
data-chip-id={entry.id}
|
||||||
].join(' ')}
|
onclick={() => onSelect(entry.id)}
|
||||||
>{entry.title}</button
|
class={[
|
||||||
>
|
'inline-flex cursor-pointer items-center gap-1 rounded-[2px] px-1.5 py-0.5 text-[11px] font-bold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
|
||||||
<button
|
entry.id === activeId
|
||||||
type="button"
|
? 'bg-accent text-primary'
|
||||||
aria-label="Entfernen"
|
: 'bg-black/[0.06] text-ink-2 hover:bg-black/10',
|
||||||
data-remove-id={entry.id}
|
entry.status === 'error'
|
||||||
onclick={() => onRemove(entry.id)}
|
? '!border !border-dashed !border-red-400 !bg-red-50/80 !text-red-700'
|
||||||
class="ml-1 text-xs text-gray-400 hover:text-brand-navy"
|
: ''
|
||||||
>
|
].join(' ')}
|
||||||
×
|
>
|
||||||
</button>
|
<span
|
||||||
</li>
|
class={[
|
||||||
{/each}
|
'rounded-[2px] px-0.5 text-[9px] font-extrabold opacity-85',
|
||||||
</ul>
|
entry.id === activeId ? 'bg-black/20' : 'bg-black/10'
|
||||||
|
].join(' ')}
|
||||||
|
>{i + 1}</span
|
||||||
|
>
|
||||||
|
{entry.title}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Entfernen"
|
||||||
|
data-remove-id={entry.id}
|
||||||
|
onclick={() => onRemove(entry.id)}
|
||||||
|
class="ml-0.5 flex h-[44px] w-[44px] items-center justify-center text-base text-ink-3 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={m.bulk_switcher_next()}
|
||||||
|
onclick={scrollNext}
|
||||||
|
class="flex h-6 w-5 shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>›</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface FileEntry {
|
|||||||
file: File;
|
file: File;
|
||||||
title: string;
|
title: string;
|
||||||
status: 'idle' | 'error';
|
status: 'idle' | 'error';
|
||||||
|
previewUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeFiles(n: number): FileEntry[] {
|
function makeFiles(n: number): FileEntry[] {
|
||||||
@@ -17,7 +18,8 @@ function makeFiles(n: number): FileEntry[] {
|
|||||||
id: `id-${i}`,
|
id: `id-${i}`,
|
||||||
file: new File([''], `file${i}.pdf`),
|
file: new File([''], `file${i}.pdf`),
|
||||||
title: `File ${i}`,
|
title: `File ${i}`,
|
||||||
status: 'idle' as const
|
status: 'idle' as const,
|
||||||
|
previewUrl: ''
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +67,13 @@ describe('FileSwitcherStrip', () => {
|
|||||||
|
|
||||||
it('error chip has aria-label containing warning indicator', async () => {
|
it('error chip has aria-label containing warning indicator', async () => {
|
||||||
const files: FileEntry[] = [
|
const files: FileEntry[] = [
|
||||||
{ id: 'e1', file: new File([''], 'bad.pdf'), title: 'Bad file', status: 'error' }
|
{
|
||||||
|
id: 'e1',
|
||||||
|
file: new File([''], 'bad.pdf'),
|
||||||
|
title: 'Bad file',
|
||||||
|
status: 'error',
|
||||||
|
previewUrl: ''
|
||||||
|
}
|
||||||
];
|
];
|
||||||
const { container } = render(FileSwitcherStrip, {
|
const { container } = render(FileSwitcherStrip, {
|
||||||
files,
|
files,
|
||||||
@@ -85,7 +93,7 @@ describe('FileSwitcherStrip', () => {
|
|||||||
onSelect: vi.fn(),
|
onSelect: vi.fn(),
|
||||||
onRemove: vi.fn()
|
onRemove: vi.fn()
|
||||||
});
|
});
|
||||||
const firstBtn = container.querySelectorAll('[role="button"]')[0] as HTMLElement;
|
const firstBtn = container.querySelectorAll('[data-chip-id]')[0] as HTMLElement;
|
||||||
firstBtn.focus();
|
firstBtn.focus();
|
||||||
await userEvent.keyboard('{ArrowRight}');
|
await userEvent.keyboard('{ArrowRight}');
|
||||||
const focused = document.activeElement;
|
const focused = document.activeElement;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
variant,
|
variant,
|
||||||
count = 0,
|
count = 0,
|
||||||
@@ -13,24 +15,26 @@ let {
|
|||||||
<div
|
<div
|
||||||
data-testid="scope-card"
|
data-testid="scope-card"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
class="rounded-sm border p-4
|
class="mb-3 rounded-sm border p-4
|
||||||
{variant === 'per-file'
|
{variant === 'per-file'
|
||||||
? 'border-brand-mint bg-brand-mint/10'
|
? 'border-accent bg-accent-bg'
|
||||||
: 'border-brand-sand bg-white'}"
|
: 'border-line bg-surface'}"
|
||||||
>
|
>
|
||||||
{#if variant === 'shared'}
|
{#if variant === 'shared'}
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
<span class="text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
Gilt für alle Dateien
|
{m.bulk_scope_shared_label({ count })}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="bg-brand-sand inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-bold text-brand-navy"
|
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 text-xs font-bold text-primary"
|
||||||
>
|
>
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mb-3 text-xs font-bold tracking-widest text-brand-mint uppercase">Diese Datei</p>
|
<p class="mb-3 text-xs font-bold tracking-widest text-accent uppercase">
|
||||||
|
{m.bulk_scope_per_file_label()}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as m from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
fileCount,
|
fileCount,
|
||||||
@@ -14,27 +14,26 @@ let {
|
|||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="shrink-0 border-t border-line bg-surface px-4 py-3">
|
||||||
class="border-brand-sand sticky bottom-0 z-10 -mx-4 border-t bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
|
||||||
>
|
|
||||||
{#if chunkProgress}
|
{#if chunkProgress}
|
||||||
<progress
|
<progress
|
||||||
value={chunkProgress.done}
|
value={chunkProgress.done}
|
||||||
max={chunkProgress.total}
|
max={chunkProgress.total}
|
||||||
class="[&::-webkit-progress-bar]:bg-brand-sand mb-3 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-brand-mint"
|
class="[&::-webkit-progress-bar]:bg-brand-sand mb-3 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent"
|
||||||
></progress>
|
></progress>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<button type="button" onclick={onDiscard} class="text-sm text-red-600/70 hover:text-red-700">
|
<button type="button" onclick={onDiscard} class="text-sm text-red-600/70 hover:text-red-700">
|
||||||
{m.bulk_discard_all()}
|
{m.bulk_discard_all()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="bulk-save-btn"
|
data-testid="bulk-save-btn"
|
||||||
|
disabled={fileCount === 0}
|
||||||
onclick={onSave}
|
onclick={onSave}
|
||||||
class="min-h-[44px] rounded-sm bg-brand-navy px-6 text-sm font-bold tracking-widest text-white uppercase hover:bg-brand-navy/90"
|
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{fileCount === 1 ? 'Speichern →' : `${fileCount} speichern →`}
|
{fileCount === 1 ? m.bulk_save_cta_one() : m.bulk_save_cta({ count: fileCount })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,37 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import BulkDocumentEditLayout from '$lib/components/document/BulkDocumentEditLayout.svelte';
|
import BulkDocumentEditLayout from '$lib/components/document/BulkDocumentEditLayout.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-7xl px-4 py-8">
|
<BulkDocumentEditLayout
|
||||||
<div class="mb-6">
|
initialSenderId={data.initialSenderId}
|
||||||
<a
|
initialSenderName={data.initialSenderName}
|
||||||
href="/"
|
initialReceivers={data.initialReceivers}
|
||||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
/>
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<h1 class="font-serif text-3xl text-ink">{m.doc_new_heading()}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BulkDocumentEditLayout
|
|
||||||
initialSenderId={data.initialSenderId}
|
|
||||||
initialSenderName={data.initialSenderName}
|
|
||||||
initialReceivers={data.initialReceivers}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -21,15 +21,14 @@ const baseData = {
|
|||||||
|
|
||||||
describe('New document page – sender prefill', () => {
|
describe('New document page – sender prefill', () => {
|
||||||
it('shows an empty sender input when no senderId is in the URL', async () => {
|
it('shows an empty sender input when no senderId is in the URL', async () => {
|
||||||
render(Page, { data: baseData, form: null });
|
render(Page, { data: baseData });
|
||||||
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
||||||
expect(input?.value).toBe('');
|
expect(input?.value).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the sender name in the typeahead input when initialSenderName is set', async () => {
|
it('shows the sender name in the typeahead input when initialSenderName is set', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
|
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
|
||||||
form: null
|
|
||||||
});
|
});
|
||||||
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
||||||
expect(input?.value).toBe('Hans Müller');
|
expect(input?.value).toBe('Hans Müller');
|
||||||
@@ -37,8 +36,7 @@ describe('New document page – sender prefill', () => {
|
|||||||
|
|
||||||
it('sets the hidden senderId input to the prefilled ID', async () => {
|
it('sets the hidden senderId input to the prefilled ID', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
|
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
|
||||||
form: null
|
|
||||||
});
|
});
|
||||||
const hidden = document.querySelector<HTMLInputElement>(
|
const hidden = document.querySelector<HTMLInputElement>(
|
||||||
'input[type="hidden"][name="senderId"]'
|
'input[type="hidden"][name="senderId"]'
|
||||||
@@ -51,7 +49,7 @@ describe('New document page – sender prefill', () => {
|
|||||||
|
|
||||||
describe('New document page – receiver prefill', () => {
|
describe('New document page – receiver prefill', () => {
|
||||||
it('shows no receiver chips when initialReceivers is empty', async () => {
|
it('shows no receiver chips when initialReceivers is empty', async () => {
|
||||||
render(Page, { data: baseData, form: null });
|
render(Page, { data: baseData });
|
||||||
await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +60,7 @@ describe('New document page – receiver prefill', () => {
|
|||||||
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
render(Page, { data, form: null });
|
render(Page, { data });
|
||||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +71,7 @@ describe('New document page – receiver prefill', () => {
|
|||||||
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
render(Page, { data, form: null });
|
render(Page, { data });
|
||||||
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
|
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
|
||||||
expect(hidden?.value).toBe('p2');
|
expect(hidden?.value).toBe('p2');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user