In dark mode --c-primary is mint (#a1dcd8), a light colour, making hardcoded white text barely readable. Replacing text-white/text-blue-100 with text-primary-fg (white in light, navy in dark) restores contrast in both modes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
346 lines
12 KiB
Svelte
346 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
|
import { untrack } from 'svelte';
|
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
import { formatDate } from '$lib/utils/date';
|
|
|
|
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 documentYears = $derived(
|
|
data.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);
|
|
|
|
// 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(`/conversations?${params.toString()}`, { keepFocus: true });
|
|
}
|
|
|
|
function toggleSort() {
|
|
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
|
|
applyFilters();
|
|
}
|
|
|
|
function swapPersons() {
|
|
const tmp = senderId;
|
|
senderId = receiverId;
|
|
receiverId = tmp;
|
|
applyFilters();
|
|
}
|
|
|
|
const enrichedDocuments = $derived(
|
|
data.documents.map((doc, i) => {
|
|
const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
|
|
const prevYear =
|
|
i > 0 && data.documents[i - 1].documentDate
|
|
? new Date(data.documents[i - 1].documentDate!).getFullYear()
|
|
: null;
|
|
return { doc, year, showYearDivider: year !== null && year !== prevYear };
|
|
})
|
|
);
|
|
</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/60">
|
|
{m.conv_subtitle()}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- FILTER BAR -->
|
|
<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={data.initialValues.senderName}
|
|
restrictToCorrespondentsOf={receiverId || undefined}
|
|
onchange={() => applyFilters()}
|
|
/>
|
|
</div>
|
|
|
|
<!-- Swap button — always rendered to hold grid column width on desktop.
|
|
On mobile: hidden (display:none) when no persons selected so no gap appears.
|
|
On desktop: invisible (visibility:hidden) when no persons so both 1fr columns stay equal. -->
|
|
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
|
|
<button
|
|
data-testid="conv-swap-btn"
|
|
onclick={swapPersons}
|
|
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-white 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={data.initialValues.receiverName}
|
|
restrictToCorrespondentsOf={senderId || undefined}
|
|
onchange={() => applyFilters()}
|
|
/>
|
|
</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={() => applyFilters()}
|
|
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={() => applyFilters()}
|
|
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={toggleSort}
|
|
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-white"
|
|
>
|
|
<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>
|
|
|
|
<!-- 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}
|
|
<!-- 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: data.documents.length, yearFrom, yearTo })}
|
|
</p>
|
|
{:else}
|
|
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
|
|
{data.documents.length}
|
|
</p>
|
|
{/if}
|
|
{#if data.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/60 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>
|
|
{/if}
|
|
</div>
|