refactor: migrate all Svelte components from Svelte 4 to Svelte 5 runes

- Replace `export let` with `$props()` and `$bindable()` across all components
- Replace `$:` reactive statements with `$derived()` and `$effect()`
- Replace `createEventDispatcher` with callback props (e.g. `onchange`)
- Replace `on:event` directives with inline event handlers (`onclick`, `oninput`, etc.)
- Replace `<slot />` with `{@render children()}` in layout
- Use `untrack()` for SSR-safe $state initialization from reactive props
- Replace `blur` + `setTimeout` anti-pattern in TagInput with `clickOutside` action
- Fix `page` store usage in layout to use `$app/state` directly
- 0 errors, 0 warnings after svelte-check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-17 11:43:26 +01:00
parent 25e095ea47
commit 4417fc9828
14 changed files with 388 additions and 441 deletions

View File

@@ -1,15 +1,20 @@
<script lang="ts"> <script lang="ts">
type Person = { id?: string; firstName?: string; lastName?: string; alias?: string }; import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
export let selectedPersons: Person[] = []; interface Props {
selectedPersons?: Person[];
}
let searchTerm = ''; let { selectedPersons = $bindable([]) }: Props = $props();
let results: Person[] = [];
let showDropdown = false; let searchTerm = $state('');
let loading = false; let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>; let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement; let inputEl: HTMLInputElement;
let dropdownStyle = ''; let dropdownStyle = $state('');
function updateDropdownPosition() { function updateDropdownPosition() {
if (!inputEl) return; if (!inputEl) return;
@@ -47,7 +52,7 @@
function clickOutside(node: HTMLElement) { function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !(e as Event).defaultPrevented) { if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showDropdown = false; showDropdown = false;
} }
}; };
@@ -56,7 +61,7 @@
} }
</script> </script>
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} /> <svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
{#each selectedPersons as person} {#each selectedPersons as person}
<input type="hidden" name="receiverIds" value={person.id} /> <input type="hidden" name="receiverIds" value={person.id} />
@@ -69,7 +74,7 @@
{person.firstName} {person.lastName} {person.firstName} {person.lastName}
<button <button
type="button" type="button"
on:click={() => removePerson(person.id)} onclick={() => removePerson(person.id)}
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5" class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
aria-label="Entfernen" aria-label="Entfernen"
> >
@@ -85,8 +90,8 @@
type="text" type="text"
autocomplete="off" autocomplete="off"
bind:value={searchTerm} bind:value={searchTerm}
on:input={handleInput} oninput={handleInput}
on:focus={() => { updateDropdownPosition(); showDropdown = true; }} onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder={selectedPersons.length === 0 ? 'Namen tippen...' : ''} placeholder={selectedPersons.length === 0 ? 'Namen tippen...' : ''}
class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none" class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
/> />
@@ -101,10 +106,10 @@
<div class="p-2 text-gray-500 text-sm">Suche...</div> <div class="p-2 text-gray-500 text-sm">Suche...</div>
{:else} {:else}
{#each results as person} {#each results as person}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900" class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
on:click={() => selectPerson(person)} onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button" role="button"
tabindex="0" tabindex="0"
> >

View File

@@ -1,30 +1,33 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
// Props interface Props {
export let name: string; name: string;
export let label: string; label: string;
export let value: string = ""; value?: string;
export let initialName: string = ""; initialName?: string;
onchange?: (value: string) => void;
}
const dispatch = createEventDispatcher(); let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props();
// Lokaler State let searchTerm = $state('');
let searchTerm = initialName;
// Sync mit externen Änderungen (z.B. Reset Button) // Sync with external changes (e.g. reset button) — also sets the initial value
$: searchTerm = initialName; $effect(() => {
searchTerm = initialName;
});
let results: any[] = []; let results: Person[] = $state([]);
let showDropdown = false; let showDropdown = $state(false);
let loading = false; let loading = $state(false);
let debounceTimer: any; let debounceTimer: ReturnType<typeof setTimeout>;
function handleInput() { function handleInput() {
// Wenn der User tippt, ist die alte ID ungültig -> Reset
if (value && searchTerm !== initialName) { if (value && searchTerm !== initialName) {
value = ""; value = '';
dispatch('change', { value: "" }); // Bescheid geben: Auswahl aufgehoben onchange?.('');
} }
showDropdown = true; showDropdown = true;
@@ -38,13 +41,9 @@
loading = true; loading = true;
try { try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`); const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
if (res.ok) { results = res.ok ? await res.json() : [];
results = await res.json();
} else {
results = [];
}
} catch (e) { } catch (e) {
console.error("Suche fehlgeschlagen", e); console.error('Suche fehlgeschlagen', e);
results = []; results = [];
} finally { } finally {
loading = false; loading = false;
@@ -52,17 +51,15 @@
}, 300); }, 300);
} }
function selectPerson(person: any) { function selectPerson(person: Person) {
value = person.id; value = person.id!;
searchTerm = `${person.firstName} ${person.lastName}`; searchTerm = `${person.firstName} ${person.lastName}`;
showDropdown = false; showDropdown = false;
onchange?.(person.id!);
// --- NEU: Event feuern ---
dispatch('change', { value: person.id });
} }
let inputEl: HTMLInputElement; let inputEl: HTMLInputElement;
let dropdownStyle = ''; let dropdownStyle = $state('');
function updateDropdownPosition() { function updateDropdownPosition() {
if (!inputEl) return; if (!inputEl) return;
@@ -71,8 +68,8 @@
} }
function clickOutside(node: HTMLElement) { function clickOutside(node: HTMLElement) {
const handleClick = (event: any) => { const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target) && !event.defaultPrevented) { if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
showDropdown = false; showDropdown = false;
} }
}; };
@@ -83,7 +80,7 @@
} }
</script> </script>
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} /> <svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
<div class="relative" use:clickOutside> <div class="relative" use:clickOutside>
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label> <label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
@@ -96,8 +93,8 @@
id="{name}-search" id="{name}-search"
autocomplete="off" autocomplete="off"
bind:value={searchTerm} bind:value={searchTerm}
on:input={handleInput} oninput={handleInput}
on:focus={() => { updateDropdownPosition(); showDropdown = true; }} onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder="Namen tippen..." 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" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500"
/> />
@@ -111,10 +108,10 @@
<div class="p-2 text-gray-500 text-sm">Suche...</div> <div class="p-2 text-gray-500 text-sm">Suche...</div>
{:else} {:else}
{#each results as person} {#each results as person}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900" class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
on:click={() => selectPerson(person)} onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button" role="button"
tabindex="0" tabindex="0"
> >

View File

@@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
export let tags: string[] = []; // Two-way binding interface Props {
export let allowCreation = true; tags?: string[];
allowCreation?: boolean;
}
let inputVal = ''; let { tags = $bindable([]), allowCreation = true }: Props = $props();
let suggestions: string[] = [];
let activeIndex = -1; let inputVal = $state('');
let showSuggestions = false; let suggestions: string[] = $state([]);
let activeIndex = $state(-1);
let showSuggestions = $state(false);
// Fetch suggestions from backend
async function fetchSuggestions(query: string) { async function fetchSuggestions(query: string) {
if (query.length < 2) { if (query.length < 2) {
suggestions = []; suggestions = [];
@@ -17,13 +20,12 @@
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`); const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
// API returns Tag objects with { id, name }
const names: string[] = data.map((t: { name: string }) => t.name); const names: string[] = data.map((t: { name: string }) => t.name);
suggestions = names.filter((t) => !tags.includes(t)); suggestions = names.filter((t) => !tags.includes(t));
showSuggestions = true; showSuggestions = true;
} }
} catch (e) { } catch (e) {
console.error("Tag fetch error", e); console.error('Tag fetch error', e);
} }
} }
@@ -47,8 +49,8 @@
e.preventDefault(); e.preventDefault();
if (activeIndex >= 0 && suggestions[activeIndex]) { if (activeIndex >= 0 && suggestions[activeIndex]) {
addTag(suggestions[activeIndex]); addTag(suggestions[activeIndex]);
} else if(allowCreation) { } else if (allowCreation) {
addTag(inputVal); // Add new tag addTag(inputVal);
} }
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) { } else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
removeTag(tags.length - 1); removeTag(tags.length - 1);
@@ -61,81 +63,86 @@
} }
} }
function handleInput() { function clickOutside(node: HTMLElement) {
fetchSuggestions(inputVal); const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showSuggestions = false;
}
};
document.addEventListener('click', handleClick, true);
return { destroy() { document.removeEventListener('click', handleClick, true); } };
} }
</script> </script>
<div class="w-full"> <div class="w-full" use:clickOutside>
<!-- Tag Container --> <!-- Tag Container -->
<div <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]" 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 --> <!-- Render Selected Tags -->
{#each tags as tag, i} {#each tags as tag, i}
<span <span
class="bg-brand-sand/30 text-brand-navy text-sm font-medium px-2 py-1 rounded flex items-center gap-1" class="bg-brand-sand/30 text-brand-navy text-sm font-medium px-2 py-1 rounded flex items-center gap-1"
> >
{tag} {tag}
<button <button
type="button" type="button"
on:click={() => removeTag(i)} onclick={() => removeTag(i)}
aria-label="Schlagwort entfernen" aria-label="Schlagwort entfernen"
class="text-brand-navy/50 hover:text-red-500 focus:outline-none" 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" <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path ><path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M6 18L18 6M6 6l12 12" d="M6 18L18 6M6 6l12 12"
/></svg /></svg
> >
</button> </button>
</span> </span>
{/each} {/each}
<!-- Input Field --> <!-- Input Field -->
<div class="relative flex-1 min-w-[120px]"> <div class="relative flex-1 min-w-[120px]">
<input <input
type="text" type="text"
bind:value={inputVal} bind:value={inputVal}
on:input={handleInput} oninput={() => fetchSuggestions(inputVal)}
on:keydown={handleKeydown} onkeydown={handleKeydown}
on:focus={() => handleInput()} onfocus={() => fetchSuggestions(inputVal)}
on:blur={() => setTimeout(() => (showSuggestions = false), 200)} placeholder={tags.length === 0
placeholder={tags.length === 0 ? allowCreation
? allowCreation ? 'Schlagworte hinzufügen...'
? 'Schlagworte hinzufügen...' : 'Nach Schlagworten filtern...'
: 'Nach Schlagworten filtern...' : ''}
: ''} class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none" />
/>
<!-- Typeahead Dropdown --> <!-- Typeahead Dropdown -->
{#if showSuggestions && suggestions.length > 0} {#if showSuggestions && suggestions.length > 0}
<ul <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" 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} {#each suggestions as suggestion, i}
<li <li
role="option" role="option"
aria-selected={i === activeIndex} aria-selected={i === activeIndex}
tabindex="0" tabindex="0"
class="px-3 py-2 text-sm cursor-pointer hover:bg-brand-sand/20 {i === activeIndex 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' ? 'bg-brand-sand/20 text-brand-navy font-bold'
: 'text-gray-700'}" : 'text-gray-700'}"
on:click={() => addTag(suggestion)} onclick={() => addTag(suggestion)}
on:keydown={(e) => e.key === 'Enter' && addTag(suggestion)} onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
> >
{suggestion} {suggestion}
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
</div> </div>
</div> </div>
{#if allowCreation} {#if allowCreation}
<p class="text-xs text-gray-400 mt-1">Enter drücken um Schlagwort zu erstellen.</p> <p class="text-xs text-gray-400 mt-1">Enter drücken um Schlagwort zu erstellen.</p>
{/if} {/if}
</div> </div>

View File

@@ -2,116 +2,112 @@
import './layout.css'; import './layout.css';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { page } from '$app/state'; import { page } from '$app/state';
$: user = page.data.user;
$: isAdmin = user?.groups.some(g => g.permissions.includes("ADMIN")) let { children } = $props();
const isAdmin = $derived(page.data.user?.groups.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')));
</script> </script>
<div class="min-h-screen bg-brand-sand"> <div class="min-h-screen bg-brand-sand">
<!-- Changed background to Sand -->
<!-- Corporate Header --> {#if !page.url.pathname.startsWith('/login')}
{#if !page.url.pathname.startsWith('/login')} <header class="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="flex justify-between h-20">
<div class="flex justify-between h-20">
<!-- Slightly taller header -->
<!-- Logo & Nav --> <!-- Logo & Nav -->
<div class="flex"> <div class="flex">
<!-- LOGO (Extracted from their SVG) --> <div class="flex-shrink-0 flex items-center mr-8">
<div class="flex-shrink-0 flex items-center mr-8"> <a href="/" class="flex items-center gap-2" aria-label="Familienarchiv">
<a href="/" class="flex items-center gap-2" aria-label="Familienarchiv"> <svg
<!-- SVG Code from their site --> width="250"
<svg height="25"
width="250" viewBox="0 0 250 25"
height="25" fill="none"
viewBox="0 0 250 25" xmlns="http://www.w3.org/2000/svg"
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"
<path fill="#B4B9FF"
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" ></path>
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"
<path fill="#B4B9FF"
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" ></path>
fill="#B4B9FF"
></path>
<text <text
x="35" x="35"
y="20" y="20"
fill="#002850" fill="#002850"
font-family="Montserrat" font-family="Montserrat"
font-weight="bold" font-weight="bold"
font-size="20">FAMILIENARCHIV</text font-size="20">FAMILIENARCHIV</text
> >
</svg> </svg>
</a> </a>
</div> </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">
<div class="hidden sm:ml-6 sm:flex sm:space-x-8 items-center"> <a
<a href="/"
href="/" class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
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')
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents') ? 'border-brand-navy text-brand-navy'
? 'border-brand-navy text-brand-navy' : 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}" >
> Dokumente
Dokumente </a>
</a>
<a <a
href="/persons" href="/persons"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans 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') {page.url.pathname.startsWith('/persons')
? 'border-brand-navy text-brand-navy' ? 'border-brand-navy text-brand-navy'
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}" : 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
> >
Personen Personen
</a> </a>
<a <a
href="/conversations" href="/conversations"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans 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') {page.url.pathname.startsWith('/conversations')
? 'border-brand-navy text-brand-navy' ? 'border-brand-navy text-brand-navy'
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}" : 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
> >
Konversationen Konversationen
</a> </a>
{#if isAdmin} {#if isAdmin}
<a <a
href="/admin" href="/admin"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans 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') {page.url.pathname.startsWith('/admin')
? 'border-brand-navy text-brand-navy' ? 'border-brand-navy text-brand-navy'
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}" : 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
> >
Admin Admin
</a> </a>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Right Side --> <!-- Right Side -->
<div class="flex items-center"> <div class="flex items-center">
<form action="/logout" method="POST" use:enhance> <form action="/logout" method="POST" use:enhance>
<button <button
type="submit" type="submit"
class="text-sm text-gray-500 hover:text-brand-navy font-bold uppercase font-sans tracking-wide px-3 py-2 transition" class="text-sm text-gray-500 hover:text-brand-navy font-bold uppercase font-sans tracking-wide px-3 py-2 transition"
> >
Abmelden Abmelden
</button> </button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</header> </header>
{/if} {/if}
<main class="py-6"> <main class="py-6">
<slot /> {@render children()}
</main> </main>
</div> </div>

View File

@@ -3,21 +3,19 @@ import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import TagInput from '$lib/components/TagInput.svelte'; import TagInput from '$lib/components/TagInput.svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { untrack } from 'svelte';
export let data; let { data } = $props();
// Local state variables let q = $state(untrack(() => data.filters?.q || ''));
let q = data.filters?.q || ''; let from = $state(untrack(() => data.filters?.from || ''));
let from = data.filters?.from || ''; let to = $state(untrack(() => data.filters?.to || ''));
let to = data.filters?.to || ''; let senderId = $state(untrack(() => data.filters?.senderId || ''));
let senderId = data.filters?.senderId || ''; let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
let receiverId = data.filters?.receiverId || ''; let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
let tagNames = data.filters?.tags || [];
// Debounce Timer let searchTimer: ReturnType<typeof setTimeout>;
let searchTimer: any; let showAdvanced = $state(false);
let showAdvanced = false;
function triggerSearch() { function triggerSearch() {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -42,27 +40,24 @@ function handleTextSearch() {
}, 500); }, 500);
} }
let previousTags = tagNames.join(','); // Trigger search when tags change
$: { let prevTagStr = untrack(() => tagNames.join(','));
const currentTags = tagNames.join(','); $effect(() => {
if (currentTags !== previousTags) { const cur = tagNames.join(',');
previousTags = currentTags; if (cur !== prevTagStr) {
prevTagStr = cur;
triggerSearch(); triggerSearch();
} }
} });
function toggleAdvanced() { // Sync local state with server data after navigation
showAdvanced = !showAdvanced; $effect(() => {
}
// Sync with server data (e.g. after reset)
$: {
q = data.filters?.q || ''; q = data.filters?.q || '';
from = data.filters?.from || ''; from = data.filters?.from || '';
to = data.filters?.to || ''; to = data.filters?.to || '';
senderId = data.filters?.senderId || ''; senderId = data.filters?.senderId || '';
receiverId = data.filters?.receiverId || ''; receiverId = data.filters?.receiverId || '';
} });
</script> </script>
<!-- Outer Container: Matches the 'Sand' background of the layout --> <!-- Outer Container: Matches the 'Sand' background of the layout -->
@@ -76,7 +71,7 @@ $: {
<input <input
type="text" type="text"
bind:value={q} bind:value={q}
on:input={handleTextSearch} oninput={handleTextSearch}
placeholder="Suche in Titel, Inhalt, Ort..." placeholder="Suche in Titel, Inhalt, Ort..."
class="block w-full border-gray-300 py-2.5 pr-10 pl-3 placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-brand-navy" class="block w-full border-gray-300 py-2.5 pr-10 pl-3 placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/> />
@@ -94,7 +89,7 @@ $: {
<!-- Toggle Advanced Button --> <!-- Toggle Advanced Button -->
<button <button
on:click={toggleAdvanced} onclick={() => (showAdvanced = !showAdvanced)}
class="flex items-center gap-2 border border-gray-300 bg-gray-50 px-4 py-2.5 text-sm font-bold tracking-wide text-gray-600 uppercase transition hover:bg-gray-100 hover:text-brand-navy" class="flex items-center gap-2 border border-gray-300 bg-gray-50 px-4 py-2.5 text-sm font-bold tracking-wide text-gray-600 uppercase transition hover:bg-gray-100 hover:text-brand-navy"
> >
<svg <svg
@@ -143,7 +138,7 @@ $: {
<p class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"> <p class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase">
Schlagworte Schlagworte
</p> </p>
<TagInput bind:tags={tagNames} allowCreation={false} on:change={triggerSearch} /> <TagInput bind:tags={tagNames} allowCreation={false} />
</div> </div>
<!-- Sender --> <!-- Sender -->
@@ -156,7 +151,7 @@ $: {
label="Absender" label="Absender"
bind:value={senderId} bind:value={senderId}
initialName={data.initialValues?.senderName} initialName={data.initialValues?.senderName}
on:change={triggerSearch} onchange={triggerSearch}
/> />
</div> </div>
</div> </div>
@@ -171,7 +166,7 @@ $: {
label="Empfänger" label="Empfänger"
bind:value={receiverId} bind:value={receiverId}
initialName={data.initialValues?.receiverName} initialName={data.initialValues?.receiverName}
on:change={triggerSearch} onchange={triggerSearch}
/> />
</div> </div>
</div> </div>
@@ -188,7 +183,7 @@ $: {
type="date" type="date"
id="from" id="from"
bind:value={from} bind:value={from}
on:change={triggerSearch} onchange={triggerSearch}
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm" class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
/> />
</div> </div>
@@ -202,7 +197,7 @@ $: {
type="date" type="date"
id="to" id="to"
bind:value={to} bind:value={to}
on:change={triggerSearch} onchange={triggerSearch}
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm" class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
/> />
</div> </div>
@@ -329,15 +324,14 @@ $: {
</div> </div>
</div> </div>
<!-- NEW: Tags Display --> <!-- Tags Display -->
{#if doc.tags && doc.tags.length > 0} {#if doc.tags && doc.tags.length > 0}
<div class="mt-4 flex flex-wrap gap-2 pt-3"> <div class="mt-4 flex flex-wrap gap-2 pt-3">
{#each doc.tags as tag} {#each doc.tags as tag}
<button <button
type="button" type="button"
class="relative z-10 inline-flex items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white" class="relative z-10 inline-flex items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
on:click|preventDefault|stopPropagation={() => onclick={(e) => { e.preventDefault(); e.stopPropagation(); goto(`/?tag=${encodeURIComponent(tag.name)}`); }}
goto(`/?tag=${encodeURIComponent(tag.name)}`)}
> >
{tag.name} {tag.name}
</button> </button>
@@ -384,7 +378,7 @@ $: {
Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern. Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern.
</p> </p>
<button <button
on:click={() => goto('/')} onclick={() => goto('/')}
class="mt-6 text-sm font-bold tracking-wide text-brand-mint uppercase transition hover:text-brand-navy" class="mt-6 text-sm font-bold tracking-wide text-brand-mint uppercase transition hover:text-brand-navy"
> >
Alle Filter löschen Alle Filter löschen

View File

@@ -2,15 +2,17 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
export let data; let { data, form } = $props();
export let form;
let activeTab = 'users'; let activeTab = $state('users');
let editingTagId: string | null = null; let editingTagId: string | null = $state(null);
let editingTagName = ''; let editingTagName = $state('');
let editingUserId: string | null = null; let editingUserId: string | null = $state(null);
let editingGroupId: string | null = $state(null);
function startEditTag(tag: any) { const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
function startEditTag(tag: { id: string; name: string }) {
editingTagId = tag.id; editingTagId = tag.id;
editingTagName = tag.name; editingTagName = tag.name;
} }
@@ -20,7 +22,7 @@
editingTagName = ''; editingTagName = '';
} }
function startEditUser(id: string) { function startEditUser(id: string) {
editingUserId = id; editingUserId = id;
} }
@@ -28,9 +30,6 @@
editingUserId = null; editingUserId = null;
} }
let editingGroupId: string | null = null;
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
function startEditGroup(id: string) { function startEditGroup(id: string) {
editingGroupId = id; editingGroupId = id;
} }
@@ -51,21 +50,21 @@
'users' 'users'
? 'bg-brand-navy text-white' ? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}" : 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'users')}>Benutzer</button onclick={() => (activeTab = 'users')}>Benutzer</button
> >
<button <button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab === class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
'groups' 'groups'
? 'bg-brand-navy text-white' ? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}" : 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'groups')}>Gruppen</button onclick={() => (activeTab = 'groups')}>Gruppen</button
> >
<button <button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab === class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
'tags' 'tags'
? 'bg-brand-navy text-white' ? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}" : 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'tags')}>Schlagworte</button onclick={() => (activeTab = 'tags')}>Schlagworte</button
> >
</div> </div>
</div> </div>
@@ -106,7 +105,6 @@
<!-- === EDIT MODE === --> <!-- === EDIT MODE === -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.username} {user.username}
<!-- Hidden ID Input for the form -->
<input <input
type="hidden" type="hidden"
name="username" name="username"
@@ -116,7 +114,6 @@
</td> </td>
<td class="px-6 py-4 text-sm"> <td class="px-6 py-4 text-sm">
<!-- Groups Select -->
<select <select
name="groupIds" name="groupIds"
multiple multiple
@@ -126,7 +123,7 @@
{#each data.groups as group} {#each data.groups as group}
<option <option
value={group.id} value={group.id}
selected={user.groups.some((g) => g.id === group.id)} selected={user.groups.some((g: { id: string }) => g.id === group.id)}
> >
{group.name} {group.name}
</option> </option>
@@ -136,7 +133,6 @@
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right align-top"> <td class="px-6 py-4 whitespace-nowrap text-right align-top">
<!-- Password & Buttons -->
<form <form
id="edit-form-{user.id}" id="edit-form-{user.id}"
method="POST" method="POST"
@@ -164,7 +160,7 @@
</button> </button>
<button <button
type="button" type="button"
on:click={cancelEditUser} onclick={cancelEditUser}
class="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-bold uppercase hover:bg-gray-300" class="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-bold uppercase hover:bg-gray-300"
> >
Abbrechen Abbrechen
@@ -194,15 +190,13 @@
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right"> <td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-4"> <div class="flex items-center justify-end gap-4">
<!-- Edit Button -->
<button <button
on:click={() => startEditUser(user.id)} onclick={() => startEditUser(user.id)}
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide" class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
> >
Bearbeiten Bearbeiten
</button> </button>
<!-- Delete Button -->
<form <form
method="POST" method="POST"
action="?/deleteUser" action="?/deleteUser"
@@ -265,7 +259,6 @@
class="rounded border-gray-300 text-sm w-full" class="rounded border-gray-300 text-sm w-full"
/> />
<!-- Multi-Select for Groups -->
<div class="md:col-span-3"> <div class="md:col-span-3">
<select <select
name="groupIds" name="groupIds"
@@ -290,7 +283,6 @@
</div> </div>
</div> </div>
{:else if activeTab === 'tags'} {: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="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"> <div class="p-6 border-b border-gray-100 bg-yellow-50/50">
<h2 class="text-lg font-bold text-gray-700">Schlagworte</h2> <h2 class="text-lg font-bold text-gray-700">Schlagworte</h2>
@@ -332,9 +324,9 @@
> >
<button <button
type="button" type="button"
on:click={cancelEditTag} onclick={cancelEditTag}
aria-label="Abbrechen" aria-label="Abbrechen"
class="text-gray-400 hover:text-gray-600" class="text-gray-400 hover:text-gray-600"
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" ><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path ><path
stroke-linecap="round" stroke-linecap="round"
@@ -353,7 +345,7 @@
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity" class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
> >
<button <button
on:click={() => startEditTag(tag)} onclick={() => startEditTag(tag)}
aria-label="Schlagwort bearbeiten" aria-label="Schlagwort bearbeiten"
class="p-1 text-gray-400 hover:text-brand-navy" class="p-1 text-gray-400 hover:text-brand-navy"
> >
@@ -370,16 +362,13 @@
method="POST" method="POST"
action="?/deleteTag" action="?/deleteTag"
use:enhance={({ cancel }) => { use:enhance={({ cancel }) => {
// This runs BEFORE the request is sent
if ( if (
!confirm( !confirm(
'Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.' 'Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.'
) )
) { ) {
cancel(); // Stop the request cancel();
} }
// This runs AFTER the server responds
return async ({ update }) => { return async ({ update }) => {
await update(); await update();
}; };
@@ -443,7 +432,6 @@
> >
<input type="hidden" name="id" value={group.id} /> <input type="hidden" name="id" value={group.id} />
<!-- Name Input -->
<div class="w-full sm:w-1/3"> <div class="w-full sm:w-1/3">
<input <input
type="text" type="text"
@@ -454,7 +442,6 @@
/> />
</div> </div>
<!-- Permissions Checkboxes -->
<div class="flex-1 flex flex-wrap gap-4 items-center h-full pt-2"> <div class="flex-1 flex flex-wrap gap-4 items-center h-full pt-2">
{#each availablePermissions as perm} {#each availablePermissions as perm}
<label <label
@@ -472,7 +459,6 @@
{/each} {/each}
</div> </div>
<!-- Actions -->
<div class="flex gap-2 self-start sm:self-center"> <div class="flex gap-2 self-start sm:self-center">
<button type="submit" aria-label="Speichern" class="text-green-600 hover:text-green-800 p-1"> <button type="submit" aria-label="Speichern" 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" <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
@@ -486,9 +472,9 @@
</button> </button>
<button <button
type="button" type="button"
on:click={cancelEditGroup} onclick={cancelEditGroup}
aria-label="Abbrechen" aria-label="Abbrechen"
class="text-gray-400 hover:text-red-500 p-1" 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" <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path ><path
@@ -524,7 +510,7 @@
<td class="px-6 py-4 whitespace-nowrap text-right"> <td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-3"> <div class="flex items-center justify-end gap-3">
<button <button
on:click={() => startEditGroup(group.id)} onclick={() => startEditGroup(group.id)}
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide" class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
> >
Bearbeiten Bearbeiten
@@ -537,7 +523,6 @@
if (!confirm('Gruppe wirklich löschen?')) { if (!confirm('Gruppe wirklich löschen?')) {
cancel(); cancel();
} }
return async ({ update }) => { return async ({ update }) => {
await update(); await update();
}; };

View File

@@ -1,58 +1,39 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte'; import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { untrack } from 'svelte';
export let data; let { data } = $props();
// Data & State let senderId = $state(untrack(() => data.filters.senderId));
let documents: typeof data.documents = []; let receiverId = $state(untrack(() => data.filters.receiverId));
let initialValues = { senderName: '', receiverName: '' }; let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir));
// Filter State // Sync with server data after navigation
let senderId = ''; $effect(() => {
let receiverId = '';
let fromDate = '';
let toDate = '';
let sortDir = 'DESC';
// Reactive Update
$: {
documents = data.documents;
initialValues = data.initialValues;
senderId = data.filters.senderId; senderId = data.filters.senderId;
receiverId = data.filters.receiverId; receiverId = data.filters.receiverId;
fromDate = data.filters.from; fromDate = data.filters.from;
toDate = data.filters.to; toDate = data.filters.to;
sortDir = data.filters.dir; sortDir = data.filters.dir;
} });
// Filter Logic
function applyFilters() { function applyFilters() {
setTimeout(() => { const params = new URLSearchParams();
const params = new URLSearchParams(); if (senderId) params.set('senderId', senderId);
if (senderId) params.set('senderId', senderId); if (receiverId) params.set('receiverId', receiverId);
if (receiverId) params.set('receiverId', receiverId); if (fromDate) params.set('from', fromDate);
if (fromDate) params.set('from', fromDate); if (toDate) params.set('to', toDate);
if (toDate) params.set('to', toDate); params.set('dir', sortDir);
params.set('dir', sortDir); goto(`?${params.toString()}`, { keepFocus: true });
goto(`?${params.toString()}`, { keepFocus: true });
}, 0);
} }
function toggleSort() { function toggleSort() {
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC'; sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
applyFilters(); applyFilters();
} }
function handleSenderChange(event: CustomEvent) {
senderId = event.detail.value;
applyFilters();
}
function handleReceiverChange(event: CustomEvent) {
receiverId = event.detail.value;
applyFilters();
}
</script> </script>
<div class="max-w-5xl mx-auto py-10 px-4"> <div class="max-w-5xl mx-auto py-10 px-4">
@@ -74,9 +55,9 @@
<PersonTypeahead <PersonTypeahead
name="senderId" name="senderId"
label="Person A (Absender)" label="Person A (Absender)"
value={senderId} bind:value={senderId}
initialName={initialValues.senderName} initialName={data.initialValues.senderName}
on:change={handleSenderChange} onchange={() => applyFilters()}
/> />
</div> </div>
@@ -87,9 +68,9 @@
<PersonTypeahead <PersonTypeahead
name="receiverId" name="receiverId"
label="Person B (Empfänger)" label="Person B (Empfänger)"
value={receiverId} bind:value={receiverId}
initialName={initialValues.receiverName} initialName={data.initialValues.receiverName}
on:change={handleReceiverChange} onchange={() => applyFilters()}
/> />
</div> </div>
</div> </div>
@@ -106,7 +87,7 @@
id="dateFrom" id="dateFrom"
type="date" type="date"
bind:value={fromDate} bind:value={fromDate}
on:change={() => applyFilters()} onchange={() => applyFilters()}
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5" class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
/> />
</div> </div>
@@ -122,7 +103,7 @@
id="dateTo" id="dateTo"
type="date" type="date"
bind:value={toDate} bind:value={toDate}
on:change={() => applyFilters()} onchange={() => applyFilters()}
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5" class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
/> />
</div> </div>
@@ -130,7 +111,7 @@
<!-- Sort Toggle --> <!-- Sort Toggle -->
<div> <div>
<button <button
on:click={toggleSort} onclick={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" 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 class="mr-2">Sortierung:</span>
@@ -169,7 +150,7 @@
<p class="text-brand-navy font-serif text-lg">Wählen Sie zwei Personen aus</p> <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> <p class="text-gray-500 font-sans text-sm mt-1">Die Korrespondenz wird hier angezeigt.</p>
</div> </div>
{:else if documents.length === 0} {:else if data.documents.length === 0}
<div <div
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand rounded-sm text-center shadow-sm" class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand rounded-sm text-center shadow-sm"
> >
@@ -178,18 +159,15 @@
</div> </div>
{:else} {:else}
<!-- CHAT CONTAINER --> <!-- 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"> <div class="bg-white border border-brand-sand shadow-sm rounded-sm relative overflow-hidden">
<!-- Decoration: Central Timeline Line --> <!-- Decoration: Central Timeline Line -->
<div <div
class="absolute left-1/2 top-0 bottom-0 w-px bg-brand-sand/30 transform -translate-x-1/2 hidden md:block" class="absolute left-1/2 top-0 bottom-0 w-px bg-brand-sand/30 transform -translate-x-1/2 hidden md:block"
></div> ></div>
<!-- Scrollable Area (optional, if you want max-height) -->
<div class="p-6 md:p-8"> <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"> <div class="flex flex-col gap-4 relative z-10">
{#each documents as doc} {#each data.documents as doc}
{@const isRight = doc.sender?.id === senderId} {@const isRight = doc.sender?.id === senderId}
<!-- Message Row --> <!-- Message Row -->
@@ -200,7 +178,7 @@
? 'flex-row-reverse' ? 'flex-row-reverse'
: 'flex-row'}" : 'flex-row'}"
> >
<!-- AVATAR (Small) --> <!-- AVATAR -->
<div class="flex-shrink-0 mt-auto mb-1 hidden sm:block"> <div class="flex-shrink-0 mt-auto mb-1 hidden sm:block">
<div <div
class="w-8 h-8 rounded-full flex items-center justify-center font-serif text-xs border shadow-sm class="w-8 h-8 rounded-full flex items-center justify-center font-serif text-xs border shadow-sm
@@ -217,7 +195,6 @@
</div> </div>
<!-- BUBBLE CARD --> <!-- BUBBLE CARD -->
<!-- Adjusted padding (p-4) and added light bg to left bubbles for contrast -->
<a <a
href="/documents/{doc.id}" 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 class="group block p-4 rounded shadow-sm transition-all duration-200 transform hover:-translate-y-0.5 hover:shadow-md border

View File

@@ -1,24 +1,24 @@
<script lang="ts"> <script lang="ts">
export let data; let { data } = $props();
$: doc = data.document;
// Instead of a direct link, we use a reactive variable for the Blob URL const doc = $derived(data.document);
let fileUrl = '';
let isLoading = false;
let error = '';
// Reactive statement: Whenever the document ID changes, load the file let fileUrl = $state('');
$: if (doc?.id && doc?.filePath) { let isLoading = $state(false);
loadFile(doc.id); let error = $state('');
}
$effect(() => {
if (doc?.id && doc?.filePath) {
loadFile(doc.id);
}
});
async function loadFile(id: string) { async function loadFile(id: string) {
isLoading = true; isLoading = true;
error = ''; error = '';
fileUrl = ''; // Reset previous URL fileUrl = '';
try { try {
// 1. Fetch with current authentication
const response = await fetch(`/api/documents/${id}/file`); const response = await fetch(`/api/documents/${id}/file`);
if (!response.ok) { if (!response.ok) {
@@ -26,10 +26,7 @@
throw new Error('Fehler beim Laden der Datei'); throw new Error('Fehler beim Laden der Datei');
} }
// 2. Create a Blob from the data
const blob = await response.blob(); const blob = await response.blob();
// 3. Create a temporary URL for this Blob
fileUrl = URL.createObjectURL(blob); fileUrl = URL.createObjectURL(blob);
} catch (e) { } catch (e) {
@@ -186,7 +183,6 @@
{#if doc.documentLocation} {#if doc.documentLocation}
<div class="flex items-start group"> <div class="flex items-start group">
<span class="text-brand-mint w-8 mt-0.5"> <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"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
stroke-linecap="round" stroke-linecap="round"
@@ -209,7 +205,6 @@
{#if doc.tags && doc.tags.length > 0} {#if doc.tags && doc.tags.length > 0}
<div class="flex items-start group"> <div class="flex items-start group">
<span class="text-brand-mint w-8 mt-0.5"> <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"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
stroke-linecap="round" stroke-linecap="round"
@@ -324,7 +319,7 @@
</div> </div>
</div> </div>
<!-- 3. INHALT GROUP (Merged Summary & Transcription) --> <!-- 3. INHALT GROUP -->
{#if doc.summary || doc.transcription} {#if doc.summary || doc.transcription}
<div> <div>
<h3 <h3
@@ -334,12 +329,9 @@
</h3> </h3>
<div class="space-y-6"> <div class="space-y-6">
<!-- Summary Sub-Section -->
{#if doc.summary} {#if doc.summary}
<div> <div>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase" <span class="text-xs font-sans text-gray-400 block mb-2 uppercase">Zusammenfassung</span>
>Zusammenfassung</span
>
<div <div
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap" class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
> >
@@ -348,12 +340,9 @@
</div> </div>
{/if} {/if}
<!-- Transcription Sub-Section -->
{#if doc.transcription} {#if doc.transcription}
<div> <div>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase" <span class="text-xs font-sans text-gray-400 block mb-2 uppercase">Transkription</span>
>Transkription</span
>
<div <div
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap" class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
> >
@@ -376,7 +365,6 @@
<!-- RIGHT: PREVIEW AREA --> <!-- RIGHT: PREVIEW AREA -->
<main class="flex-1 bg-[#2A2A2A] relative flex flex-col items-center justify-center"> <main class="flex-1 bg-[#2A2A2A] relative flex flex-col items-center justify-center">
{#if isLoading} {#if isLoading}
<!-- Loading Spinner -->
<div class="text-brand-mint flex flex-col items-center"> <div class="text-brand-mint flex flex-col items-center">
<svg <svg
class="animate-spin h-8 w-8 mb-4" class="animate-spin h-8 w-8 mb-4"
@@ -398,7 +386,6 @@
<div class="text-gray-400 text-center px-4"> <div class="text-gray-400 text-center px-4">
<p class="font-serif mb-2">{error}</p> <p class="font-serif mb-2">{error}</p>
{#if doc.filePath} {#if doc.filePath}
<!-- Direct link as fallback -->
<a <a
href={`/api/documents/${doc.id}/file`} href={`/api/documents/${doc.id}/file`}
target="_blank" target="_blank"
@@ -409,10 +396,8 @@
{/if} {/if}
</div> </div>
{:else if !doc.filePath} {:else if !doc.filePath}
<!-- No File State -->
<div class="flex flex-col items-center text-gray-400"> <div class="flex flex-col items-center text-gray-400">
<div class="bg-white/5 p-8 rounded-full mb-6"> <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" <svg class="w-12 h-12 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path ><path
stroke-linecap="round" stroke-linecap="round"
@@ -425,14 +410,12 @@
<p class="font-sans text-sm tracking-wide uppercase">Kein Scan vorhanden</p> <p class="font-sans text-sm tracking-wide uppercase">Kein Scan vorhanden</p>
</div> </div>
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')} {:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')}
<!-- PDF Iframe with Blob URL -->
<iframe <iframe
src={fileUrl} src={fileUrl}
title="Document Preview" title="Document Preview"
class="w-full h-full border-none bg-white" class="w-full h-full border-none bg-white"
></iframe> ></iframe>
{:else if fileUrl} {:else if fileUrl}
<!-- Image with Blob URL -->
<div class="w-full h-full flex items-center justify-center overflow-auto p-8"> <div class="w-full h-full flex items-center justify-center overflow-auto p-8">
<img <img
src={fileUrl} src={fileUrl}

View File

@@ -3,14 +3,14 @@
import TagInput from '$lib/components/TagInput.svelte'; import TagInput from '$lib/components/TagInput.svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte'; import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte'; import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import { untrack } from 'svelte';
export let data; let { data, form } = $props();
export let form;
let { document: doc } = data; let { document: doc } = untrack(() => data);
let tags = doc.tags ? doc.tags.map((t: any) => t.name) : []; let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []);
let senderId = doc.sender?.id ?? ''; let senderId = $state(doc.sender?.id ?? '');
let selectedReceivers = doc.receivers ?? []; let selectedReceivers = $state(doc.receivers ?? []);
function isoToGerman(iso: string): string { function isoToGerman(iso: string): string {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return ''; if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
@@ -25,9 +25,11 @@
return `${y}-${m}-${d}`; return `${y}-${m}-${d}`;
} }
let dateDisplay = isoToGerman(doc.documentDate ?? ''); let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
let dateIso = doc.documentDate ?? ''; let dateIso = $state(doc.documentDate ?? '');
let dateDirty = false; let dateDirty = $state(false);
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
function handleDateInput(e: Event) { function handleDateInput(e: Event) {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
@@ -45,8 +47,6 @@
dateIso = germanToIso(formatted); dateIso = germanToIso(formatted);
dateDirty = true; dateDirty = true;
} }
$: dateInvalid = dateDirty && dateDisplay.length > 0 && dateIso === '';
</script> </script>
<div class="max-w-4xl mx-auto py-8 px-4"> <div class="max-w-4xl mx-auto py-8 px-4">
@@ -84,7 +84,7 @@
type="text" type="text"
inputmode="numeric" inputmode="numeric"
value={dateDisplay} value={dateDisplay}
on:input={handleDateInput} oninput={handleDateInput}
placeholder="TT.MM.JJJJ" placeholder="TT.MM.JJJJ"
maxlength="10" maxlength="10"
class="block w-full rounded border-gray-300 shadow-sm p-2 border text-sm class="block w-full rounded border-gray-300 shadow-sm p-2 border text-sm

View File

@@ -4,15 +4,17 @@ import TagInput from '$lib/components/TagInput.svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte'; import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte'; import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
export let form; let { form } = $props();
let tags: string[] = []; let tags: string[] = $state([]);
let senderId = ''; let senderId = $state('');
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = []; let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state([]);
let dateDisplay = ''; let dateDisplay = $state('');
let dateIso = ''; let dateIso = $state('');
let dateDirty = false; let dateDirty = $state(false);
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
function germanToIso(german: string): string { function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
@@ -37,8 +39,6 @@ function handleDateInput(e: Event) {
dateIso = germanToIso(formatted); dateIso = germanToIso(formatted);
dateDirty = true; dateDirty = true;
} }
$: dateInvalid = dateDirty && dateDisplay.length > 0 && dateIso === '';
</script> </script>
<div class="mx-auto max-w-4xl px-4 py-8"> <div class="mx-auto max-w-4xl px-4 py-8">
@@ -86,7 +86,7 @@ $: dateInvalid = dateDirty && dateDisplay.length > 0 && dateIso === '';
type="text" type="text"
inputmode="numeric" inputmode="numeric"
value={dateDisplay} value={dateDisplay}
on:input={handleDateInput} oninput={handleDateInput}
placeholder="TT.MM.JJJJ" placeholder="TT.MM.JJJJ"
maxlength="10" maxlength="10"
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
// TypeScript Typen für die Form-Antwort let { form }: { form?: { error?: string; success?: boolean } } = $props();
export let form: { error?: string, success?: boolean };
</script> </script>
<div class="min-h-screen flex items-center justify-center"> <div class="min-h-screen flex items-center justify-center">
@@ -10,13 +9,13 @@
<form method="POST" action="?/login" class="space-y-4"> <form method="POST" action="?/login" class="space-y-4">
<div> <div>
<label for="username" class="block text-sm font-medium text-gray-700">Benutzername</label> <label for="username" class="block text-sm font-medium text-gray-700">Benutzername</label>
<input type="text" name="username" id="username" required <input type="text" name="username" id="username" required
class="mt-1 block w-full rounded border-gray-300 shadow-sm p-2 border" /> class="mt-1 block w-full rounded border-gray-300 shadow-sm p-2 border" />
</div> </div>
<div> <div>
<label for="password" class="block text-sm font-medium text-gray-700">Passwort</label> <label for="password" class="block text-sm font-medium text-gray-700">Passwort</label>
<input type="password" name="password" id="password" required <input type="password" name="password" id="password" required
class="mt-1 block w-full rounded border-gray-300 shadow-sm p-2 border" /> class="mt-1 block w-full rounded border-gray-300 shadow-sm p-2 border" />
</div> </div>
@@ -24,7 +23,7 @@
<div class="text-red-600 text-sm text-center">{form.error}</div> <div class="text-red-600 text-sm text-center">{form.error}</div>
{/if} {/if}
<button type="submit" <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"> 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 Anmelden
</button> </button>

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
export let data;
let searchTimeout: any; let { data } = $props();
let searchTimeout: ReturnType<typeof setTimeout>;
// Live-Suche (Debounce)
function handleSearch(e: Event) { function handleSearch(e: Event) {
const value = (e.target as HTMLInputElement).value; const value = (e.target as HTMLInputElement).value;
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
@@ -49,7 +49,7 @@ function handleSearch(e: Event) {
type="text" type="text"
placeholder="Namen suchen..." placeholder="Namen suchen..."
value={data.q || ''} value={data.q || ''}
on:input={handleSearch} oninput={handleSearch}
class="block w-full rounded-sm border border-gray-300 bg-white py-2.5 pr-10 pl-4 font-sans text-sm text-brand-navy placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" class="block w-full rounded-sm border border-gray-300 bg-white py-2.5 pr-10 pl-4 font-sans text-sm text-brand-navy placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
/> />
<div <div

View File

@@ -2,21 +2,25 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte'; import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
export let data; let { data, form } = $props();
export let form;
$: ({ person, documents } = data); const person = $derived(data.person);
const documents = $derived(data.documents);
let editMode = false; let editMode = $state(false);
let mergeTargetId = ''; let mergeTargetId = $state('');
let showMergeConfirm = false; let showMergeConfirm = $state(false);
function enterEdit() { editMode = true; } $effect(() => {
function cancelEdit() { editMode = false; } if (form?.updated) editMode = false;
});
$: if (form?.updated) { editMode = false; } $effect(() => {
// Reset merge state whenever person changes
$: person.id, (() => { mergeTargetId = ''; showMergeConfirm = false; })(); person.id;
mergeTargetId = '';
showMergeConfirm = false;
});
</script> </script>
<div class="max-w-4xl mx-auto py-10 px-4"> <div class="max-w-4xl mx-auto py-10 px-4">
@@ -83,7 +87,7 @@
<button type="submit" class="px-5 py-2 bg-brand-navy text-white text-sm font-bold uppercase tracking-widest rounded hover:bg-brand-navy/80 transition-colors"> <button type="submit" class="px-5 py-2 bg-brand-navy text-white text-sm font-bold uppercase tracking-widest rounded hover:bg-brand-navy/80 transition-colors">
Speichern Speichern
</button> </button>
<button type="button" on:click={cancelEdit} class="px-5 py-2 border border-gray-300 text-gray-600 text-sm font-bold uppercase tracking-widest rounded hover:bg-gray-50 transition-colors"> <button type="button" onclick={() => (editMode = false)} class="px-5 py-2 border border-gray-300 text-gray-600 text-sm font-bold uppercase tracking-widest rounded hover:bg-gray-50 transition-colors">
Abbrechen Abbrechen
</button> </button>
</div> </div>
@@ -103,7 +107,7 @@
<h1 class="text-4xl font-serif text-brand-navy"> <h1 class="text-4xl font-serif text-brand-navy">
{person.firstName} {person.lastName} {person.firstName} {person.lastName}
</h1> </h1>
<button on:click={enterEdit} class="ml-4 flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors"> <button onclick={() => (editMode = true)} class="ml-4 flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Bearbeiten Bearbeiten
</button> </button>
@@ -150,7 +154,7 @@
name="_targetPersonDisplay" name="_targetPersonDisplay"
label="Zusammenführen mit" label="Zusammenführen mit"
value={mergeTargetId} value={mergeTargetId}
on:change={(e) => { mergeTargetId = e.detail.value; showMergeConfirm = false; }} onchange={(value) => { mergeTargetId = value; showMergeConfirm = false; }}
/> />
</div> </div>
@@ -158,7 +162,7 @@
<button <button
type="button" type="button"
disabled={!mergeTargetId} disabled={!mergeTargetId}
on:click={() => showMergeConfirm = true} onclick={() => (showMergeConfirm = true)}
class="px-4 py-2 text-sm font-bold uppercase tracking-widest border border-red-300 text-red-600 rounded hover:bg-red-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed" class="px-4 py-2 text-sm font-bold uppercase tracking-widest border border-red-300 text-red-600 rounded hover:bg-red-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
> >
Zusammenführen Zusammenführen
@@ -173,7 +177,7 @@
</button> </button>
<button <button
type="button" type="button"
on:click={() => showMergeConfirm = false} onclick={() => (showMergeConfirm = false)}
class="px-4 py-2 text-sm font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:bg-gray-50 transition-colors" class="px-4 py-2 text-sm font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:bg-gray-50 transition-colors"
> >
Abbrechen Abbrechen

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
export let form; let { form } = $props();
</script> </script>
<div class="mx-auto max-w-2xl px-4 py-8"> <div class="mx-auto max-w-2xl px-4 py-8">