Files
familienarchiv/frontend/src/lib/components/document/DocumentEditLayout.svelte
Marcel ddbd6ef92f feat(i18n): extract hardcoded strings in DocumentEditLayout to i18n keys
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>
2026-04-18 16:12:01 +02:00

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>