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">
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 results: Person[] = [];
let showDropdown = false;
let loading = false;
let { selectedPersons = $bindable([]) }: Props = $props();
let searchTerm = $state('');
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = '';
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
@@ -47,7 +52,7 @@
function clickOutside(node: HTMLElement) {
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;
}
};
@@ -56,7 +61,7 @@
}
</script>
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} />
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
{#each selectedPersons as person}
<input type="hidden" name="receiverIds" value={person.id} />
@@ -69,7 +74,7 @@
{person.firstName} {person.lastName}
<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"
aria-label="Entfernen"
>
@@ -85,8 +90,8 @@
type="text"
autocomplete="off"
bind:value={searchTerm}
on:input={handleInput}
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
oninput={handleInput}
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
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"
/>
@@ -101,10 +106,10 @@
<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 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"
tabindex="0"
>

View File

@@ -1,30 +1,33 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
// Props
export let name: string;
export let label: string;
export let value: string = "";
export let initialName: string = "";
interface Props {
name: string;
label: string;
value?: string;
initialName?: string;
onchange?: (value: string) => void;
}
const dispatch = createEventDispatcher();
let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props();
// Lokaler State
let searchTerm = initialName;
let searchTerm = $state('');
// Sync mit externen Änderungen (z.B. Reset Button)
$: searchTerm = initialName;
// Sync with external changes (e.g. reset button) — also sets the initial value
$effect(() => {
searchTerm = initialName;
});
let results: any[] = [];
let showDropdown = false;
let loading = false;
let debounceTimer: any;
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
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
value = '';
onchange?.('');
}
showDropdown = true;
@@ -38,13 +41,9 @@
loading = true;
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
if (res.ok) {
results = await res.json();
} else {
results = [];
}
results = res.ok ? await res.json() : [];
} catch (e) {
console.error("Suche fehlgeschlagen", e);
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
@@ -52,17 +51,15 @@
}, 300);
}
function selectPerson(person: any) {
value = person.id;
function selectPerson(person: Person) {
value = person.id!;
searchTerm = `${person.firstName} ${person.lastName}`;
showDropdown = false;
// --- NEU: Event feuern ---
dispatch('change', { value: person.id });
onchange?.(person.id!);
}
let inputEl: HTMLInputElement;
let dropdownStyle = '';
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
@@ -71,8 +68,8 @@
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: any) => {
if (node && !node.contains(event.target) && !event.defaultPrevented) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
showDropdown = false;
}
};
@@ -83,7 +80,7 @@
}
</script>
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} />
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
<div class="relative" use:clickOutside>
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
@@ -96,8 +93,8 @@
id="{name}-search"
autocomplete="off"
bind:value={searchTerm}
on:input={handleInput}
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
oninput={handleInput}
onfocus={() => { updateDropdownPosition(); 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"
/>
@@ -111,10 +108,10 @@
<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)}
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button"
tabindex="0"
>

View File

