feat: Typeahead-Vorschläge bei Volltextsuche (#11)

- GET /api/vorlagen/suggest?q= mit FTS5 Prefix-Matching
- Debounce 300ms, Top 7 Vorschläge mit Snippet
- Keyboard-Navigation (↑↓ Enter Escape)
- Klick/Enter → direkt zur Vorlage
- Enter ohne Auswahl → normale Filtersuche
This commit is contained in:
Dotty Dotter 2026-04-01 13:52:51 +02:00
parent 2ab8046b78
commit 77371fafc9
3 changed files with 152 additions and 4 deletions

View File

@ -29,6 +29,65 @@ def _db():
conn.close()
@router.get("/suggest")
def suggest(q: str = Query("", min_length=2, max_length=200), conn=Depends(_db)):
"""Typeahead suggestions using FTS5."""
has_fts = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='vorlagen_fts'"
).fetchone()
if not has_fts or not q.strip():
return {"items": []}
# Build prefix query for typeahead (last word gets *)
words = q.strip().split()
parts = [f'"{w}"' for w in words[:-1]]
parts.append(f'"{words[-1]}"*') # prefix match on last word
fts_query = " ".join(parts)
try:
rows = conn.execute(
"""SELECT rowid,
snippet(vorlagen_fts, 1, '<mark>', '</mark>', '', 10) as snip
FROM vorlagen_fts
WHERE vorlagen_fts MATCH ?
ORDER BY rank
LIMIT 7""",
(fts_query,),
).fetchall()
except Exception:
return {"items": []}
if not rows:
return {"items": []}
ids = [r["rowid"] for r in rows]
placeholders = ",".join("?" * len(ids))
vorlagen = conn.execute(
f"""SELECT id, aktenzeichen, betreff, typ
FROM vorlagen WHERE id IN ({placeholders})""",
ids,
).fetchall()
v_map = {v["id"]: v for v in vorlagen}
snip_map = {r["rowid"]: r["snip"] for r in rows}
items = []
for rid in ids:
v = v_map.get(rid)
if v:
items.append({
"id": v["id"],
"aktenzeichen": v["aktenzeichen"],
"betreff": v["betreff"],
"typ": v["typ"],
"snippet": snip_map.get(rid, ""),
})
return {"items": items}
@router.get("")
def list_vorlagen(
page: int = Query(1, ge=1),

View File

@ -204,6 +204,17 @@ export const reevalKette = (id: number, anmerkung: string) =>
export const fetchJobStatus = (jobId: string) =>
get<{ status: string; result?: object; error?: string }>(`/bewertung/status/${jobId}`);
export interface SuchVorschlag {
id: number;
aktenzeichen: string | null;
betreff: string | null;
typ: string | null;
snippet?: string;
}
export const fetchSuchvorschlaege = (q: string) =>
get<{ items: SuchVorschlag[] }>(`/vorlagen/suggest?q=${encodeURIComponent(q)}`);
export const fetchFraktionen = () => get<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>('/fraktionen');
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string) => {
const params = jahr ? `?jahr=${jahr}` : '';

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { fetchVorlagen, fetchFraktionen, type VorlageKurz, type Paginated } from '$lib/api';
import { fetchVorlagen, fetchFraktionen, fetchSuchvorschlaege, type VorlageKurz, type Paginated, type SuchVorschlag } from '$lib/api';
import { formatDate } from '$lib/status';
let data: Paginated<VorlageKurz & { antragsteller?: { kuerzel: string; name: string; farbe: string | null }[] }> | null = $state(null);
@ -20,6 +20,63 @@
let filterPartei = $state('');
let currentPage = $state(1);
// Typeahead
let suggestions = $state<SuchVorschlag[]>([]);
let showSuggestions = $state(false);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let selectedIdx = $state(-1);
function onSucheInput() {
selectedIdx = -1;
if (debounceTimer) clearTimeout(debounceTimer);
const q = filterSuche.trim();
if (q.length < 2) {
suggestions = [];
showSuggestions = false;
return;
}
debounceTimer = setTimeout(async () => {
try {
const res = await fetchSuchvorschlaege(q);
suggestions = res.items;
showSuggestions = suggestions.length > 0;
} catch {
suggestions = [];
showSuggestions = false;
}
}, 300);
}
function onSucheKeydown(e: KeyboardEvent) {
if (!showSuggestions) {
if (e.key === 'Enter') applyFilters();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIdx = Math.min(selectedIdx + 1, suggestions.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIdx = Math.max(selectedIdx - 1, -1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIdx >= 0 && selectedIdx < suggestions.length) {
goto(`/vorlagen/${suggestions[selectedIdx].id}`);
showSuggestions = false;
} else {
showSuggestions = false;
applyFilters();
}
} else if (e.key === 'Escape') {
showSuggestions = false;
}
}
function pickSuggestion(s: SuchVorschlag) {
showSuggestions = false;
goto(`/vorlagen/${s.id}`);
}
function syncFromUrl() {
const p = new URL(window.location.href).searchParams;
filterTyp = p.get('typ') || '';
@ -81,11 +138,32 @@
<!-- Filters -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
<div class="flex flex-wrap gap-3 items-end">
<div>
<div class="relative">
<label for="suche" class="block text-xs font-medium text-gray-500 mb-1">Suche</label>
<input id="suche" type="text" bind:value={filterSuche} placeholder="Volltextsuche..."
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
onkeydown={(e) => { if (e.key === 'Enter') applyFilters(); }} />
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 w-64"
oninput={onSucheInput}
onkeydown={onSucheKeydown}
onfocusout={() => { setTimeout(() => showSuggestions = false, 200); }}
autocomplete="off" />
{#if showSuggestions}
<div class="absolute z-50 top-full left-0 mt-1 w-96 bg-white rounded-lg shadow-lg border border-gray-200 max-h-80 overflow-y-auto">
{#each suggestions as s, i}
<button
class="w-full text-left px-3 py-2 hover:bg-green-50 border-b border-gray-100 last:border-0 transition-colors {i === selectedIdx ? 'bg-green-50' : ''}"
onmousedown={() => pickSuggestion(s)}>
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-green-700 shrink-0">{s.aktenzeichen || `#${s.id}`}</span>
<span class="text-xs text-gray-400 capitalize">{s.typ || ''}</span>
</div>
<div class="text-sm text-gray-800 truncate">{s.betreff || ''}</div>
{#if s.snippet}
<div class="text-xs text-gray-500 mt-0.5 line-clamp-1">{@html s.snippet}</div>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<div>
<label for="typ" class="block text-xs font-medium text-gray-500 mb-1">Typ</label>