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:
25
frontend/src/app.d.ts
vendored
Normal file
25
frontend/src/app.d.ts
vendored
Normal 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
11
frontend/src/app.html
Normal 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>
|
||||
7
frontend/src/demo.spec.ts
Normal file
7
frontend/src/demo.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
66
frontend/src/hooks.server.ts
Normal file
66
frontend/src/hooks.server.ts
Normal 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
3
frontend/src/hooks.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { deLocalizeUrl } from '$lib/paraglide/runtime';
|
||||
|
||||
export const reroute = (request) => deLocalizeUrl(request.url).pathname;
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal 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 |
116
frontend/src/lib/components/PersonTypeahead.svelte
Normal file
116
frontend/src/lib/components/PersonTypeahead.svelte
Normal 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>
|
||||
138
frontend/src/lib/components/TagInput.svelte
Normal file
138
frontend/src/lib/components/TagInput.svelte
Normal 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>
|
||||
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
7
frontend/src/routes/+layout.server.ts
Normal file
7
frontend/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
117
frontend/src/routes/+layout.svelte
Normal file
117
frontend/src/routes/+layout.svelte
Normal 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>
|
||||
72
frontend/src/routes/+page.server.ts
Normal file
72
frontend/src/routes/+page.server.ts
Normal 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."
|
||||
};
|
||||
}
|
||||
};
|
||||
385
frontend/src/routes/+page.svelte
Normal file
385
frontend/src/routes/+page.svelte
Normal 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>
|
||||
141
frontend/src/routes/admin/+page.server.ts
Normal file
141
frontend/src/routes/admin/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
613
frontend/src/routes/admin/+page.svelte
Normal file
613
frontend/src/routes/admin/+page.svelte
Normal 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>
|
||||
34
frontend/src/routes/api/persons/+server.ts
Normal file
34
frontend/src/routes/api/persons/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
35
frontend/src/routes/api/tags/+server.ts
Normal file
35
frontend/src/routes/api/tags/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
62
frontend/src/routes/conversations/+page.server.ts
Normal file
62
frontend/src/routes/conversations/+page.server.ts
Normal 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 }
|
||||
};
|
||||
}
|
||||
272
frontend/src/routes/conversations/+page.svelte
Normal file
272
frontend/src/routes/conversations/+page.svelte
Normal 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>
|
||||
5
frontend/src/routes/demo/+page.svelte
Normal file
5
frontend/src/routes/demo/+page.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
</script>
|
||||
|
||||
<a href={resolve('/demo/paraglide')}>paraglide</a>
|
||||
17
frontend/src/routes/demo/paraglide/+page.svelte
Normal file
17
frontend/src/routes/demo/paraglide/+page.svelte
Normal 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>
|
||||
37
frontend/src/routes/documents/[id]/+page.server.ts
Normal file
37
frontend/src/routes/documents/[id]/+page.server.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
447
frontend/src/routes/documents/[id]/+page.svelte
Normal file
447
frontend/src/routes/documents/[id]/+page.svelte
Normal 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>
|
||||
49
frontend/src/routes/documents/[id]/edit/+page.server.ts
Normal file
49
frontend/src/routes/documents/[id]/edit/+page.server.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
190
frontend/src/routes/documents/[id]/edit/+page.svelte
Normal file
190
frontend/src/routes/documents/[id]/edit/+page.svelte
Normal 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>
|
||||
48
frontend/src/routes/layout.css
Normal file
48
frontend/src/routes/layout.css
Normal 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 */
|
||||
}
|
||||
}
|
||||
53
frontend/src/routes/login/+page.server.ts
Normal file
53
frontend/src/routes/login/+page.server.ts
Normal 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;
|
||||
33
frontend/src/routes/login/+page.svelte
Normal file
33
frontend/src/routes/login/+page.svelte
Normal 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>
|
||||
12
frontend/src/routes/logout/+page.server.ts
Normal file
12
frontend/src/routes/logout/+page.server.ts
Normal 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;
|
||||
13
frontend/src/routes/page.svelte.spec.ts
Normal file
13
frontend/src/routes/page.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
18
frontend/src/routes/persons/+page.server.ts
Normal file
18
frontend/src/routes/persons/+page.server.ts
Normal 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 };
|
||||
}
|
||||
84
frontend/src/routes/persons/+page.svelte
Normal file
84
frontend/src/routes/persons/+page.svelte
Normal 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>
|
||||
27
frontend/src/routes/persons/[id]/+page.server.ts
Normal file
27
frontend/src/routes/persons/[id]/+page.server.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
126
frontend/src/routes/persons/[id]/+page.svelte
Normal file
126
frontend/src/routes/persons/[id]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user