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:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user