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">
|
<script lang="ts">
|
||||||
type Person = { id?: string; firstName?: string; lastName?: string; alias?: string };
|
import type { components } from '$lib/generated/api';
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
export let selectedPersons: Person[] = [];
|
interface Props {
|
||||||
|
selectedPersons?: Person[];
|
||||||
|
}
|
||||||
|
|
||||||
let searchTerm = '';
|
let { selectedPersons = $bindable([]) }: Props = $props();
|
||||||
let results: Person[] = [];
|
|
||||||
let showDropdown = false;
|
let searchTerm = $state('');
|
||||||
let loading = false;
|
let results: Person[] = $state([]);
|
||||||
|
let showDropdown = $state(false);
|
||||||
|
let loading = $state(false);
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
let inputEl: HTMLInputElement;
|
let inputEl: HTMLInputElement;
|
||||||
let dropdownStyle = '';
|
let dropdownStyle = $state('');
|
||||||
|
|
||||||
function updateDropdownPosition() {
|
function updateDropdownPosition() {
|
||||||
if (!inputEl) return;
|
if (!inputEl) return;
|
||||||
@@ -47,7 +52,7 @@
|
|||||||
|
|
||||||
function clickOutside(node: HTMLElement) {
|
function clickOutside(node: HTMLElement) {
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
if (node && !node.contains(e.target as Node) && !(e as Event).defaultPrevented) {
|
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||||
showDropdown = false;
|
showDropdown = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -56,7 +61,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} />
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
|
|
||||||
{#each selectedPersons as person}
|
{#each selectedPersons as person}
|
||||||
<input type="hidden" name="receiverIds" value={person.id} />
|
<input type="hidden" name="receiverIds" value={person.id} />
|
||||||
@@ -69,7 +74,7 @@
|
|||||||
{person.firstName} {person.lastName}
|
{person.firstName} {person.lastName}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => removePerson(person.id)}
|
onclick={() => removePerson(person.id)}
|
||||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
|
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
|
||||||
aria-label="Entfernen"
|
aria-label="Entfernen"
|
||||||
>
|
>
|
||||||
@@ -85,8 +90,8 @@
|
|||||||
type="text"
|
type="text"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
on:input={handleInput}
|
oninput={handleInput}
|
||||||
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
|
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||||
placeholder={selectedPersons.length === 0 ? 'Namen tippen...' : ''}
|
placeholder={selectedPersons.length === 0 ? 'Namen tippen...' : ''}
|
||||||
class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
|
class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
|
||||||
/>
|
/>
|
||||||
@@ -101,10 +106,10 @@
|
|||||||
<div class="p-2 text-gray-500 text-sm">Suche...</div>
|
<div class="p-2 text-gray-500 text-sm">Suche...</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each results as person}
|
{#each results as person}
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
|
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
|
||||||
on:click={() => selectPerson(person)}
|
onclick={() => selectPerson(person)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,30 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import type { components } from '$lib/generated/api';
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
// Props
|
interface Props {
|
||||||
export let name: string;
|
name: string;
|
||||||
export let label: string;
|
label: string;
|
||||||
export let value: string = "";
|
value?: string;
|
||||||
export let initialName: string = "";
|
initialName?: string;
|
||||||
|
onchange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props();
|
||||||
|
|
||||||
// Lokaler State
|
let searchTerm = $state('');
|
||||||
let searchTerm = initialName;
|
|
||||||
|
|
||||||
// Sync mit externen Änderungen (z.B. Reset Button)
|
// Sync with external changes (e.g. reset button) — also sets the initial value
|
||||||
$: searchTerm = initialName;
|
$effect(() => {
|
||||||
|
searchTerm = initialName;
|
||||||
|
});
|
||||||
|
|
||||||
let results: any[] = [];
|
let results: Person[] = $state([]);
|
||||||
let showDropdown = false;
|
let showDropdown = $state(false);
|
||||||
let loading = false;
|
let loading = $state(false);
|
||||||
let debounceTimer: any;
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
function handleInput() {
|
function handleInput() {
|
||||||
// Wenn der User tippt, ist die alte ID ungültig -> Reset
|
|
||||||
if (value && searchTerm !== initialName) {
|
if (value && searchTerm !== initialName) {
|
||||||
value = "";
|
value = '';
|
||||||
dispatch('change', { value: "" }); // Bescheid geben: Auswahl aufgehoben
|
onchange?.('');
|
||||||
}
|
}
|
||||||
|
|
||||||
showDropdown = true;
|
showDropdown = true;
|
||||||
@@ -38,13 +41,9 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
||||||
if (res.ok) {
|
results = res.ok ? await res.json() : [];
|
||||||
results = await res.json();
|
|
||||||
} else {
|
|
||||||
results = [];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Suche fehlgeschlagen", e);
|
console.error('Suche fehlgeschlagen', e);
|
||||||
results = [];
|
results = [];
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -52,17 +51,15 @@
|
|||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPerson(person: any) {
|
function selectPerson(person: Person) {
|
||||||
value = person.id;
|
value = person.id!;
|
||||||
searchTerm = `${person.firstName} ${person.lastName}`;
|
searchTerm = `${person.firstName} ${person.lastName}`;
|
||||||
showDropdown = false;
|
showDropdown = false;
|
||||||
|
onchange?.(person.id!);
|
||||||
// --- NEU: Event feuern ---
|
|
||||||
dispatch('change', { value: person.id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let inputEl: HTMLInputElement;
|
let inputEl: HTMLInputElement;
|
||||||
let dropdownStyle = '';
|
let dropdownStyle = $state('');
|
||||||
|
|
||||||
function updateDropdownPosition() {
|
function updateDropdownPosition() {
|
||||||
if (!inputEl) return;
|
if (!inputEl) return;
|
||||||
@@ -71,8 +68,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clickOutside(node: HTMLElement) {
|
function clickOutside(node: HTMLElement) {
|
||||||
const handleClick = (event: any) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
if (node && !node.contains(event.target) && !event.defaultPrevented) {
|
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||||
showDropdown = false;
|
showDropdown = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -83,7 +80,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} />
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
|
|
||||||
<div class="relative" use:clickOutside>
|
<div class="relative" use:clickOutside>
|
||||||
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
|
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
|
||||||
@@ -96,8 +93,8 @@
|
|||||||
id="{name}-search"
|
id="{name}-search"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
on:input={handleInput}
|
oninput={handleInput}
|
||||||
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
|
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||||
placeholder="Namen tippen..."
|
placeholder="Namen tippen..."
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500"
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
/>
|
/>
|
||||||
@@ -111,10 +108,10 @@
|
|||||||
<div class="p-2 text-gray-500 text-sm">Suche...</div>
|
<div class="p-2 text-gray-500 text-sm">Suche...</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each results as person}
|
{#each results as person}
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
|
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
|
||||||
on:click={() => selectPerson(person)}
|
onclick={() => selectPerson(person)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let tags: string[] = []; // Two-way binding
|
interface Props {
|
||||||
export let allowCreation = true;
|
tags?: string[];
|
||||||
|
allowCreation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
let inputVal = '';
|
let { tags = $bindable([]), allowCreation = true }: Props = $props();
|
||||||
let suggestions: string[] = [];
|
|
||||||
let activeIndex = -1;
|
let inputVal = $state('');
|
||||||
let showSuggestions = false;
|
let suggestions: string[] = $state([]);
|
||||||
|
let activeIndex = $state(-1);
|
||||||
|
let showSuggestions = $state(false);
|
||||||
|
|
||||||
// Fetch suggestions from backend
|
|
||||||
async function fetchSuggestions(query: string) {
|
async function fetchSuggestions(query: string) {
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
@@ -17,13 +20,12 @@
|
|||||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// API returns Tag objects with { id, name }
|
|
||||||
const names: string[] = data.map((t: { name: string }) => t.name);
|
const names: string[] = data.map((t: { name: string }) => t.name);
|
||||||
suggestions = names.filter((t) => !tags.includes(t));
|
suggestions = names.filter((t) => !tags.includes(t));
|
||||||
showSuggestions = true;
|
showSuggestions = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Tag fetch error", e);
|
console.error('Tag fetch error', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,8 +49,8 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeIndex >= 0 && suggestions[activeIndex]) {
|
if (activeIndex >= 0 && suggestions[activeIndex]) {
|
||||||
addTag(suggestions[activeIndex]);
|
addTag(suggestions[activeIndex]);
|
||||||
} else if(allowCreation) {
|
} else if (allowCreation) {
|
||||||
addTag(inputVal); // Add new tag
|
addTag(inputVal);
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
|
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
|
||||||
removeTag(tags.length - 1);
|
removeTag(tags.length - 1);
|
||||||
@@ -61,81 +63,86 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInput() {
|
function clickOutside(node: HTMLElement) {
|
||||||
fetchSuggestions(inputVal);
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||||
|
showSuggestions = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', handleClick, true);
|
||||||
|
return { destroy() { document.removeEventListener('click', handleClick, true); } };
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full" use:clickOutside>
|
||||||
<!-- Tag Container -->
|
<!-- Tag Container -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy bg-white min-h-[42px]"
|
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy bg-white min-h-[42px]"
|
||||||
>
|
>
|
||||||
<!-- Render Selected Tags -->
|
<!-- Render Selected Tags -->
|
||||||
{#each tags as tag, i}
|
{#each tags as tag, i}
|
||||||
<span
|
<span
|
||||||
class="bg-brand-sand/30 text-brand-navy text-sm font-medium px-2 py-1 rounded flex items-center gap-1"
|
class="bg-brand-sand/30 text-brand-navy text-sm font-medium px-2 py-1 rounded flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => removeTag(i)}
|
onclick={() => removeTag(i)}
|
||||||
aria-label="Schlagwort entfernen"
|
aria-label="Schlagwort entfernen"
|
||||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M6 18L18 6M6 6l12 12"
|
d="M6 18L18 6M6 6l12 12"
|
||||||
/></svg
|
/></svg
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Input Field -->
|
<!-- Input Field -->
|
||||||
<div class="relative flex-1 min-w-[120px]">
|
<div class="relative flex-1 min-w-[120px]">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={inputVal}
|
bind:value={inputVal}
|
||||||
on:input={handleInput}
|
oninput={() => fetchSuggestions(inputVal)}
|
||||||
on:keydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
on:focus={() => handleInput()}
|
onfocus={() => fetchSuggestions(inputVal)}
|
||||||
on:blur={() => setTimeout(() => (showSuggestions = false), 200)}
|
placeholder={tags.length === 0
|
||||||
placeholder={tags.length === 0
|
? allowCreation
|
||||||
? allowCreation
|
? 'Schlagworte hinzufügen...'
|
||||||
? 'Schlagworte hinzufügen...'
|
: 'Nach Schlagworten filtern...'
|
||||||
: 'Nach Schlagworten filtern...'
|
: ''}
|
||||||
: ''}
|
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
|
||||||
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Typeahead Dropdown -->
|
<!-- Typeahead Dropdown -->
|
||||||
{#if showSuggestions && suggestions.length > 0}
|
{#if showSuggestions && suggestions.length > 0}
|
||||||
<ul
|
<ul
|
||||||
class="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded shadow-lg z-50 max-h-48 overflow-y-auto"
|
class="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded shadow-lg z-50 max-h-48 overflow-y-auto"
|
||||||
>
|
>
|
||||||
{#each suggestions as suggestion, i}
|
{#each suggestions as suggestion, i}
|
||||||
<li
|
<li
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === activeIndex}
|
aria-selected={i === activeIndex}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="px-3 py-2 text-sm cursor-pointer hover:bg-brand-sand/20 {i === activeIndex
|
class="px-3 py-2 text-sm cursor-pointer hover:bg-brand-sand/20 {i === activeIndex
|
||||||
? 'bg-brand-sand/20 text-brand-navy font-bold'
|
? 'bg-brand-sand/20 text-brand-navy font-bold'
|
||||||
: 'text-gray-700'}"
|
: 'text-gray-700'}"
|
||||||
on:click={() => addTag(suggestion)}
|
onclick={() => addTag(suggestion)}
|
||||||
on:keydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
||||||
>
|
>
|
||||||
{suggestion}
|
{suggestion}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if allowCreation}
|
{#if allowCreation}
|
||||||
<p class="text-xs text-gray-400 mt-1">Enter drücken um Schlagwort zu erstellen.</p>
|
<p class="text-xs text-gray-400 mt-1">Enter drücken um Schlagwort zu erstellen.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,116 +2,112 @@
|
|||||||
import './layout.css';
|
import './layout.css';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
$: user = page.data.user;
|
|
||||||
$: isAdmin = user?.groups.some(g => g.permissions.includes("ADMIN"))
|
let { children } = $props();
|
||||||
|
|
||||||
|
const isAdmin = $derived(page.data.user?.groups.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-brand-sand">
|
<div class="min-h-screen bg-brand-sand">
|
||||||
<!-- Changed background to Sand -->
|
|
||||||
|
|
||||||
<!-- Corporate Header -->
|
{#if !page.url.pathname.startsWith('/login')}
|
||||||
{#if !page.url.pathname.startsWith('/login')}
|
<header class="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
|
||||||
<header class="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="flex justify-between h-20">
|
||||||
<div class="flex justify-between h-20">
|
|
||||||
<!-- Slightly taller header -->
|
|
||||||
|
|
||||||
<!-- Logo & Nav -->
|
<!-- Logo & Nav -->
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<!-- LOGO (Extracted from their SVG) -->
|
<div class="flex-shrink-0 flex items-center mr-8">
|
||||||
<div class="flex-shrink-0 flex items-center mr-8">
|
<a href="/" class="flex items-center gap-2" aria-label="Familienarchiv">
|
||||||
<a href="/" class="flex items-center gap-2" aria-label="Familienarchiv">
|
<svg
|
||||||
<!-- SVG Code from their site -->
|
width="250"
|
||||||
<svg
|
height="25"
|
||||||
width="250"
|
viewBox="0 0 250 25"
|
||||||
height="25"
|
fill="none"
|
||||||
viewBox="0 0 250 25"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path
|
||||||
>
|
d="M0.156128 1.01562C5.375 1.43431 9.621 4.65591 9.621 11.6669V19.8779H0.156128V10.4467H5.76852C5.76852 5.6736 3.70661 3.72129 0.156128 2.8334V1.01562Z"
|
||||||
<path
|
fill="#B4B9FF"
|
||||||
d="M0.156128 1.01562C5.375 1.43431 9.621 4.65591 9.621 11.6669V19.8779H0.156128V10.4467H5.76852C5.76852 5.6736 3.70661 3.72129 0.156128 2.8334V1.01562Z"
|
></path>
|
||||||
fill="#B4B9FF"
|
<path
|
||||||
></path>
|
d="M10.5892 19.8779C15.8076 19.4592 20.0541 16.2371 20.0541 9.22655V1.01562H10.5892V10.4467H16.2012C16.2012 15.2199 14.1397 17.1722 10.5892 18.0601V19.8779Z"
|
||||||
<path
|
fill="#B4B9FF"
|
||||||
d="M10.5892 19.8779C15.8076 19.4592 20.0541 16.2371 20.0541 9.22655V1.01562H10.5892V10.4467H16.2012C16.2012 15.2199 14.1397 17.1722 10.5892 18.0601V19.8779Z"
|
></path>
|
||||||
fill="#B4B9FF"
|
|
||||||
></path>
|
|
||||||
|
|
||||||
<text
|
<text
|
||||||
x="35"
|
x="35"
|
||||||
y="20"
|
y="20"
|
||||||
fill="#002850"
|
fill="#002850"
|
||||||
font-family="Montserrat"
|
font-family="Montserrat"
|
||||||
font-weight="bold"
|
font-weight="bold"
|
||||||
font-size="20">FAMILIENARCHIV</text
|
font-size="20">FAMILIENARCHIV</text
|
||||||
>
|
>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nav Links (Montserrat font, Uppercase style often used in corporate) -->
|
<div class="hidden sm:ml-6 sm:flex sm:space-x-8 items-center">
|
||||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8 items-center">
|
<a
|
||||||
<a
|
href="/"
|
||||||
href="/"
|
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
|
||||||
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
|
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
? 'border-brand-navy text-brand-navy'
|
||||||
? 'border-brand-navy text-brand-navy'
|
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
|
||||||
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
|
>
|
||||||
>
|
Dokumente
|
||||||
Dokumente
|
</a>
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/persons"
|
href="/persons"
|
||||||
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
|
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
|
||||||
{page.url.pathname.startsWith('/persons')
|
{page.url.pathname.startsWith('/persons')
|
||||||
? 'border-brand-navy text-brand-navy'
|
? 'border-brand-navy text-brand-navy'
|
||||||
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
|
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
|
||||||
>
|
>
|
||||||
Personen
|
Personen
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/conversations"
|
href="/conversations"
|
||||||
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
|
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
|
||||||
{page.url.pathname.startsWith('/conversations')
|
{page.url.pathname.startsWith('/conversations')
|
||||||
? 'border-brand-navy text-brand-navy'
|
? 'border-brand-navy text-brand-navy'
|
||||||
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
|
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
|
||||||
>
|
>
|
||||||
Konversationen
|
Konversationen
|
||||||
</a>
|
</a>
|
||||||
{#if isAdmin}
|
{#if isAdmin}
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
|
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
|
||||||
{page.url.pathname.startsWith('/admin')
|
{page.url.pathname.startsWith('/admin')
|
||||||
? 'border-brand-navy text-brand-navy'
|
? 'border-brand-navy text-brand-navy'
|
||||||
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
|
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
|
||||||
>
|
>
|
||||||
Admin
|
Admin
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Side -->
|
<!-- Right Side -->
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<form action="/logout" method="POST" use:enhance>
|
<form action="/logout" method="POST" use:enhance>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="text-sm text-gray-500 hover:text-brand-navy font-bold uppercase font-sans tracking-wide px-3 py-2 transition"
|
class="text-sm text-gray-500 hover:text-brand-navy font-bold uppercase font-sans tracking-wide px-3 py-2 transition"
|
||||||
>
|
>
|
||||||
Abmelden
|
Abmelden
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<main class="py-6">
|
<main class="py-6">
|
||||||
<slot />
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,21 +3,19 @@ import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import TagInput from '$lib/components/TagInput.svelte';
|
import TagInput from '$lib/components/TagInput.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
export let data;
|
let { data } = $props();
|
||||||
|
|
||||||
// Local state variables
|
let q = $state(untrack(() => data.filters?.q || ''));
|
||||||
let q = data.filters?.q || '';
|
let from = $state(untrack(() => data.filters?.from || ''));
|
||||||
let from = data.filters?.from || '';
|
let to = $state(untrack(() => data.filters?.to || ''));
|
||||||
let to = data.filters?.to || '';
|
let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
||||||
let senderId = data.filters?.senderId || '';
|
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
||||||
let receiverId = data.filters?.receiverId || '';
|
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
||||||
let tagNames = data.filters?.tags || [];
|
|
||||||
|
|
||||||
// Debounce Timer
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
let searchTimer: any;
|
let showAdvanced = $state(false);
|
||||||
|
|
||||||
let showAdvanced = false;
|
|
||||||
|
|
||||||
function triggerSearch() {
|
function triggerSearch() {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -42,27 +40,24 @@ function handleTextSearch() {
|
|||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
let previousTags = tagNames.join(',');
|
// Trigger search when tags change
|
||||||
$: {
|
let prevTagStr = untrack(() => tagNames.join(','));
|
||||||
const currentTags = tagNames.join(',');
|
$effect(() => {
|
||||||
if (currentTags !== previousTags) {
|
const cur = tagNames.join(',');
|
||||||
previousTags = currentTags;
|
if (cur !== prevTagStr) {
|
||||||
|
prevTagStr = cur;
|
||||||
triggerSearch();
|
triggerSearch();
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
function toggleAdvanced() {
|
// Sync local state with server data after navigation
|
||||||
showAdvanced = !showAdvanced;
|
$effect(() => {
|
||||||
}
|
|
||||||
|
|
||||||
// Sync with server data (e.g. after reset)
|
|
||||||
$: {
|
|
||||||
q = data.filters?.q || '';
|
q = data.filters?.q || '';
|
||||||
from = data.filters?.from || '';
|
from = data.filters?.from || '';
|
||||||
to = data.filters?.to || '';
|
to = data.filters?.to || '';
|
||||||
senderId = data.filters?.senderId || '';
|
senderId = data.filters?.senderId || '';
|
||||||
receiverId = data.filters?.receiverId || '';
|
receiverId = data.filters?.receiverId || '';
|
||||||
}
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Outer Container: Matches the 'Sand' background of the layout -->
|
<!-- Outer Container: Matches the 'Sand' background of the layout -->
|
||||||
@@ -76,7 +71,7 @@ $: {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={q}
|
bind:value={q}
|
||||||
on:input={handleTextSearch}
|
oninput={handleTextSearch}
|
||||||
placeholder="Suche in Titel, Inhalt, Ort..."
|
placeholder="Suche in Titel, Inhalt, Ort..."
|
||||||
class="block w-full border-gray-300 py-2.5 pr-10 pl-3 placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
class="block w-full border-gray-300 py-2.5 pr-10 pl-3 placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||||
/>
|
/>
|
||||||
@@ -94,7 +89,7 @@ $: {
|
|||||||
|
|
||||||
<!-- Toggle Advanced Button -->
|
<!-- Toggle Advanced Button -->
|
||||||
<button
|
<button
|
||||||
on:click={toggleAdvanced}
|
onclick={() => (showAdvanced = !showAdvanced)}
|
||||||
class="flex items-center gap-2 border border-gray-300 bg-gray-50 px-4 py-2.5 text-sm font-bold tracking-wide text-gray-600 uppercase transition hover:bg-gray-100 hover:text-brand-navy"
|
class="flex items-center gap-2 border border-gray-300 bg-gray-50 px-4 py-2.5 text-sm font-bold tracking-wide text-gray-600 uppercase transition hover:bg-gray-100 hover:text-brand-navy"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -143,7 +138,7 @@ $: {
|
|||||||
<p class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase">
|
<p class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase">
|
||||||
Schlagworte
|
Schlagworte
|
||||||
</p>
|
</p>
|
||||||
<TagInput bind:tags={tagNames} allowCreation={false} on:change={triggerSearch} />
|
<TagInput bind:tags={tagNames} allowCreation={false} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sender -->
|
<!-- Sender -->
|
||||||
@@ -156,7 +151,7 @@ $: {
|
|||||||
label="Absender"
|
label="Absender"
|
||||||
bind:value={senderId}
|
bind:value={senderId}
|
||||||
initialName={data.initialValues?.senderName}
|
initialName={data.initialValues?.senderName}
|
||||||
on:change={triggerSearch}
|
onchange={triggerSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +166,7 @@ $: {
|
|||||||
label="Empfänger"
|
label="Empfänger"
|
||||||
bind:value={receiverId}
|
bind:value={receiverId}
|
||||||
initialName={data.initialValues?.receiverName}
|
initialName={data.initialValues?.receiverName}
|
||||||
on:change={triggerSearch}
|
onchange={triggerSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +183,7 @@ $: {
|
|||||||
type="date"
|
type="date"
|
||||||
id="from"
|
id="from"
|
||||||
bind:value={from}
|
bind:value={from}
|
||||||
on:change={triggerSearch}
|
onchange={triggerSearch}
|
||||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
|
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +197,7 @@ $: {
|
|||||||
type="date"
|
type="date"
|
||||||
id="to"
|
id="to"
|
||||||
bind:value={to}
|
bind:value={to}
|
||||||
on:change={triggerSearch}
|
onchange={triggerSearch}
|
||||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
|
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,15 +324,14 @@ $: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- NEW: Tags Display -->
|
<!-- Tags Display -->
|
||||||
{#if doc.tags && doc.tags.length > 0}
|
{#if doc.tags && doc.tags.length > 0}
|
||||||
<div class="mt-4 flex flex-wrap gap-2 pt-3">
|
<div class="mt-4 flex flex-wrap gap-2 pt-3">
|
||||||
{#each doc.tags as tag}
|
{#each doc.tags as tag}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative z-10 inline-flex items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
class="relative z-10 inline-flex items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
||||||
on:click|preventDefault|stopPropagation={() =>
|
onclick={(e) => { e.preventDefault(); e.stopPropagation(); goto(`/?tag=${encodeURIComponent(tag.name)}`); }}
|
||||||
goto(`/?tag=${encodeURIComponent(tag.name)}`)}
|
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</button>
|
</button>
|
||||||
@@ -384,7 +378,7 @@ $: {
|
|||||||
Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern.
|
Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
on:click={() => goto('/')}
|
onclick={() => goto('/')}
|
||||||
class="mt-6 text-sm font-bold tracking-wide text-brand-mint uppercase transition hover:text-brand-navy"
|
class="mt-6 text-sm font-bold tracking-wide text-brand-mint uppercase transition hover:text-brand-navy"
|
||||||
>
|
>
|
||||||
Alle Filter löschen
|
Alle Filter löschen
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
export let data;
|
let { data, form } = $props();
|
||||||
export let form;
|
|
||||||
|
|
||||||
let activeTab = 'users';
|
let activeTab = $state('users');
|
||||||
let editingTagId: string | null = null;
|
let editingTagId: string | null = $state(null);
|
||||||
let editingTagName = '';
|
let editingTagName = $state('');
|
||||||
let editingUserId: string | null = null;
|
let editingUserId: string | null = $state(null);
|
||||||
|
let editingGroupId: string | null = $state(null);
|
||||||
|
|
||||||
function startEditTag(tag: any) {
|
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||||
|
|
||||||
|
function startEditTag(tag: { id: string; name: string }) {
|
||||||
editingTagId = tag.id;
|
editingTagId = tag.id;
|
||||||
editingTagName = tag.name;
|
editingTagName = tag.name;
|
||||||
}
|
}
|
||||||
@@ -20,7 +22,7 @@
|
|||||||
editingTagName = '';
|
editingTagName = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEditUser(id: string) {
|
function startEditUser(id: string) {
|
||||||
editingUserId = id;
|
editingUserId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,9 +30,6 @@
|
|||||||
editingUserId = null;
|
editingUserId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let editingGroupId: string | null = null;
|
|
||||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
|
||||||
|
|
||||||
function startEditGroup(id: string) {
|
function startEditGroup(id: string) {
|
||||||
editingGroupId = id;
|
editingGroupId = id;
|
||||||
}
|
}
|
||||||
@@ -51,21 +50,21 @@
|
|||||||
'users'
|
'users'
|
||||||
? 'bg-brand-navy text-white'
|
? 'bg-brand-navy text-white'
|
||||||
: 'text-gray-500 hover:text-brand-navy'}"
|
: 'text-gray-500 hover:text-brand-navy'}"
|
||||||
on:click={() => (activeTab = 'users')}>Benutzer</button
|
onclick={() => (activeTab = 'users')}>Benutzer</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
|
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
|
||||||
'groups'
|
'groups'
|
||||||
? 'bg-brand-navy text-white'
|
? 'bg-brand-navy text-white'
|
||||||
: 'text-gray-500 hover:text-brand-navy'}"
|
: 'text-gray-500 hover:text-brand-navy'}"
|
||||||
on:click={() => (activeTab = 'groups')}>Gruppen</button
|
onclick={() => (activeTab = 'groups')}>Gruppen</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
|
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
|
||||||
'tags'
|
'tags'
|
||||||
? 'bg-brand-navy text-white'
|
? 'bg-brand-navy text-white'
|
||||||
: 'text-gray-500 hover:text-brand-navy'}"
|
: 'text-gray-500 hover:text-brand-navy'}"
|
||||||
on:click={() => (activeTab = 'tags')}>Schlagworte</button
|
onclick={() => (activeTab = 'tags')}>Schlagworte</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +105,6 @@
|
|||||||
<!-- === EDIT MODE === -->
|
<!-- === EDIT MODE === -->
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{user.username}
|
{user.username}
|
||||||
<!-- Hidden ID Input for the form -->
|
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="username"
|
name="username"
|
||||||
@@ -116,7 +114,6 @@
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="px-6 py-4 text-sm">
|
<td class="px-6 py-4 text-sm">
|
||||||
<!-- Groups Select -->
|
|
||||||
<select
|
<select
|
||||||
name="groupIds"
|
name="groupIds"
|
||||||
multiple
|
multiple
|
||||||
@@ -126,7 +123,7 @@
|
|||||||
{#each data.groups as group}
|
{#each data.groups as group}
|
||||||
<option
|
<option
|
||||||
value={group.id}
|
value={group.id}
|
||||||
selected={user.groups.some((g) => g.id === group.id)}
|
selected={user.groups.some((g: { id: string }) => g.id === group.id)}
|
||||||
>
|
>
|
||||||
{group.name}
|
{group.name}
|
||||||
</option>
|
</option>
|
||||||
@@ -136,7 +133,6 @@
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right align-top">
|
<td class="px-6 py-4 whitespace-nowrap text-right align-top">
|
||||||
<!-- Password & Buttons -->
|
|
||||||
<form
|
<form
|
||||||
id="edit-form-{user.id}"
|
id="edit-form-{user.id}"
|
||||||
method="POST"
|
method="POST"
|
||||||
@@ -164,7 +160,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={cancelEditUser}
|
onclick={cancelEditUser}
|
||||||
class="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-bold uppercase hover:bg-gray-300"
|
class="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-bold uppercase hover:bg-gray-300"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
@@ -194,15 +190,13 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<div class="flex items-center justify-end gap-4">
|
<div class="flex items-center justify-end gap-4">
|
||||||
<!-- Edit Button -->
|
|
||||||
<button
|
<button
|
||||||
on:click={() => startEditUser(user.id)}
|
onclick={() => startEditUser(user.id)}
|
||||||
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
|
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Delete Button -->
|
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/deleteUser"
|
action="?/deleteUser"
|
||||||
@@ -265,7 +259,6 @@
|
|||||||
class="rounded border-gray-300 text-sm w-full"
|
class="rounded border-gray-300 text-sm w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Multi-Select for Groups -->
|
|
||||||
<div class="md:col-span-3">
|
<div class="md:col-span-3">
|
||||||
<select
|
<select
|
||||||
name="groupIds"
|
name="groupIds"
|
||||||
@@ -290,7 +283,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab === 'tags'}
|
{:else if activeTab === 'tags'}
|
||||||
<!-- TAGS SECTION (unchanged logic, just ensuring style consistency) -->
|
|
||||||
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
|
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
|
||||||
<div class="p-6 border-b border-gray-100 bg-yellow-50/50">
|
<div class="p-6 border-b border-gray-100 bg-yellow-50/50">
|
||||||
<h2 class="text-lg font-bold text-gray-700">Schlagworte</h2>
|
<h2 class="text-lg font-bold text-gray-700">Schlagworte</h2>
|
||||||
@@ -332,9 +324,9 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={cancelEditTag}
|
onclick={cancelEditTag}
|
||||||
aria-label="Abbrechen"
|
aria-label="Abbrechen"
|
||||||
class="text-gray-400 hover:text-gray-600"
|
class="text-gray-400 hover:text-gray-600"
|
||||||
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -353,7 +345,7 @@
|
|||||||
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
on:click={() => startEditTag(tag)}
|
onclick={() => startEditTag(tag)}
|
||||||
aria-label="Schlagwort bearbeiten"
|
aria-label="Schlagwort bearbeiten"
|
||||||
class="p-1 text-gray-400 hover:text-brand-navy"
|
class="p-1 text-gray-400 hover:text-brand-navy"
|
||||||
>
|
>
|
||||||
@@ -370,16 +362,13 @@
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/deleteTag"
|
action="?/deleteTag"
|
||||||
use:enhance={({ cancel }) => {
|
use:enhance={({ cancel }) => {
|
||||||
// This runs BEFORE the request is sent
|
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
'Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.'
|
'Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.'
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
cancel(); // Stop the request
|
cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
// This runs AFTER the server responds
|
|
||||||
return async ({ update }) => {
|
return async ({ update }) => {
|
||||||
await update();
|
await update();
|
||||||
};
|
};
|
||||||
@@ -443,7 +432,6 @@
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={group.id} />
|
<input type="hidden" name="id" value={group.id} />
|
||||||
|
|
||||||
<!-- Name Input -->
|
|
||||||
<div class="w-full sm:w-1/3">
|
<div class="w-full sm:w-1/3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -454,7 +442,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Permissions Checkboxes -->
|
|
||||||
<div class="flex-1 flex flex-wrap gap-4 items-center h-full pt-2">
|
<div class="flex-1 flex flex-wrap gap-4 items-center h-full pt-2">
|
||||||
{#each availablePermissions as perm}
|
{#each availablePermissions as perm}
|
||||||
<label
|
<label
|
||||||
@@ -472,7 +459,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="flex gap-2 self-start sm:self-center">
|
<div class="flex gap-2 self-start sm:self-center">
|
||||||
<button type="submit" aria-label="Speichern" class="text-green-600 hover:text-green-800 p-1">
|
<button type="submit" aria-label="Speichern" class="text-green-600 hover:text-green-800 p-1">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
@@ -486,9 +472,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={cancelEditGroup}
|
onclick={cancelEditGroup}
|
||||||
aria-label="Abbrechen"
|
aria-label="Abbrechen"
|
||||||
class="text-gray-400 hover:text-red-500 p-1"
|
class="text-gray-400 hover:text-red-500 p-1"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
@@ -524,7 +510,7 @@
|
|||||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<div class="flex items-center justify-end gap-3">
|
<div class="flex items-center justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
on:click={() => startEditGroup(group.id)}
|
onclick={() => startEditGroup(group.id)}
|
||||||
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
|
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
@@ -537,7 +523,6 @@
|
|||||||
if (!confirm('Gruppe wirklich löschen?')) {
|
if (!confirm('Gruppe wirklich löschen?')) {
|
||||||
cancel();
|
cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
return async ({ update }) => {
|
return async ({ update }) => {
|
||||||
await update();
|
await update();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,58 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
export let data;
|
let { data } = $props();
|
||||||
|
|
||||||
// Data & State
|
let senderId = $state(untrack(() => data.filters.senderId));
|
||||||
let documents: typeof data.documents = [];
|
let receiverId = $state(untrack(() => data.filters.receiverId));
|
||||||
let initialValues = { senderName: '', receiverName: '' };
|
let fromDate = $state(untrack(() => data.filters.from));
|
||||||
|
let toDate = $state(untrack(() => data.filters.to));
|
||||||
|
let sortDir = $state(untrack(() => data.filters.dir));
|
||||||
|
|
||||||
// Filter State
|
// Sync with server data after navigation
|
||||||
let senderId = '';
|
$effect(() => {
|
||||||
let receiverId = '';
|
|
||||||
let fromDate = '';
|
|
||||||
let toDate = '';
|
|
||||||
let sortDir = 'DESC';
|
|
||||||
|
|
||||||
// Reactive Update
|
|
||||||
$: {
|
|
||||||
documents = data.documents;
|
|
||||||
initialValues = data.initialValues;
|
|
||||||
senderId = data.filters.senderId;
|
senderId = data.filters.senderId;
|
||||||
receiverId = data.filters.receiverId;
|
receiverId = data.filters.receiverId;
|
||||||
fromDate = data.filters.from;
|
fromDate = data.filters.from;
|
||||||
toDate = data.filters.to;
|
toDate = data.filters.to;
|
||||||
sortDir = data.filters.dir;
|
sortDir = data.filters.dir;
|
||||||
}
|
});
|
||||||
|
|
||||||
// Filter Logic
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
setTimeout(() => {
|
const params = new URLSearchParams();
|
||||||
const params = new URLSearchParams();
|
if (senderId) params.set('senderId', senderId);
|
||||||
if (senderId) params.set('senderId', senderId);
|
if (receiverId) params.set('receiverId', receiverId);
|
||||||
if (receiverId) params.set('receiverId', receiverId);
|
if (fromDate) params.set('from', fromDate);
|
||||||
if (fromDate) params.set('from', fromDate);
|
if (toDate) params.set('to', toDate);
|
||||||
if (toDate) params.set('to', toDate);
|
params.set('dir', sortDir);
|
||||||
params.set('dir', sortDir);
|
goto(`?${params.toString()}`, { keepFocus: true });
|
||||||
goto(`?${params.toString()}`, { keepFocus: true });
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSort() {
|
function toggleSort() {
|
||||||
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
|
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
|
||||||
applyFilters();
|
applyFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSenderChange(event: CustomEvent) {
|
|
||||||
senderId = event.detail.value;
|
|
||||||
applyFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReceiverChange(event: CustomEvent) {
|
|
||||||
receiverId = event.detail.value;
|
|
||||||
applyFilters();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-5xl mx-auto py-10 px-4">
|
<div class="max-w-5xl mx-auto py-10 px-4">
|
||||||
@@ -74,9 +55,9 @@
|
|||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="senderId"
|
name="senderId"
|
||||||
label="Person A (Absender)"
|
label="Person A (Absender)"
|
||||||
value={senderId}
|
bind:value={senderId}
|
||||||
initialName={initialValues.senderName}
|
initialName={data.initialValues.senderName}
|
||||||
on:change={handleSenderChange}
|
onchange={() => applyFilters()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,9 +68,9 @@
|
|||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="receiverId"
|
name="receiverId"
|
||||||
label="Person B (Empfänger)"
|
label="Person B (Empfänger)"
|
||||||
value={receiverId}
|
bind:value={receiverId}
|
||||||
initialName={initialValues.receiverName}
|
initialName={data.initialValues.receiverName}
|
||||||
on:change={handleReceiverChange}
|
onchange={() => applyFilters()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +87,7 @@
|
|||||||
id="dateFrom"
|
id="dateFrom"
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={fromDate}
|
bind:value={fromDate}
|
||||||
on:change={() => applyFilters()}
|
onchange={() => applyFilters()}
|
||||||
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
|
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +103,7 @@
|
|||||||
id="dateTo"
|
id="dateTo"
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={toDate}
|
bind:value={toDate}
|
||||||
on:change={() => applyFilters()}
|
onchange={() => applyFilters()}
|
||||||
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
|
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +111,7 @@
|
|||||||
<!-- Sort Toggle -->
|
<!-- Sort Toggle -->
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
on:click={toggleSort}
|
onclick={toggleSort}
|
||||||
class="w-full flex items-center justify-center h-[42px] border border-brand-sand text-xs font-bold uppercase tracking-wide text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
|
class="w-full flex items-center justify-center h-[42px] border border-brand-sand text-xs font-bold uppercase tracking-wide text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<span class="mr-2">Sortierung:</span>
|
<span class="mr-2">Sortierung:</span>
|
||||||
@@ -169,7 +150,7 @@
|
|||||||
<p class="text-brand-navy font-serif text-lg">Wählen Sie zwei Personen aus</p>
|
<p class="text-brand-navy font-serif text-lg">Wählen Sie zwei Personen aus</p>
|
||||||
<p class="text-gray-500 font-sans text-sm mt-1">Die Korrespondenz wird hier angezeigt.</p>
|
<p class="text-gray-500 font-sans text-sm mt-1">Die Korrespondenz wird hier angezeigt.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if documents.length === 0}
|
{:else if data.documents.length === 0}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand rounded-sm text-center shadow-sm"
|
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand rounded-sm text-center shadow-sm"
|
||||||
>
|
>
|
||||||
@@ -178,18 +159,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- CHAT CONTAINER -->
|
<!-- CHAT CONTAINER -->
|
||||||
<!-- Added: White background, Border, Shadow to separate from page -->
|
|
||||||
<div class="bg-white border border-brand-sand shadow-sm rounded-sm relative overflow-hidden">
|
<div class="bg-white border border-brand-sand shadow-sm rounded-sm relative overflow-hidden">
|
||||||
<!-- Decoration: Central Timeline Line -->
|
<!-- Decoration: Central Timeline Line -->
|
||||||
<div
|
<div
|
||||||
class="absolute left-1/2 top-0 bottom-0 w-px bg-brand-sand/30 transform -translate-x-1/2 hidden md:block"
|
class="absolute left-1/2 top-0 bottom-0 w-px bg-brand-sand/30 transform -translate-x-1/2 hidden md:block"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Scrollable Area (optional, if you want max-height) -->
|
|
||||||
<div class="p-6 md:p-8">
|
<div class="p-6 md:p-8">
|
||||||
<!-- TIGHTER GAP: Changed from gap-8 to gap-4 -->
|
|
||||||
<div class="flex flex-col gap-4 relative z-10">
|
<div class="flex flex-col gap-4 relative z-10">
|
||||||
{#each documents as doc}
|
{#each data.documents as doc}
|
||||||
{@const isRight = doc.sender?.id === senderId}
|
{@const isRight = doc.sender?.id === senderId}
|
||||||
|
|
||||||
<!-- Message Row -->
|
<!-- Message Row -->
|
||||||
@@ -200,7 +178,7 @@
|
|||||||
? 'flex-row-reverse'
|
? 'flex-row-reverse'
|
||||||
: 'flex-row'}"
|
: 'flex-row'}"
|
||||||
>
|
>
|
||||||
<!-- AVATAR (Small) -->
|
<!-- AVATAR -->
|
||||||
<div class="flex-shrink-0 mt-auto mb-1 hidden sm:block">
|
<div class="flex-shrink-0 mt-auto mb-1 hidden sm:block">
|
||||||
<div
|
<div
|
||||||
class="w-8 h-8 rounded-full flex items-center justify-center font-serif text-xs border shadow-sm
|
class="w-8 h-8 rounded-full flex items-center justify-center font-serif text-xs border shadow-sm
|
||||||
@@ -217,7 +195,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- BUBBLE CARD -->
|
<!-- BUBBLE CARD -->
|
||||||
<!-- Adjusted padding (p-4) and added light bg to left bubbles for contrast -->
|
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}"
|
href="/documents/{doc.id}"
|
||||||
class="group block p-4 rounded shadow-sm transition-all duration-200 transform hover:-translate-y-0.5 hover:shadow-md border
|
class="group block p-4 rounded shadow-sm transition-all duration-200 transform hover:-translate-y-0.5 hover:shadow-md border
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let data;
|
let { data } = $props();
|
||||||
$: doc = data.document;
|
|
||||||
|
|
||||||
// Instead of a direct link, we use a reactive variable for the Blob URL
|
const doc = $derived(data.document);
|
||||||
let fileUrl = '';
|
|
||||||
let isLoading = false;
|
|
||||||
let error = '';
|
|
||||||
|
|
||||||
// Reactive statement: Whenever the document ID changes, load the file
|
let fileUrl = $state('');
|
||||||
$: if (doc?.id && doc?.filePath) {
|
let isLoading = $state(false);
|
||||||
loadFile(doc.id);
|
let error = $state('');
|
||||||
}
|
|
||||||
|
$effect(() => {
|
||||||
|
if (doc?.id && doc?.filePath) {
|
||||||
|
loadFile(doc.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function loadFile(id: string) {
|
async function loadFile(id: string) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = '';
|
error = '';
|
||||||
fileUrl = ''; // Reset previous URL
|
fileUrl = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Fetch with current authentication
|
|
||||||
const response = await fetch(`/api/documents/${id}/file`);
|
const response = await fetch(`/api/documents/${id}/file`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -26,10 +26,7 @@
|
|||||||
throw new Error('Fehler beim Laden der Datei');
|
throw new Error('Fehler beim Laden der Datei');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Create a Blob from the data
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
|
||||||
// 3. Create a temporary URL for this Blob
|
|
||||||
fileUrl = URL.createObjectURL(blob);
|
fileUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -186,7 +183,6 @@
|
|||||||
{#if doc.documentLocation}
|
{#if doc.documentLocation}
|
||||||
<div class="flex items-start group">
|
<div class="flex items-start group">
|
||||||
<span class="text-brand-mint w-8 mt-0.5">
|
<span class="text-brand-mint w-8 mt-0.5">
|
||||||
<!-- Archive Box Icon -->
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -209,7 +205,6 @@
|
|||||||
{#if doc.tags && doc.tags.length > 0}
|
{#if doc.tags && doc.tags.length > 0}
|
||||||
<div class="flex items-start group">
|
<div class="flex items-start group">
|
||||||
<span class="text-brand-mint w-8 mt-0.5">
|
<span class="text-brand-mint w-8 mt-0.5">
|
||||||
<!-- Tag Icon -->
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -324,7 +319,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 3. INHALT GROUP (Merged Summary & Transcription) -->
|
<!-- 3. INHALT GROUP -->
|
||||||
{#if doc.summary || doc.transcription}
|
{#if doc.summary || doc.transcription}
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
@@ -334,12 +329,9 @@
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Summary Sub-Section -->
|
|
||||||
{#if doc.summary}
|
{#if doc.summary}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase"
|
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">Zusammenfassung</span>
|
||||||
>Zusammenfassung</span
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
|
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
|
||||||
>
|
>
|
||||||
@@ -348,12 +340,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Transcription Sub-Section -->
|
|
||||||
{#if doc.transcription}
|
{#if doc.transcription}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase"
|
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">Transkription</span>
|
||||||
>Transkription</span
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
|
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
|
||||||
>
|
>
|
||||||
@@ -376,7 +365,6 @@
|
|||||||
<!-- RIGHT: PREVIEW AREA -->
|
<!-- RIGHT: PREVIEW AREA -->
|
||||||
<main class="flex-1 bg-[#2A2A2A] relative flex flex-col items-center justify-center">
|
<main class="flex-1 bg-[#2A2A2A] relative flex flex-col items-center justify-center">
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<!-- Loading Spinner -->
|
|
||||||
<div class="text-brand-mint flex flex-col items-center">
|
<div class="text-brand-mint flex flex-col items-center">
|
||||||
<svg
|
<svg
|
||||||
class="animate-spin h-8 w-8 mb-4"
|
class="animate-spin h-8 w-8 mb-4"
|
||||||
@@ -398,7 +386,6 @@
|
|||||||
<div class="text-gray-400 text-center px-4">
|
<div class="text-gray-400 text-center px-4">
|
||||||
<p class="font-serif mb-2">{error}</p>
|
<p class="font-serif mb-2">{error}</p>
|
||||||
{#if doc.filePath}
|
{#if doc.filePath}
|
||||||
<!-- Direct link as fallback -->
|
|
||||||
<a
|
<a
|
||||||
href={`/api/documents/${doc.id}/file`}
|
href={`/api/documents/${doc.id}/file`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -409,10 +396,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if !doc.filePath}
|
{:else if !doc.filePath}
|
||||||
<!-- No File State -->
|
|
||||||
<div class="flex flex-col items-center text-gray-400">
|
<div class="flex flex-col items-center text-gray-400">
|
||||||
<div class="bg-white/5 p-8 rounded-full mb-6">
|
<div class="bg-white/5 p-8 rounded-full mb-6">
|
||||||
<!-- Icon... -->
|
|
||||||
<svg class="w-12 h-12 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="w-12 h-12 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
><path
|
><path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -425,14 +410,12 @@
|
|||||||
<p class="font-sans text-sm tracking-wide uppercase">Kein Scan vorhanden</p>
|
<p class="font-sans text-sm tracking-wide uppercase">Kein Scan vorhanden</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')}
|
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')}
|
||||||
<!-- PDF Iframe with Blob URL -->
|
|
||||||
<iframe
|
<iframe
|
||||||
src={fileUrl}
|
src={fileUrl}
|
||||||
title="Document Preview"
|
title="Document Preview"
|
||||||
class="w-full h-full border-none bg-white"
|
class="w-full h-full border-none bg-white"
|
||||||
></iframe>
|
></iframe>
|
||||||
{:else if fileUrl}
|
{:else if fileUrl}
|
||||||
<!-- Image with Blob URL -->
|
|
||||||
<div class="w-full h-full flex items-center justify-center overflow-auto p-8">
|
<div class="w-full h-full flex items-center justify-center overflow-auto p-8">
|
||||||
<img
|
<img
|
||||||
src={fileUrl}
|
src={fileUrl}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
import TagInput from '$lib/components/TagInput.svelte';
|
import TagInput from '$lib/components/TagInput.svelte';
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
export let data;
|
let { data, form } = $props();
|
||||||
export let form;
|
|
||||||
|
|
||||||
let { document: doc } = data;
|
let { document: doc } = untrack(() => data);
|
||||||
let tags = doc.tags ? doc.tags.map((t: any) => t.name) : [];
|
let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []);
|
||||||
let senderId = doc.sender?.id ?? '';
|
let senderId = $state(doc.sender?.id ?? '');
|
||||||
let selectedReceivers = doc.receivers ?? [];
|
let selectedReceivers = $state(doc.receivers ?? []);
|
||||||
|
|
||||||
function isoToGerman(iso: string): string {
|
function isoToGerman(iso: string): string {
|
||||||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
|
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
|
||||||
@@ -25,9 +25,11 @@
|
|||||||
return `${y}-${m}-${d}`;
|
return `${y}-${m}-${d}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dateDisplay = isoToGerman(doc.documentDate ?? '');
|
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
|
||||||
let dateIso = doc.documentDate ?? '';
|
let dateIso = $state(doc.documentDate ?? '');
|
||||||
let dateDirty = false;
|
let dateDirty = $state(false);
|
||||||
|
|
||||||
|
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||||
|
|
||||||
function handleDateInput(e: Event) {
|
function handleDateInput(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
@@ -45,8 +47,6 @@
|
|||||||
dateIso = germanToIso(formatted);
|
dateIso = germanToIso(formatted);
|
||||||
dateDirty = true;
|
dateDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: dateInvalid = dateDirty && dateDisplay.length > 0 && dateIso === '';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto py-8 px-4">
|
<div class="max-w-4xl mx-auto py-8 px-4">
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
value={dateDisplay}
|
value={dateDisplay}
|
||||||
on:input={handleDateInput}
|
oninput={handleDateInput}
|
||||||
placeholder="TT.MM.JJJJ"
|
placeholder="TT.MM.JJJJ"
|
||||||
maxlength="10"
|
maxlength="10"
|
||||||
class="block w-full rounded border-gray-300 shadow-sm p-2 border text-sm
|
class="block w-full rounded border-gray-300 shadow-sm p-2 border text-sm
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ import TagInput from '$lib/components/TagInput.svelte';
|
|||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
||||||
|
|
||||||
export let form;
|
let { form } = $props();
|
||||||
|
|
||||||
let tags: string[] = [];
|
let tags: string[] = $state([]);
|
||||||
let senderId = '';
|
let senderId = $state('');
|
||||||
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = [];
|
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state([]);
|
||||||
|
|
||||||
let dateDisplay = '';
|
let dateDisplay = $state('');
|
||||||
let dateIso = '';
|
let dateIso = $state('');
|
||||||
let dateDirty = false;
|
let dateDirty = $state(false);
|
||||||
|
|
||||||
|
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||||
|
|
||||||
function germanToIso(german: string): string {
|
function germanToIso(german: string): string {
|
||||||
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||||
@@ -37,8 +39,6 @@ function handleDateInput(e: Event) {
|
|||||||
dateIso = germanToIso(formatted);
|
dateIso = germanToIso(formatted);
|
||||||
dateDirty = true;
|
dateDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: dateInvalid = dateDirty && dateDisplay.length > 0 && dateIso === '';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||||
@@ -86,7 +86,7 @@ $: dateInvalid = dateDirty && dateDisplay.length > 0 && dateIso === '';
|
|||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
value={dateDisplay}
|
value={dateDisplay}
|
||||||
on:input={handleDateInput}
|
oninput={handleDateInput}
|
||||||
placeholder="TT.MM.JJJJ"
|
placeholder="TT.MM.JJJJ"
|
||||||
maxlength="10"
|
maxlength="10"
|
||||||
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm
|
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// TypeScript Typen für die Form-Antwort
|
let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
||||||
export let form: { error?: string, success?: boolean };
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
@@ -10,13 +9,13 @@
|
|||||||
<form method="POST" action="?/login" class="space-y-4">
|
<form method="POST" action="?/login" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="username" class="block text-sm font-medium text-gray-700">Benutzername</label>
|
<label for="username" class="block text-sm font-medium text-gray-700">Benutzername</label>
|
||||||
<input type="text" name="username" id="username" required
|
<input type="text" name="username" id="username" required
|
||||||
class="mt-1 block w-full rounded border-gray-300 shadow-sm p-2 border" />
|
class="mt-1 block w-full rounded border-gray-300 shadow-sm p-2 border" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="block text-sm font-medium text-gray-700">Passwort</label>
|
<label for="password" class="block text-sm font-medium text-gray-700">Passwort</label>
|
||||||
<input type="password" name="password" id="password" required
|
<input type="password" name="password" id="password" required
|
||||||
class="mt-1 block w-full rounded border-gray-300 shadow-sm p-2 border" />
|
class="mt-1 block w-full rounded border-gray-300 shadow-sm p-2 border" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
<div class="text-red-600 text-sm text-center">{form.error}</div>
|
<div class="text-red-600 text-sm text-center">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="bg-brand-navy text-white h-[42px] rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full">
|
class="bg-brand-navy text-white h-[42px] rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full">
|
||||||
Anmelden
|
Anmelden
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
export let data;
|
|
||||||
|
|
||||||
let searchTimeout: any;
|
let { data } = $props();
|
||||||
|
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
// Live-Suche (Debounce)
|
|
||||||
function handleSearch(e: Event) {
|
function handleSearch(e: Event) {
|
||||||
const value = (e.target as HTMLInputElement).value;
|
const value = (e.target as HTMLInputElement).value;
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
@@ -49,7 +49,7 @@ function handleSearch(e: Event) {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Namen suchen..."
|
placeholder="Namen suchen..."
|
||||||
value={data.q || ''}
|
value={data.q || ''}
|
||||||
on:input={handleSearch}
|
oninput={handleSearch}
|
||||||
class="block w-full rounded-sm border border-gray-300 bg-white py-2.5 pr-10 pl-4 font-sans text-sm text-brand-navy placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
class="block w-full rounded-sm border border-gray-300 bg-white py-2.5 pr-10 pl-4 font-sans text-sm text-brand-navy placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,21 +2,25 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
|
|
||||||
export let data;
|
let { data, form } = $props();
|
||||||
export let form;
|
|
||||||
|
|
||||||
$: ({ person, documents } = data);
|
const person = $derived(data.person);
|
||||||
|
const documents = $derived(data.documents);
|
||||||
|
|
||||||
let editMode = false;
|
let editMode = $state(false);
|
||||||
let mergeTargetId = '';
|
let mergeTargetId = $state('');
|
||||||
let showMergeConfirm = false;
|
let showMergeConfirm = $state(false);
|
||||||
|
|
||||||
function enterEdit() { editMode = true; }
|
$effect(() => {
|
||||||
function cancelEdit() { editMode = false; }
|
if (form?.updated) editMode = false;
|
||||||
|
});
|
||||||
|
|
||||||
$: if (form?.updated) { editMode = false; }
|
$effect(() => {
|
||||||
|
// Reset merge state whenever person changes
|
||||||
$: person.id, (() => { mergeTargetId = ''; showMergeConfirm = false; })();
|
person.id;
|
||||||
|
mergeTargetId = '';
|
||||||
|
showMergeConfirm = false;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto py-10 px-4">
|
<div class="max-w-4xl mx-auto py-10 px-4">
|
||||||
@@ -83,7 +87,7 @@
|
|||||||
<button type="submit" class="px-5 py-2 bg-brand-navy text-white text-sm font-bold uppercase tracking-widest rounded hover:bg-brand-navy/80 transition-colors">
|
<button type="submit" class="px-5 py-2 bg-brand-navy text-white text-sm font-bold uppercase tracking-widest rounded hover:bg-brand-navy/80 transition-colors">
|
||||||
Speichern
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
<button type="button" on:click={cancelEdit} class="px-5 py-2 border border-gray-300 text-gray-600 text-sm font-bold uppercase tracking-widest rounded hover:bg-gray-50 transition-colors">
|
<button type="button" onclick={() => (editMode = false)} class="px-5 py-2 border border-gray-300 text-gray-600 text-sm font-bold uppercase tracking-widest rounded hover:bg-gray-50 transition-colors">
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +107,7 @@
|
|||||||
<h1 class="text-4xl font-serif text-brand-navy">
|
<h1 class="text-4xl font-serif text-brand-navy">
|
||||||
{person.firstName} {person.lastName}
|
{person.firstName} {person.lastName}
|
||||||
</h1>
|
</h1>
|
||||||
<button on:click={enterEdit} class="ml-4 flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors">
|
<button onclick={() => (editMode = true)} class="ml-4 flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors">
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
@@ -150,7 +154,7 @@
|
|||||||
name="_targetPersonDisplay"
|
name="_targetPersonDisplay"
|
||||||
label="Zusammenführen mit"
|
label="Zusammenführen mit"
|
||||||
value={mergeTargetId}
|
value={mergeTargetId}
|
||||||
on:change={(e) => { mergeTargetId = e.detail.value; showMergeConfirm = false; }}
|
onchange={(value) => { mergeTargetId = value; showMergeConfirm = false; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,7 +162,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!mergeTargetId}
|
disabled={!mergeTargetId}
|
||||||
on:click={() => showMergeConfirm = true}
|
onclick={() => (showMergeConfirm = true)}
|
||||||
class="px-4 py-2 text-sm font-bold uppercase tracking-widest border border-red-300 text-red-600 rounded hover:bg-red-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
class="px-4 py-2 text-sm font-bold uppercase tracking-widest border border-red-300 text-red-600 rounded hover:bg-red-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Zusammenführen
|
Zusammenführen
|
||||||
@@ -173,7 +177,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => showMergeConfirm = false}
|
onclick={() => (showMergeConfirm = false)}
|
||||||
class="px-4 py-2 text-sm font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:bg-gray-50 transition-colors"
|
class="px-4 py-2 text-sm font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let form;
|
let { form } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user