feat: Briefwechsel hero redesign — discovery framing + padding #186
@@ -16,7 +16,7 @@
|
|||||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||||
"nav_documents": "Dokumente",
|
"nav_documents": "Dokumente",
|
||||||
"nav_persons": "Personen",
|
"nav_persons": "Personen",
|
||||||
"nav_conversations": "Korrespondenz",
|
"nav_conversations": "Briefwechsel",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Abmelden",
|
"nav_logout": "Abmelden",
|
||||||
"btn_save": "Speichern",
|
"btn_save": "Speichern",
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
"person_co_correspondents_heading": "Häufige Korrespondenten",
|
"person_co_correspondents_heading": "Häufige Korrespondenten",
|
||||||
"person_correspondents_hint": "klicken für Konversation",
|
"person_correspondents_hint": "klicken für Konversation",
|
||||||
"person_show_more": "+ {count} weitere anzeigen",
|
"person_show_more": "+ {count} weitere anzeigen",
|
||||||
"conv_heading": "Korrespondenz",
|
"conv_heading": "Briefwechsel",
|
||||||
"conv_subtitle": "Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent.",
|
"conv_subtitle": "Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent.",
|
||||||
"conv_label_person_a": "Person A (Absender)",
|
"conv_label_person_a": "Person A (Absender)",
|
||||||
"conv_label_person_b": "Korrespondent",
|
"conv_label_person_b": "Korrespondent",
|
||||||
@@ -139,13 +139,14 @@
|
|||||||
"conv_sort_label": "Sortierung:",
|
"conv_sort_label": "Sortierung:",
|
||||||
"conv_sort_newest": "Neueste zuerst",
|
"conv_sort_newest": "Neueste zuerst",
|
||||||
"conv_sort_oldest": "Älteste zuerst",
|
"conv_sort_oldest": "Älteste zuerst",
|
||||||
"conv_empty_heading": "Korrespondenz durchsuchen",
|
"conv_empty_heading": "Wessen Briefe möchten Sie lesen?",
|
||||||
"conv_empty_text": "Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.",
|
"conv_empty_text": "Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.",
|
||||||
|
"conv_hero_crosslink": "Suchen Sie ein bestimmtes Dokument? → Zur Dokumentensuche",
|
||||||
"conv_no_results_heading": "Keine Dokumente gefunden.",
|
"conv_no_results_heading": "Keine Dokumente gefunden.",
|
||||||
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
|
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
|
||||||
"conv_swap_btn": "Personen tauschen",
|
"conv_swap_btn": "Personen tauschen",
|
||||||
"conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}",
|
"conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}",
|
||||||
"conv_new_doc_link": "Neues Dokument in dieser Korrespondenz",
|
"conv_new_doc_link": "Neues Dokument in diesem Briefwechsel",
|
||||||
"conv_label_correspondent_optional": "Korrespondent",
|
"conv_label_correspondent_optional": "Korrespondent",
|
||||||
"conv_hint_single_person": "Alle Briefe von {name} — wähle einen Korrespondenten oben um einzugrenzen",
|
"conv_hint_single_person": "Alle Briefe von {name} — wähle einen Korrespondenten oben um einzugrenzen",
|
||||||
"conv_hint_single_person_filtered": "Alle Briefe von {name} · {from}–{to} · {sortLabel}",
|
"conv_hint_single_person_filtered": "Alle Briefe von {name} · {from}–{to} · {sortLabel}",
|
||||||
@@ -159,6 +160,7 @@
|
|||||||
"conv_suggestions_all_label": "Alle Korrespondenten von {name}",
|
"conv_suggestions_all_label": "Alle Korrespondenten von {name}",
|
||||||
"conv_letters_count": "{count} Briefe",
|
"conv_letters_count": "{count} Briefe",
|
||||||
"conv_empty_search_placeholder": "Person suchen…",
|
"conv_empty_search_placeholder": "Person suchen…",
|
||||||
|
"conv_hero_divider": "oder",
|
||||||
"conv_empty_recent_label": "Zuletzt geöffnet",
|
"conv_empty_recent_label": "Zuletzt geöffnet",
|
||||||
"conv_asym_sent": "{count} von {name} →",
|
"conv_asym_sent": "{count} von {name} →",
|
||||||
"conv_asym_received": "{count} von {name} ←",
|
"conv_asym_received": "{count} von {name} ←",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"error_internal_error": "An unexpected error occurred.",
|
"error_internal_error": "An unexpected error occurred.",
|
||||||
"nav_documents": "Documents",
|
"nav_documents": "Documents",
|
||||||
"nav_persons": "Persons",
|
"nav_persons": "Persons",
|
||||||
"nav_conversations": "Correspondence",
|
"nav_conversations": "Letters",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Sign out",
|
"nav_logout": "Sign out",
|
||||||
"btn_save": "Save",
|
"btn_save": "Save",
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
"person_co_correspondents_heading": "Frequent correspondents",
|
"person_co_correspondents_heading": "Frequent correspondents",
|
||||||
"person_correspondents_hint": "click to view conversation",
|
"person_correspondents_hint": "click to view conversation",
|
||||||
"person_show_more": "+ {count} more",
|
"person_show_more": "+ {count} more",
|
||||||
"conv_heading": "Correspondence",
|
"conv_heading": "Letters",
|
||||||
"conv_subtitle": "Browse a person's letters — with or without a correspondent.",
|
"conv_subtitle": "Browse a person's letters — with or without a correspondent.",
|
||||||
"conv_label_person_a": "Person A (Sender)",
|
"conv_label_person_a": "Person A (Sender)",
|
||||||
"conv_label_person_b": "Correspondent",
|
"conv_label_person_b": "Correspondent",
|
||||||
@@ -139,13 +139,14 @@
|
|||||||
"conv_sort_label": "Sort:",
|
"conv_sort_label": "Sort:",
|
||||||
"conv_sort_newest": "Newest first",
|
"conv_sort_newest": "Newest first",
|
||||||
"conv_sort_oldest": "Oldest first",
|
"conv_sort_oldest": "Oldest first",
|
||||||
"conv_empty_heading": "Browse correspondence",
|
"conv_empty_heading": "Whose letters would you like to read?",
|
||||||
"conv_empty_text": "Choose a person from the archive to see their letters — with or without a correspondent.",
|
"conv_empty_text": "Choose a person from the archive to see their letters — with or without a correspondent.",
|
||||||
|
"conv_hero_crosslink": "Looking for a specific document? → Go to document search",
|
||||||
"conv_no_results_heading": "No documents found.",
|
"conv_no_results_heading": "No documents found.",
|
||||||
"conv_no_results_text": "Try adjusting the time period.",
|
"conv_no_results_text": "Try adjusting the time period.",
|
||||||
"conv_swap_btn": "Swap persons",
|
"conv_swap_btn": "Swap persons",
|
||||||
"conv_summary": "{count} documents · {yearFrom}–{yearTo}",
|
"conv_summary": "{count} documents · {yearFrom}–{yearTo}",
|
||||||
"conv_new_doc_link": "New document in this correspondence",
|
"conv_new_doc_link": "New document in this exchange",
|
||||||
"conv_label_correspondent_optional": "Correspondent",
|
"conv_label_correspondent_optional": "Correspondent",
|
||||||
"conv_hint_single_person": "All letters from {name} — choose a correspondent above to narrow down",
|
"conv_hint_single_person": "All letters from {name} — choose a correspondent above to narrow down",
|
||||||
"conv_hint_single_person_filtered": "All letters from {name} · {from}–{to} · {sortLabel}",
|
"conv_hint_single_person_filtered": "All letters from {name} · {from}–{to} · {sortLabel}",
|
||||||
@@ -159,6 +160,7 @@
|
|||||||
"conv_suggestions_all_label": "All correspondents of {name}",
|
"conv_suggestions_all_label": "All correspondents of {name}",
|
||||||
"conv_letters_count": "{count} letters",
|
"conv_letters_count": "{count} letters",
|
||||||
"conv_empty_search_placeholder": "Search person…",
|
"conv_empty_search_placeholder": "Search person…",
|
||||||
|
"conv_hero_divider": "or",
|
||||||
"conv_empty_recent_label": "Recently opened",
|
"conv_empty_recent_label": "Recently opened",
|
||||||
"conv_asym_sent": "{count} from {name} →",
|
"conv_asym_sent": "{count} from {name} →",
|
||||||
"conv_asym_received": "{count} from {name} ←",
|
"conv_asym_received": "{count} from {name} ←",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"error_internal_error": "Se ha producido un error inesperado.",
|
"error_internal_error": "Se ha producido un error inesperado.",
|
||||||
"nav_documents": "Documentos",
|
"nav_documents": "Documentos",
|
||||||
"nav_persons": "Personas",
|
"nav_persons": "Personas",
|
||||||
"nav_conversations": "Correspondencia",
|
"nav_conversations": "Cartas",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Cerrar sesión",
|
"nav_logout": "Cerrar sesión",
|
||||||
"btn_save": "Guardar",
|
"btn_save": "Guardar",
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
||||||
"person_correspondents_hint": "clic para ver conversación",
|
"person_correspondents_hint": "clic para ver conversación",
|
||||||
"person_show_more": "+ {count} más",
|
"person_show_more": "+ {count} más",
|
||||||
"conv_heading": "Correspondencia",
|
"conv_heading": "Cartas",
|
||||||
"conv_subtitle": "Explore las cartas de una persona — con o sin corresponsal.",
|
"conv_subtitle": "Explore las cartas de una persona — con o sin corresponsal.",
|
||||||
"conv_label_person_a": "Persona A (Remitente)",
|
"conv_label_person_a": "Persona A (Remitente)",
|
||||||
"conv_label_person_b": "Corresponsal",
|
"conv_label_person_b": "Corresponsal",
|
||||||
@@ -139,13 +139,14 @@
|
|||||||
"conv_sort_label": "Ordenar:",
|
"conv_sort_label": "Ordenar:",
|
||||||
"conv_sort_newest": "Más reciente primero",
|
"conv_sort_newest": "Más reciente primero",
|
||||||
"conv_sort_oldest": "Más antiguo primero",
|
"conv_sort_oldest": "Más antiguo primero",
|
||||||
"conv_empty_heading": "Explorar correspondencia",
|
"conv_empty_heading": "¿De quién desea leer las cartas?",
|
||||||
"conv_empty_text": "Elige una persona del archivo para ver sus cartas — con o sin corresponsal.",
|
"conv_empty_text": "Elige una persona del archivo para ver sus cartas — con o sin corresponsal.",
|
||||||
|
"conv_hero_crosslink": "¿Busca un documento en particular? → Ir a la búsqueda",
|
||||||
"conv_no_results_heading": "No se encontraron documentos.",
|
"conv_no_results_heading": "No se encontraron documentos.",
|
||||||
"conv_no_results_text": "Intente ajustar el período de tiempo.",
|
"conv_no_results_text": "Intente ajustar el período de tiempo.",
|
||||||
"conv_swap_btn": "Intercambiar personas",
|
"conv_swap_btn": "Intercambiar personas",
|
||||||
"conv_summary": "{count} documentos · {yearFrom}–{yearTo}",
|
"conv_summary": "{count} documentos · {yearFrom}–{yearTo}",
|
||||||
"conv_new_doc_link": "Nuevo documento en esta correspondencia",
|
"conv_new_doc_link": "Nuevo documento en este intercambio",
|
||||||
"conv_label_correspondent_optional": "Corresponsal",
|
"conv_label_correspondent_optional": "Corresponsal",
|
||||||
"conv_hint_single_person": "Todas las cartas de {name} — elige un corresponsal arriba para filtrar",
|
"conv_hint_single_person": "Todas las cartas de {name} — elige un corresponsal arriba para filtrar",
|
||||||
"conv_hint_single_person_filtered": "Todas las cartas de {name} · {from}–{to} · {sortLabel}",
|
"conv_hint_single_person_filtered": "Todas las cartas de {name} · {from}–{to} · {sortLabel}",
|
||||||
@@ -159,6 +160,7 @@
|
|||||||
"conv_suggestions_all_label": "Todos los corresponsales de {name}",
|
"conv_suggestions_all_label": "Todos los corresponsales de {name}",
|
||||||
"conv_letters_count": "{count} cartas",
|
"conv_letters_count": "{count} cartas",
|
||||||
"conv_empty_search_placeholder": "Buscar persona…",
|
"conv_empty_search_placeholder": "Buscar persona…",
|
||||||
|
"conv_hero_divider": "o",
|
||||||
"conv_empty_recent_label": "Recientemente abiertos",
|
"conv_empty_recent_label": "Recientemente abiertos",
|
||||||
"conv_asym_sent": "{count} de {name} →",
|
"conv_asym_sent": "{count} de {name} →",
|
||||||
"conv_asym_received": "{count} de {name} ←",
|
"conv_asym_received": "{count} de {name} ←",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface Props {
|
|||||||
suggestedName?: string;
|
suggestedName?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
large?: boolean;
|
||||||
restrictToCorrespondentsOf?: string;
|
restrictToCorrespondentsOf?: string;
|
||||||
onchange?: (value: string) => void;
|
onchange?: (value: string) => void;
|
||||||
onfocused?: () => void;
|
onfocused?: () => void;
|
||||||
@@ -26,6 +27,7 @@ let {
|
|||||||
suggestedName = '',
|
suggestedName = '',
|
||||||
placeholder,
|
placeholder,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
large = false,
|
||||||
restrictToCorrespondentsOf,
|
restrictToCorrespondentsOf,
|
||||||
onchange,
|
onchange,
|
||||||
onfocused
|
onfocused
|
||||||
@@ -140,9 +142,11 @@ function selectPerson(person: Person) {
|
|||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onfocus={handleFocus}
|
onfocus={handleFocus}
|
||||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||||
class={compact
|
class={large
|
||||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
: compact
|
||||||
|
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||||
|
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if showDropdown && (results.length > 0 || loading)}
|
{#if showDropdown && (results.length > 0 || loading)}
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ function toggleDir() {
|
|||||||
class="-ml-px flex items-center justify-center border border-line bg-muted px-3 py-2.5 text-sm font-bold text-ink-2 transition hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="-ml-px flex items-center justify-center border border-line bg-muted px-3 py-2.5 text-sm font-bold text-ink-2 transition hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
aria-label={dir === 'asc' ? m.sort_dir_asc() : m.sort_dir_desc()}
|
aria-label={dir === 'asc' ? m.sort_dir_asc() : m.sort_dir_desc()}
|
||||||
>
|
>
|
||||||
{dir === 'asc' ? '↑' : '↓'}
|
{#if dir === 'asc'}
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Up-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5 opacity-60"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Down-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5 opacity-60"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,15 +22,19 @@ describe('SortDropdown', () => {
|
|||||||
await expect.element(btn).toBeInTheDocument();
|
await expect.element(btn).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('direction button shows ↑ when dir is asc', async () => {
|
it('direction button shows up arrow when dir is asc', async () => {
|
||||||
render(SortDropdown, { sort: 'DATE', dir: 'asc' });
|
render(SortDropdown, { sort: 'DATE', dir: 'asc' });
|
||||||
const btn = page.getByRole('button');
|
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||||
await expect.element(btn).toHaveTextContent('↑');
|
const img = document.querySelector('button img') as HTMLImageElement;
|
||||||
|
expect(img).not.toBeNull();
|
||||||
|
expect(img.src).toContain('Long-Arrow-Up');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('direction button shows ↓ when dir is desc', async () => {
|
it('direction button shows down arrow when dir is desc', async () => {
|
||||||
render(SortDropdown, { sort: 'DATE', dir: 'desc' });
|
render(SortDropdown, { sort: 'DATE', dir: 'desc' });
|
||||||
const btn = page.getByRole('button');
|
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||||
await expect.element(btn).toHaveTextContent('↓');
|
const img = document.querySelector('button img') as HTMLImageElement;
|
||||||
|
expect(img).not.toBeNull();
|
||||||
|
expect(img.src).toContain('Long-Arrow-Down');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/korrespondenz"
|
href="/briefwechsel"
|
||||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||||
{page.url.pathname.startsWith('/korrespondenz')
|
{page.url.pathname.startsWith('/briefwechsel')
|
||||||
? 'border-b-2 border-accent text-white'
|
? 'border-b-2 border-accent text-white'
|
||||||
: 'text-white/70 hover:text-white'}"
|
: 'text-white/70 hover:text-white'}"
|
||||||
>
|
>
|
||||||
@@ -161,9 +161,9 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/korrespondenz"
|
href="/briefwechsel"
|
||||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||||
{page.url.pathname.startsWith('/korrespondenz')
|
{page.url.pathname.startsWith('/briefwechsel')
|
||||||
? 'bg-accent-bg text-ink'
|
? 'bg-accent-bg text-ink'
|
||||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
import TagInput from '$lib/components/TagInput.svelte';
|
import TagInput from '$lib/components/TagInput.svelte';
|
||||||
|
import DateInput from '$lib/components/DateInput.svelte';
|
||||||
import SortDropdown from '$lib/components/SortDropdown.svelte';
|
import SortDropdown from '$lib/components/SortDropdown.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
@@ -192,24 +193,22 @@ $effect(() => {
|
|||||||
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
>{m.docs_filter_label_from()}</label
|
>{m.docs_filter_label_from()}</label
|
||||||
>
|
>
|
||||||
<input
|
<DateInput
|
||||||
type="date"
|
|
||||||
id="from"
|
id="from"
|
||||||
bind:value={from}
|
bind:value={from}
|
||||||
onchange={onSearch}
|
onchange={onSearch}
|
||||||
class="block w-full border-line py-2.5 text-sm shadow-sm"
|
class="block w-full rounded-md border border-line bg-surface px-3 py-2.5 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="to" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
<label for="to" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
>{m.docs_filter_label_to()}</label
|
>{m.docs_filter_label_to()}</label
|
||||||
>
|
>
|
||||||
<input
|
<DateInput
|
||||||
type="date"
|
|
||||||
id="to"
|
id="to"
|
||||||
bind:value={to}
|
bind:value={to}
|
||||||
onchange={onSearch}
|
onchange={onSearch}
|
||||||
class="block w-full border-line py-2.5 text-sm shadow-sm"
|
class="block w-full rounded-md border border-line bg-surface px-3 py-2.5 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
160
frontend/src/routes/briefwechsel/+page.svelte
Normal file
160
frontend/src/routes/briefwechsel/+page.svelte
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
|
import CorrespondenzPersonBar from './CorrespondenzPersonBar.svelte';
|
||||||
|
import CorrespondenzFilterControls from './CorrespondenzFilterControls.svelte';
|
||||||
|
import SinglePersonHintBar from './SinglePersonHintBar.svelte';
|
||||||
|
import ConversationTimeline from './ConversationTimeline.svelte';
|
||||||
|
import CorrespondenzHero from './CorrespondenzHero.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
const senderName = $derived(data.initialValues.senderName);
|
||||||
|
const receiverName = $derived(data.initialValues.receiverName);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (data.filters.senderId && data.initialValues.senderName) {
|
||||||
|
persistRecentPerson(data.filters.senderId, data.initialValues.senderName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSinglePerson = $derived(!!senderId && !receiverId);
|
||||||
|
const showHero = $derived(!senderId && !data.filters.senderId);
|
||||||
|
|
||||||
|
let showAdvanced = $state(false);
|
||||||
|
|
||||||
|
const RECENT_STORAGE_KEY = 'korrespondenz_recent_persons';
|
||||||
|
const MAX_RECENT = 5;
|
||||||
|
|
||||||
|
interface RecentPerson {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recentPersons = $state<RecentPerson[]>([]);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
recentPersons = JSON.parse(raw) as RecentPerson[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
recentPersons = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function persistRecentPerson(id: string, name: string) {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
|
||||||
|
const existing: RecentPerson[] = raw ? JSON.parse(raw) : [];
|
||||||
|
const filtered = existing.filter((p) => p.id !== id);
|
||||||
|
const updated = [{ id, name }, ...filtered].slice(0, MAX_RECENT);
|
||||||
|
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable — silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const params = new SvelteURLSearchParams();
|
||||||
|
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(`/briefwechsel?${params.toString()}`, { keepFocus: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort() {
|
||||||
|
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPersons() {
|
||||||
|
const tmp = senderId;
|
||||||
|
senderId = receiverId;
|
||||||
|
receiverId = tmp;
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPerson(id: string) {
|
||||||
|
if (!id) return;
|
||||||
|
senderId = id;
|
||||||
|
receiverId = '';
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if showHero}
|
||||||
|
<!-- Hero state: only on fresh page load with no context -->
|
||||||
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<CorrespondenzHero onSelectPerson={selectPerson} recentPersons={recentPersons} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Results state: card + content -->
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<div class="mb-8 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<CorrespondenzPersonBar
|
||||||
|
bind:senderId={senderId}
|
||||||
|
bind:receiverId={receiverId}
|
||||||
|
initialSenderName={data.initialValues.senderName}
|
||||||
|
initialReceiverName={data.initialValues.receiverName}
|
||||||
|
sortDir={sortDir}
|
||||||
|
showAdvanced={showAdvanced}
|
||||||
|
documentCount={data.documents.length}
|
||||||
|
onapplyFilters={applyFilters}
|
||||||
|
onswapPersons={swapPersons}
|
||||||
|
ontoggleSort={toggleSort}
|
||||||
|
ontoggleAdvanced={() => (showAdvanced = !showAdvanced)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if showAdvanced}
|
||||||
|
<CorrespondenzFilterControls
|
||||||
|
bind:fromDate={fromDate}
|
||||||
|
bind:toDate={toDate}
|
||||||
|
onapplyFilters={applyFilters}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isSinglePerson}
|
||||||
|
<SinglePersonHintBar
|
||||||
|
senderName={senderName}
|
||||||
|
fromDate={fromDate || undefined}
|
||||||
|
toDate={toDate || undefined}
|
||||||
|
sortDir={sortDir}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#if data.documents.length === 0}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center rounded-sm border border-line bg-muted py-24 text-center shadow-sm"
|
||||||
|
>
|
||||||
|
<p class="font-serif text-ink">{m.conv_no_results_heading()}</p>
|
||||||
|
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ConversationTimeline
|
||||||
|
documents={data.documents}
|
||||||
|
senderId={senderId}
|
||||||
|
receiverId={receiverId}
|
||||||
|
canWrite={data.canWrite}
|
||||||
|
senderName={senderName}
|
||||||
|
receiverName={receiverName}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -84,8 +84,24 @@ const newDocUrl = $derived(
|
|||||||
aria-label="Briefverteilung in diesem Zeitraum: {outCount} von {senderName ?? ''}, {inCount} von {receiverName ?? ''}"
|
aria-label="Briefverteilung in diesem Zeitraum: {outCount} von {senderName ?? ''}, {inCount} von {receiverName ?? ''}"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between text-sm font-bold">
|
<div class="flex justify-between text-sm font-bold">
|
||||||
<span class="text-primary">{outCount} von {shortSenderName} →</span>
|
<span class="inline-flex items-center gap-1 text-primary"
|
||||||
<span class="text-accent">{inCount} von {shortReceiverName} ←</span>
|
>{outCount} von {shortSenderName}
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="inline h-3.5 w-3.5 opacity-60"
|
||||||
|
/></span
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-1 text-accent"
|
||||||
|
>{inCount} von {shortReceiverName}
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="inline h-3.5 w-3.5 opacity-60"
|
||||||
|
/></span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex h-[5px] overflow-hidden rounded-full bg-line">
|
<div class="flex h-[5px] overflow-hidden rounded-full bg-line">
|
||||||
<div class="h-full bg-primary transition-all" style="width: {outPct}%"></div>
|
<div class="h-full bg-primary transition-all" style="width: {outPct}%"></div>
|
||||||
@@ -115,14 +131,14 @@ const newDocUrl = $derived(
|
|||||||
class:border-l-primary={isOut}
|
class:border-l-primary={isOut}
|
||||||
class:border-l-accent={!isOut}
|
class:border-l-accent={!isOut}
|
||||||
>
|
>
|
||||||
<span
|
<img
|
||||||
class="w-[16px] shrink-0 text-sm font-black"
|
src={isOut
|
||||||
class:text-primary={isOut}
|
? '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg'
|
||||||
class:text-accent={!isOut}
|
: '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg'}
|
||||||
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
class="h-4 w-4 shrink-0 opacity-60"
|
||||||
{isOut ? '→' : '←'}
|
/>
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="mb-[2px] truncate text-sm font-bold text-ink">
|
<div class="mb-[2px] truncate text-sm font-bold text-ink">
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import DateInput from '$lib/components/DateInput.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fromDate?: string;
|
||||||
|
toDate?: string;
|
||||||
|
onapplyFilters: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { fromDate = $bindable(''), toDate = $bindable(''), onapplyFilters }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="conv-filter-controls"
|
||||||
|
transition:slide
|
||||||
|
class="mt-6 grid grid-cols-1 gap-6 border-t border-line-2 pt-6 md:grid-cols-12"
|
||||||
|
>
|
||||||
|
<!-- From date -->
|
||||||
|
<div class="md:col-span-3">
|
||||||
|
<label
|
||||||
|
for="conv-from"
|
||||||
|
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
|
>
|
||||||
|
{m.conv_label_from()}
|
||||||
|
</label>
|
||||||
|
<DateInput
|
||||||
|
id="conv-from"
|
||||||
|
bind:value={fromDate}
|
||||||
|
onchange={() => onapplyFilters()}
|
||||||
|
class="block w-full rounded-md border border-line bg-surface px-3 py-2.5 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- To date -->
|
||||||
|
<div class="md:col-span-3">
|
||||||
|
<label for="conv-to" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
|
{m.conv_label_to()}
|
||||||
|
</label>
|
||||||
|
<DateInput
|
||||||
|
id="conv-to"
|
||||||
|
bind:value={toDate}
|
||||||
|
onchange={() => onapplyFilters()}
|
||||||
|
class="block w-full rounded-md border border-line bg-surface px-3 py-2.5 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
92
frontend/src/routes/briefwechsel/CorrespondenzHero.svelte
Normal file
92
frontend/src/routes/briefwechsel/CorrespondenzHero.svelte
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface RecentPerson {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSelectPerson: (id: string) => void;
|
||||||
|
recentPersons?: RecentPerson[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onSelectPerson, recentPersons = [] }: Props = $props();
|
||||||
|
|
||||||
|
let senderId = $state('');
|
||||||
|
let typeaheadEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await tick();
|
||||||
|
typeaheadEl?.querySelector<HTMLInputElement>('input[type="text"]')?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function handlePersonChange(id: string) {
|
||||||
|
if (id) {
|
||||||
|
onSelectPerson(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="conv-hero"
|
||||||
|
class="mx-auto flex max-w-lg flex-col items-center gap-6 py-12 text-center sm:py-20"
|
||||||
|
>
|
||||||
|
<!-- Discovery headline -->
|
||||||
|
<h1 class="font-serif text-2xl font-black text-ink">
|
||||||
|
{m.conv_empty_heading()}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Cross-link to document search -->
|
||||||
|
<a href="/" class="text-sm text-ink-3 transition-colors hover:text-primary">
|
||||||
|
{m.conv_hero_crosslink()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Person typeahead (large, hero-sized) -->
|
||||||
|
<div class="w-full max-w-sm" bind:this={typeaheadEl}>
|
||||||
|
<PersonTypeahead
|
||||||
|
name="senderId"
|
||||||
|
label="Person"
|
||||||
|
large={true}
|
||||||
|
bind:value={senderId}
|
||||||
|
onchange={handlePersonChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent persons -->
|
||||||
|
{#if recentPersons.length > 0}
|
||||||
|
<div class="flex w-full max-w-sm items-center gap-2">
|
||||||
|
<div class="flex-1 border-t border-line"></div>
|
||||||
|
<span class="text-xs font-bold tracking-wider text-ink-3 uppercase"
|
||||||
|
>{m.conv_hero_divider()}</span
|
||||||
|
>
|
||||||
|
<div class="flex-1 border-t border-line"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full max-w-sm flex-col items-center gap-3">
|
||||||
|
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.conv_empty_recent_label()}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-wrap justify-center gap-2">
|
||||||
|
{#each recentPersons as person (person.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="recent-person-{person.id}"
|
||||||
|
onclick={() => onSelectPerson(person.id)}
|
||||||
|
class="flex items-center gap-2 rounded-full border border-line bg-surface px-4 py-2 text-sm font-bold text-ink transition-colors hover:border-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-xs text-primary-fg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{person.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{person.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import CorrespondenzHero from './CorrespondenzHero.svelte';
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
describe('CorrespondenzHero — headline and cross-link', () => {
|
||||||
|
it('renders the discovery headline', async () => {
|
||||||
|
render(CorrespondenzHero, { onSelectPerson: noop });
|
||||||
|
await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a cross-link to the document search page', async () => {
|
||||||
|
render(CorrespondenzHero, { onSelectPerson: noop });
|
||||||
|
const link = page.getByRole('link', { name: /Zur Dokumentensuche/i });
|
||||||
|
await expect.element(link).toBeInTheDocument();
|
||||||
|
await expect.element(link).toHaveAttribute('href', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a person typeahead input', async () => {
|
||||||
|
render(CorrespondenzHero, { onSelectPerson: noop });
|
||||||
|
await expect.element(page.getByTestId('conv-hero').getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CorrespondenzHero — recent persons', () => {
|
||||||
|
it('shows recent person chips when provided', async () => {
|
||||||
|
render(CorrespondenzHero, {
|
||||||
|
onSelectPerson: noop,
|
||||||
|
recentPersons: [{ id: 'r1', name: 'Clara Braun' }]
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Clara Braun')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSelectPerson when a recent person chip is clicked', async () => {
|
||||||
|
const spy = vi.fn();
|
||||||
|
render(CorrespondenzHero, {
|
||||||
|
onSelectPerson: spy,
|
||||||
|
recentPersons: [{ id: 'r1', name: 'Clara Braun' }]
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Clara Braun')).toBeInTheDocument();
|
||||||
|
document.querySelector<HTMLElement>('[data-testid="recent-person-r1"]')!.click();
|
||||||
|
expect(spy).toHaveBeenCalledWith('r1');
|
||||||
|
});
|
||||||
|
});
|
||||||
187
frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte
Normal file
187
frontend/src/routes/briefwechsel/CorrespondenzPersonBar.svelte
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
|
import CorrespondentSuggestionsDropdown from './CorrespondentSuggestionsDropdown.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
senderId?: string;
|
||||||
|
receiverId?: string;
|
||||||
|
initialSenderName?: string;
|
||||||
|
initialReceiverName?: string;
|
||||||
|
sortDir?: string;
|
||||||
|
showAdvanced?: boolean;
|
||||||
|
documentCount?: number;
|
||||||
|
onapplyFilters: () => void;
|
||||||
|
onswapPersons: () => void;
|
||||||
|
ontoggleSort: () => void;
|
||||||
|
ontoggleAdvanced: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
senderId = $bindable(''),
|
||||||
|
receiverId = $bindable(''),
|
||||||
|
initialSenderName = '',
|
||||||
|
initialReceiverName = '',
|
||||||
|
sortDir = 'DESC',
|
||||||
|
showAdvanced = false,
|
||||||
|
documentCount = 0,
|
||||||
|
onapplyFilters,
|
||||||
|
onswapPersons,
|
||||||
|
ontoggleSort,
|
||||||
|
ontoggleAdvanced
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
interface Correspondent {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let swapVisible = $derived(!!(senderId && receiverId));
|
||||||
|
|
||||||
|
let showSuggestions = $state(false);
|
||||||
|
let correspondents = $state<Correspondent[]>([]);
|
||||||
|
let loadingCorrespondents = $state(false);
|
||||||
|
|
||||||
|
async function handleCorrespondentFocused() {
|
||||||
|
if (!senderId) return;
|
||||||
|
showSuggestions = true;
|
||||||
|
loadingCorrespondents = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/persons/${senderId}/correspondents`);
|
||||||
|
correspondents = res.ok ? await res.json() : [];
|
||||||
|
} catch {
|
||||||
|
correspondents = [];
|
||||||
|
} finally {
|
||||||
|
loadingCorrespondents = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSuggestionSelect(id: string) {
|
||||||
|
receiverId = id;
|
||||||
|
showSuggestions = false;
|
||||||
|
onapplyFilters();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Row 1: Person inputs -->
|
||||||
|
<div data-testid="conv-person-bar" class="flex items-end gap-4">
|
||||||
|
<!-- Person A -->
|
||||||
|
<div
|
||||||
|
class="min-w-0 flex-1 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||||
|
>
|
||||||
|
<PersonTypeahead
|
||||||
|
name="senderId"
|
||||||
|
label="Person"
|
||||||
|
bind:value={senderId}
|
||||||
|
initialName={initialSenderName}
|
||||||
|
restrictToCorrespondentsOf={receiverId || undefined}
|
||||||
|
onchange={(id) => { if (id) onapplyFilters(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Swap button -->
|
||||||
|
<button
|
||||||
|
data-testid="conv-swap-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Personen tauschen"
|
||||||
|
onclick={onswapPersons}
|
||||||
|
class="mb-[3px] flex items-center justify-center rounded border border-line bg-muted px-3 py-2.5 text-ink-3 transition-colors hover:border-primary hover:text-primary"
|
||||||
|
class:opacity-0={!swapVisible}
|
||||||
|
class:pointer-events-none={!swapVisible}
|
||||||
|
tabindex={swapVisible ? 0 : -1}
|
||||||
|
>
|
||||||
|
<div class="-my-1 flex flex-col items-center">
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-3.5 w-3.5 opacity-60"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-3.5 w-3.5 opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Korrespondent field -->
|
||||||
|
<div
|
||||||
|
class="relative min-w-0 flex-1 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||||
|
>
|
||||||
|
<PersonTypeahead
|
||||||
|
name="receiverId"
|
||||||
|
label={receiverId ? 'Korrespondent' : 'Korrespondent — optional'}
|
||||||
|
bind:value={receiverId}
|
||||||
|
initialName={initialReceiverName}
|
||||||
|
placeholder="Alle Korrespondenten"
|
||||||
|
restrictToCorrespondentsOf={senderId || undefined}
|
||||||
|
onchange={() => {
|
||||||
|
showSuggestions = false;
|
||||||
|
onapplyFilters();
|
||||||
|
}}
|
||||||
|
onfocused={handleCorrespondentFocused}
|
||||||
|
/>
|
||||||
|
{#if showSuggestions && senderId && !receiverId}
|
||||||
|
<CorrespondentSuggestionsDropdown
|
||||||
|
correspondents={correspondents}
|
||||||
|
loading={loadingCorrespondents}
|
||||||
|
senderName=""
|
||||||
|
onselect={handleSuggestionSelect}
|
||||||
|
onclose={() => (showSuggestions = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Sort + Filter toggle + Count (mirrors document search bar pattern) -->
|
||||||
|
<div class="mt-4 flex items-center gap-4">
|
||||||
|
<!-- Sort button -->
|
||||||
|
<button
|
||||||
|
data-testid="conv-sort-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Sortierung umkehren"
|
||||||
|
aria-pressed={sortDir === 'ASC'}
|
||||||
|
onclick={ontoggleSort}
|
||||||
|
class="flex items-center gap-2 border border-line bg-muted px-4 py-2.5 text-sm font-bold tracking-wide text-ink-2 uppercase transition hover:bg-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
{#if sortDir === 'ASC'}
|
||||||
|
{m.conv_strip_sort_oldest()}
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Up-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5 opacity-60"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{m.conv_strip_sort_newest()}
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Down-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5 opacity-60"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Filter toggle button -->
|
||||||
|
<button
|
||||||
|
onclick={ontoggleAdvanced}
|
||||||
|
class="flex items-center gap-2 border border-line bg-muted px-4 py-2.5 text-sm font-bold tracking-wide text-ink-2 uppercase transition hover:bg-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}"
|
||||||
|
/>
|
||||||
|
{m.docs_btn_filter()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Document count -->
|
||||||
|
<span data-testid="conv-strip-count" class="ml-auto text-sm font-bold text-ink-3">
|
||||||
|
{m.conv_letters_count({ count: documentCount })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
@@ -19,7 +19,7 @@ let toYear = $derived(toDate ? toDate.substring(0, 4) : '');
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-[5px] border-b border-accent bg-accent-bg px-[18px] py-[6px] text-xs text-ink"
|
class="mt-4 flex items-center gap-1.5 rounded-sm border border-accent bg-accent-bg px-4 py-2.5 text-sm text-ink"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -53,17 +53,29 @@ const withDocs = {
|
|||||||
documents: [makeDoc()]
|
documents: [makeDoc()]
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Empty state (no senderId) ────────────────────────────────────────────────
|
// ─── Hero state (no senderId) ────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Korrespondenz page – empty state', () => {
|
describe('Briefwechsel page – hero state', () => {
|
||||||
it('shows the search heading when no person is selected', async () => {
|
it('shows the hero when no person is selected', async () => {
|
||||||
render(Page, { data: baseData });
|
render(Page, { data: baseData });
|
||||||
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
|
await expect.element(page.getByTestId('conv-hero')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the empty-search button', async () => {
|
it('shows the discovery headline', async () => {
|
||||||
render(Page, { data: baseData });
|
render(Page, { data: baseData });
|
||||||
await expect.element(page.getByTestId('conv-empty-search')).toBeInTheDocument();
|
await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show the person bar in hero state', async () => {
|
||||||
|
render(Page, { data: baseData });
|
||||||
|
await expect.element(page.getByTestId('conv-hero')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByTestId('conv-person-bar')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show filter controls in hero state', async () => {
|
||||||
|
render(Page, { data: baseData });
|
||||||
|
await expect.element(page.getByTestId('conv-hero')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByTestId('conv-filter-controls')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show the new document link when no person is selected', async () => {
|
it('does not show the new document link when no person is selected', async () => {
|
||||||
@@ -77,9 +89,30 @@ describe('Korrespondenz page – empty state', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Results state (senderId set) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Briefwechsel page – results state', () => {
|
||||||
|
it('does not show the hero when senderId is set', async () => {
|
||||||
|
render(Page, { data: withSender });
|
||||||
|
await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByTestId('conv-hero')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the person bar when senderId is set', async () => {
|
||||||
|
render(Page, { data: withSender });
|
||||||
|
await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides filter controls by default (collapsible)', async () => {
|
||||||
|
render(Page, { data: withSender });
|
||||||
|
await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByTestId('conv-filter-controls')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Recent persons chips ─────────────────────────────────────────────────────
|
// ─── Recent persons chips ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Korrespondenz page – recent persons', () => {
|
describe('Briefwechsel page – recent persons', () => {
|
||||||
it('shows recent person chips from localStorage', async () => {
|
it('shows recent person chips from localStorage', async () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'korrespondenz_recent_persons',
|
'korrespondenz_recent_persons',
|
||||||
@@ -93,15 +126,14 @@ describe('Korrespondenz page – recent persons', () => {
|
|||||||
it('does not crash when localStorage contains corrupt JSON', async () => {
|
it('does not crash when localStorage contains corrupt JSON', async () => {
|
||||||
localStorage.setItem('korrespondenz_recent_persons', '}{not valid json');
|
localStorage.setItem('korrespondenz_recent_persons', '}{not valid json');
|
||||||
render(Page, { data: baseData });
|
render(Page, { data: baseData });
|
||||||
// Empty state heading is still shown — no chip list crash
|
await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument();
|
||||||
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
|
|
||||||
localStorage.removeItem('korrespondenz_recent_persons');
|
localStorage.removeItem('korrespondenz_recent_persons');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Single-person hint bar ───────────────────────────────────────────────────
|
// ─── Single-person hint bar ───────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Korrespondenz page – single-person hint bar', () => {
|
describe('Briefwechsel page – single-person hint bar', () => {
|
||||||
it('shows hint bar when only senderId is set', async () => {
|
it('shows hint bar when only senderId is set', async () => {
|
||||||
render(Page, { data: withSender });
|
render(Page, { data: withSender });
|
||||||
await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).toBeInTheDocument();
|
await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).toBeInTheDocument();
|
||||||
@@ -118,25 +150,9 @@ describe('Korrespondenz page – single-person hint bar', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Filter controls disabled state ──────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Korrespondenz page – filter strip Row 2 disabled state', () => {
|
|
||||||
it('renders filter controls with aria-disabled when no senderId', async () => {
|
|
||||||
render(Page, { data: baseData });
|
|
||||||
const strip = document.querySelector('[aria-disabled="true"]');
|
|
||||||
expect(strip).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filter controls are not aria-disabled when senderId is set', async () => {
|
|
||||||
render(Page, { data: withSender });
|
|
||||||
const strip = document.querySelector('[aria-disabled="false"]');
|
|
||||||
expect(strip).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Strip letter count ───────────────────────────────────────────────────────
|
// ─── Strip letter count ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Korrespondenz page – strip letter count', () => {
|
describe('Briefwechsel page – strip letter count', () => {
|
||||||
it('shows 0 Briefe when senderId is set but no documents', async () => {
|
it('shows 0 Briefe when senderId is set but no documents', async () => {
|
||||||
render(Page, { data: withSender });
|
render(Page, { data: withSender });
|
||||||
await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('0 Briefe');
|
await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('0 Briefe');
|
||||||
@@ -150,7 +166,7 @@ describe('Korrespondenz page – strip letter count', () => {
|
|||||||
|
|
||||||
// ─── No results ───────────────────────────────────────────────────────────────
|
// ─── No results ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Korrespondenz page – no results', () => {
|
describe('Briefwechsel page – no results', () => {
|
||||||
it('shows "no documents found" when a person is selected but there are no documents', async () => {
|
it('shows "no documents found" when a person is selected but there are no documents', async () => {
|
||||||
render(Page, { data: withSender });
|
render(Page, { data: withSender });
|
||||||
await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
|
await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
|
||||||
@@ -159,12 +175,11 @@ describe('Korrespondenz page – no results', () => {
|
|||||||
|
|
||||||
// ─── Swap button ──────────────────────────────────────────────────────────────
|
// ─── Swap button ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Korrespondenz page – swap button', () => {
|
describe('Briefwechsel page – swap button', () => {
|
||||||
it('swap button is invisible when only one person is set', async () => {
|
it('swap button is invisible when only one person is set', async () => {
|
||||||
render(Page, { data: withSender });
|
render(Page, { data: withSender });
|
||||||
const btn = document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]');
|
const btn = document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]');
|
||||||
expect(btn).not.toBeNull();
|
expect(btn).not.toBeNull();
|
||||||
// opacity-0 is applied via class when swapVisible is false
|
|
||||||
expect(btn!.className).toMatch(/opacity-0/);
|
expect(btn!.className).toMatch(/opacity-0/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,7 +202,7 @@ describe('Korrespondenz page – swap button', () => {
|
|||||||
|
|
||||||
// ─── Year dividers ────────────────────────────────────────────────────────────
|
// ─── Year dividers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Korrespondenz page – year dividers', () => {
|
describe('Briefwechsel page – year dividers', () => {
|
||||||
it('renders a year divider for the first document', async () => {
|
it('renders a year divider for the first document', async () => {
|
||||||
render(Page, { data: withDocs });
|
render(Page, { data: withDocs });
|
||||||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||||||
@@ -222,7 +237,7 @@ describe('Korrespondenz page – year dividers', () => {
|
|||||||
|
|
||||||
// ─── New document link ────────────────────────────────────────────────────────
|
// ─── New document link ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Korrespondenz page – new document link', () => {
|
describe('Briefwechsel page – new document link', () => {
|
||||||
it('shows the link with correct href for a write user (bilateral)', async () => {
|
it('shows the link with correct href for a write user (bilateral)', async () => {
|
||||||
render(Page, { data: { ...withDocs, canWrite: true } });
|
render(Page, { data: { ...withDocs, canWrite: true } });
|
||||||
const link = page.getByTestId('conv-new-doc-link');
|
const link = page.getByTestId('conv-new-doc-link');
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
|
||||||
import CorrespondenzPersonBar from './CorrespondenzPersonBar.svelte';
|
|
||||||
import CorrespondenzFilterControls from './CorrespondenzFilterControls.svelte';
|
|
||||||
import SinglePersonHintBar from './SinglePersonHintBar.svelte';
|
|
||||||
import ConversationTimeline from './ConversationTimeline.svelte';
|
|
||||||
import CorrespondenzEmptyState from './CorrespondenzEmptyState.svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
|
|
||||||
// Filter values are local $state so swapPersons/toggleSort can mutate them before goto.
|
|
||||||
// They are initialised once from server data and never re-synced — navigation replaces
|
|
||||||
// the page component, so each load gets a fresh init.
|
|
||||||
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));
|
|
||||||
|
|
||||||
// Names are pure reads of server data — no local mutation needed.
|
|
||||||
const senderName = $derived(data.initialValues.senderName);
|
|
||||||
const receiverName = $derived(data.initialValues.receiverName);
|
|
||||||
|
|
||||||
// Side-effect only: persist the resolved sender to localStorage once the name is available.
|
|
||||||
$effect(() => {
|
|
||||||
if (data.filters.senderId && data.initialValues.senderName) {
|
|
||||||
persistRecentPerson(data.filters.senderId, data.initialValues.senderName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const isSinglePerson = $derived(!!senderId && !receiverId);
|
|
||||||
|
|
||||||
const RECENT_STORAGE_KEY = 'korrespondenz_recent_persons';
|
|
||||||
const MAX_RECENT = 5;
|
|
||||||
|
|
||||||
function persistRecentPerson(id: string, name: string) {
|
|
||||||
if (!id) return;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
|
|
||||||
const existing: { id: string; name: string }[] = raw ? JSON.parse(raw) : [];
|
|
||||||
const filtered = existing.filter((p) => p.id !== id);
|
|
||||||
const updated = [{ id, name }, ...filtered].slice(0, MAX_RECENT);
|
|
||||||
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(updated));
|
|
||||||
} catch {
|
|
||||||
// localStorage unavailable — silently ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters() {
|
|
||||||
const params = new SvelteURLSearchParams();
|
|
||||||
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(`/korrespondenz?${params.toString()}`, { keepFocus: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSort() {
|
|
||||||
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
|
|
||||||
applyFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
function swapPersons() {
|
|
||||||
const tmp = senderId;
|
|
||||||
senderId = receiverId;
|
|
||||||
receiverId = tmp;
|
|
||||||
applyFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectPerson(id: string) {
|
|
||||||
if (!id) {
|
|
||||||
document.querySelector<HTMLInputElement>('#senderId-search')?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
senderId = id;
|
|
||||||
receiverId = '';
|
|
||||||
applyFilters();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Strips — pulled up to negate main's py-6 top padding so they sit flush -->
|
|
||||||
<div class="-mt-6">
|
|
||||||
<!-- Strip: Row 1 — full width, no container -->
|
|
||||||
<CorrespondenzPersonBar
|
|
||||||
bind:senderId={senderId}
|
|
||||||
bind:receiverId={receiverId}
|
|
||||||
initialSenderName={data.initialValues.senderName}
|
|
||||||
initialReceiverName={data.initialValues.receiverName}
|
|
||||||
onapplyFilters={applyFilters}
|
|
||||||
onswapPersons={swapPersons}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Strip: Row 2 — full width -->
|
|
||||||
<CorrespondenzFilterControls
|
|
||||||
senderId={senderId}
|
|
||||||
bind:fromDate={fromDate}
|
|
||||||
bind:toDate={toDate}
|
|
||||||
bind:sortDir={sortDir}
|
|
||||||
documentCount={data.documents.length}
|
|
||||||
onapplyFilters={applyFilters}
|
|
||||||
ontoggleSort={toggleSort}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Single-person hint bar -->
|
|
||||||
{#if isSinglePerson}
|
|
||||||
<SinglePersonHintBar
|
|
||||||
senderName={senderName}
|
|
||||||
fromDate={fromDate || undefined}
|
|
||||||
toDate={toDate || undefined}
|
|
||||||
sortDir={sortDir}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content area with padding -->
|
|
||||||
<div class="px-[18px] py-[14px]">
|
|
||||||
{#if !senderId}
|
|
||||||
<CorrespondenzEmptyState onSelectPerson={selectPerson} />
|
|
||||||
{:else if data.documents.length === 0}
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center justify-center rounded-sm border border-line bg-muted py-24 text-center shadow-sm"
|
|
||||||
>
|
|
||||||
<p class="font-serif text-ink">{m.conv_no_results_heading()}</p>
|
|
||||||
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<ConversationTimeline
|
|
||||||
documents={data.documents}
|
|
||||||
senderId={senderId}
|
|
||||||
receiverId={receiverId}
|
|
||||||
canWrite={data.canWrite}
|
|
||||||
senderName={senderName}
|
|
||||||
receiverName={receiverName}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
|
|
||||||
interface RecentPerson {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onSelectPerson: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { onSelectPerson }: Props = $props();
|
|
||||||
|
|
||||||
let recentPersons = $state<RecentPerson[]>([]);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem('korrespondenz_recent_persons');
|
|
||||||
if (raw) {
|
|
||||||
// Svelte auto-escapes firstName/lastName — do not use {@html} with these values
|
|
||||||
recentPersons = JSON.parse(raw) as RecentPerson[];
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
recentPersons = [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mx-auto flex max-w-lg flex-col items-center gap-5 py-12 text-center">
|
|
||||||
<!-- Icon circle -->
|
|
||||||
<div class="rounded-full bg-muted p-5">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="36"
|
|
||||||
height="36"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="text-primary"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
|
||||||
<path d="M2 7l10 7 10-7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Heading -->
|
|
||||||
<h2 class="font-serif text-xl font-black text-ink">{m.conv_empty_heading()}</h2>
|
|
||||||
|
|
||||||
<!-- Subtext -->
|
|
||||||
<p class="max-w-sm text-base text-ink-3">
|
|
||||||
{m.conv_empty_text()}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Search input placeholder (visual only — clicking focuses Person A typeahead above) -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="conv-empty-search"
|
|
||||||
aria-label={m.conv_empty_search_placeholder()}
|
|
||||||
onclick={() => onSelectPerson('')}
|
|
||||||
class="flex h-10 w-full max-w-sm items-center rounded border border-line bg-muted px-4 text-sm text-ink-3 italic transition-colors hover:border-primary"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="mr-2 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
{m.conv_empty_search_placeholder()}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Recent persons — only shown when localStorage has entries -->
|
|
||||||
{#if recentPersons.length > 0}
|
|
||||||
<!-- Divider -->
|
|
||||||
<div class="flex w-full max-w-sm items-center gap-2">
|
|
||||||
<div class="flex-1 border-t border-line"></div>
|
|
||||||
<span class="text-xs font-bold tracking-wider text-ink-3 uppercase">oder</span>
|
|
||||||
<div class="flex-1 border-t border-line"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex w-full max-w-sm flex-col items-center gap-3">
|
|
||||||
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
|
||||||
{m.conv_empty_recent_label()}
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-wrap justify-center gap-2">
|
|
||||||
{#each recentPersons as person (person.id)}
|
|
||||||
<!-- TODO: allow clearing recent history -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => onSelectPerson(person.id)}
|
|
||||||
class="flex items-center gap-2 rounded-full border border-line bg-surface px-4 py-2 text-sm font-bold text-ink transition-colors hover:border-primary hover:text-primary"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-xs text-primary-fg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{person.name.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
{person.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import DateInput from '$lib/components/DateInput.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
senderId: string;
|
|
||||||
fromDate?: string;
|
|
||||||
toDate?: string;
|
|
||||||
sortDir?: string;
|
|
||||||
documentCount?: number;
|
|
||||||
onapplyFilters: () => void;
|
|
||||||
ontoggleSort: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
senderId,
|
|
||||||
fromDate = $bindable(''),
|
|
||||||
toDate = $bindable(''),
|
|
||||||
sortDir = $bindable('DESC'),
|
|
||||||
documentCount,
|
|
||||||
onapplyFilters,
|
|
||||||
ontoggleSort
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let hasDateFilter = $derived(!!(fromDate || toDate));
|
|
||||||
let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-[10px] border-b border-line bg-muted px-4 py-[5px] transition-opacity sm:px-[18px]"
|
|
||||||
class:opacity-40={!senderId}
|
|
||||||
class:pointer-events-none={!senderId}
|
|
||||||
aria-disabled={!senderId}
|
|
||||||
>
|
|
||||||
<!-- Period label -->
|
|
||||||
<span class="hidden text-xs font-bold tracking-wide text-ink-3 uppercase sm:block">
|
|
||||||
{m.conv_strip_period()}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- From date -->
|
|
||||||
<DateInput
|
|
||||||
bind:value={fromDate}
|
|
||||||
onchange={() => onapplyFilters()}
|
|
||||||
placeholder={m.conv_strip_from_placeholder()}
|
|
||||||
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {fromDate ? 'border-primary' : 'border-line'}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span class="text-xs text-ink-3">–</span>
|
|
||||||
|
|
||||||
<!-- To date -->
|
|
||||||
<DateInput
|
|
||||||
bind:value={toDate}
|
|
||||||
onchange={() => onapplyFilters()}
|
|
||||||
placeholder={m.conv_strip_to_placeholder()}
|
|
||||||
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {toDate ? 'border-primary' : 'border-line'}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Document count -->
|
|
||||||
<span
|
|
||||||
data-testid="conv-strip-count"
|
|
||||||
class="ml-auto text-xs font-bold"
|
|
||||||
class:text-primary={hasDateFilter}
|
|
||||||
class:text-ink-3={!hasDateFilter}
|
|
||||||
>
|
|
||||||
{m.conv_letters_count({ count: documentCount ?? 0 })}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Sort button -->
|
|
||||||
<button
|
|
||||||
data-testid="conv-sort-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Sortierung umkehren"
|
|
||||||
aria-pressed={sortDir === 'ASC'}
|
|
||||||
onclick={ontoggleSort}
|
|
||||||
class="flex h-8 min-h-[44px] items-center gap-1 rounded border px-3 text-xs font-bold"
|
|
||||||
class:border-primary={isActive}
|
|
||||||
class:text-primary={isActive}
|
|
||||||
class:border-line={!isActive}
|
|
||||||
class:text-ink-3={!isActive}
|
|
||||||
>
|
|
||||||
{#if sortDir === 'ASC'}
|
|
||||||
{m.conv_strip_sort_oldest()}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="10"
|
|
||||||
height="10"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<polyline points="18 15 12 9 6 15" />
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
{m.conv_strip_sort_newest()}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="10"
|
|
||||||
height="10"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<polyline points="6 9 12 15 18 9" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
|
||||||
import CorrespondentSuggestionsDropdown from './CorrespondentSuggestionsDropdown.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
senderId?: string;
|
|
||||||
receiverId?: string;
|
|
||||||
initialSenderName?: string;
|
|
||||||
initialReceiverName?: string;
|
|
||||||
onapplyFilters: () => void;
|
|
||||||
onswapPersons: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
senderId = $bindable(''),
|
|
||||||
receiverId = $bindable(''),
|
|
||||||
initialSenderName = '',
|
|
||||||
initialReceiverName = '',
|
|
||||||
onapplyFilters,
|
|
||||||
onswapPersons
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
interface Correspondent {
|
|
||||||
id: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let swapVisible = $derived(!!(senderId && receiverId));
|
|
||||||
|
|
||||||
let showSuggestions = $state(false);
|
|
||||||
let correspondents = $state<Correspondent[]>([]);
|
|
||||||
let loadingCorrespondents = $state(false);
|
|
||||||
|
|
||||||
async function handleCorrespondentFocused() {
|
|
||||||
if (!senderId) return;
|
|
||||||
showSuggestions = true;
|
|
||||||
loadingCorrespondents = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/persons/${senderId}/correspondents`);
|
|
||||||
correspondents = res.ok ? await res.json() : [];
|
|
||||||
} catch {
|
|
||||||
correspondents = [];
|
|
||||||
} finally {
|
|
||||||
loadingCorrespondents = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSuggestionSelect(id: string) {
|
|
||||||
receiverId = id;
|
|
||||||
showSuggestions = false;
|
|
||||||
onapplyFilters();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex items-end gap-[9px] border-b border-line bg-surface px-4 py-[9px] sm:px-[18px]">
|
|
||||||
<!-- Person A -->
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<PersonTypeahead
|
|
||||||
name="senderId"
|
|
||||||
label="Person"
|
|
||||||
bind:value={senderId}
|
|
||||||
initialName={initialSenderName}
|
|
||||||
compact={true}
|
|
||||||
restrictToCorrespondentsOf={receiverId || undefined}
|
|
||||||
onchange={() => onapplyFilters()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Swap button -->
|
|
||||||
<button
|
|
||||||
data-testid="conv-swap-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Personen tauschen"
|
|
||||||
onclick={onswapPersons}
|
|
||||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded border border-line bg-surface text-ink-3 transition-colors hover:border-primary hover:text-primary"
|
|
||||||
class:opacity-0={!swapVisible}
|
|
||||||
class:pointer-events-none={!swapVisible}
|
|
||||||
tabindex={swapVisible ? 0 : -1}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M7 16V4m0 0L3 8m4-4l4 4" />
|
|
||||||
<path d="M17 8v12m0 0l4-4m-4 4l-4-4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Korrespondent field -->
|
|
||||||
<div
|
|
||||||
class="relative min-w-0 flex-1"
|
|
||||||
class:[&_input]:border-dashed={!receiverId}
|
|
||||||
class:[&_input]:border-solid={!!receiverId}
|
|
||||||
class:[&_input]:bg-canvas={!receiverId}
|
|
||||||
>
|
|
||||||
<PersonTypeahead
|
|
||||||
name="receiverId"
|
|
||||||
label={receiverId ? 'Korrespondent' : 'Korrespondent — optional'}
|
|
||||||
bind:value={receiverId}
|
|
||||||
initialName={initialReceiverName}
|
|
||||||
compact={true}
|
|
||||||
placeholder="Alle Korrespondenten"
|
|
||||||
restrictToCorrespondentsOf={senderId || undefined}
|
|
||||||
onchange={() => {
|
|
||||||
showSuggestions = false;
|
|
||||||
onapplyFilters();
|
|
||||||
}}
|
|
||||||
onfocused={handleCorrespondentFocused}
|
|
||||||
/>
|
|
||||||
{#if showSuggestions && senderId && !receiverId}
|
|
||||||
<CorrespondentSuggestionsDropdown
|
|
||||||
correspondents={correspondents}
|
|
||||||
loading={loadingCorrespondents}
|
|
||||||
senderName=""
|
|
||||||
onselect={handleSuggestionSelect}
|
|
||||||
onclose={() => (showSuggestions = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -30,7 +30,7 @@ function initials(name: string): string {
|
|||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each coCorrespondents as c (c.id)}
|
{#each coCorrespondents as c (c.id)}
|
||||||
<a
|
<a
|
||||||
href="/korrespondenz?senderId={personId}&receiverId={c.id}"
|
href="/briefwechsel?senderId={personId}&receiverId={c.id}"
|
||||||
title={m.doc_conversation_title()}
|
title={m.doc_conversation_title()}
|
||||||
class="inline-flex items-center gap-1.5 rounded-full border border-line bg-muted px-3 py-1.5 font-sans text-xs font-bold text-ink transition-colors hover:border-primary hover:bg-surface"
|
class="inline-flex items-center gap-1.5 rounded-full border border-line bg-muted px-3 py-1.5 font-sans text-xs font-bold text-ink transition-colors hover:border-primary hover:bg-surface"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user