@@ -1,13 +1,16 @@
<script lang="ts">
export let tags: string[] = []; // Two-way binding
export let allowCreation = true;
interface Props {
tags?: string[];
allowCreation?: boolean;
}
let inputVal = '';
let suggestions: string[] = [];
let activeIndex = -1;
let showSuggestions = false;
let { tags = $bindable([]), allowCreation = true }: Props = $props();
let inputVal = $state('');
let suggestions: string[] = $state([]);
let activeIndex = $state(-1);
let showSuggestions = $state(false);
// Fetch suggestions from backend
async function fetchSuggestions(query: string) {
if (query.length < 2) {
suggestions = [];
@@ -17,13 +20,12 @@
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
if (res.ok) {
const data = await res.json();
// API returns Tag objects with { id, name }
const names: string[] = data.map((t: { name: string }) => t.name);
suggestions = names.filter((t) => !tags.includes(t));
showSuggestions = true;
}
} catch (e) {
console.error("Tag fetch error", e);
console.error('Tag fetch error', e);
}
}
@@ -47,8 +49,8 @@
e.preventDefault();
if (activeIndex >= 0 && suggestions[activeIndex]) {
addTag(suggestions[activeIndex]);
} else if(allowCreation) {
addTag(inputVal); // Add new tag
} else if (allowCreation) {
addTag(inputVal);
}
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
removeTag(tags.length - 1);
@@ -61,81 +63,86 @@
}
}
function handleInput() {
fetchSuggestions(inputVal);
function clickOutside(node: HTMLElement) {
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>
<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)}
aria-label="Schlagwort entfernen"
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}
<div class="w-full" use:clickOutside>
<!-- 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"
onclick={() => removeTag(i)}
aria-label="Schlagwort entfernen"
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"
/>
<!-- Input Field -->
<div class="relative flex-1 min-w-[120px]">
<input
type="text"
bind:value={inputVal}
oninput={() => fetchSuggestions(inputVal)}
onkeydown={handleKeydown}
onfocus={() => fetchSuggestions(inputVal)}
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}
<li
role="option"
aria-selected={i === activeIndex}
tabindex="0"
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)}
on:keydown={(e) => e.key === 'Enter' && 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}
<!-- 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}
<li
role="option"
aria-selected={i === activeIndex}
tabindex="0"
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'}"
onclick={() => addTag(suggestion)}
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
>
{suggestion}
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if allowCreation}
<p class="text-xs text-gray-400 mt-1">Enter drücken um Schlagwort zu erstellen.</p>
{/if}
</div>

View File

@@ -2,116 +2,112 @@
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"))
let { children } = $props();
const isAdmin = $derived(page.data.user?.groups.some((g: { permissions: string[] }) => 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 -->
{#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">
<!-- 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>
<!-- Logo & Nav -->
<div class="flex">
<div class="flex-shrink-0 flex items-center mr-8">
<a href="/" class="flex items-center gap-2" aria-label="Familienarchiv">
<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>
<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>
<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="/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>
<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}
<!-- 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>
<main class="py-6">
{@render children()}
</main>
</div>

View File

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

View File

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

View File

@@ -1,58 +1,39 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { untrack } from 'svelte';
export let data;
let { data } = $props();
// Data & State
let documents: typeof data.documents = [];
let initialValues = { senderName: '', receiverName: '' };
let senderId = $state(untrack(() => data.filters.senderId));
let receiverId = $state(untrack(() => data.filters.receiverId));
let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir));
// Filter State
let senderId = '';
let receiverId = '';
let fromDate = '';
let toDate = '';
let sortDir = 'DESC';
// Reactive Update
$: {
documents = data.documents;
initialValues = data.initialValues;
// Sync with server data after navigation
$effect(() => {
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);
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 });
}
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">
@@ -74,9 +55,9 @@
<PersonTypeahead
name="senderId"
label="Person A (Absender)"
value={senderId}
initialName={initialValues.senderName}
on:change={handleSenderChange}
bind:value={senderId}
initialName={data.initialValues.senderName}
onchange={() => applyFilters()}
/>
</div>
@@ -87,9 +68,9 @@
<PersonTypeahead
name="receiverId"
label="Person B (Empfänger)"
value={receiverId}
initialName={initialValues.receiverName}
on:change={handleReceiverChange}
bind:value={receiverId}
initialName={data.initialValues.receiverName}
onchange={() => applyFilters()}
/>
</div>
</div>
@@ -106,7 +87,7 @@
id="dateFrom"
type="date"
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"
/>
</div>
@@ -122,7 +103,7 @@
id="dateTo"
type="date"
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"
/>
</div>
@@ -130,7 +111,7 @@
<!-- Sort Toggle -->
<div>
<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"
>
<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-gray-500 font-sans text-sm mt-1">Die Korrespondenz wird hier angezeigt.</p>
</div>
{:else if documents.length === 0}
{:else if data.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"
>
@@ -178,18 +159,15 @@
</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}
{#each data.documents as doc}
{@const isRight = doc.sender?.id === senderId}
<!-- Message Row -->
@@ -200,7 +178,7 @@
? 'flex-row-reverse'
: 'flex-row'}"
>
<!-- AVATAR (Small) -->
<!-- AVATAR -->
<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
@@ -217,7 +195,6 @@
</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

