fix(frontend): enforce lint locally and in CI, fix all pre-existing violations
Some checks failed
CI / Unit & Component Tests (push) Successful in 1m59s
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled

## Pre-commit hook
- Add .husky/pre-commit at repo root: runs `cd frontend && npm run lint`
- Update prepare script in package.json to auto-configure git hooks path
  on npm install (git -C .. config core.hooksPath .husky)
- Add lint step to CI unit-tests job so it catches issues before tests run
- Add generated dirs to .prettierignore (paraglide_bak*, test-results, .auth)
- Add src/lib/paraglide_bak* to .gitignore so ESLint can ignore them

## ESLint fixes (all pre-existing)
- Disable svelte/no-navigation-without-resolve: false positive in SvelteKit
  (rule targets Svelte 5 standalone routing, not SvelteKit <a href>)
- Fix svelte/require-each-key: add (item.id)/(item) keys to all {#each} blocks
  across 10 files — improves Svelte reconciliation performance
- Fix svelte/prefer-writable-derived in PersonTypeahead: $state+$effect → $derived
- Fix svelte/prefer-svelte-reactivity: URLSearchParams → SvelteURLSearchParams,
  Map → SvelteMap (enables Svelte reactive tracking)
- Fix @typescript-eslint/no-unused-vars: remove dead imports/variables

## Prettier
- Run npm run format to bring all source files in line with .prettierrc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-20 15:55:42 +01:00
parent 28dea45cc3
commit db2fc33e99
53 changed files with 2522 additions and 2061 deletions

View File

@@ -3,17 +3,17 @@ import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export async function load({ params, fetch }) {
const { id } = params;
const api = createApiClient(fetch);
const { id } = params;
const api = createApiClient(fetch);
const result = await api.GET('/api/documents/{id}', { params: { path: { id } } });
const result = await api.GET('/api/documents/{id}', { params: { path: { id } } });
if (result.response.status === 401) throw redirect(302, '/login');
if (result.response.status === 401) throw redirect(302, '/login');
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { document: result.data! };
return { document: result.data! };
}

View File

@@ -1,178 +1,216 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
let { data } = $props();
let { data } = $props();
const doc = $derived(data.document);
const doc = $derived(data.document);
let fileUrl = $state('');
let isLoading = $state(false);
let error = $state('');
let fileUrl = $state('');
let isLoading = $state(false);
let error = $state('');
$effect(() => {
if (doc?.id && doc?.filePath) {
loadFile(doc.id);
}
});
$effect(() => {
if (doc?.id && doc?.filePath) {
loadFile(doc.id);
}
});
async function loadFile(id: string) {
isLoading = true;
error = '';
fileUrl = '';
async function loadFile(id: string) {
isLoading = true;
error = '';
fileUrl = '';
try {
const response = await fetch(`/api/documents/${id}/file`);
try {
const response = await fetch(`/api/documents/${id}/file`);
if (!response.ok) {
if (response.status === 401) throw new Error('Nicht eingeloggt');
throw new Error('Fehler beim Laden der Datei');
}
if (!response.ok) {
if (response.status === 401) throw new Error('Nicht eingeloggt');
throw new Error('Fehler beim Laden der Datei');
}
const blob = await response.blob();
fileUrl = URL.createObjectURL(blob);
} catch (e) {
console.error(e);
error = m.doc_file_error_preview();
} finally {
isLoading = false;
}
}
const blob = await response.blob();
fileUrl = URL.createObjectURL(blob);
} catch (e) {
console.error(e);
error = m.doc_file_error_preview();
} finally {
isLoading = false;
}
}
</script>
<div class="h-screen flex flex-col bg-white">
<div class="flex h-screen flex-col bg-white">
<!-- Top Bar -->
<div
class="bg-white border-b border-brand-sand px-6 py-4 flex items-center justify-between z-10 shadow-sm"
class="z-10 flex items-center justify-between border-b border-brand-sand bg-white px-6 py-4 shadow-sm"
>
<div class="flex items-center gap-6 overflow-hidden">
<a
href="/"
class="group flex-shrink-0 flex items-center gap-2 text-sm font-sans font-medium text-gray-500 hover:text-brand-navy transition-colors"
class="group flex flex-shrink-0 items-center gap-2 font-sans text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
>
<div
class="w-8 h-8 rounded-full bg-brand-sand group-hover:bg-brand-mint flex items-center justify-center transition-colors"
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-sand transition-colors group-hover:bg-brand-mint"
>
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg" alt="" aria-hidden="true" class="w-4 h-4" />
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
</div>
<span>{m.btn_back()}</span>
</a>
<div class="flex items-center gap-4 overflow-hidden border-l border-gray-200 pl-6">
<h1 class="text-xl font-serif text-brand-navy truncate" title={doc.title}>
<h1 class="truncate font-serif text-xl text-brand-navy" title={doc.title}>
{doc.title || doc.originalFilename}
</h1>
<span
class="flex-shrink-0 px-3 py-1 rounded-full text-xs font-sans font-bold tracking-wide uppercase
class="flex-shrink-0 rounded-full px-3 py-1 font-sans text-xs font-bold tracking-wide uppercase
{doc.status === 'UPLOADED'
? 'bg-brand-mint/30 text-brand-navy border border-brand-mint'
: 'bg-yellow-100 text-yellow-800 border border-yellow-200'}"
? 'border border-brand-mint bg-brand-mint/30 text-brand-navy'
: 'border border-yellow-200 bg-yellow-100 text-yellow-800'}"
>
{doc.status}
</span>
</div>
</div>
<div class="flex items-center gap-3 flex-shrink-0 ml-4 font-sans">
<div class="ml-4 flex flex-shrink-0 items-center gap-3 font-sans">
{#if data.canWrite}
<a
href="/documents/{doc.id}/edit"
class="text-brand-navy bg-transparent border border-brand-navy hover:bg-brand-navy hover:text-white px-4 py-2 rounded text-sm font-medium transition flex items-center gap-2"
>
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg" alt="" aria-hidden="true" class="w-4 h-4" />
{m.btn_edit()}
</a>
<a
href="/documents/{doc.id}/edit"
class="flex items-center gap-2 rounded border border-brand-navy bg-transparent px-4 py-2 text-sm font-medium text-brand-navy transition hover:bg-brand-navy hover:text-white"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.btn_edit()}
</a>
{/if}
{#if doc.filePath}
<a
href={fileUrl}
download={doc.originalFilename}
class="text-brand-navy bg-brand-sand/50 hover:bg-brand-mint border border-transparent p-2 rounded transition"
class="rounded border border-transparent bg-brand-sand/50 p-2 text-brand-navy transition hover:bg-brand-mint"
title={m.doc_download_title()}
>
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</a>
{/if}
</div>
</div>
<!-- Content Area -->
<div class="flex-1 flex overflow-hidden">
<div class="flex flex-1 overflow-hidden">
<!-- LEFT SIDEBAR: METADATA -->
<aside
class="w-96 bg-white border-r border-brand-sand overflow-y-auto p-8 flex-shrink-0 custom-scrollbar"
class="custom-scrollbar w-96 flex-shrink-0 overflow-y-auto border-r border-brand-sand bg-white p-8"
>
<div class="space-y-10">
<!-- 1. DETAILS GROUP -->
<div>
<h3
class="text-xs font-sans font-bold text-brand-navy uppercase tracking-widest mb-4 border-b border-brand-sand pb-2"
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
>
{m.doc_section_details()}
</h3>
<div class="space-y-5">
<!-- Date -->
<div class="flex items-start group">
<span class="text-brand-mint w-8 mt-0.5">
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
<div class="group flex items-start">
<span class="mt-0.5 w-8 text-brand-mint">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-brand-navy">
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
<span class="text-xs font-sans text-gray-500">{m.doc_label_document_date()}</span>
<span class="font-sans text-xs text-gray-500">{m.doc_label_document_date()}</span>
</div>
</div>
<!-- Creation Location -->
<div class="flex items-start group">
<span class="text-brand-mint w-8 mt-0.5">
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
<div class="group flex items-start">
<span class="mt-0.5 w-8 text-brand-mint">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-brand-navy">
{doc.location ? doc.location : '—'}
</span>
<span class="text-xs font-sans text-gray-500">{m.doc_label_creation_location()}</span>
<span class="font-sans text-xs text-gray-500"
>{m.doc_label_creation_location()}</span
>
</div>
</div>
<!-- Physical Archive Location -->
{#if doc.documentLocation}
<div class="flex items-start group">
<span class="text-brand-mint w-8 mt-0.5">
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
<div class="group flex items-start">
<span class="mt-0.5 w-8 text-brand-mint">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-brand-navy">
{doc.documentLocation}
</span>
<span class="text-xs font-sans text-gray-500">{m.doc_label_archive_location_original()}</span>
<span class="font-sans text-xs text-gray-500"
>{m.doc_label_archive_location_original()}</span
>
</div>
</div>
{/if}
<!-- TAGS / SCHLAGWORTE -->
{#if doc.tags && doc.tags.length > 0}
<div class="flex items-start group">
<span class="text-brand-mint w-8 mt-0.5">
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
<div class="group flex items-start">
<span class="mt-0.5 w-8 text-brand-mint">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div class="flex-1">
<div class="flex flex-wrap gap-2 mb-1">
{#each doc.tags as tag}
<div class="mb-1 flex flex-wrap gap-2">
{#each doc.tags as tag (tag.id)}
<a
href="/?tag={encodeURIComponent(tag.name)}"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wide bg-brand-sand/50 text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
class="inline-flex items-center rounded bg-brand-sand/50 px-2 py-0.5 text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
title={m.doc_tag_filter_title({ name: tag.name })}
>
{tag.name}
</a>
{/each}
</div>
<span class="text-xs font-sans text-gray-500">{m.form_label_tags()}</span>
<span class="font-sans text-xs text-gray-500">{m.form_label_tags()}</span>
</div>
</div>
{/if}
@@ -182,58 +220,64 @@
<!-- 2. PERSONEN GROUP -->
<div>
<h3
class="text-xs font-sans font-bold text-brand-navy uppercase tracking-widest mb-4 border-b border-brand-sand pb-2"
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
>
{m.doc_section_persons()}
</h3>
<div class="mb-6">
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">{m.form_label_sender()}</span>
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
>{m.form_label_sender()}</span
>
{#if doc.sender}
<a
href="/persons/{doc.sender.id}"
class="block p-3 rounded border border-brand-sand bg-brand-sand/20 hover:border-brand-mint hover:bg-brand-mint/10 transition group"
class="group block rounded border border-brand-sand bg-brand-sand/20 p-3 transition hover:border-brand-mint hover:bg-brand-mint/10"
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full bg-brand-navy text-white flex items-center justify-center font-serif text-sm"
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-serif text-sm text-white"
>
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
</div>
<div>
<p
class="font-serif text-brand-navy group-hover:underline decoration-brand-mint underline-offset-2"
class="font-serif text-brand-navy decoration-brand-mint underline-offset-2 group-hover:underline"
>
{doc.sender.firstName}
{doc.sender.lastName}
</p>
{#if doc.sender.alias}
<p class="text-xs font-sans text-gray-500">{doc.sender.alias}</p>
<p class="font-sans text-xs text-gray-500">{doc.sender.alias}</p>
{/if}
</div>
</div>
</a>
{:else}
<span class="text-sm font-serif text-gray-400 italic">{m.doc_sender_not_specified()}</span>
<span class="font-serif text-sm text-gray-400 italic"
>{m.doc_sender_not_specified()}</span
>
{/if}
</div>
<div>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">{m.form_label_receivers()}</span>
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
>{m.form_label_receivers()}</span
>
{#if doc.receivers && doc.receivers.length > 0}
<div class="space-y-2">
{#each doc.receivers as receiver}
{#each doc.receivers as receiver (receiver.id)}
<div
class="flex items-center justify-between p-3 rounded border border-brand-sand bg-white hover:border-brand-navy transition group"
class="group flex items-center justify-between rounded border border-brand-sand bg-white p-3 transition hover:border-brand-navy"
>
<a href="/persons/{receiver.id}" class="flex items-center gap-3 flex-1 min-w-0">
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
<div
class="w-6 h-6 rounded-full bg-gray-100 text-gray-500 flex items-center justify-center text-xs font-serif"
class="flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 font-serif text-xs text-gray-500"
>
{receiver.firstName[0]}{receiver.lastName[0]}
</div>
<span
class="font-serif text-sm text-brand-navy group-hover:text-brand-navy truncate"
class="truncate font-serif text-sm text-brand-navy group-hover:text-brand-navy"
>
{receiver.firstName}
{receiver.lastName}
@@ -243,17 +287,22 @@
{#if doc.sender}
<a
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
class="text-gray-300 hover:text-brand-mint transition"
class="text-gray-300 transition hover:text-brand-mint"
title={m.doc_conversation_title()}
>
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg" alt="" aria-hidden="true" class="w-5 h-5" />
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</a>
{/if}
</div>
{/each}
</div>
{:else}
<span class="text-sm font-serif text-gray-400 italic">{m.doc_no_receivers()}</span>
<span class="font-serif text-sm text-gray-400 italic">{m.doc_no_receivers()}</span>
{/if}
</div>
</div>
@@ -262,7 +311,7 @@
{#if doc.summary || doc.transcription}
<div>
<h3
class="text-xs font-sans font-bold text-brand-navy uppercase tracking-widest mb-4 border-b border-brand-sand pb-2"
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
>
{m.doc_section_content()}
</h3>
@@ -270,9 +319,11 @@
<div class="space-y-6">
{#if doc.summary}
<div>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">{m.doc_label_summary()}</span>
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
>{m.doc_label_summary()}</span
>
<div
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
>
{doc.summary}
</div>
@@ -281,9 +332,11 @@
{#if doc.transcription}
<div>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">{m.form_label_transcription()}</span>
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
>{m.form_label_transcription()}</span
>
<div
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
>
{doc.transcription}
</div>
@@ -294,19 +347,19 @@
{/if}
<!-- Footer -->
<div class="pt-4 border-t border-brand-sand text-[10px] font-sans text-gray-400">
<div class="border-t border-brand-sand pt-4 font-sans text-[10px] text-gray-400">
<p class="truncate">ID: {doc.id}</p>
<p class="truncate mt-1">{doc.originalFilename}</p>
<p class="mt-1 truncate">{doc.originalFilename}</p>
</div>
</div>
</aside>
<!-- RIGHT: PREVIEW AREA -->
<main class="flex-1 bg-[#2A2A2A] relative flex flex-col items-center justify-center">
<main class="relative flex flex-1 flex-col items-center justify-center bg-[#2A2A2A]">
{#if isLoading}
<div class="text-brand-mint flex flex-col items-center">
<div class="flex flex-col items-center text-brand-mint">
<svg
class="animate-spin h-8 w-8 mb-4"
class="mb-4 h-8 w-8 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -322,13 +375,13 @@
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
</div>
{:else if error}
<div class="text-gray-400 text-center px-4">
<p class="font-serif mb-2">{error}</p>
<div class="px-4 text-center text-gray-400">
<p class="mb-2 font-serif">{error}</p>
{#if doc.filePath}
<a
href={`/api/documents/${doc.id}/file`}
target="_blank"
class="underline hover:text-white text-sm"
class="text-sm underline hover:text-white"
>
{m.doc_download_link()}
</a>
@@ -336,8 +389,13 @@
</div>
{:else if !doc.filePath}
<div class="flex flex-col items-center text-gray-400">
<div class="bg-white/5 p-8 rounded-full mb-6">
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg" alt="" aria-hidden="true" class="w-12 h-12 opacity-50 invert" />
<div class="mb-6 rounded-full bg-white/5 p-8">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
aria-hidden="true"
class="h-12 w-12 opacity-50 invert"
/>
</div>
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
</div>
@@ -345,14 +403,14 @@
<iframe
src={fileUrl}
title={m.doc_preview_iframe_title()}
class="w-full h-full border-none bg-white"
class="h-full w-full border-none bg-white"
></iframe>
{:else if fileUrl}
<div class="w-full h-full flex items-center justify-center overflow-auto p-8">
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
<img
src={fileUrl}
alt={m.doc_image_alt()}
class="max-w-full max-h-full object-contain shadow-2xl"
class="max-h-full max-w-full object-contain shadow-2xl"
/>
</div>
{/if}

View File

@@ -3,49 +3,60 @@ import { env } from '$env/dynamic/private';
import { createApiClient } from '$lib/api.server';
import { parseBackendError, getErrorMessage } from '$lib/errors';
export async function load({ params, fetch, locals }: { params: { id: string }; fetch: typeof globalThis.fetch; locals: App.Locals }) {
const canWrite = locals.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('WRITE_ALL')) ?? false;
if (!canWrite) throw error(403, 'Forbidden');
export async function load({
params,
fetch,
locals
}: {
params: { id: string };
fetch: typeof globalThis.fetch;
locals: App.Locals;
}) {
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw error(403, 'Forbidden');
const { id } = params;
const api = createApiClient(fetch);
const { id } = params;
const api = createApiClient(fetch);
const [docResult, personsResult] = await Promise.all([
api.GET('/api/documents/{id}', { params: { path: { id } } }),
api.GET('/api/persons')
]);
const [docResult, personsResult] = await Promise.all([
api.GET('/api/documents/{id}', { params: { path: { id } } }),
api.GET('/api/persons')
]);
if (!docResult.response.ok) {
const code = (docResult.error as unknown as { code?: string })?.code;
throw error(docResult.response.status, getErrorMessage(code));
}
if (!personsResult.response.ok) {
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
}
if (!docResult.response.ok) {
const code = (docResult.error as unknown as { code?: string })?.code;
throw error(docResult.response.status, getErrorMessage(code));
}
if (!personsResult.response.ok) {
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
}
return {
document: docResult.data!,
persons: personsResult.data
};
return {
document: docResult.data!,
persons: personsResult.data
};
}
export const actions = {
default: async ({ request, params, fetch }) => {
// Raw fetch is used here because FormData multipart bodies are passed through
// directly from the browser without transformation.
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const formData = await request.formData();
default: async ({ request, params, fetch }) => {
// Raw fetch is used here because FormData multipart bodies are passed through
// directly from the browser without transformation.
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const formData = await request.formData();
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
method: 'PUT',
body: formData
});
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
method: 'PUT',
body: formData
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { error: getErrorMessage(backendError?.code) });
}
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { error: getErrorMessage(backendError?.code) });
}
throw redirect(303, `/documents/${params.id}`);
}
throw redirect(303, `/documents/${params.id}`);
}
};

View File

@@ -1,228 +1,263 @@
<script lang="ts">
import { enhance } from '$app/forms';
import TagInput from '$lib/components/TagInput.svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import { untrack } from 'svelte';
import { isoToGerman, germanToIso } from '$lib/utils';
import { m } from '$lib/paraglide/messages.js';
import { enhance } from '$app/forms';
import TagInput from '$lib/components/TagInput.svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import { untrack } from 'svelte';
import { isoToGerman, germanToIso } from '$lib/utils';
import { m } from '$lib/paraglide/messages.js';
let { data, form } = $props();
let { data, form } = $props();
let { document: doc } = untrack(() => data);
let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []);
let senderId = $state(doc.sender?.id ?? '');
let selectedReceivers = $state(doc.receivers ?? []);
let { document: doc } = untrack(() => data);
let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []);
let senderId = $state(doc.sender?.id ?? '');
let selectedReceivers = $state(doc.receivers ?? []);
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
let dateIso = $state(doc.documentDate ?? '');
let dateDirty = $state(false);
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
let dateIso = $state(doc.documentDate ?? '');
let dateDirty = $state(false);
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
function handleDateInput(e: Event) {
const input = e.target as HTMLInputElement;
const digits = input.value.replace(/\D/g, '').slice(0, 8);
let formatted: string;
if (digits.length <= 2) {
formatted = digits;
} else if (digits.length <= 4) {
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
} else {
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
input.value = formatted;
dateDisplay = formatted;
dateIso = germanToIso(formatted);
dateDirty = true;
}
function handleDateInput(e: Event) {
const input = e.target as HTMLInputElement;
const digits = input.value.replace(/\D/g, '').slice(0, 8);
let formatted: string;
if (digits.length <= 2) {
formatted = digits;
} else if (digits.length <= 4) {
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
} else {
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
input.value = formatted;
dateDisplay = formatted;
dateIso = germanToIso(formatted);
dateDirty = true;
}
</script>
<div class="max-w-4xl mx-auto py-8 px-4">
<div class="mx-auto max-w-4xl px-4 py-8">
<!-- Heading -->
<div class="mb-6">
<a
href="/documents/{doc.id}"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
/>
{m.btn_back_to_document()}
</a>
<h1 class="font-serif text-3xl text-brand-navy">
{m.doc_edit_heading()}
<span class="text-brand-navy/70">{doc.title || doc.originalFilename}</span>
</h1>
</div>
<!-- Heading -->
<div class="mb-6">
<a href="/documents/{doc.id}" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4">
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg" alt="" aria-hidden="true" class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" />
{m.btn_back_to_document()}
</a>
<h1 class="text-3xl font-serif text-brand-navy">
{m.doc_edit_heading()}<span class="text-brand-navy/70">{doc.title || doc.originalFilename}</span>
</h1>
</div>
{#if form?.error}
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">{form.error}</div>
{/if}
{#if form?.error}
<div class="bg-red-50 text-red-700 border border-red-200 p-4 rounded mb-6">{form.error}</div>
{/if}
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
<!-- ── Section 1: Wer & Wann ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.doc_section_who_when()}
</h2>
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
<!-- ── Section 1: Wer & Wann ── -->
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">{m.doc_section_who_when()}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- Datum -->
<div>
<label for="documentDate" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_date()}</label>
<input
id="documentDate"
type="text"
inputmode="numeric"
value={dateDisplay}
oninput={handleDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block w-full rounded border-gray-300 shadow-sm p-2 border text-sm
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<!-- Datum -->
<div>
<label for="documentDate" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_date()}</label
>
<input
id="documentDate"
type="text"
inputmode="numeric"
value={dateDisplay}
oninput={handleDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-brand-navy focus:ring-brand-navy'}"
aria-describedby={dateInvalid ? 'date-error' : undefined}
/>
<input type="hidden" name="documentDate" value={dateIso} />
{#if dateInvalid}
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
{/if}
</div>
aria-describedby={dateInvalid ? 'date-error' : undefined}
/>
<input type="hidden" name="documentDate" value={dateIso} />
{#if dateInvalid}
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
{/if}
</div>
<!-- Ort -->
<div>
<label for="location" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_location()}</label>
<input
id="location"
type="text"
name="location"
value={doc.location || ''}
placeholder={m.form_placeholder_location()}
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm"
/>
</div>
<!-- Ort -->
<div>
<label for="location" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_location()}</label
>
<input
id="location"
type="text"
name="location"
value={doc.location || ''}
placeholder={m.form_placeholder_location()}
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
<!-- Absender -->
<div>
<PersonTypeahead
name="senderId"
label={m.form_label_sender()}
bind:value={senderId}
initialName={doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : ''}
/>
</div>
<!-- Absender -->
<div>
<PersonTypeahead
name="senderId"
label={m.form_label_sender()}
bind:value={senderId}
initialName={doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : ''}
/>
</div>
<!-- Empfänger -->
<div>
<p class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_receivers()}</p>
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
</div>
<!-- Empfänger -->
<div>
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_receivers()}</p>
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
</div>
</div>
</div>
</div>
</div>
<!-- ── Section 2: Beschreibung ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.doc_section_description()}
</h2>
<!-- ── Section 2: Beschreibung ── -->
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">{m.doc_section_description()}</h2>
<div class="space-y-5">
<!-- Titel -->
<div>
<label for="title" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_title()} *</label
>
<input
id="title"
type="text"
name="title"
value={doc.title || ''}
required
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
<div class="space-y-5">
<!-- Aufbewahrungsort -->
<div>
<label for="documentLocation" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_archive_location()}</label
>
<input
id="documentLocation"
type="text"
name="documentLocation"
value={doc.documentLocation || ''}
placeholder={m.form_placeholder_archive_location()}
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
<p class="mt-1 text-xs text-gray-400">{m.form_helper_archive_location()}</p>
</div>
<!-- Titel -->
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_title()} *</label>
<input
id="title"
type="text"
name="title"
value={doc.title || ''}
required
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm"
/>
</div>
<!-- Schlagworte -->
<div>
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_tags()}</p>
<TagInput bind:tags={tags} />
<input type="hidden" name="tags" value={tags.join(',')} />
</div>
<!-- Aufbewahrungsort -->
<div>
<label for="documentLocation" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_archive_location()}</label>
<input
id="documentLocation"
type="text"
name="documentLocation"
value={doc.documentLocation || ''}
placeholder={m.form_placeholder_archive_location()}
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm"
/>
<p class="mt-1 text-xs text-gray-400">{m.form_helper_archive_location()}</p>
</div>
<!-- Inhalt -->
<div>
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_content()}</label
>
<textarea
id="summary"
name="summary"
rows="5"
placeholder={m.form_placeholder_content()}
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
>{doc.summary || ''}</textarea
>
</div>
</div>
</div>
<!-- Schlagworte -->
<div>
<p class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_tags()}</p>
<TagInput bind:tags />
<input type="hidden" name="tags" value={tags.join(',')} />
</div>
<!-- ── Section 3: Transkription ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.form_label_transcription()}
</h2>
<textarea
id="transcription"
name="transcription"
rows="12"
placeholder={m.form_placeholder_transcription()}
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
>{doc.transcription || ''}</textarea
>
</div>
<!-- Inhalt -->
<div>
<label for="summary" class="block text-sm font-medium text-gray-700 mb-1">{m.form_label_content()}</label>
<textarea
id="summary"
name="summary"
rows="5"
placeholder={m.form_placeholder_content()}
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm font-serif"
>{doc.summary || ''}</textarea>
</div>
<!-- ── Section 4: Datei ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.doc_section_file()}
</h2>
</div>
</div>
<div
class="mb-4 flex items-center gap-3 rounded bg-brand-sand/20 px-3 py-2 text-sm text-gray-600"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 flex-shrink-0"
/>
<span
>{m.doc_current_file_label()}
<strong class="font-medium text-brand-navy">{doc.originalFilename}</strong></span
>
</div>
<!-- ── Section 3: Transkription ── -->
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">{m.form_label_transcription()}</h2>
<textarea
id="transcription"
name="transcription"
rows="12"
placeholder={m.form_placeholder_transcription()}
class="block w-full rounded border-gray-300 shadow-sm p-2 border focus:border-brand-navy focus:ring-brand-navy text-sm font-serif"
>{doc.transcription || ''}</textarea>
</div>
<!-- ── Section 4: Datei ── -->
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">{m.doc_section_file()}</h2>
<div class="flex items-center gap-3 mb-4 text-sm text-gray-600 bg-brand-sand/20 rounded px-3 py-2">
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg" alt="" aria-hidden="true" class="w-4 h-4 flex-shrink-0" />
<span>{m.doc_current_file_label()} <strong class="text-brand-navy font-medium">{doc.originalFilename}</strong></span>
</div>
<label for="file-upload" class="block text-sm font-medium text-gray-700 mb-1">
{m.doc_file_replace_label()} <span class="font-normal text-gray-400">({m.doc_file_replace_note()})</span>
</label>
<input
id="file-upload"
type="file"
name="file"
class="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded file:border-0
<label for="file-upload" class="mb-1 block text-sm font-medium text-gray-700">
{m.doc_file_replace_label()}
<span class="font-normal text-gray-400">({m.doc_file_replace_note()})</span>
</label>
<input
id="file-upload"
type="file"
name="file"
class="block w-full cursor-pointer text-sm
text-gray-500 file:mr-4 file:rounded
file:border-0 file:bg-brand-sand/40
file:px-4 file:py-2
file:text-sm file:font-semibold
file:bg-brand-sand/40 file:text-brand-navy
hover:file:bg-brand-sand/60 cursor-pointer"
/>
</div>
file:text-brand-navy hover:file:bg-brand-sand/60"
/>
</div>
<!-- ── Sticky Save Bar ── -->
<div class="sticky bottom-0 z-10 bg-white border-t border-brand-sand shadow-[0_-2px_8px_rgba(0,0,0,0.06)] -mx-4 px-6 py-4 flex items-center justify-between">
<a
href="/documents/{doc.id}"
class="text-sm text-gray-500 hover:text-brand-navy transition-colors font-medium"
>
{m.btn_cancel()}
</a>
<button
type="submit"
class="px-6 py-2 bg-brand-navy text-white text-sm font-bold uppercase tracking-widest rounded hover:bg-brand-navy/80 transition-colors"
>
{m.btn_save()}
</button>
</div>
</form>
<!-- ── Sticky Save Bar ── -->
<div
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-brand-sand bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
>
<a
href="/documents/{doc.id}"
class="text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
>
{m.btn_cancel()}
</a>
<button
type="submit"
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80"
>
{m.btn_save()}
</button>
</div>
</form>
</div>

