restructure: flatten workspace nesting, move devcontainer to root

- backend/workspaces/backend/ → backend/
- backend/workspaces/frontend/ → frontend/
- backend/.devcontainer/ + .vscode/ → repo root (where VS Code expects them)
- loose scripts/SQL files → scripts/
- replace nested git repo with single repo at project root
- update docker-compose.yml build context and devcontainer.json path
- add root .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-15 11:47:58 +01:00
parent 7e725090fe
commit e63adb964d
155 changed files with 650 additions and 29 deletions

25
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,25 @@
// src/app.d.ts
declare global {
namespace App {
// Define the User structure matching your Java Entity
interface User {
id: string;
username: string;
groups: {
name: string;
permissions: string[];
}[];
}
interface Locals {
user?: User; // locals.user is optional (undefined if not logged in)
}
interface PageData {
user?: User; // Available in $page.data.user
}
}
}
export {};

11
frontend/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="%paraglide.lang%">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

View File

@@ -0,0 +1,66 @@
import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit';
import { paraglideMiddleware } from '$lib/paraglide/server';
import { sequence } from '@sveltejs/kit/hooks';
import { env } from 'process';
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request;
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
});
});
const userGroup: Handle = async ({ event, resolve }) => {
const auth = event.cookies.get('auth_token');
if (auth) {
try {
const response = await fetch('http://localhost:8080/api/users/me', {
headers: { Authorization: auth }
});
if (response.ok) {
const user = await response.json();
event.locals.user = user;
}
} catch (error) {
console.error('Error fetching user in hook:', error);
}
}
return resolve(event);
};
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
const isNotLoginTest = !request.url.includes('/api/users/me');
if (isApi && isNotLoginTest) {
const token = event.cookies.get('auth_token');
if (!token) {
throw redirect(302, '/login');
}
// Clone the request first to preserve the body
const clonedRequest = request.clone();
// Create new request with Authorization header and preserved body
const modifiedRequest = new Request(clonedRequest, {
headers: {
...Object.fromEntries(clonedRequest.headers),
'Authorization': token
}
});
return fetch(modifiedRequest);
}
return fetch(request);
};
export const handle = sequence(userGroup, handleParaglide);

3
frontend/src/hooks.ts Normal file
View File

