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:
parent
2ab8046b78
commit
77371fafc9
@ -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),
|
||||
|
||||
@ -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}` : '';
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user