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

@@ -1,123 +1,143 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
interface Props {
selectedPersons?: Person[];
}
interface Props {
selectedPersons?: Person[];
}
let { selectedPersons = $bindable([]) }: Props = $props();
let { selectedPersons = $bindable([]) }: Props = $props();
let searchTerm = $state('');
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
let searchTerm = $state('');
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function handleInput() {
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (searchTerm.length < 1) { results = []; return; }
loading = true;
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
if (res.ok) {
const all: Person[] = await res.json();
results = all.filter(p => !selectedPersons.some(s => s.id === p.id));
}
} catch { results = []; }
finally { loading = false; }
}, 300);
}
function handleInput() {
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (searchTerm.length < 1) {
results = [];
return;
}
loading = true;
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
if (res.ok) {
const all: Person[] = await res.json();
results = all.filter((p) => !selectedPersons.some((s) => s.id === p.id));
}
} catch {
results = [];
} finally {
loading = false;
}
}, 300);
}
function selectPerson(person: Person) {
selectedPersons = [...selectedPersons, person];
searchTerm = '';
showDropdown = false;
results = [];
}
function selectPerson(person: Person) {
selectedPersons = [...selectedPersons, person];
searchTerm = '';
showDropdown = false;
results = [];
}
function removePerson(id: string | undefined) {
selectedPersons = selectedPersons.filter(p => p.id !== id);
}
function removePerson(id: string | undefined) {
selectedPersons = selectedPersons.filter((p) => p.id !== id);
}
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return { destroy() { document.removeEventListener('click', handleClick, true); } };
}
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
{#each selectedPersons as person}
<input type="hidden" name="receiverIds" value={person.id} />
{#each selectedPersons as person (person.id)}
<input type="hidden" name="receiverIds" value={person.id} />
{/each}
<div class="relative" use:clickOutside>
<div class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded bg-white min-h-[42px] focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy">
{#each selectedPersons as person}
<span class="inline-flex items-center gap-1 bg-brand-sand/40 text-brand-navy text-sm font-medium px-2 py-1 rounded">
{person.firstName} {person.lastName}
<button
type="button"
onclick={() => removePerson(person.id)}
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
aria-label={m.comp_multiselect_remove()}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>
{/each}
<div
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
>
{#each selectedPersons as person (person.id)}
<span
class="inline-flex items-center gap-1 rounded bg-brand-sand/40 px-2 py-1 text-sm font-medium text-brand-navy"
>
{person.firstName}
{person.lastName}
<button
type="button"
onclick={() => removePerson(person.id)}
class="ml-0.5 text-brand-navy/50 hover:text-red-500 focus:outline-none"
aria-label={m.comp_multiselect_remove()}
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
{/each}
<input
bind:this={inputEl}
type="text"
autocomplete="off"
bind:value={searchTerm}
oninput={handleInput}
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''}
class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
/>
</div>
<input
bind:this={inputEl}
type="text"
autocomplete="off"
bind:value={searchTerm}
oninput={handleInput}
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''}
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
/>
</div>
{#if showDropdown && (results.length > 0 || loading)}
<div
style={dropdownStyle}
class="z-50 bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm"
>
{#if loading}
<div class="p-2 text-gray-500 text-sm">{m.comp_multiselect_loading()}</div>
{:else}
{#each results as person}
<div
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button"
tabindex="0"
>
{person.lastName}, {person.firstName}
</div>
{/each}
{/if}
</div>
{/if}
{#if showDropdown && (results.length > 0 || loading)}
<div
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
>
{#if loading}
<div class="p-2 text-sm text-gray-500">{m.comp_multiselect_loading()}</div>
{:else}
{#each results as person (person.id)}
<div
class="cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-brand-sand/30"
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button"
tabindex="0"
>
{person.lastName}, {person.firstName}
</div>
{/each}
{/if}
</div>
{/if}
</div>