feat(edit): unify edit page with enrich split-panel layout
Extract DocumentEditLayout shared component for the PDF+form split-panel UI, replacing the old scrolling layout on /documents/[id]/edit with the same fixed-panel structure used by /enrich/[id]. Removes TranscriptionSection and FileSectionEdit from the edit page; file upload/replace is now handled by the shared layout. Delete SaveBar and FileSectionEdit as dead code. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
221
frontend/src/lib/components/document/DocumentEditLayout.svelte
Normal file
221
frontend/src/lib/components/document/DocumentEditLayout.svelte
Normal file
@@ -0,0 +1,221 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { onMount, onDestroy, untrack } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { countRequiredFilled } from '$lib/utils/requiredFields';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import UploadZone from '$lib/components/document/UploadZone.svelte';
|
||||
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
||||
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
|
||||
import type { Tag } from '$lib/components/TagInput.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
let {
|
||||
doc,
|
||||
formId,
|
||||
formAction,
|
||||
formError = null,
|
||||
tags = $bindable<Tag[]>([]),
|
||||
senderId = $bindable(''),
|
||||
selectedReceivers = $bindable<Person[]>([]),
|
||||
dateIso = $bindable(''),
|
||||
currentTitle = $bindable(''),
|
||||
topbar,
|
||||
actionbar
|
||||
}: {
|
||||
doc: {
|
||||
id: string;
|
||||
filePath?: string | null;
|
||||
originalFilename?: string | null;
|
||||
title?: string | null;
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
documentLocation?: string | null;
|
||||
summary?: string | null;
|
||||
sender?: { id: string; displayName: string } | null;
|
||||
receivers?: Person[] | null;
|
||||
tags?: Tag[] | null;
|
||||
};
|
||||
formId: string;
|
||||
formAction: string;
|
||||
formError?: string | null;
|
||||
tags?: Tag[];
|
||||
senderId?: string;
|
||||
selectedReceivers?: Person[];
|
||||
dateIso?: string;
|
||||
currentTitle?: string;
|
||||
topbar: Snippet;
|
||||
actionbar: Snippet;
|
||||
} = $props();
|
||||
|
||||
tags = untrack(() => (doc.tags as Tag[]) ?? []);
|
||||
senderId = untrack(() => doc.sender?.id ?? '');
|
||||
selectedReceivers = untrack(() => (doc.receivers as Person[]) ?? []);
|
||||
dateIso = untrack(() => doc.documentDate ?? '');
|
||||
currentTitle = untrack(() => doc.title ?? '');
|
||||
|
||||
const fileLoader = createFileLoader();
|
||||
let navHeight = $state(0);
|
||||
onMount(() => {
|
||||
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
||||
});
|
||||
let activeAnnotationId = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (doc?.id && doc?.filePath) {
|
||||
fileLoader.loadFile(`/api/documents/${doc.id}/file`);
|
||||
}
|
||||
});
|
||||
onDestroy(() => fileLoader.destroy());
|
||||
|
||||
const requiredFilled = $derived(countRequiredFilled(currentTitle, dateIso, senderId));
|
||||
const requiredPct = $derived((requiredFilled / 3) * 100);
|
||||
|
||||
let isUploading = $state(false);
|
||||
let isDragging = $state(false);
|
||||
let uploadError = $state<string | null>(null);
|
||||
let abortController = $state<AbortController | null>(null);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
uploadError = null;
|
||||
isUploading = true;
|
||||
const controller = new AbortController();
|
||||
abortController = controller;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch(`/api/documents/${doc.id}/file`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!res.ok) throw new Error('Upload fehlgeschlagen');
|
||||
await invalidate('app:document');
|
||||
} catch (e) {
|
||||
if ((e as Error).name === 'AbortError') return;
|
||||
uploadError = m.error_file_upload_failed();
|
||||
} finally {
|
||||
isUploading = false;
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
abortController?.abort();
|
||||
isUploading = false;
|
||||
}
|
||||
|
||||
async function handleReplaceFile(e: Event) {
|
||||
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
await handleFile(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: {navHeight}px">
|
||||
<!-- Top bar — caller-supplied via snippet -->
|
||||
<div class="flex items-center justify-between border-b border-line bg-surface px-6 py-3">
|
||||
{@render topbar()}
|
||||
</div>
|
||||
|
||||
<!-- Required-fields progress bar -->
|
||||
<div class="flex items-center gap-3 border-b border-line bg-surface px-6 py-1.5">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">Pflichtfelder</span>
|
||||
<div
|
||||
class="h-0.5 flex-1 rounded-full bg-line"
|
||||
role="progressbar"
|
||||
aria-valuenow={requiredFilled}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={3}
|
||||
aria-label="Pflichtfelder"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-brand-navy transition-all duration-300"
|
||||
style="width:{requiredPct}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs font-bold text-brand-navy">{requiredFilled} / 3</span>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left: PDF preview / upload zone (60%) -->
|
||||
<div class="relative flex flex-[6] flex-col overflow-hidden border-r border-line">
|
||||
{#if !doc.filePath}
|
||||
<UploadZone
|
||||
filename={doc.originalFilename ?? ''}
|
||||
isUploading={isUploading}
|
||||
bind:isDragging={isDragging}
|
||||
error={uploadError}
|
||||
onFile={handleFile}
|
||||
onCancel={cancelUpload}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Datei ersetzen toolbar -->
|
||||
<div class="flex shrink-0 items-center border-b border-white/10 bg-pdf-bg px-4 py-1.5">
|
||||
<label
|
||||
class="ml-auto flex min-h-[44px] cursor-pointer items-center text-xs font-bold tracking-widest text-white/40 uppercase transition-colors hover:text-white/70"
|
||||
>
|
||||
Datei ersetzen
|
||||
<input type="file" class="sr-only" onchange={handleReplaceFile} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
<DocumentViewer
|
||||
doc={doc}
|
||||
fileUrl={fileLoader.fileUrl}
|
||||
isLoading={fileLoader.isLoading}
|
||||
error={fileLoader.fileError}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
onAnnotationClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: form (40%) -->
|
||||
<div class="flex flex-[4] flex-col overflow-hidden">
|
||||
{#if formError}
|
||||
<div class="border-b border-red-200 bg-red-50 px-6 py-3 text-sm text-red-700">
|
||||
{formError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
id={formId}
|
||||
method="POST"
|
||||
action={formAction}
|
||||
enctype="multipart/form-data"
|
||||
use:enhance
|
||||
class="flex-1 space-y-5 overflow-y-auto p-6"
|
||||
>
|
||||
<WhoWhenSection
|
||||
bind:senderId={senderId}
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
bind:dateIso={dateIso}
|
||||
initialDateIso={doc.documentDate ?? ''}
|
||||
initialLocation={doc.location ?? ''}
|
||||
initialSenderName={doc.sender?.displayName ?? ''}
|
||||
/>
|
||||
<DescriptionSection
|
||||
bind:tags={tags}
|
||||
bind:currentTitle={currentTitle}
|
||||
initialTitle={doc.title ?? ''}
|
||||
initialDocumentLocation={doc.documentLocation ?? ''}
|
||||
initialSummary={doc.summary ?? ''}
|
||||
titleRequired={true}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<!-- Action bar — caller-supplied via snippet -->
|
||||
<div class="flex items-center justify-between gap-3 border-t border-line bg-surface p-4">
|
||||
{@render actionbar()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user