@@ -0,0 +1,3 @@
import { deLocalizeUrl } from '$lib/paraglide/runtime';
export const reroute = (request) => deLocalizeUrl(request.url).pathname;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
// Props
export let name: string;
export let label: string;
export let value: string = "";
export let initialName: string = "";
const dispatch = createEventDispatcher();
// Lokaler State
let searchTerm = initialName;
// Sync mit externen Änderungen (z.B. Reset Button)
$: searchTerm = initialName;
let results: any[] = [];
let showDropdown = false;
let loading = false;
let debounceTimer: any;
function handleInput() {
// Wenn der User tippt, ist die alte ID ungültig -> Reset
if (value && searchTerm !== initialName) {
value = "";
dispatch('change', { value: "" }); // Bescheid geben: Auswahl aufgehoben
}
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (searchTerm.length < 1) {
results = [];
return;
}
loading = true;
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
if (res.ok) {
results = await res.json();
} else {
results = [];
}
} catch (e) {
console.error("Suche fehlgeschlagen", e);
results = [];
} finally {
loading = false;
}
}, 300);
}
function selectPerson(person: any) {
value = person.id;
searchTerm = `${person.firstName} ${person.lastName}`;
showDropdown = false;
// --- NEU: Event feuern ---
dispatch('change', { value: person.id });
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: any) => {
if (node && !node.contains(event.target) && !event.defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() { document.removeEventListener('click', handleClick, true); }
};
}
</script>
<div class="relative" use:clickOutside>
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
<input type="hidden" {name} bind:value={value} />
<input
type="text"
id="{name}-search"
autocomplete="off"
bind:value={searchTerm}
on:input={handleInput}
on:focus={() => showDropdown = true}
placeholder="Namen tippen..."
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500"
/>
{#if showDropdown && (results.length > 0 || loading)}
<div class="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{#if loading}
<div class="p-2 text-gray-500 text-sm">Suche...</div>
{:else}
{#each results as person}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
on:click={() => selectPerson(person)}
role="button"
tabindex="0"
>
<div class="flex items-center">
<span class="font-medium block truncate">
{person.lastName}, {person.firstName}
</span>
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,138 @@
<script lang="ts">
export let tags: string[] = []; // Two-way binding
export let allowCreation = true;
let inputVal = '';
let suggestions: string[] = [];
let activeIndex = -1;
let showSuggestions = false;
// Fetch suggestions from backend
async function fetchSuggestions(query: string) {
if (query.length < 2) {
suggestions = [];
return;
}
try {
console.log('fetch tags')
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
if (res.ok) {
const data = await res.json();
// Filter out tags already selected
suggestions = data.filter((t: string) => !tags.includes(t));
showSuggestions = true;
}
} catch (e) {
console.error("Tag fetch error", e);
}
}
function addTag(tag: string) {
const trimmed = tag.trim();
if (trimmed && !tags.includes(trimmed)) {
tags = [...tags, trimmed];
}
inputVal = '';
suggestions = [];
showSuggestions = false;
activeIndex = -1;
}
function removeTag(index: number) {
tags = tags.filter((_, i) => i !== index);
}
function handleKeydown(e: KeyboardEvent) {
console.log("keydown",e)
if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && suggestions[activeIndex]) {
addTag(suggestions[activeIndex]);
} else if(allowCreation) {
addTag(inputVal); // Add new tag
}
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
removeTag(tags.length - 1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % suggestions.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
}
}
function handleInput() {
fetchSuggestions(inputVal);
}
</script>
<div class="w-full">
<!-- Tag Container -->
<div
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy bg-white min-h-[42px]"
>
<!-- Render Selected Tags -->
{#each tags as tag, i}
<span
class="bg-brand-sand/30 text-brand-navy text-sm font-medium px-2 py-1 rounded flex items-center gap-1"
>
{tag}
<button
type="button"
on:click={() => removeTag(i)}
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
</button>
</span>
{/each}
<!-- Input Field -->
<div class="relative flex-1 min-w-[120px]">
<input
type="text"
bind:value={inputVal}
on:input={handleInput}
on:keydown={handleKeydown}
on:focus={() => handleInput()}
on:blur={() => setTimeout(() => (showSuggestions = false), 200)}
placeholder={tags.length === 0
? allowCreation
? 'Schlagworte hinzufügen...'
: 'Nach Schlagworten filtern...'
: ''}
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
/>
<!-- Typeahead Dropdown -->
{#if showSuggestions && suggestions.length > 0}
<ul
class="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded shadow-lg z-50 max-h-48 overflow-y-auto"
>
{#each suggestions as suggestion, i}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
class="px-3 py-2 text-sm cursor-pointer hover:bg-brand-sand/20 {i === activeIndex
? 'bg-brand-sand/20 text-brand-navy font-bold'
: 'text-gray-700'}"
on:click={() => addTag(suggestion)}
>
{suggestion}
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if allowCreation}
<p class="text-xs text-gray-400 mt-1">Enter drücken um Schlagwort zu erstellen.</p>
{/if}
</div>

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user
};
};

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import './layout.css';
import { enhance } from '$app/forms';
import { page } from '$app/state';
$: user = page.data.user;
$: isAdmin = user?.groups.some(g => g.permissions.includes("ADMIN"))
</script>
<div class="min-h-screen bg-brand-sand">
<!-- Changed background to Sand -->
<!-- Corporate Header -->
{#if !page.url.pathname.startsWith('/login')}
<header class="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-20">
<!-- Slightly taller header -->
<!-- Logo & Nav -->
<div class="flex">
<!-- LOGO (Extracted from their SVG) -->
<div class="flex-shrink-0 flex items-center mr-8">
<a href="/" class="flex items-center gap-2" aria-label="Familienarchiv">
<!-- SVG Code from their site -->
<svg
width="250"
height="25"
viewBox="0 0 250 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.156128 1.01562C5.375 1.43431 9.621 4.65591 9.621 11.6669V19.8779H0.156128V10.4467H5.76852C5.76852 5.6736 3.70661 3.72129 0.156128 2.8334V1.01562Z"
fill="#B4B9FF"
></path>
<path
d="M10.5892 19.8779C15.8076 19.4592 20.0541 16.2371 20.0541 9.22655V1.01562H10.5892V10.4467H16.2012C16.2012 15.2199 14.1397 17.1722 10.5892 18.0601V19.8779Z"
fill="#B4B9FF"
></path>
<text
x="35"
y="20"
fill="#002850"
font-family="Montserrat"
font-weight="bold"
font-size="20">FAMILIENARCHIV</text
>
</svg>
</a>
</div>
<!-- Nav Links (Montserrat font, Uppercase style often used in corporate) -->
<div class="hidden sm:ml-6 sm:flex sm:space-x-8 items-center">
<a
href="/"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
? 'border-brand-navy text-brand-navy'
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
>
Dokumente
</a>
<a
href="/persons"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
{page.url.pathname.startsWith('/persons')
? 'border-brand-navy text-brand-navy'
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
>
Personen
</a>
<a
href="/conversations"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
{page.url.pathname.startsWith('/conversations')
? 'border-brand-navy text-brand-navy'
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
>
Konversationen
</a>
{#if isAdmin}
<a
href="/admin"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
{page.url.pathname.startsWith('/admin')
? 'border-brand-navy text-brand-navy'
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
>
Admin
</a>
{/if}
</div>
</div>
<!-- Right Side -->
<div class="flex items-center">
<form action="/logout" method="POST" use:enhance>
<button
type="submit"
class="text-sm text-gray-500 hover:text-brand-navy font-bold uppercase font-sans tracking-wide px-3 py-2 transition"
>
Abmelden
</button>
</form>
</div>
</div>
</div>
</header>
{/if}
<main class="py-6">
<slot />
</main>
</div>

View File

@@ -0,0 +1,72 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, fetch }) => {
// 1. Extract params
const q = url.searchParams.get('q') || '';
const from = url.searchParams.get('from') || '';
const to = url.searchParams.get('to') || '';
const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || '';
const tags = url.searchParams.getAll('tag') || '';
// 2. Build Search URL
const searchUrl = new URL('http://localhost:8080/api/documents/search');
if (q) searchUrl.searchParams.set('q', q);
if (from) searchUrl.searchParams.set('from', from);
if (to) searchUrl.searchParams.set('to', to);
if (senderId) searchUrl.searchParams.set('senderId', senderId);
if (receiverId) searchUrl.searchParams.set('receiverId', receiverId);
if(tags) tags.forEach(tag => searchUrl.searchParams.append('tag', tag));
// 3. Build Persons URL (to resolve names for the typeahead initial value)
// Ideally, we would have endpoints like /api/persons/{id}, but for now we load the list or search.
// To keep it simple and performant enough for now, we fetch all to find the names.
const personsUrl = 'http://localhost:8080/api/persons';
try {
const [docsRes, personsRes] = await Promise.all([
fetch(searchUrl.toString()),
fetch(personsUrl)
]);
if (docsRes.status === 401 || personsRes.status === 401) {
throw redirect(302, '/login');
}
const documents = await docsRes.json();
const allPersons = await personsRes.json();
// Resolve Names for the Typeahead Inputs
const senderObj = allPersons.find((p: any) => p.id === senderId);
const receiverObj = allPersons.find((p: any) => p.id === receiverId);
const senderName = senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '';
const receiverName = receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : '';
return {
documents,
// We don't need to pass the full persons list to the frontend anymore,
// as the Typeahead fetches it dynamically. We only pass the resolved names.
initialValues: {
senderName,
receiverName
},
filters: { q, from, to, senderId, receiverId, tags }
};
} catch (error) {
console.error("Error loading data:", error);
return {
documents: [],
initialValues: { senderName: '', receiverName: '' },
filters: { q, from, to, senderId, receiverId },
error: "Could not load data."
};
}
};

View File

@@ -0,0 +1,385 @@
<script lang="ts">
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { goto } from '$app/navigation';
import TagInput from '$lib/components/TagInput.svelte';
import { slide } from 'svelte/transition';
export let data;
// Local state variables
let q = data.filters?.q || '';
let from = data.filters?.from || '';
let to = data.filters?.to || '';
let senderId = data.filters?.senderId || '';
let receiverId = data.filters?.receiverId || '';
let tagNames = data.filters?.tags || [];
// Debounce Timer
let searchTimer: any;
let showAdvanced = false;
function triggerSearch() {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (from) params.set('from', from);
if (to) params.set('to', to);
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if(tagNames) tagNames.forEach(tag => params.append('tag', tag));
goto(`/?${params.toString()}`, {
keepFocus: true,
noScroll: true
});
}
function handleTextSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
triggerSearch();
}, 500);
}
let previousTags = tagNames.join(',');
$: {
const currentTags = tagNames.join(',');
if (currentTags !== previousTags) {
previousTags = currentTags;
triggerSearch();
}
}
function toggleAdvanced() {
showAdvanced = !showAdvanced;
}
// Sync with server data (e.g. after reset)
$: {
q = data.filters?.q || '';
from = data.filters?.from || '';
to = data.filters?.to || '';
senderId = data.filters?.senderId || '';
receiverId = data.filters?.receiverId || '';
}
</script>
<!-- Outer Container: Matches the 'Sand' background of the layout -->
<main class="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8 font-sans">
<!-- SEARCH & FILTER CARD -->
<div class="bg-white p-6 shadow-sm border border-brand-sand mb-8 rounded-sm">
<!-- ROW 1: Main Search (One Line) -->
<div class="flex gap-4 items-center">
<!-- Full Text Search -->
<div class="flex-1 relative">
<input
type="text"
bind:value={q}
on:input={handleTextSearch}
placeholder="Suche in Titel, Inhalt, Ort..."
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy placeholder-gray-400 py-2.5 pl-3 pr-10"
/>
<div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/></svg
>
</div>
</div>
<!-- Toggle Advanced Button -->
<button
on:click={toggleAdvanced}
class="flex items-center gap-2 px-4 py-2.5 border border-gray-300 bg-gray-50 text-gray-600 text-sm font-bold uppercase tracking-wide hover:bg-gray-100 hover:text-brand-navy transition"
>
<svg
class="w-4 h-4 transform transition-transform duration-200 {showAdvanced
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
Filter
</button>
<!-- Reset Button -->
<a
href="/"
class="flex items-center justify-center px-3 py-2.5 border border-transparent text-gray-400 hover:text-red-500 transition"
title="Filter zurücksetzen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
</a>
</div>
<!-- ROW 2: Advanced Filters (Collapsible) -->
{#if showAdvanced}
<div
transition:slide
class="mt-6 pt-6 border-t border-gray-100 grid grid-cols-1 md:grid-cols-12 gap-6"
>
<!-- Tag Filter -->
<div class="md:col-span-12">
<label class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
>Schlagworte</label
>
<TagInput bind:tags={tagNames} allowCreation={false} on:change={triggerSearch} />
</div>
<!-- Sender -->
<div class="md:col-span-3">
<div
class="[&_label]:text-xs [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:mb-2 [&_input]:py-2.5 [&_input]:border-gray-300"
>
<PersonTypeahead
name="senderId"
label="Absender"
bind:value={senderId}
initialName={data.initialValues?.senderName}
on:change={triggerSearch}
/>
</div>
</div>
<!-- Receiver -->
<div class="md:col-span-3">
<div
class="[&_label]:text-xs [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:mb-2 [&_input]:py-2.5 [&_input]:border-gray-300"
>
<PersonTypeahead
name="receiverId"
label="Empfänger"
bind:value={receiverId}
initialName={data.initialValues?.receiverName}
on:change={triggerSearch}
/>
</div>
</div>
<!-- Dates -->
<div class="md:col-span-6 grid grid-cols-2 gap-4">
<div>
<label
for="from"
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
>Von</label
>
<input
type="date"
id="from"
bind:value={from}
on:change={triggerSearch}
class="block w-full border-gray-300 shadow-sm text-sm py-2.5"
/>
</div>
<div>
<label
for="to"
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
>Bis</label
>
<input
type="date"
id="to"
bind:value={to}
on:change={triggerSearch}
class="block w-full border-gray-300 shadow-sm text-sm py-2.5"
/>
</div>
</div>
</div>
{/if}
</div>
<!-- DOCUMENT LIST -->
<div class="bg-white shadow-sm border border-brand-sand">
{#if data.error}
<div class="p-8 text-center text-red-600 bg-red-50">
{data.error}
</div>
{:else if data.documents && data.documents.length > 0}
<ul class="divide-y divide-gray-100">
{#each data.documents as doc}
<li class="group hover:bg-brand-sand/10 transition-colors duration-200">
<!-- LINK TO DETAIL PAGE -->
<a href="/documents/{doc.id}" class="block p-6">
<div class="flex flex-col sm:flex-row gap-6">
<!-- Main Info -->
<div class="flex-1">
<div class="flex items-baseline justify-between mb-2">
<!-- Title: Serif & Brand Navy -->
<h3
class="text-xl font-serif font-medium text-brand-navy group-hover:underline decoration-brand-mint decoration-2 underline-offset-4"
>
{doc.title || doc.originalFilename}
</h3>
<!-- Status Badge -->
<span
class="ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border
{doc.status === 'UPLOADED'
? 'bg-brand-mint/20 text-brand-navy border-brand-mint/50'
: 'bg-yellow-50 text-yellow-700 border-yellow-200'}"
>
{doc.status}
</span>
</div>
<!-- Metadata Row -->
<div class="flex flex-wrap gap-6 text-sm text-gray-500 mb-4 font-sans">
<div class="flex items-center">
<svg
class="mr-1.5 h-4 w-4 text-brand-mint"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/></svg
>
{doc.documentDate ? doc.documentDate : '—'}
</div>
{#if doc.location}
<div class="flex items-center">
<svg
class="mr-1.5 h-4 w-4 text-brand-mint"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/></svg
>
{doc.location}
</div>
{/if}
</div>
<!-- Sender/Receiver Info -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm font-serif">
<div class="flex items-baseline">
<span
class="w-10 text-xs font-sans font-bold text-gray-400 uppercase tracking-wide"
>Von</span
>
{#if doc.sender}
<span class="text-gray-900"
>{doc.sender.firstName} {doc.sender.lastName}</span
>
{:else}
<span class="text-gray-400 italic">Unbekannt</span>
{/if}
</div>
<div class="flex items-baseline">
<span
class="w-10 text-xs font-sans font-bold text-gray-400 uppercase tracking-wide"
>An</span
>
{#if doc.receivers && doc.receivers.length > 0}
<span class="text-gray-900">
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
</span>
{:else}
<span class="text-gray-400 italic">Unbekannt</span>
{/if}
</div>
</div>
<!-- NEW: Tags Display -->
{#if doc.tags && doc.tags.length > 0}
<div class="flex flex-wrap gap-2 mt-4 pt-3">
{#each doc.tags as tag}
<button
type="button"
class="inline-flex items-center px-2 py-1 rounded text-[10px] font-bold uppercase tracking-widest bg-brand-sand/30 text-brand-navy hover:bg-brand-navy hover:text-white transition-colors relative z-10"
on:click|preventDefault|stopPropagation={() =>
goto(`/?tag=${encodeURIComponent(tag.name)}`)}
>
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
<!-- Arrow Icon -->
<div
class="hidden sm:flex items-center text-gray-300 group-hover:text-brand-mint transition-colors"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
</a>
</li>
{/each}
</ul>
{:else}
<!-- Empty State -->
<div class="p-16 text-center">
<div
class="mx-auto w-12 h-12 bg-brand-sand/30 rounded-full flex items-center justify-center mb-4"
>
<svg class="w-6 h-6 text-brand-navy" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/></svg
>
</div>
<h3 class="text-lg font-serif font-medium text-brand-navy">Keine Dokumente gefunden</h3>
<p class="text-gray-500 mt-1 font-sans text-sm">
Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern.
</p>
<button
on:click={() => goto('/')}
class="mt-6 text-brand-mint font-bold text-sm uppercase tracking-wide hover:text-brand-navy transition"
>
Alle Filter löschen
</button>
</div>
{/if}
</div>
</main>

View File

@@ -0,0 +1,141 @@
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export async function load({ fetch, locals }) {
// 1. Check Permissions (Adapt logic to your user object)
const user = locals.user;
// Assuming user.group.permissions is an array of strings
const hasAdmin = user?.groups.some(g => g.permissions.includes("ADMIN"));
if (!hasAdmin) throw error(403, 'Zugriff verweigert');
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
// 2. Load Data
const [usersRes, groupsRes, tagsRes] = await Promise.all([
fetch(baseUrl + '/api/users'),
fetch(baseUrl + '/api/groups'),
fetch(baseUrl + '/api/tags')
]);
return {
users: await usersRes.json(),
groups: await groupsRes.json(),
tags: await tagsRes.json()
};
}
export const actions = {
createUser: async ({ request, fetch }) => {
const data = await request.formData();
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
// Extract array of group IDs
// "groupIds" matches the name attribute in the <select>
const groupIds = data.getAll('groupIds');
const payload = {
username: data.get('username'),
initialPassword: data.get('password'),
groupIds: groupIds // Send array to backend
};
console.log("Payload", JSON.stringify(payload))
const res = await fetch(baseUrl + '/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) return { success: false, message: 'Fehler beim Erstellen' };
return { success: true, message: 'User angelegt' };
},
deleteUser: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id');
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const res = await fetch(baseUrl + `/api/users/${id}`, {
method: 'DELETE'
});
if (!res.ok) {
return { success: false, message: 'Fehler beim Löschen des Benutzers' };
}
return { success: true, message: 'Benutzer erfolgreich gelöscht' };
},
updateTag: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id');
const name = data.get('name');
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
await fetch(baseUrl + `/api/tags/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
return { success: true };
},
deleteTag: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id');
await fetch(`/api/tags/${id}`, { method: 'DELETE' });
return { success: true };
},
createGroup: async ({ request, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const data = await request.formData();
const payload = {
name: data.get('name'),
permissions: data.getAll('permissions') // Gets all checked checkboxes
};
const res = await fetch(baseUrl + '/api/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) return { success: false, message: 'Fehler beim Erstellen der Gruppe' };
return { success: true };
},
updateGroup: async ({ request, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const data = await request.formData();
const id = data.get('id');
const payload = {
name: data.get('name'),
permissions: data.getAll('permissions')
};
const res = await fetch(baseUrl + `/api/groups/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) return { success: false, message: 'Fehler beim Aktualisieren' };
return { success: true };
},
deleteGroup: async ({ request, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const data = await request.formData();
const id = data.get('id');
const res = await fetch(baseUrl + `/api/groups/${id}`, { method: 'DELETE' });
if (!res.ok) return { success: false, message: 'Gruppe kann nicht gelöscht werden (evtl. noch Benutzer zugeordnet?)' };
return { success: true };
}
};

View File

@@ -0,0 +1,613 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { slide } from 'svelte/transition';
export let data;
export let form;
console.log(data)
let activeTab = 'users';
let editingTagId: string | null = null;
let editingTagName = '';
let editingUserId: string | null = null;
function startEditTag(tag: any) {
editingTagId = tag.id;
editingTagName = tag.name;
}
function cancelEditTag() {
editingTagId = null;
editingTagName = '';
}
function startEditUser(id: string) {
editingUserId = id;
}
function cancelEditUser() {
editingUserId = null;
}
let editingGroupId: string | null = null;
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
function startEditGroup(id: string) {
editingGroupId = id;
}
function cancelEditGroup() {
editingGroupId = null;
}
</script>
<div class="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8 font-sans">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-serif text-brand-navy">Admin Dashboard</h1>
<!-- Tabs -->
<div class="flex bg-white rounded-lg p-1 shadow-sm border border-gray-200">
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
'users'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'users')}>Benutzer</button
>
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
'groups'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'groups')}>Gruppen</button
>
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
'tags'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'tags')}>Schlagworte</button
>
</div>
</div>
{#if form?.message}
<div class="bg-brand-mint/20 text-brand-navy p-4 rounded mb-6 border border-brand-mint/50">
{form.message}
</div>
{/if}
{#if activeTab === 'users'}
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h2 class="text-lg font-bold text-gray-700">Benutzerverwaltung</h2>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Login</th
>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Gruppen</th
>
{#if editingUserId}
<th
class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"
>Passwort</th
>
{/if}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.users as user}
<tr class="group/row hover:bg-gray-50">
{#if editingUserId === user.id}
<!-- === EDIT MODE === -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.username}
<!-- Hidden ID Input for the form -->
<input
type="hidden"
name="username"
value={user.username}
form="edit-form-{user.id}"
/>
</td>
<td class="px-6 py-4 text-sm">
<!-- Groups Select -->
<select
name="groupIds"
multiple
form="edit-form-{user.id}"
class="block w-full rounded border-brand-mint text-xs p-1 min-h-[80px]"
>
{#each data.groups as group}
<option
value={group.id}
selected={user.groups.some((g) => g.id === group.id)}
>
{group.name}
</option>
{/each}
</select>
<p class="text-[10px] text-gray-400 mt-1">Strg+Klick für Auswahl</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right align-top">
<!-- Password & Buttons -->
<form
id="edit-form-{user.id}"
method="POST"
action="?/createUser"
use:enhance={() =>
async ({ update }) => {
await update();
cancelEditUser();
}}
class="flex flex-col items-end gap-2"
>
<input
type="password"
name="password"
placeholder="Neues PW (optional)"
class="w-32 py-1 px-2 text-xs border border-brand-mint rounded"
/>
<div class="flex gap-2 mt-1">
<button
type="submit"
class="bg-green-600 text-white px-2 py-1 rounded text-xs font-bold uppercase hover:bg-green-700"
>
Speichern
</button>
<button
type="button"
on:click={cancelEditUser}
class="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-bold uppercase hover:bg-gray-300"
>
Abbrechen
</button>
</div>
</form>
</td>
{:else}
<!-- === VIEW MODE === -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.username}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#if user.groups && user.groups.length > 0}
{#each user.groups as group}
<span
class="px-2 py-0.5 text-[10px] font-bold uppercase rounded-full bg-blue-50 text-blue-700 border border-blue-100"
>
{group.name}
</span>
{/each}
{:else}
<span class="text-xs text-gray-400 italic">Keine Gruppen</span>
{/if}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-4">
<!-- Edit Button -->
<button
on:click={() => startEditUser(user.id)}
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
>
Bearbeiten
</button>
<!-- Delete Button -->
<form
method="POST"
action="?/deleteUser"
use:enhance={({ cancel }) => {
if (!confirm(`Benutzer '${user.username}' wirklich löschen?`)) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
class="flex items-center"
>
<input type="hidden" name="id" value={user.id} />
<button
class="text-gray-300 hover:text-red-600 transition-colors p-1"
title="Benutzer löschen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
<!-- Create User Form -->
<div class="p-6 bg-gray-50 border-t border-gray-200">
<h3 class="text-xs font-bold uppercase text-gray-500 mb-4 tracking-wide">
Neuen Benutzer anlegen
</h3>
<form
method="POST"
action="?/createUser"
use:enhance
class="grid grid-cols-1 md:grid-cols-6 gap-4 items-start"
>
<input
type="text"
name="username"
placeholder="Login"
required
class="rounded border-gray-300 text-sm w-full"
/>
<input
type="password"
name="password"
placeholder="Passwort"
required
class="rounded border-gray-300 text-sm w-full"
/>
<!-- Multi-Select for Groups -->
<div class="md:col-span-3">
<select
name="groupIds"
multiple
class="rounded border-gray-300 text-sm w-full h-[42px] py-1"
required
title="Strg+Klick für mehrere"
>
{#each data.groups as group}
<option value={group.id}>{group.name}</option>
{/each}
</select>
<p class="text-[10px] text-gray-400 mt-1">Strg+Klick für Mehrfachauswahl</p>
</div>
<button
type="submit"
class="bg-brand-navy text-white h-[42px] rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full"
>Anlegen</button
>
</form>
</div>
</div>
{:else if activeTab === 'tags'}
<!-- TAGS SECTION (unchanged logic, just ensuring style consistency) -->
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 bg-yellow-50/50">
<h2 class="text-lg font-bold text-gray-700">Schlagworte</h2>
<p class="text-xs text-yellow-800 mt-1">
Warnung: Umbenennen oder Löschen wirkt sich auf alle verknüpften Dokumente aus.
</p>
</div>
<ul class="divide-y divide-gray-100 max-h-[600px] overflow-y-auto">
{#each data.tags as tag}
<li class="px-6 py-3 flex items-center justify-between hover:bg-gray-50 group">
{#if editingTagId === tag.id}
<form
method="POST"
action="?/updateTag"
use:enhance={() =>
async ({ update }) => {
await update();
cancelEditTag();
}}
class="flex-1 flex gap-2 items-center"
>
<input type="hidden" name="id" value={tag.id} />
<input
type="text"
name="name"
bind:value={editingTagName}
class="flex-1 border-brand-mint ring-1 ring-brand-mint rounded px-2 py-1 text-sm"
autofocus
/>
<button class="text-green-600 hover:text-green-800"
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/></svg
></button
>
<button
type="button"
on:click={cancelEditTag}
class="text-gray-400 hover:text-gray-600"
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
></button
>
</form>
{:else}
<span class="text-sm font-medium text-brand-navy bg-brand-sand/30 px-2 py-1 rounded">
{tag.name}
</span>
<div
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
on:click={() => startEditTag(tag)}
class="p-1 text-gray-400 hover:text-brand-navy"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/></svg
>
</button>
<form
method="POST"
action="?/deleteTag"
use:enhance={({ cancel }) => {
// This runs BEFORE the request is sent
if (
!confirm(
'Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.'
)
) {
cancel(); // Stop the request
}
// This runs AFTER the server responds
return async ({ update }) => {
await update();
};
}}
class="inline"
>
<input type="hidden" name="id" value={tag.id} />
<button class="p-1 text-gray-400 hover:text-red-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/></svg
>
</button>
</form>
</div>
{/if}
</li>
{/each}
</ul>
</div>
{:else if activeTab === 'groups'}
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h2 class="text-lg font-bold text-gray-700">Gruppenverwaltung</h2>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Name</th
>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Berechtigungen</th
>
<th
class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"
>Aktionen</th
>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.groups as group}
<tr class="group/row hover:bg-gray-50">
{#if editingGroupId === group.id}
<!-- EDIT MODE -->
<td colspan="3" class="px-6 py-4">
<form
method="POST"
action="?/updateGroup"
use:enhance={() =>
async ({ update }) => {
await update();
cancelEditGroup();
}}
class="flex flex-col sm:flex-row items-start gap-4 w-full"
>
<input type="hidden" name="id" value={group.id} />
<!-- Name Input -->
<div class="w-full sm:w-1/3">
<input
type="text"
name="name"
value={group.name}
class="w-full text-sm border-brand-mint rounded"
required
/>
</div>
<!-- Permissions Checkboxes -->
<div class="flex-1 flex flex-wrap gap-4 items-center h-full pt-2">
{#each availablePermissions as perm}
<label
class="inline-flex items-center text-xs font-bold text-gray-600 uppercase"
>
<input
type="checkbox"
name="permissions"
value={perm}
checked={group.permissions.includes(perm)}
class="mr-2 text-brand-navy focus:ring-brand-mint rounded border-gray-300"
/>
{perm.replace('_', ' ')}
</label>
{/each}
</div>
<!-- Actions -->
<div class="flex gap-2 self-start sm:self-center">
<button type="submit" class="text-green-600 hover:text-green-800 p-1">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/></svg
>
</button>
<button
type="button"
on:click={cancelEditGroup}
class="text-gray-400 hover:text-red-500 p-1"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
</button>
</div>
</form>
</td>
{:else}
<!-- VIEW MODE -->
<td class="px-6 py-4 whitespace-nowrap text-sm font-bold text-brand-navy">
{group.name}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#each group.permissions as perm}
<span
class="px-2 py-0.5 text-[10px] font-bold uppercase rounded-full
{perm === 'ADMIN'
? 'bg-red-50 text-red-700 border-red-100'
: 'bg-gray-100 text-gray-600 border-gray-200'}"
>
{perm}
</span>
{/each}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-3">
<button
on:click={() => startEditGroup(group.id)}
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
>
Bearbeiten
</button>
<form
method="POST"
action="?/deleteGroup"
use:enhance={({ cancel }) => {
if (!confirm('Gruppe wirklich löschen?')) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
>
<input type="hidden" name="id" value={group.id} />
<button
class="text-gray-300 hover:text-red-600 p-1 transition-colors"
title="Löschen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
<!-- CREATE GROUP FORM -->
<div class="p-6 bg-gray-50 border-t border-gray-200">
<h3 class="text-xs font-bold uppercase text-gray-500 mb-4 tracking-wide">
Neue Gruppe anlegen
</h3>
<form
method="POST"
action="?/createGroup"
use:enhance
class="flex flex-col md:flex-row gap-4 items-start md:items-center"
>
<div class="flex-1 w-full">
<input
type="text"
name="name"
placeholder="Gruppenname (z.B. Editoren)"
required
class="rounded border-gray-300 text-sm w-full"
/>
</div>
<div class="flex gap-4 items-center">
{#each availablePermissions as perm}
<label class="inline-flex items-center text-xs font-bold text-gray-600 uppercase">
<input
type="checkbox"
name="permissions"
value={perm}
class="mr-2 text-brand-navy focus:ring-brand-mint rounded border-gray-300"
/>
{perm.replace('_', ' ')}
</label>
{/each}
</div>
<button
type="submit"
class="bg-brand-navy text-white px-6 py-2 rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full md:w-auto"
>
Anlegen
</button>
</form>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,34 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, fetch }) => {
// 1. Suchparameter aus der URL des Browsers holen
const q = url.searchParams.get('q') || '';
try {
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
// Wir nutzen hier den internen Docker-Hostnamen oder localhost, je nach Netzwerk
const backendUrl = `http://localhost:8080/api/persons?q=${encodeURIComponent(q)}`;
const response = await fetch(backendUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
console.error(`Backend Error: ${response.status}`);
return json([], { status: response.status });
}
const data = await response.json();
// 4. Daten zurück an den Browser schicken
return json(data);
} catch (error) {
console.error("Proxy Error:", error);
return json([], { status: 500 });
}
};

View File

@@ -0,0 +1,35 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, fetch }) => {
// 1. Suchparameter aus der URL des Browsers holen
const q = url.searchParams.get('q') || '';
try {
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
// Wir nutzen hier den internen Docker-Hostnamen oder localhost, je nach Netzwerk
const backendUrl = `http://localhost:8080/api/tags?q=${encodeURIComponent(q)}`;
const response = await fetch(backendUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
console.error(`Backend Error: ${response.status}`);
return json([], { status: response.status });
}
const data = await response.json();
console.log("Tags Data", data)
// 4. Daten zurück an den Browser schicken
return json(data);
} catch (error) {
console.error("Proxy Error:", error);
return json([], { status: 500 });
}
};

View File

@@ -0,0 +1,62 @@
import { env } from '$env/dynamic/private';
export async function load({ url, fetch }) {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
// 1. Parameter auslesen
const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || '';
const from = url.searchParams.get('from') || '';
const to = url.searchParams.get('to') || '';
const dir = url.searchParams.get('dir') || 'DESC';
let documents = [];
let senderName = '';
let receiverName = '';
// 2. Fetch-Requests vorbereiten
const requests = [];
// Dokumente laden (nur wenn beide IDs da sind)
if (senderId && receiverId) {
const query = new URLSearchParams({ senderId, receiverId, dir });
if (from) query.set('from', from);
if (to) query.set('to', to);
requests.push(
fetch(`${baseUrl}/api/documents/conversation?${query}`)
.then(r => r.ok ? r.json() : [])
.then(data => documents = data)
);
}
// Namen auflösen für Typeahead Initial Value
if (senderId) {
requests.push(
fetch(`${baseUrl}/api/persons/${senderId}`)
.then(r => r.ok ? r.json() : null)
.then(p => senderName = p ? `${p.firstName} ${p.lastName}` : '')
);
}
if (receiverId) {
requests.push(
fetch(`${baseUrl}/api/persons/${receiverId}`)
.then(r => r.ok ? r.json() : null)
.then(p => receiverName = p ? `${p.firstName} ${p.lastName}` : '')
);
}
// Alles parallel abfeuern
await Promise.all(requests);
return {
documents,
initialValues: {
senderName,
receiverName
},
filters: { senderId, receiverId, from, to, dir }
};
}

View File

@@ -0,0 +1,272 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
export let data;
// Data & State
let documents = [];
let initialValues = { senderName: '', receiverName: '' };
// Filter State
let senderId = '';
let receiverId = '';
let fromDate = '';
let toDate = '';
let sortDir = 'DESC';
// Reactive Update
$: {
documents = data.documents;
initialValues = data.initialValues;
senderId = data.filters.senderId;
receiverId = data.filters.receiverId;
fromDate = data.filters.from;
toDate = data.filters.to;
sortDir = data.filters.dir;
}
// Filter Logic
function applyFilters() {
setTimeout(() => {
const params = new URLSearchParams();
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (fromDate) params.set('from', fromDate);
if (toDate) params.set('to', toDate);
params.set('dir', sortDir);
goto(`?${params.toString()}`, { keepFocus: true });
}, 0);
}
function toggleSort() {
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
applyFilters();
}
function handleSenderChange(event: CustomEvent) {
senderId = event.detail.value;
applyFilters();
}
function handleReceiverChange(event: CustomEvent) {
receiverId = event.detail.value;
applyFilters();
}
</script>
<div class="max-w-5xl mx-auto py-10 px-4">
<!-- Page Header -->
<div class="mb-8 border-b border-brand-navy/10 pb-4">
<h1 class="text-3xl font-serif font-medium text-brand-navy">Konversationen</h1>
<p class="text-brand-navy/60 font-sans text-sm mt-2">
Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.
</p>
</div>
<!-- FILTER BAR -->
<div class="bg-white p-8 shadow-sm border border-brand-sand mb-10 relative z-20">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-6">
<!-- Sender -->
<div
class="relative z-30 [&_label]:text-xs [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:mb-2 [&_input]:py-2.5 [&_input]:border-gray-300 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy"
>
<PersonTypeahead
name="senderId"
label="Person A (Absender)"
value={senderId}
initialName={initialValues.senderName}
on:change={handleSenderChange}
/>
</div>
<!-- Receiver -->
<div
class="relative z-30 [&_label]:text-xs [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:mb-2 [&_input]:py-2.5 [&_input]:border-gray-300 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy"
>
<PersonTypeahead
name="receiverId"
label="Person B (Empfänger)"
value={receiverId}
initialName={initialValues.receiverName}
on:change={handleReceiverChange}
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 items-end relative z-10">
<!-- Date From -->
<div>
<label
for="dateFrom"
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
>Zeitraum von</label
>
<input
id="dateFrom"
type="date"
bind:value={fromDate}
on:change={() => applyFilters()}
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
/>
</div>
<!-- Date To -->
<div>
<label
for="dateTo"
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
>Zeitraum bis</label
>
<input
id="dateTo"
type="date"
bind:value={toDate}
on:change={() => applyFilters()}
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
/>
</div>
<!-- Sort Toggle -->
<div>
<button
on:click={toggleSort}
class="w-full flex items-center justify-center h-[42px] border border-brand-sand text-xs font-bold uppercase tracking-wide text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
>
<span class="mr-2">Sortierung:</span>
<span>{sortDir === 'DESC' ? 'Neueste zuerst' : 'Älteste zuerst'}</span>
<svg
class="w-4 h-4 ml-2 transform {sortDir === 'ASC'
? 'rotate-180'
: ''} transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
</div>
</div>
</div>
<!-- RESULTS LIST SECTION -->
{#if !senderId || !receiverId}
<div
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand border-dashed rounded-sm text-center"
>
<div class="bg-brand-sand/30 p-4 rounded-full mb-4 text-brand-navy">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/></svg
>
</div>
<p class="text-brand-navy font-serif text-lg">Wählen Sie zwei Personen aus</p>
<p class="text-gray-500 font-sans text-sm mt-1">Die Korrespondenz wird hier angezeigt.</p>
</div>
{:else if documents.length === 0}
<div
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand rounded-sm text-center shadow-sm"
>
<p class="text-brand-navy font-serif">Keine Dokumente gefunden.</p>
<p class="text-gray-400 text-sm mt-2">Versuchen Sie, den Zeitraum anzupassen.</p>
</div>
{:else}
<!-- CHAT CONTAINER -->
<!-- Added: White background, Border, Shadow to separate from page -->
<div class="bg-white border border-brand-sand shadow-sm rounded-sm relative overflow-hidden">
<!-- Decoration: Central Timeline Line -->
<div
class="absolute left-1/2 top-0 bottom-0 w-px bg-brand-sand/30 transform -translate-x-1/2 hidden md:block"
></div>
<!-- Scrollable Area (optional, if you want max-height) -->
<div class="p-6 md:p-8">
<!-- TIGHTER GAP: Changed from gap-8 to gap-4 -->
<div class="flex flex-col gap-4 relative z-10">
{#each documents as doc}
{@const isRight = doc.sender?.id === senderId}
<!-- Message Row -->
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
<!-- Bubble Group -->
<div
class="flex max-w-[90%] md:max-w-[70%] gap-3 {isRight
? 'flex-row-reverse'
: 'flex-row'}"
>
<!-- AVATAR (Small) -->
<div class="flex-shrink-0 mt-auto mb-1 hidden sm:block">
<div
class="w-8 h-8 rounded-full flex items-center justify-center font-serif text-xs border shadow-sm
{isRight
? 'bg-brand-navy text-white border-brand-navy'
: 'bg-white text-brand-navy border-brand-sand'}"
>
{#if doc.sender}
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
{:else}
?
{/if}
</div>
</div>
<!-- BUBBLE CARD -->
<!-- Adjusted padding (p-4) and added light bg to left bubbles for contrast -->
<a
href="/documents/{doc.id}"
class="group block p-4 rounded shadow-sm transition-all duration-200 transform hover:-translate-y-0.5 hover:shadow-md border
{isRight
? 'bg-brand-navy text-white border-brand-navy rounded-br-none'
: 'bg-brand-sand/10 text-brand-navy border-brand-sand rounded-bl-none'}"
>
<!-- Header -->
<div class="flex justify-between items-start gap-4 mb-2">
<h3
class="font-serif font-medium text-sm leading-snug {isRight
? 'text-white'
: 'text-brand-navy'}"
>
{doc.title || doc.originalFilename}
</h3>
<!-- Status Dot -->
<span
class="flex-shrink-0 w-1.5 h-1.5 rounded-full mt-1.5
{doc.status === 'UPLOADED'
? 'bg-brand-mint'
: 'bg-yellow-400'}"
title={doc.status}
>
</span>
</div>
<!-- Metadata -->
<div
class="flex flex-wrap gap-3 text-[10px] font-sans uppercase tracking-wider opacity-80 {isRight
? 'text-blue-100'
: 'text-gray-500'}"
>
<span class="flex items-center">
{doc.documentDate || '—'}
</span>
{#if doc.location}
<span class="flex items-center">
{doc.location}
</span>
{/if}
</div>
</a>
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
</script>
<a href={resolve('/demo/paraglide')}>paraglide</a>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { setLocale } from '$lib/paraglide/runtime';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
</script>
<h1>{m.hello_world({ name: 'SvelteKit User' })}</h1>
<div>
<button onclick={() => setLocale('en')}>en</button>
<button onclick={() => setLocale('es')}>es</button>
<button onclick={() => setLocale('de')}>de</button>
</div><p>
If you use VSCode, install the <a href="https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension" target="_blank">Sherlock i18n extension</a> for a better i18n experience.
</p>

View File

@@ -0,0 +1,37 @@
import { error, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export async function load({ params, fetch }) {
const { id } = params;
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
try {
const res = await fetch(`${baseUrl}/api/documents/${id}`);
if (res.status === 404) {
throw error(404, 'Dokument nicht gefunden');
}
if (res.status === 401) {
throw redirect(302, '/login');
}
if (!res.ok) {
console.error(`Backend Fehler (${res.status}):`, res.statusText);
throw error(500, 'Fehler beim Laden des Dokuments');
}
const document = await res.json();
return {
document
};
} catch (e) {
// Fehlerbehandlung
if (e.status) throw e; // Redirects und HttpErrors durchlassen
console.error("Ladefehler:", e);
throw error(500, 'Verbindung zum Server fehlgeschlagen');
}
}

View File

@@ -0,0 +1,447 @@
<script lang="ts">
export let data;
$: doc = data.document;
// Instead of a direct link, we use a reactive variable for the Blob URL
let fileUrl = '';
let isLoading = false;
let error = '';
// Reactive statement: Whenever the document ID changes, load the file
$: if (doc?.id && doc?.filePath) {
loadFile(doc.id);
}
async function loadFile(id) {
isLoading = true;
error = '';
fileUrl = ''; // Reset previous URL
try {
// 1. Fetch with current authentication
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');
}
// 2. Create a Blob from the data
const blob = await response.blob();
// 3. Create a temporary URL for this Blob
fileUrl = URL.createObjectURL(blob);
} catch (e) {
console.error(e);
error = 'Vorschau konnte nicht geladen werden.';
} finally {
isLoading = false;
}
}
</script>
<div class="h-screen flex flex-col bg-brand-sand">
<!-- Top Bar -->
<div
class="bg-white border-b border-brand-sand px-6 py-4 flex items-center justify-between z-10 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"
>
<div
class="w-8 h-8 rounded-full bg-brand-sand group-hover:bg-brand-mint flex items-center justify-center transition-colors"
>
<svg
class="w-4 h-4 text-brand-navy"
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>
</div>
<span>Zurück</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}>
{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
{doc.status === 'UPLOADED'
? 'bg-brand-mint/30 text-brand-navy border border-brand-mint'
: 'bg-yellow-100 text-yellow-800 border border-yellow-200'}"
>
{doc.status}
</span>
</div>
</div>
<div class="flex items-center gap-3 flex-shrink-0 ml-4 font-sans">
<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"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
Bearbeiten
</a>
{#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"
title="Download"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</a>
{/if}
</div>
</div>
<!-- Content Area -->
<div class="flex-1 flex 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"
>
<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"
>
Details
</h3>
<div class="space-y-5">
<!-- Date -->
<div class="flex items-start group">
<span class="text-brand-mint w-8 mt-0.5">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/></svg
>
</span>
<div>
<span class="block font-serif text-lg text-brand-navy">
{doc.documentDate ? doc.documentDate : '—'}
</span>
<span class="text-xs font-sans text-gray-500">Dokumentendatum</span>
</div>
</div>
<!-- Creation Location -->
<div class="flex items-start group">
<span class="text-brand-mint w-8 mt-0.5">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/></svg
>
</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">Erstellungsort</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">
<!-- Archive Box Icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
</span>
<div>
<span class="block font-serif text-lg text-brand-navy">
{doc.documentLocation}
</span>
<span class="text-xs font-sans text-gray-500">Aufbewahrungsort (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">
<!-- Tag Icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 8V3c0-1.105.895-2 2-2z"
/>
</svg>
</span>
<div class="flex-1">
<div class="flex flex-wrap gap-2 mb-1">
{#each doc.tags as tag}
<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"
title="Nach '{tag.name}' filtern"
>
{tag.name}
</a>
{/each}
</div>
<span class="text-xs font-sans text-gray-500">Schlagworte</span>
</div>
</div>
{/if}
</div>
</div>
<!-- 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"
>
Personen
</h3>
<div class="mb-6">
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">Absender</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"
>
<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"
>
{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"
>
{doc.sender.firstName}
{doc.sender.lastName}
</p>
{#if doc.sender.alias}
<p class="text-xs font-sans text-gray-500">{doc.sender.alias}</p>
{/if}
</div>
</div>
</a>
{:else}
<span class="text-sm font-serif text-gray-400 italic">Nicht angegeben</span>
{/if}
</div>
<div>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">Empfänger</span>
{#if doc.receivers && doc.receivers.length > 0}
<div class="space-y-2">
{#each doc.receivers as receiver}
<div
class="flex items-center justify-between p-3 rounded border border-brand-sand bg-white hover:border-brand-navy transition group"
>
<a href="/persons/{receiver.id}" class="flex items-center gap-3 flex-1 min-w-0">
<div
class="w-6 h-6 rounded-full bg-gray-100 text-gray-500 flex items-center justify-center text-xs font-serif"
>
{receiver.firstName[0]}{receiver.lastName[0]}
</div>
<span
class="font-serif text-sm text-brand-navy group-hover:text-brand-navy truncate"
>
{receiver.firstName}
{receiver.lastName}
</span>
</a>
{#if doc.sender}
<a
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
class="text-gray-300 hover:text-brand-mint transition"
title="Konversation anzeigen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</a>
{/if}
</div>
{/each}
</div>
{:else}
<span class="text-sm font-serif text-gray-400 italic">Keine Empfänger</span>
{/if}
</div>
</div>
<!-- 3. INHALT GROUP (Merged Summary & Transcription) -->
{#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"
>
Inhalt
</h3>
<div class="space-y-6">
<!-- Summary Sub-Section -->
{#if doc.summary}
<div>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase"
>Zusammenfassung</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"
>
{doc.summary}
</div>
</div>
{/if}
<!-- Transcription Sub-Section -->
{#if doc.transcription}
<div>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase"
>Transkription</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"
>
{doc.transcription}
</div>
</div>
{/if}
</div>
</div>
{/if}
<!-- Footer -->
<div class="pt-4 border-t border-brand-sand text-[10px] font-sans text-gray-400">
<p class="truncate">ID: {doc.id}</p>
<p class="truncate mt-1">{doc.originalFilename}</p>
</div>
</div>
</aside>
<!-- RIGHT: PREVIEW AREA -->
<main class="flex-1 bg-[#2A2A2A] relative flex flex-col items-center justify-center">
{#if isLoading}
<!-- Loading Spinner -->
<div class="text-brand-mint flex flex-col items-center">
<svg
class="animate-spin h-8 w-8 mb-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span class="font-sans text-sm tracking-wide">Lade Dokument...</span>
</div>
{:else if error}
<div class="text-gray-400 text-center px-4">
<p class="font-serif mb-2">{error}</p>
{#if doc.filePath}
<!-- Direct link as fallback -->
<a
href={`/api/documents/${doc.id}/file`}
target="_blank"
class="underline hover:text-white text-sm"
>
Direkter Download versuchen
</a>
{/if}
</div>
{:else if !doc.filePath}
<!-- No File State -->
<div class="flex flex-col items-center text-gray-400">
<div class="bg-white/5 p-8 rounded-full mb-6">
<!-- Icon... -->
<svg class="w-12 h-12 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/></svg
>
</div>
<p class="font-sans text-sm tracking-wide uppercase">Kein Scan vorhanden</p>
</div>
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')}
<!-- PDF Iframe with Blob URL -->
<iframe
src={fileUrl}
title="Document Preview"
class="w-full h-full border-none bg-white"
type="application/pdf"
></iframe>
{:else if fileUrl}
<!-- Image with Blob URL -->
<div class="w-full h-full flex items-center justify-center overflow-auto p-8">
<img
src={fileUrl}
alt="Original Scan"
class="max-w-full max-h-full object-contain shadow-2xl"
/>
</div>
{/if}
</main>
</div>
</div>

View File

@@ -0,0 +1,49 @@
import { error, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export async function load({ params, fetch }) {
const { id } = params;
const baseUrl = 'http://localhost:8080';
try {
// Parallel Dokument und Personen laden
const [docRes, personsRes] = await Promise.all([
fetch(`${baseUrl}/api/documents/${id}`),
fetch(`${baseUrl}/api/persons`)
]);
if (!docRes.ok) throw error(docRes.status, 'Dokument nicht gefunden');
if (!personsRes.ok) throw error(personsRes.status, 'Personen konnten nicht geladen werden');
return {
document: await docRes.json(),
persons: await personsRes.json()
};
} catch (e) {
console.error(e);
throw error(500, 'Ladefehler');
}
}
export const actions = {
default: async ({ request, params, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const formData = await request.formData();
// Sende den FormData Request direkt an das Spring Backend weiter
// (Spring kann Multipart verarbeiten)
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
method: "PUT",
body: formData
});
if (!res.ok) {
return { success: false, message: 'Speichern fehlgeschlagen' };
}
throw redirect(303, `/documents/${params.id}`);
}
};

View File

@@ -0,0 +1,190 @@
<script lang="ts">
import { enhance } from '$app/forms';
import TagInput from '$lib/components/TagInput.svelte';
export let data;
export let form; // Rückgabe der Action (Fehler etc.)
let { document: doc, persons } = data;
let tags = doc.tags ? doc.tags.map(t => t.name) : [];
</script>
<div class="max-w-4xl mx-auto p-6 bg-white shadow mt-10 rounded-lg">
<h1 class="text-2xl font-bold mb-6">Dokument bearbeiten</h1>
{#if form?.message}
<div class="bg-red-100 text-red-700 p-3 rounded mb-4">{form.message}</div>
{/if}
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6">
<!-- Datei Austausch -->
<div class="bg-blue-50 p-4 rounded border border-blue-100">
<label for="file-upload" class="block text-sm font-medium text-gray-700 mb-2"
>Datei ersetzen (optional)</label
>
<div class="flex items-center gap-4">
<span class="text-xs text-gray-500">Aktuell: {doc.originalFilename}</span>
<!-- ID hinzugefügt -->
<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-full file:border-0
file:text-sm file:font-semibold
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Titel -->
<div>
<label for="title" class="block text-sm font-medium text-gray-700">Titel</label>
<!-- ID hinzugefügt -->
<input
id="title"
type="text"
name="title"
value={doc.title || ''}
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label for="documentLocation" class="block text-sm font-medium text-gray-700"
>Dokumentenort</label
>
<!-- ID hinzugefügt -->
<input
id="documentLocation"
type="text"
name="documentLocation"
value={doc.documentLocation || ''}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
/>
</div>
<!-- Datum -->
<div>
<label for="documentDate" class="block text-sm font-medium text-gray-700">Datum</label>
<!-- ID hinzugefügt -->
<input
id="documentDate"
type="text"
name="documentDate"
value={doc.documentDate || ''}
placeholder="YYYY-MM-DD"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
/>
</div>
<!-- Ort -->
<div>
<label for="location" class="block text-sm font-medium text-gray-700">Ort</label>
<!-- ID hinzugefügt -->
<input
id="location"
type="text"
name="location"
value={doc.location || ''}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
/>
</div>
</div>
<!-- Sender -->
<div>
<label for="senderId" class="block text-sm font-medium text-gray-700">Absender</label>
<!-- ID hinzugefügt -->
<select
id="senderId"
name="senderId"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
>
<option value="">-- Unbekannt --</option>
{#each persons as person}
<option value={person.id} selected={doc.sender?.id === person.id}>
{person.firstName}
{person.lastName}
</option>
{/each}
</select>
</div>
<!-- Empfänger (Multi-Select) -->
<div>
<label for="receiverIds" class="block text-sm font-medium text-gray-700"
>Empfänger (Strg+Klick für mehrere)</label
>
<!-- ID hinzugefügt -->
<select
id="receiverIds"
name="receiverIds"
multiple
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm h-32"
>
{#each persons as person}
<option value={person.id} selected={doc.receivers?.some((r) => r.id === person.id)}>
{person.firstName}
{person.lastName}
</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm font-bold text-brand-navy uppercase tracking-widest mb-2">
Schlagworte
</label>
<TagInput bind:tags />
<input type="hidden" name="tags" value={tags.join(',')} />
</div>
<!-- Inhalt -->
<div>
<label for="summary" class="block text-sm font-medium text-gray-700">Inhalt</label>
<!-- ID hinzugefügt -->
<textarea
id="summary"
name="summary"
rows="2"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm font-serif"
>{doc.summary || ''}</textarea
>
</div>
<!-- Transkription -->
<div>
<label for="transcription" class="block text-sm font-medium text-gray-700"
>Transkription</label
>
<!-- ID hinzugefügt -->
<textarea
id="transcription"
name="transcription"
rows="10"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm font-serif"
>{doc.transcription || ''}</textarea
>
</div>
<!-- Buttons -->
<div class="flex justify-end gap-4">
<a
href="/documents/{doc.id}"
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Abbrechen
</a>
<button
type="submit"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
Speichern
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,48 @@
/* 1. Import Tailwind (replaces @tailwind base/components/utilities) */
@import url('https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&family=Montserrat:wght@400;500;600;700&display=swap');
@import "tailwindcss";
/* 2. Define Custom Theme Variables */
@theme {
/* COLORS:
Defining a variable starting with --color-* automatically creates
utilities like bg-brand-navy, text-brand-navy, border-brand-navy, etc.
*/
--color-brand-navy: #002850;
--color-brand-mint: #A6DAD8;
--color-brand-sand: #E4E2D7;
--color-brand-white: #ffffff;
--color-brand-dark: #1A1A1A;
/* FONTS:
Defining --font-* creates utilities like font-sans, font-serif.
We override the defaults here.
*/
--font-sans: "Montserrat", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Merriweather", ui-serif, Georgia, serif;
/* SPACING/SIZING (Optional):
You can also define custom sizes if needed, e.g., for that huge number
*/
--text-huge: 4rem;
}
/* 3. Custom Utilities & Base Styles */
/* Import the fonts from Google */
/* Apply base styles directly to HTML elements */
@layer base {
body {
/* Use the theme variable directly using standard CSS syntax */
background-color: var(--color-brand-sand);
color: var(--color-brand-navy);
font-family: var(--font-serif);
}
/* Set headings to use the brand sans-serif font */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-sans);
font-weight: 500; /* Medium weight matches the screenshot headers */
}
}

View File

@@ -0,0 +1,53 @@
import { fail, redirect, type Actions } from '@sveltejs/kit';
export const actions = {
login: async ({ request, cookies, fetch }) => {
const data = await request.formData();
const username = data.get('username') as string;
const password = data.get('password') as string;
if (!username || !password) {
return fail(400, { error: 'Bitte Benutzername und Passwort eingeben.' });
}
// Wir bauen den Basic Auth Header
const credentials = btoa(`${username}:${password}`);
const authHeader = `Basic ${credentials}`;
try {
// Test-Request an das Backend (z.B. an den Upload-Endpunkt oder einen speziellen /me Endpunkt)
// Wir nutzen hier http://localhost:8080, da beide Container im selben Netz sind (oder localhost im DevContainer)
const response = await fetch('http://localhost:8080/api/users/me', {
method: 'GET',
headers: {
'Authorization': authHeader
}
});
if (response.status === 401 || response.status === 403) {
return fail(401, { error: 'Ungültige Zugangsdaten.' });
}
if (!response.ok) {
return fail(500, { error: 'Serverfehler beim Login.' });
}
// Login erfolgreich! Wir speichern den Header in einem Cookie.
// (In Produktion würde man hier ein Session-Token nutzen, aber für Basic Auth müssen wir es mitschleifen)
cookies.set('auth_token', authHeader, {
path: '/',
httpOnly: true, // JavaScript kann das Cookie nicht lesen (Schutz vor XSS)
sameSite: 'strict',
secure: false, // Auf true setzen, wenn wir HTTPS haben
maxAge: 60 * 60 * 24 // 1 Tag
});
} catch (error) {
console.error(error);
return fail(500, { error: 'Verbindung zum Backend fehlgeschlagen.' });
}
// Weiterleitung zur Startseite
return redirect(303, '/');
}
} satisfies Actions;

View File

@@ -0,0 +1,33 @@
<script lang="ts">
// TypeScript Typen für die Form-Antwort
export let form: { error?: string, success?: boolean };
</script>
<div class="min-h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded shadow-md w-96">
<h1 class="text-2xl font-bold mb-6 text-center">Familienarchiv</h1>
<form method="POST" action="?/login" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Benutzername</label>
<input type="text" name="username" id="username" required
class="mt-1 block w-full rounded border-gray-300 shadow-sm p-2 border" />
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Passwort</label>
<input type="password" name="password" id="password" required
class="mt-1 block w-full rounded border-gray-300 shadow-sm p-2 border" />
</div>
{#if form?.error}
<div class="text-red-600 text-sm text-center">{form.error}</div>
{/if}
<button type="submit"
class="bg-brand-navy text-white h-[42px] rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full">
Anmelden
</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,12 @@
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions = {
default: async ({ cookies }) => {
// Das Auth-Cookie löschen
cookies.delete('auth_token', { path: '/' });
// Zur Login-Seite werfen
throw redirect(302, '/login');
}
} satisfies Actions;

View File

@@ -0,0 +1,13 @@
import { page } from 'vitest/browser';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export async function load({ url, fetch }) {
const q = url.searchParams.get('q') || '';
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
// Query Parameter an Backend durchreichen
const apiUrl = new URL(`${baseUrl}/api/persons`);
if (q) apiUrl.searchParams.set('q', q);
const res = await fetch(apiUrl.toString());
if (!res.ok) return { persons: [] };
const persons = await res.json();
return { persons, q };
}

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import { goto } from '$app/navigation';
export let data;
let searchTimeout: any;
// Live-Suche (Debounce)
function handleSearch(e: Event) {
const value = (e.target as HTMLInputElement).value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
goto(`/persons?q=${value}`, { keepFocus: true });
}, 300);
}
</script>
<div class="max-w-7xl mx-auto py-12 sm:px-6 lg:px-8">
<!-- Header Area -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-10 border-b border-brand-navy/10 pb-6">
<div>
<h1 class="text-3xl font-serif font-medium text-brand-navy">Personenverzeichnis</h1>
<p class="text-brand-navy/60 font-sans text-sm mt-2 max-w-xl">
Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.
</p>
</div>
<!-- Search Input -->
<div class="w-full md:w-72">
<label for="search" class="sr-only">Suche</label>
<div class="relative">
<input
id="search"
type="text"
placeholder="Namen suchen..."
value={data.q || ''}
on:input={handleSearch}
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy placeholder-gray-400 py-2 pl-3 pr-10 text-sm font-sans"
/>
<div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-gray-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div>
</div>
</div>
</div>
{#if data.persons.length === 0}
<div class="flex flex-col items-center justify-center py-16 bg-white border border-brand-sand border-dashed rounded-lg text-center">
<div class="w-12 h-12 bg-brand-sand/30 rounded-full flex items-center justify-center mb-3 text-brand-navy">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
</div>
<p class="text-brand-navy font-serif text-lg">Keine Personen gefunden.</p>
<p class="text-gray-500 font-sans text-sm mt-1">Versuchen Sie einen anderen Suchbegriff.</p>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{#each data.persons as person}
<a href="/persons/{person.id}" class="block group h-full">
<div class="h-full bg-white rounded shadow-sm border border-brand-sand p-6 flex items-center gap-4 hover:border-brand-navy hover:shadow-md transition-all duration-200 relative overflow-hidden">
<!-- Decorative Accent on Hover -->
<div class="absolute left-0 top-0 bottom-0 w-1 bg-brand-navy opacity-0 group-hover:opacity-100 transition-opacity"></div>
<!-- Avatar -->
<div class="flex-shrink-0">
<div class="h-12 w-12 rounded-full bg-brand-navy text-white flex items-center justify-center font-serif text-lg group-hover:bg-brand-mint group-hover:text-brand-navy transition-colors">
{person.firstName[0]}{person.lastName[0]}
</div>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="text-base font-serif font-medium text-brand-navy truncate group-hover:underline decoration-brand-mint decoration-2 underline-offset-2">
{person.firstName} {person.lastName}
</p>
{#if person.alias}
<p class="text-xs font-sans text-gray-500 truncate mt-0.5">"{person.alias}"</p>
{/if}
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,27 @@
import { error, } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export async function load({ params, fetch }) {
const { id } = params;
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
try {
// Parallel Fetching: Person Infos + Ihre Dokumente
const [personRes, docsRes] = await Promise.all([
fetch(`${baseUrl}/api/persons/${id}`),
fetch(`${baseUrl}/api/persons/${id}/documents`)
]);
if (personRes.status === 404) throw error(404, 'Person nicht gefunden');
return {
person: await personRes.json(),
documents: await docsRes.json()
};
} catch (e) {
if (e.status) throw e;
throw error(500, 'Ladefehler');
}
}

View File

@@ -0,0 +1,126 @@
<script lang="ts">
export let data;
const { person, documents } = data;
</script>
<div class="max-w-4xl mx-auto py-10 px-4">
<!-- Back Link -->
<div class="mb-6">
<a href="/persons" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group">
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" 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>
Zurück zur Übersicht
</a>
</div>
<!-- Header / Metadata Card -->
<div class="bg-white shadow-sm border border-brand-sand rounded-sm overflow-hidden mb-10">
<!-- Blue Top Border accent -->
<div class="h-2 bg-brand-navy w-full"></div>
<div class="p-8 md:p-10">
<div class="flex flex-col md:flex-row gap-8 items-start">
<!-- Big Avatar / Icon -->
<div class="flex-shrink-0">
<div class="w-24 h-24 rounded-full bg-brand-sand/30 flex items-center justify-center text-brand-navy border border-brand-sand">
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
</div>
</div>
<!-- Info Grid -->
<div class="flex-1 w-full">
<h1 class="text-4xl font-serif text-brand-navy mb-8 border-b border-gray-100 pb-4">
{person.firstName} {person.lastName}
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<span class="block text-xs font-bold font-sans text-gray-400 uppercase tracking-widest mb-1">Voller Name</span>
<span class="block text-lg font-serif text-brand-navy">{person.firstName} {person.lastName}</span>
</div>
{#if person.alias}
<div>
<span class="block text-xs font-bold font-sans text-gray-400 uppercase tracking-widest mb-1">Rufname / Alias</span>
<span class="block text-lg font-serif text-brand-navy italic">"{person.alias}"</span>
</div>
{/if}
{#if person.birthDate}
<div>
<span class="block text-xs font-bold font-sans text-gray-400 uppercase tracking-widest mb-1">Geburtsdatum</span>
<span class="block text-lg font-serif text-brand-navy">{person.birthDate}</span>
</div>
{/if}
<!-- Empty slot or Placeholder for bio/notes -->
</div>
</div>
</div>
</div>
</div>
<!-- Linked Documents Section -->
<div>
<div class="flex items-center justify-between mb-6 border-b border-brand-navy/10 pb-2">
<h2 class="text-xl font-serif text-brand-navy">
Gesendete Dokumente
</h2>
<span class="bg-brand-navy text-white text-xs font-bold px-2 py-1 rounded-full">
{documents.length}
</span>
</div>
{#if documents.length === 0}
<div class="p-12 text-center bg-white border border-brand-sand border-dashed rounded-sm">
<p class="text-gray-500 font-sans">Diese Person ist noch nicht als Absender verknüpft.</p>
</div>
{:else}
<ul class="space-y-3">
{#each documents as doc}
<li class="group">
<a href="/documents/{doc.id}" class="block bg-white border border-brand-sand p-4 hover:border-brand-navy hover:shadow-md transition-all duration-200">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 overflow-hidden">
<!-- Icon Box -->
<div class="flex-shrink-0 h-10 w-10 bg-brand-sand/20 text-brand-navy rounded flex items-center justify-center group-hover:bg-brand-mint group-hover:text-brand-navy transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
</div>
<!-- Text Info -->
<div class="min-w-0">
<div class="font-serif text-base font-medium text-brand-navy truncate group-hover:underline decoration-brand-mint decoration-2 underline-offset-2">
{doc.title || doc.originalFilename}
</div>
<div class="flex items-center text-xs font-sans text-gray-500 mt-0.5 space-x-2">
<span>{doc.documentDate || 'Kein Datum'}</span>
{#if doc.location}
<span class="text-brand-mint"></span>
<span>{doc.location}</span>
{/if}
</div>
</div>
</div>
<!-- Status & Arrow -->
<div class="flex items-center flex-shrink-0 pl-4">
<span class="hidden sm:inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border
{doc.status === 'UPLOADED'
? 'bg-brand-mint/20 text-brand-navy border-brand-mint/50'
: 'bg-yellow-50 text-yellow-800 border-yellow-200'}">
{doc.status}
</span>
<svg class="h-5 w-5 text-gray-300 ml-4 group-hover:text-brand-navy transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</a>
</li>
{/each}
</ul>
{/if}
</div>
</div>