View File

@@ -1,24 +1,24 @@
<script lang="ts">
export let data;
$: doc = data.document;
let { data } = $props();
// Instead of a direct link, we use a reactive variable for the Blob URL
let fileUrl = '';
let isLoading = false;
let error = '';
const doc = $derived(data.document);
// Reactive statement: Whenever the document ID changes, load the file
$: if (doc?.id && doc?.filePath) {
loadFile(doc.id);
}
let fileUrl = $state('');
let isLoading = $state(false);
let error = $state('');
$effect(() => {
if (doc?.id && doc?.filePath) {
loadFile(doc.id);
}
});
async function loadFile(id: string) {
isLoading = true;
error = '';
fileUrl = ''; // Reset previous URL
fileUrl = '';
try {
// 1. Fetch with current authentication
const response = await fetch(`/api/documents/${id}/file`);
if (!response.ok) {
@@ -26,10 +26,7 @@
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) {
@@ -186,7 +183,6 @@
{#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"
@@ -209,7 +205,6 @@
{#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"
@@ -324,7 +319,7 @@
</div>
</div>
<!-- 3. INHALT GROUP (Merged Summary & Transcription) -->
<!-- 3. INHALT GROUP -->
{#if doc.summary || doc.transcription}
<div>
<h3
@@ -334,12 +329,9 @@
</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
>
<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"
>
@@ -348,12 +340,9 @@
</div>
{/if}
<!-- Transcription Sub-Section -->
{#if doc.transcription}
<div>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase"
>Transkription</span
>
<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"
>
@@ -376,7 +365,6 @@
<!-- 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"
@@ -398,7 +386,6 @@
<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"
@@ -409,10 +396,8 @@
{/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"
@@ -425,14 +410,12 @@
<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"
></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}

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
<script lang="ts">
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) {
const value = (e.target as HTMLInputElement).value;
clearTimeout(searchTimeout);
@@ -49,7 +49,7 @@ function handleSearch(e: Event) {
type="text"
placeholder="Namen suchen..."
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"
/>
<div

View File

@@ -2,21 +2,25 @@
import { enhance } from '$app/forms';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
export let data;
export let form;
let { data, form } = $props();
$: ({ person, documents } = data);
const person = $derived(data.person);
const documents = $derived(data.documents);
let editMode = false;
let mergeTargetId = '';
let showMergeConfirm = false;
let editMode = $state(false);
let mergeTargetId = $state('');
let showMergeConfirm = $state(false);
function enterEdit() { editMode = true; }
function cancelEdit() { editMode = false; }
$effect(() => {
if (form?.updated) editMode = false;
});
$: if (form?.updated) { editMode = false; }
$: person.id, (() => { mergeTargetId = ''; showMergeConfirm = false; })();
$effect(() => {
// Reset merge state whenever person changes
person.id;
mergeTargetId = '';
showMergeConfirm = false;
});
</script>
<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">
Speichern
</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
</button>
</div>
@@ -103,7 +107,7 @@
<h1 class="text-4xl font-serif text-brand-navy">
{person.firstName} {person.lastName}
</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>
Bearbeiten
</button>
@@ -150,7 +154,7 @@
name="_targetPersonDisplay"
label="Zusammenführen mit"
value={mergeTargetId}
on:change={(e) => { mergeTargetId = e.detail.value; showMergeConfirm = false; }}
onchange={(value) => { mergeTargetId = value; showMergeConfirm = false; }}
/>
</div>
@@ -158,7 +162,7 @@
<button
type="button"
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"
>
Zusammenführen
@@ -173,7 +177,7 @@
</button>
<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"
>
Abbrechen

View File

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