fix(frontend): enforce lint locally and in CI, fix all pre-existing violations
Some checks failed
CI / Unit & Component Tests (push) Successful in 1m59s
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled

## Pre-commit hook
- Add .husky/pre-commit at repo root: runs `cd frontend && npm run lint`
- Update prepare script in package.json to auto-configure git hooks path
  on npm install (git -C .. config core.hooksPath .husky)
- Add lint step to CI unit-tests job so it catches issues before tests run
- Add generated dirs to .prettierignore (paraglide_bak*, test-results, .auth)
- Add src/lib/paraglide_bak* to .gitignore so ESLint can ignore them

## ESLint fixes (all pre-existing)
- Disable svelte/no-navigation-without-resolve: false positive in SvelteKit
  (rule targets Svelte 5 standalone routing, not SvelteKit <a href>)
- Fix svelte/require-each-key: add (item.id)/(item) keys to all {#each} blocks
  across 10 files — improves Svelte reconciliation performance
- Fix svelte/prefer-writable-derived in PersonTypeahead: $state+$effect → $derived
- Fix svelte/prefer-svelte-reactivity: URLSearchParams → SvelteURLSearchParams,
  Map → SvelteMap (enables Svelte reactive tracking)
- Fix @typescript-eslint/no-unused-vars: remove dead imports/variables

## Prettier
- Run npm run format to bring all source files in line with .prettierrc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-20 15:55:42 +01:00
parent 28dea45cc3
commit db2fc33e99
53 changed files with 2522 additions and 2061 deletions

View File

@@ -2,61 +2,63 @@ 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 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);
const api = createApiClient(fetch);
let documents: components['schemas']['Document'][] = [];
let senderName = '';
let receiverName = '';
let documents: components['schemas']['Document'][] = [];
let senderName = '';
let receiverName = '';
const requests: Promise<void>[] = [];
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 && 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 (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}`;
})
);
}
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);
await Promise.all(requests);
return {
documents,
initialValues: { senderName, receiverName },
filters: { senderId, receiverId, from, to, dir }
};
return {
documents,
initialValues: { senderName, receiverName },
filters: { senderId, receiverId, from, to, dir }
};
}

View File

@@ -1,58 +1,59 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
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 { 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));
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;
});
// 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 URLSearchParams();
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (fromDate) params.set('from', fromDate);
if (toDate) params.set('to', toDate);
params.set('dir', sortDir);
goto(`/conversations?${params.toString()}`, { keepFocus: true });
}
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 toggleSort() {
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
applyFilters();
}
</script>
<div class="max-w-5xl mx-auto py-10 px-4">
<div class="mx-auto max-w-5xl px-4 py-10">
<!-- Page Header -->
<div class="mb-8 border-b border-brand-navy/10 pb-4">
<h1 class="text-3xl font-serif font-medium text-brand-navy">{m.conv_heading()}</h1>
<p class="text-brand-navy/60 font-sans text-sm mt-2">
<h1 class="font-serif text-3xl font-medium text-brand-navy">{m.conv_heading()}</h1>
<p class="mt-2 font-sans text-sm text-brand-navy/60">
{m.conv_subtitle()}
</p>
</div>
<!-- FILTER BAR -->
<div class="bg-white p-8 shadow-sm border border-brand-sand mb-10 relative z-20">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-6">
<div class="relative z-20 mb-10 border border-brand-sand bg-white p-8 shadow-sm">
<div class="mb-6 grid grid-cols-1 gap-8 md:grid-cols-2">
<!-- Sender -->
<div
class="relative z-30 [&_label]:text-xs [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:mb-2 [&_input]:py-2.5 [&_input]:border-gray-300 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy"
class="relative z-30 [&_input]:border-gray-300 [&_input]:py-2.5 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
@@ -65,7 +66,7 @@
<!-- Receiver -->
<div
class="relative z-30 [&_label]:text-xs [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:mb-2 [&_input]:py-2.5 [&_input]:border-gray-300 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy"
class="relative z-30 [&_input]:border-gray-300 [&_input]:py-2.5 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
@@ -77,12 +78,12 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 items-end relative z-10">
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
<!-- Date From -->
<div>
<label
for="dateFrom"
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
>{m.conv_label_from()}</label
>
<input
@@ -90,7 +91,7 @@
type="date"
bind:value={fromDate}
onchange={() => applyFilters()}
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
@@ -98,7 +99,7 @@
<div>
<label
for="dateTo"
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
>{m.conv_label_to()}</label
>
<input
@@ -106,7 +107,7 @@
type="date"
bind:value={toDate}
onchange={() => applyFilters()}
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
@@ -114,12 +115,12 @@
<div>
<button
onclick={toggleSort}
class="w-full flex items-center justify-center h-[42px] border border-brand-sand text-xs font-bold uppercase tracking-wide text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
class="flex h-[42px] w-full items-center justify-center border border-brand-sand text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy 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="w-4 h-4 ml-2 transform {sortDir === 'ASC'
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
? 'rotate-180'
: ''} transition-transform"
fill="none"
@@ -137,10 +138,10 @@
<!-- RESULTS LIST SECTION -->
{#if !senderId || !receiverId}
<div
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand border-dashed rounded-sm text-center"
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-brand-sand bg-white py-24 text-center"
>
<div class="bg-brand-sand/30 p-4 rounded-full mb-4 text-brand-navy">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
<div class="mb-4 rounded-full bg-brand-sand/30 p-4 text-brand-navy">
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
@@ -149,44 +150,44 @@
/></svg
>
</div>
<p class="text-brand-navy font-serif text-lg">{m.conv_empty_heading()}</p>
<p class="text-gray-500 font-sans text-sm mt-1">{m.conv_empty_text()}</p>
<p class="font-serif text-lg text-brand-navy">{m.conv_empty_heading()}</p>
<p class="mt-1 font-sans text-sm text-gray-500">{m.conv_empty_text()}</p>
</div>
{:else if data.documents.length === 0}
<div
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand rounded-sm text-center shadow-sm"
class="flex flex-col items-center justify-center rounded-sm border border-brand-sand bg-white py-24 text-center shadow-sm"
>
<p class="text-brand-navy font-serif">{m.conv_no_results_heading()}</p>
<p class="text-gray-400 text-sm mt-2">{m.conv_no_results_text()}</p>
<p class="font-serif text-brand-navy">{m.conv_no_results_heading()}</p>
<p class="mt-2 text-sm text-gray-400">{m.conv_no_results_text()}</p>
</div>
{:else}
<!-- CHAT CONTAINER -->
<div class="bg-white border border-brand-sand shadow-sm rounded-sm relative overflow-hidden">
<div class="relative overflow-hidden rounded-sm border border-brand-sand bg-white shadow-sm">
<!-- Decoration: Central Timeline Line -->
<div
class="absolute left-1/2 top-0 bottom-0 w-px bg-brand-sand/30 transform -translate-x-1/2 hidden md:block"
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-brand-sand/30 md:block"
></div>
<div class="p-6 md:p-8">
<div class="flex flex-col gap-4 relative z-10">
{#each data.documents as doc}
<div class="relative z-10 flex flex-col gap-4">
{#each data.documents as doc (doc.id)}
{@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%] md:max-w-[70%] gap-3 {isRight
class="flex max-w-[90%] gap-3 md:max-w-[70%] {isRight
? 'flex-row-reverse'
: 'flex-row'}"
>
<!-- AVATAR -->
<div class="flex-shrink-0 mt-auto mb-1 hidden sm:block">
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
<div
class="w-8 h-8 rounded-full flex items-center justify-center font-serif text-xs border shadow-sm
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
{isRight
? 'bg-brand-navy text-white border-brand-navy'
: 'bg-white text-brand-navy border-brand-sand'}"
? 'border-brand-navy bg-brand-navy text-white'
: 'border-brand-sand bg-white text-brand-navy'}"
>
{#if doc.sender}
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
@@ -199,15 +200,15 @@
<!-- BUBBLE CARD -->
<a
href="/documents/{doc.id}"
class="group block p-4 rounded shadow-sm transition-all duration-200 transform hover:-translate-y-0.5 hover:shadow-md border
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
{isRight
? 'bg-brand-navy text-white border-brand-navy rounded-br-none'
: 'bg-brand-sand/10 text-brand-navy border-brand-sand rounded-bl-none'}"
? 'rounded-br-none border-brand-navy bg-brand-navy text-white'
: 'rounded-bl-none border-brand-sand bg-brand-sand/10 text-brand-navy'}"
>
<!-- Header -->
<div class="flex justify-between items-start gap-4 mb-2">
<div class="mb-2 flex items-start justify-between gap-4">
<h3
class="font-serif font-medium text-sm leading-snug {isRight
class="font-serif text-sm leading-snug font-medium {isRight
? 'text-white'
: 'text-brand-navy'}"
>
@@ -216,7 +217,7 @@
<!-- Status Dot -->
<span
class="flex-shrink-0 w-1.5 h-1.5 rounded-full mt-1.5
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
{doc.status === 'UPLOADED'
? 'bg-brand-mint'
: 'bg-yellow-400'}"
@@ -227,7 +228,7 @@
<!-- Metadata -->
<div
class="flex flex-wrap gap-3 text-[10px] font-sans uppercase tracking-wider opacity-80 {isRight
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
? 'text-blue-100'
: 'text-gray-500'}"
>