feat(frontend): rename route to /korrespondenz, update i18n, regen API types
Moves conversations/ to korrespondenz/, updates all internal links, renames nav label and page heading to Korrespondenz across de/en/es, and adds all new i18n keys for the redesigned strip and log. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||
"nav_documents": "Dokumente",
|
||||
"nav_persons": "Personen",
|
||||
"nav_conversations": "Konversationen",
|
||||
"nav_conversations": "Korrespondenz",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Abmelden",
|
||||
"btn_save": "Speichern",
|
||||
@@ -122,22 +122,39 @@
|
||||
"person_co_correspondents_heading": "Häufige Korrespondenten",
|
||||
"person_correspondents_hint": "klicken für Konversation",
|
||||
"person_show_more": "+ {count} weitere anzeigen",
|
||||
"conv_heading": "Konversationen",
|
||||
"conv_subtitle": "Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.",
|
||||
"conv_heading": "Korrespondenz",
|
||||
"conv_subtitle": "Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent.",
|
||||
"conv_label_person_a": "Person A (Absender)",
|
||||
"conv_label_person_b": "Person B (Empfänger)",
|
||||
"conv_label_person_b": "Korrespondent",
|
||||
"conv_label_from": "Zeitraum von",
|
||||
"conv_label_to": "Zeitraum bis",
|
||||
"conv_sort_label": "Sortierung:",
|
||||
"conv_sort_newest": "Neueste zuerst",
|
||||
"conv_sort_oldest": "Älteste zuerst",
|
||||
"conv_empty_heading": "Wählen Sie zwei Personen aus",
|
||||
"conv_empty_text": "Die Korrespondenz wird hier angezeigt.",
|
||||
"conv_empty_heading": "Korrespondenz durchsuchen",
|
||||
"conv_empty_text": "Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.",
|
||||
"conv_no_results_heading": "Keine Dokumente gefunden.",
|
||||
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
|
||||
"conv_swap_btn": "Personen tauschen",
|
||||
"conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}",
|
||||
"conv_new_doc_link": "Neues Dokument in dieser Korrespondenz",
|
||||
"conv_label_correspondent_optional": "Korrespondent",
|
||||
"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_strip_period": "Zeitraum",
|
||||
"conv_strip_from_placeholder": "Von…",
|
||||
"conv_strip_to_placeholder": "Bis…",
|
||||
"conv_strip_all_correspondents": "Alle Korrespondenten",
|
||||
"conv_strip_sort_newest": "Neueste",
|
||||
"conv_strip_sort_oldest": "Älteste",
|
||||
"conv_suggestions_heading": "Häufigste Korrespondenten",
|
||||
"conv_suggestions_all_label": "Alle Korrespondenten von {name}",
|
||||
"conv_letters_count": "{count} Briefe",
|
||||
"conv_empty_search_placeholder": "Person suchen…",
|
||||
"conv_empty_recent_label": "Zuletzt geöffnet",
|
||||
"conv_asym_sent": "{count} von {name} →",
|
||||
"conv_asym_received": "{count} von {name} ←",
|
||||
"conv_no_party": "—",
|
||||
"admin_heading": "Admin Dashboard",
|
||||
"admin_tab_users": "Benutzer",
|
||||
"admin_tab_groups": "Gruppen",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"error_internal_error": "An unexpected error occurred.",
|
||||
"nav_documents": "Documents",
|
||||
"nav_persons": "Persons",
|
||||
"nav_conversations": "Conversations",
|
||||
"nav_conversations": "Correspondence",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Sign out",
|
||||
"btn_save": "Save",
|
||||
@@ -122,22 +122,39 @@
|
||||
"person_co_correspondents_heading": "Frequent correspondents",
|
||||
"person_correspondents_hint": "click to view conversation",
|
||||
"person_show_more": "+ {count} more",
|
||||
"conv_heading": "Conversations",
|
||||
"conv_subtitle": "Follow the correspondence between two persons chronologically.",
|
||||
"conv_heading": "Correspondence",
|
||||
"conv_subtitle": "Browse a person's letters — with or without a correspondent.",
|
||||
"conv_label_person_a": "Person A (Sender)",
|
||||
"conv_label_person_b": "Person B (Recipient)",
|
||||
"conv_label_person_b": "Correspondent",
|
||||
"conv_label_from": "Period from",
|
||||
"conv_label_to": "Period to",
|
||||
"conv_sort_label": "Sort:",
|
||||
"conv_sort_newest": "Newest first",
|
||||
"conv_sort_oldest": "Oldest first",
|
||||
"conv_empty_heading": "Select two persons",
|
||||
"conv_empty_text": "The correspondence will be shown here.",
|
||||
"conv_empty_heading": "Browse correspondence",
|
||||
"conv_empty_text": "Choose a person from the archive to see their letters — with or without a correspondent.",
|
||||
"conv_no_results_heading": "No documents found.",
|
||||
"conv_no_results_text": "Try adjusting the time period.",
|
||||
"conv_swap_btn": "Swap persons",
|
||||
"conv_summary": "{count} documents · {yearFrom}–{yearTo}",
|
||||
"conv_new_doc_link": "New document in this correspondence",
|
||||
"conv_label_correspondent_optional": "Correspondent",
|
||||
"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_strip_period": "Period",
|
||||
"conv_strip_from_placeholder": "From…",
|
||||
"conv_strip_to_placeholder": "To…",
|
||||
"conv_strip_all_correspondents": "All correspondents",
|
||||
"conv_strip_sort_newest": "Newest",
|
||||
"conv_strip_sort_oldest": "Oldest",
|
||||
"conv_suggestions_heading": "Top correspondents",
|
||||
"conv_suggestions_all_label": "All correspondents of {name}",
|
||||
"conv_letters_count": "{count} letters",
|
||||
"conv_empty_search_placeholder": "Search person…",
|
||||
"conv_empty_recent_label": "Recently opened",
|
||||
"conv_asym_sent": "{count} from {name} →",
|
||||
"conv_asym_received": "{count} from {name} ←",
|
||||
"conv_no_party": "—",
|
||||
"admin_heading": "Admin Dashboard",
|
||||
"admin_tab_users": "Users",
|
||||
"admin_tab_groups": "Groups",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"error_internal_error": "Se ha producido un error inesperado.",
|
||||
"nav_documents": "Documentos",
|
||||
"nav_persons": "Personas",
|
||||
"nav_conversations": "Conversaciones",
|
||||
"nav_conversations": "Correspondencia",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Cerrar sesión",
|
||||
"btn_save": "Guardar",
|
||||
@@ -122,22 +122,39 @@
|
||||
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
||||
"person_correspondents_hint": "clic para ver conversación",
|
||||
"person_show_more": "+ {count} más",
|
||||
"conv_heading": "Conversaciones",
|
||||
"conv_subtitle": "Siga la correspondencia entre dos personas cronológicamente.",
|
||||
"conv_heading": "Correspondencia",
|
||||
"conv_subtitle": "Explore las cartas de una persona — con o sin corresponsal.",
|
||||
"conv_label_person_a": "Persona A (Remitente)",
|
||||
"conv_label_person_b": "Persona B (Destinatario)",
|
||||
"conv_label_person_b": "Corresponsal",
|
||||
"conv_label_from": "Período desde",
|
||||
"conv_label_to": "Período hasta",
|
||||
"conv_sort_label": "Ordenar:",
|
||||
"conv_sort_newest": "Más reciente primero",
|
||||
"conv_sort_oldest": "Más antiguo primero",
|
||||
"conv_empty_heading": "Seleccione dos personas",
|
||||
"conv_empty_text": "La correspondencia se mostrará aquí.",
|
||||
"conv_empty_heading": "Explorar correspondencia",
|
||||
"conv_empty_text": "Elige una persona del archivo para ver sus cartas — con o sin corresponsal.",
|
||||
"conv_no_results_heading": "No se encontraron documentos.",
|
||||
"conv_no_results_text": "Intente ajustar el período de tiempo.",
|
||||
"conv_swap_btn": "Intercambiar personas",
|
||||
"conv_summary": "{count} documentos · {yearFrom}–{yearTo}",
|
||||
"conv_new_doc_link": "Nuevo documento en esta correspondencia",
|
||||
"conv_label_correspondent_optional": "Corresponsal",
|
||||
"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_strip_period": "Período",
|
||||
"conv_strip_from_placeholder": "Desde…",
|
||||
"conv_strip_to_placeholder": "Hasta…",
|
||||
"conv_strip_all_correspondents": "Todos los corresponsales",
|
||||
"conv_strip_sort_newest": "Más reciente",
|
||||
"conv_strip_sort_oldest": "Más antiguo",
|
||||
"conv_suggestions_heading": "Corresponsales frecuentes",
|
||||
"conv_suggestions_all_label": "Todos los corresponsales de {name}",
|
||||
"conv_letters_count": "{count} cartas",
|
||||
"conv_empty_search_placeholder": "Buscar persona…",
|
||||
"conv_empty_recent_label": "Recientemente abiertos",
|
||||
"conv_asym_sent": "{count} de {name} →",
|
||||
"conv_asym_received": "{count} de {name} ←",
|
||||
"conv_no_party": "—",
|
||||
"admin_heading": "Panel de administración",
|
||||
"admin_tab_users": "Usuarios",
|
||||
"admin_tab_groups": "Grupos",
|
||||
|
||||
@@ -175,7 +175,7 @@ let { doc }: { doc: Doc } = $props();
|
||||
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
|
||||
href="/korrespondenz?senderId={doc.sender.id}&receiverId={receiver.id}"
|
||||
class="text-ink-3 transition hover:text-accent"
|
||||
title={m.doc_conversation_title()}
|
||||
>
|
||||
|
||||
@@ -724,6 +724,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/reset-token-for-test": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getResetTokenForTest"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/import-status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1030,8 +1046,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
pageable?: components["schemas"]["PageableObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
content?: components["schemas"]["NotificationDTO"][];
|
||||
@@ -1040,14 +1054,16 @@ export interface components {
|
||||
sort?: components["schemas"]["SortObject"];
|
||||
/** Format: int32 */
|
||||
numberOfElements?: number;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
empty?: boolean;
|
||||
};
|
||||
PageableObject: {
|
||||
paged?: boolean;
|
||||
/** Format: int32 */
|
||||
pageNumber?: number;
|
||||
/** Format: int32 */
|
||||
pageSize?: number;
|
||||
paged?: boolean;
|
||||
/** Format: int64 */
|
||||
offset?: number;
|
||||
sort?: components["schemas"]["SortObject"];
|
||||
@@ -2223,7 +2239,9 @@ export interface operations {
|
||||
query?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
/** @description Filter by notification type */
|
||||
type?: "REPLY" | "MENTION";
|
||||
/** @description Filter by read status */
|
||||
read?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
@@ -2474,7 +2492,7 @@ export interface operations {
|
||||
parameters: {
|
||||
query: {
|
||||
senderId: string;
|
||||
receiverId: string;
|
||||
receiverId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
dir?: string;
|
||||
@@ -2496,6 +2514,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getResetTokenForTest: {
|
||||
parameters: {
|
||||
query: {
|
||||
email: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
importStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -60,9 +60,9 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/conversations"
|
||||
href="/korrespondenz"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname.startsWith('/conversations')
|
||||
{page.url.pathname.startsWith('/korrespondenz')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
@@ -161,9 +161,9 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/conversations"
|
||||
href="/korrespondenz"
|
||||
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
|
||||
{page.url.pathname.startsWith('/conversations')
|
||||
{page.url.pathname.startsWith('/korrespondenz')
|
||||
? 'bg-nav-active text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
|
||||
64
frontend/src/routes/korrespondenz/+page.server.ts
Normal file
64
frontend/src/routes/korrespondenz/+page.server.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
const senderId = url.searchParams.get('senderId') || '';
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
const from = url.searchParams.get('from') || '';
|
||||
const to = url.searchParams.get('to') || '';
|
||||
const dir = url.searchParams.get('dir') || 'DESC';
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
let documents: components['schemas']['Document'][] = [];
|
||||
let senderName = '';
|
||||
let receiverName = '';
|
||||
|
||||
const requests: Promise<void>[] = [];
|
||||
|
||||
if (senderId && receiverId) {
|
||||
requests.push(
|
||||
api
|
||||
.GET('/api/documents/conversation', {
|
||||
params: {
|
||||
query: {
|
||||
senderId,
|
||||
receiverId,
|
||||
dir,
|
||||
from: from || undefined,
|
||||
to: to || undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(({ data }) => {
|
||||
documents = data ?? [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (senderId) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
|
||||
const p = data as { firstName: string; lastName: string } | undefined;
|
||||
if (p) senderName = `${p.firstName} ${p.lastName}`;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (receiverId) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
|
||||
const p = data as { firstName: string; lastName: string } | undefined;
|
||||
if (p) receiverName = `${p.firstName} ${p.lastName}`;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(requests);
|
||||
|
||||
return {
|
||||
documents,
|
||||
initialValues: { senderName, receiverName },
|
||||
filters: { senderId, receiverId, from, to, dir }
|
||||
};
|
||||
}
|
||||
104
frontend/src/routes/korrespondenz/+page.svelte
Normal file
104
frontend/src/routes/korrespondenz/+page.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import ConversationFilterBar from './ConversationFilterBar.svelte';
|
||||
import ConversationTimeline from './ConversationTimeline.svelte';
|
||||
|
||||
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));
|
||||
|
||||
// Sync with server data after navigation
|
||||
$effect(() => {
|
||||
senderId = data.filters.senderId;
|
||||
receiverId = data.filters.receiverId;
|
||||
fromDate = data.filters.from;
|
||||
toDate = data.filters.to;
|
||||
sortDir = data.filters.dir;
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 border-b border-ink/10 pb-4">
|
||||
<h1 class="font-serif text-3xl font-medium text-ink">{m.conv_heading()}</h1>
|
||||
<p class="mt-2 font-sans text-sm text-ink-2">
|
||||
{m.conv_subtitle()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ConversationFilterBar
|
||||
bind:senderId={senderId}
|
||||
bind:receiverId={receiverId}
|
||||
bind:fromDate={fromDate}
|
||||
bind:toDate={toDate}
|
||||
bind:sortDir={sortDir}
|
||||
initialSenderName={data.initialValues.senderName}
|
||||
initialReceiverName={data.initialValues.receiverName}
|
||||
onapplyFilters={applyFilters}
|
||||
ontoggleSort={toggleSort}
|
||||
onswapPersons={swapPersons}
|
||||
/>
|
||||
|
||||
<!-- RESULTS LIST SECTION -->
|
||||
{#if !senderId || !receiverId}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface py-24 text-center"
|
||||
>
|
||||
<div class="mb-4 rounded-full bg-muted p-4 text-ink">
|
||||
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<p class="font-serif text-lg text-ink">{m.conv_empty_heading()}</p>
|
||||
<p class="mt-1 font-sans text-sm text-ink-2">{m.conv_empty_text()}</p>
|
||||
</div>
|
||||
{:else if data.documents.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-sm border border-line bg-surface 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}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
142
frontend/src/routes/korrespondenz/ConversationFilterBar.svelte
Normal file
142
frontend/src/routes/korrespondenz/ConversationFilterBar.svelte
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
senderId = $bindable(''),
|
||||
receiverId = $bindable(''),
|
||||
fromDate = $bindable(''),
|
||||
toDate = $bindable(''),
|
||||
sortDir = $bindable('DESC'),
|
||||
initialSenderName = '',
|
||||
initialReceiverName = '',
|
||||
onapplyFilters,
|
||||
ontoggleSort,
|
||||
onswapPersons
|
||||
}: {
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
sortDir?: string;
|
||||
initialSenderName?: string;
|
||||
initialReceiverName?: string;
|
||||
onapplyFilters: () => void;
|
||||
ontoggleSort: () => void;
|
||||
onswapPersons: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative z-20 mb-10 border border-line bg-surface p-8 shadow-sm">
|
||||
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
|
||||
<!-- Sender -->
|
||||
<div
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label={m.conv_label_person_a()}
|
||||
bind:value={senderId}
|
||||
initialName={initialSenderName}
|
||||
restrictToCorrespondentsOf={receiverId || undefined}
|
||||
onchange={() => onapplyFilters()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Swap button -->
|
||||
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
|
||||
<button
|
||||
data-testid="conv-swap-btn"
|
||||
onclick={onswapPersons}
|
||||
class="flex w-full items-center justify-center gap-2 border border-line px-3 py-2.5 text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg md:w-auto {senderId &&
|
||||
receiverId
|
||||
? ''
|
||||
: 'invisible'}"
|
||||
title={m.conv_swap_btn()}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 flex-shrink-0 md:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="md:hidden">{m.conv_swap_btn()}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Receiver -->
|
||||
<div
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="receiverId"
|
||||
label={m.conv_label_person_b()}
|
||||
bind:value={receiverId}
|
||||
initialName={initialReceiverName}
|
||||
restrictToCorrespondentsOf={senderId || undefined}
|
||||
onchange={() => onapplyFilters()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
|
||||
<!-- Date From -->
|
||||
<div>
|
||||
<label
|
||||
for="dateFrom"
|
||||
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.conv_label_from()}</label
|
||||
>
|
||||
<input
|
||||
id="dateFrom"
|
||||
type="date"
|
||||
bind:value={fromDate}
|
||||
onchange={() => onapplyFilters()}
|
||||
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date To -->
|
||||
<div>
|
||||
<label for="dateTo" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.conv_label_to()}</label
|
||||
>
|
||||
<input
|
||||
id="dateTo"
|
||||
type="date"
|
||||
bind:value={toDate}
|
||||
onchange={() => onapplyFilters()}
|
||||
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sort Toggle -->
|
||||
<div>
|
||||
<button
|
||||
onclick={ontoggleSort}
|
||||
class="flex h-[42px] w-full items-center justify-center border border-line text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
|
||||
>
|
||||
<span class="mr-2">{m.conv_sort_label()}</span>
|
||||
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
|
||||
<svg
|
||||
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
|
||||
? 'rotate-180'
|
||||
: ''} transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
164
frontend/src/routes/korrespondenz/ConversationTimeline.svelte
Normal file
164
frontend/src/routes/korrespondenz/ConversationTimeline.svelte
Normal file
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
let {
|
||||
documents,
|
||||
senderId,
|
||||
receiverId,
|
||||
canWrite
|
||||
}: {
|
||||
documents: {
|
||||
id: string;
|
||||
title?: string;
|
||||
originalFilename: string;
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
status: string;
|
||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
||||
}[];
|
||||
senderId: string;
|
||||
receiverId: string;
|
||||
canWrite: boolean;
|
||||
} = $props();
|
||||
|
||||
const documentYears = $derived(
|
||||
documents
|
||||
.map((doc) => (doc.documentDate ? new Date(doc.documentDate).getFullYear() : null))
|
||||
.filter((y): y is number => y !== null)
|
||||
);
|
||||
const yearFrom = $derived(documentYears.length > 0 ? Math.min(...documentYears) : null);
|
||||
const yearTo = $derived(documentYears.length > 0 ? Math.max(...documentYears) : null);
|
||||
|
||||
const enrichedDocuments = $derived(
|
||||
documents.map((doc, i) => {
|
||||
const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
|
||||
const prevYear =
|
||||
i > 0 && documents[i - 1].documentDate
|
||||
? new Date(documents[i - 1].documentDate!).getFullYear()
|
||||
: null;
|
||||
return { doc, year, showYearDivider: year !== null && year !== prevYear };
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- Summary bar -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
{#if yearFrom !== null && yearTo !== null}
|
||||
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
|
||||
{m.conv_summary({ count: documents.length, yearFrom, yearTo })}
|
||||
</p>
|
||||
{:else}
|
||||
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
|
||||
{documents.length}
|
||||
</p>
|
||||
{/if}
|
||||
{#if canWrite}
|
||||
<a
|
||||
data-testid="conv-new-doc-link"
|
||||
href="/documents/new?senderId={senderId}&receiverId={receiverId}"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"
|
||||
></path>
|
||||
</svg>
|
||||
{m.conv_new_doc_link()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- CHAT CONTAINER -->
|
||||
<div class="relative overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||
<!-- Decoration: Central Timeline Line -->
|
||||
<div
|
||||
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-muted md:block"
|
||||
></div>
|
||||
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="relative z-10 flex flex-col gap-4">
|
||||
{#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)}
|
||||
{#if showYearDivider}
|
||||
<div data-testid="year-divider" class="relative flex items-center py-2 text-center">
|
||||
<div class="flex-grow border-t border-line"></div>
|
||||
<span class="mx-4 font-sans text-xs font-bold tracking-widest text-ink/40 uppercase"
|
||||
>{year}</span
|
||||
>
|
||||
<div class="flex-grow border-t border-line"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{@const isRight = doc.sender?.id === senderId}
|
||||
|
||||
<!-- Message Row -->
|
||||
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
|
||||
<!-- Bubble Group -->
|
||||
<div
|
||||
class="flex max-w-[90%] gap-3 md:max-w-[70%] {isRight
|
||||
? 'flex-row-reverse'
|
||||
: 'flex-row'}"
|
||||
>
|
||||
<!-- AVATAR -->
|
||||
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
|
||||
{isRight
|
||||
? 'border-primary bg-primary text-primary-fg'
|
||||
: 'border-line bg-surface text-ink'}"
|
||||
>
|
||||
{#if doc.sender}
|
||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||
{:else}
|
||||
?
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BUBBLE CARD -->
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
|
||||
{isRight
|
||||
? 'rounded-br-none border-primary bg-primary text-primary-fg'
|
||||
: 'rounded-bl-none border-line bg-muted/50 text-ink'}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="mb-2 flex items-start justify-between gap-4">
|
||||
<h3
|
||||
class="font-serif text-sm leading-snug font-medium {isRight
|
||||
? 'text-primary-fg'
|
||||
: 'text-ink'}"
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h3>
|
||||
|
||||
<!-- Status Dot -->
|
||||
<span
|
||||
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
|
||||
{doc.status === 'UPLOADED' ? 'bg-accent' : 'bg-yellow-400'}"
|
||||
title={doc.status}
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div
|
||||
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
|
||||
? 'text-primary-fg/70'
|
||||
: 'text-ink-2'}"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</span>
|
||||
{#if doc.location}
|
||||
<span class="flex items-center">
|
||||
• {doc.location}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
164
frontend/src/routes/korrespondenz/page.svelte.spec.ts
Normal file
164
frontend/src/routes/korrespondenz/page.svelte.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── Test data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
||||
};
|
||||
|
||||
const withPersons = {
|
||||
...baseData,
|
||||
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
|
||||
};
|
||||
|
||||
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'd1',
|
||||
title: 'Testbrief',
|
||||
originalFilename: 'testbrief.pdf',
|
||||
status: 'UPLOADED' as const,
|
||||
documentDate: '1923-04-12',
|
||||
location: 'Berlin',
|
||||
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
|
||||
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
|
||||
tags: [],
|
||||
transcription: undefined,
|
||||
filePath: undefined,
|
||||
createdAt: '1923-04-12T00:00:00Z',
|
||||
updatedAt: '1923-04-12T00:00:00Z',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const withDocs = {
|
||||
...withPersons,
|
||||
documents: [makeDoc()]
|
||||
};
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – empty state', () => {
|
||||
it('shows the "select two persons" prompt when no persons are selected', async () => {
|
||||
render(Page, { data: baseData });
|
||||
await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the swap button when no persons are selected', async () => {
|
||||
render(Page, { data: baseData });
|
||||
// Button is always in the DOM (holds grid column width on desktop) but made invisible
|
||||
await expect.element(page.getByTestId('conv-swap-btn')).toHaveClass('invisible');
|
||||
});
|
||||
|
||||
it('does not show the new document link when no persons are selected', async () => {
|
||||
render(Page, { data: baseData });
|
||||
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── No results ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – no results', () => {
|
||||
it('shows "no documents found" when both persons are selected but there are no documents', async () => {
|
||||
render(Page, { data: withPersons });
|
||||
await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Swap button ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – swap button', () => {
|
||||
it('shows the swap button when both persons are selected', async () => {
|
||||
render(Page, { data: withPersons });
|
||||
await expect.element(page.getByTestId('conv-swap-btn')).not.toHaveClass('invisible');
|
||||
});
|
||||
|
||||
it('calls goto with swapped sender and receiver when clicked', async () => {
|
||||
const { goto } = await import('$app/navigation');
|
||||
vi.mocked(goto).mockClear();
|
||||
render(Page, { data: withPersons });
|
||||
document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]')!.click();
|
||||
expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything());
|
||||
expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – summary', () => {
|
||||
it('shows document count and year range when documents are loaded', async () => {
|
||||
const data = {
|
||||
...withPersons,
|
||||
documents: [
|
||||
makeDoc({ documentDate: '1923-04-12' }),
|
||||
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
|
||||
]
|
||||
};
|
||||
render(Page, { data });
|
||||
const summary = page.getByTestId('conv-summary');
|
||||
await expect.element(summary).toHaveTextContent('2');
|
||||
await expect.element(summary).toHaveTextContent('1923');
|
||||
await expect.element(summary).toHaveTextContent('1965');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Year dividers ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – year dividers', () => {
|
||||
it('renders a year divider for the first document', async () => {
|
||||
render(Page, { data: withDocs });
|
||||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||||
});
|
||||
|
||||
it('renders a divider for each new year in the document list', async () => {
|
||||
const data = {
|
||||
...withPersons,
|
||||
documents: [
|
||||
makeDoc({ documentDate: '1923-04-12' }),
|
||||
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
|
||||
]
|
||||
};
|
||||
render(Page, { data });
|
||||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||||
await expect.element(page.getByTestId('year-divider').nth(1)).toHaveTextContent('1965');
|
||||
});
|
||||
|
||||
it('does not render a second divider for documents from the same year', async () => {
|
||||
const data = {
|
||||
...withPersons,
|
||||
documents: [
|
||||
makeDoc({ documentDate: '1923-04-12' }),
|
||||
makeDoc({ id: 'd2', documentDate: '1923-09-01' })
|
||||
]
|
||||
};
|
||||
render(Page, { data });
|
||||
// Only one divider for 1923; 1965 divider should not appear
|
||||
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
|
||||
await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── New document link ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – new document link', () => {
|
||||
it('shows the link with correct href for a write user', async () => {
|
||||
render(Page, { data: { ...withDocs, canWrite: true } });
|
||||
const link = page.getByTestId('conv-new-doc-link');
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/new?senderId=p1&receiverId=p2');
|
||||
});
|
||||
|
||||
it('hides the link for a read-only user', async () => {
|
||||
render(Page, { data: { ...withDocs, canWrite: false } });
|
||||
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ function initials(name: string): string {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each coCorrespondents as c (c.id)}
|
||||
<a
|
||||
href="/conversations?senderId={personId}&receiverId={c.id}"
|
||||
href="/korrespondenz?senderId={personId}&receiverId={c.id}"
|
||||
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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user