You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
109 lines
4.0 KiB
109 lines
4.0 KiB
'use client';
|
|
|
|
import { search, SearchResult } from '@/lib/api';
|
|
import { useState, useCallback } from 'react';
|
|
import { Search, Loader2, FileText, Tag } from 'lucide-react';
|
|
import Link from 'next/link';
|
|
|
|
export default function SearchPage() {
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [queryTime, setQueryTime] = useState<number | null>(null);
|
|
const [error, setError] = useState('');
|
|
|
|
const handleSearch = useCallback(async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!query.trim()) return;
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const res = await search(query.trim());
|
|
setResults(res.results);
|
|
setQueryTime(res.query_time_ms);
|
|
} catch (err) {
|
|
setError('Search failed. Is the API running?');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [query]);
|
|
|
|
return (
|
|
<div className="max-w-3xl mx-auto">
|
|
<h1 className="text-2xl font-bold text-slate-900 mb-6">Search</h1>
|
|
|
|
<form onSubmit={handleSearch} className="flex gap-2 mb-6">
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Search your knowledge base..."
|
|
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brain-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={loading || !query.trim()}
|
|
className="px-5 py-2.5 bg-brain-600 text-white rounded-lg font-medium hover:bg-brain-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
{loading && <Loader2 size={16} className="animate-spin" />}
|
|
Search
|
|
</button>
|
|
</form>
|
|
|
|
{error && (
|
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg border border-red-200">{error}</div>
|
|
)}
|
|
|
|
{queryTime !== null && results.length > 0 && (
|
|
<p className="text-sm text-slate-400 mb-4">
|
|
{results.length} results in {queryTime}ms
|
|
</p>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
{results.map((result) => (
|
|
<Link
|
|
key={result.chunk_id}
|
|
href={`/documents/${result.document_id}`}
|
|
className="block bg-white border border-slate-200 rounded-xl p-5 hover:border-brain-400 hover:shadow-sm transition-all"
|
|
>
|
|
<div className="flex items-start justify-between gap-3 mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<FileText size={16} className="text-brain-500 shrink-0 mt-0.5" />
|
|
<h3 className="font-semibold text-slate-900">{result.title}</h3>
|
|
</div>
|
|
<span className="text-xs text-slate-400 shrink-0 bg-slate-100 px-2 py-0.5 rounded-full">
|
|
{(result.score * 100).toFixed(0)}%
|
|
</span>
|
|
</div>
|
|
|
|
{result.highlight && (
|
|
<p
|
|
className="text-sm text-slate-600 mb-3 line-clamp-3"
|
|
dangerouslySetInnerHTML={{ __html: result.highlight }}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-xs text-slate-400">{result.path}</span>
|
|
{result.tags.slice(0, 4).map((tag) => (
|
|
<span key={tag} className="text-xs bg-brain-50 text-brain-700 px-2 py-0.5 rounded-full flex items-center gap-1">
|
|
<Tag size={10} />
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</Link>
|
|
))}
|
|
|
|
{results.length === 0 && queryTime !== null && !loading && (
|
|
<p className="text-center text-slate-400 py-12">No results found for "{query}"</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|