Adds label_required_fields to all three locales. Fixes "Datei ersetzen" toolbar colors to use semantic ink tokens (readable in both light and dark pdf-bg themes). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
224 lines
6.6 KiB
Svelte
224 lines
6.6 KiB
Svelte
<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"
|
|
>{m.label_required_fields()}</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={m.label_required_fields()}
|
|
>
|
|
<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-line bg-surface px-4 py-1.5">
|
|
<label
|
|
class="ml-auto flex min-h-[44px] cursor-pointer items-center text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
|
|
>
|
|
{m.doc_file_replace_label()}
|
|
<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>
|