View File

@@ -3,8 +3,17 @@ import { env } from '$env/dynamic/private';
import { createApiClient } from '$lib/api.server';
import { parseBackendError, getErrorMessage } from '$lib/errors';
export async function load({ fetch, locals }: { fetch: typeof globalThis.fetch; locals: App.Locals }) {
const canWrite = locals.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('WRITE_ALL')) ?? false;
export async function load({
fetch,
locals
}: {
fetch: typeof globalThis.fetch;
locals: App.Locals;
}) {
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw error(403, 'Forbidden');
const api = createApiClient(fetch);

View File

@@ -74,7 +74,9 @@ function handleDateInput(e: Event) {
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
<!-- ── Section 1: Wer & Wann ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">{m.doc_section_who_when()}</h2>
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.doc_section_who_when()}
</h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<!-- Datum -->
@@ -104,7 +106,9 @@ function handleDateInput(e: Event) {
<!-- Ort -->
<div>
<label for="location" class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_location()}</label>
<label for="location" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_location()}</label
>
<input
id="location"
type="text"
@@ -129,12 +133,16 @@ function handleDateInput(e: Event) {
<!-- ── Section 2: Beschreibung ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">{m.doc_section_description()}</h2>
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.doc_section_description()}
</h2>
<div class="space-y-5">
<!-- Titel -->
<div>
<label for="title" class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_title()} *</label>
<label for="title" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_title()} *</label
>
<input
id="title"
type="text"
@@ -168,7 +176,9 @@ function handleDateInput(e: Event) {
<!-- Inhalt -->
<div>
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_content()}</label>
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_content()}</label
>
<textarea
id="summary"
name="summary"
@@ -182,7 +192,9 @@ function handleDateInput(e: Event) {
<!-- ── Section 3: Transkription ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">{m.form_label_transcription()}</h2>
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.form_label_transcription()}
</h2>
<textarea
id="transcription"
name="transcription"
@@ -194,10 +206,13 @@ function handleDateInput(e: Event) {
<!-- ── Section 4: Datei ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">{m.doc_section_file()}</h2>
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.doc_section_file()}
</h2>
<label for="file-upload" class="mb-1 block text-sm font-medium text-gray-700">
{m.doc_file_upload_label()} <span class="font-normal text-gray-400">({m.doc_file_upload_note()})</span>
{m.doc_file_upload_label()}
<span class="font-normal text-gray-400">({m.doc_file_upload_note()})</span>
</label>
<input
id="file-upload"