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()
|
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("")
|
@router.get("")
|
||||||
def list_vorlagen(
|
def list_vorlagen(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
|
|||||||
@ -204,6 +204,17 @@ export const reevalKette = (id: number, anmerkung: string) =>
|
|||||||
export const fetchJobStatus = (jobId: string) =>
|
export const fetchJobStatus = (jobId: string) =>
|
||||||
get<{ status: string; result?: object; error?: string }>(`/bewertung/status/${jobId}`);
|
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 fetchFraktionen = () => get<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>('/fraktionen');
|
||||||
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string) => {
|
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string) => {
|
||||||
const params = jahr ? `?jahr=${jahr}` : '';
|
const params = jahr ? `?jahr=${jahr}` : '';
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
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';
|
import { formatDate } from '$lib/status';
|
||||||
|
|
||||||
let data: Paginated<VorlageKurz & { antragsteller?: { kuerzel: string; name: string; farbe: string | null }[] }> | null = $state(null);
|
let data: Paginated<VorlageKurz & { antragsteller?: { kuerzel: string; name: string; farbe: string | null }[] }> | null = $state(null);
|
||||||
@ -20,6 +20,63 @@
|
|||||||
let filterPartei = $state('');
|
let filterPartei = $state('');
|
||||||
let currentPage = $state(1);
|
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() {
|
function syncFromUrl() {
|
||||||
const p = new URL(window.location.href).searchParams;
|
const p = new URL(window.location.href).searchParams;
|
||||||
filterTyp = p.get('typ') || '';
|
filterTyp = p.get('typ') || '';
|
||||||
@ -81,11 +138,32 @@
|
|||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
<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 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>
|
<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..."
|
<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"
|
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"
|
||||||
onkeydown={(e) => { if (e.key === 'Enter') applyFilters(); }} />
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label for="typ" class="block text-xs font-medium text-gray-500 mb-1">Typ</label>
|
<label for="typ" class="block text-xs font-medium text-gray-500 mb-1">Typ</label>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user