/** * Full search results page. * * Reads `q` from URL search params, calls searchApi, groups results by type * (technique_pages first, then key_moments). Shows fallback banner when * keyword search was used. */ import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useSearchParams, useNavigate } from "react-router-dom"; import { searchApi, type SearchResultItem } from "../api/public-client"; export default function SearchResults() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); const q = searchParams.get("q") ?? ""; const [results, setResults] = useState([]); const [fallbackUsed, setFallbackUsed] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [localQuery, setLocalQuery] = useState(q); const debounceRef = useRef | null>(null); const doSearch = useCallback(async (query: string) => { if (!query.trim()) { setResults([]); setFallbackUsed(false); return; } setLoading(true); setError(null); try { const res = await searchApi(query.trim()); setResults(res.items); setFallbackUsed(res.fallback_used); } catch (err) { setError(err instanceof Error ? err.message : "Search failed"); setResults([]); } finally { setLoading(false); } }, []); // Search when URL param changes useEffect(() => { setLocalQuery(q); if (q) void doSearch(q); }, [q, doSearch]); function handleInputChange(value: string) { setLocalQuery(value); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { if (value.trim()) { navigate(`/search?q=${encodeURIComponent(value.trim())}`, { replace: true, }); } }, 400); } function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (debounceRef.current) clearTimeout(debounceRef.current); if (localQuery.trim()) { navigate(`/search?q=${encodeURIComponent(localQuery.trim())}`, { replace: true, }); } } // Group results by type const techniqueResults = results.filter((r) => r.type === "technique_page"); const momentResults = results.filter((r) => r.type === "key_moment"); return (
{/* Inline search bar */}
handleInputChange(e.target.value)} aria-label="Refine search" />
{/* Status */} {loading &&
Searching…
} {error &&
Error: {error}
} {/* Fallback banner */} {!loading && fallbackUsed && results.length > 0 && (
Showing keyword results — semantic search unavailable
)} {/* No results */} {!loading && !error && q && results.length === 0 && (

No results found for "{q}"

)} {/* Technique pages */} {techniqueResults.length > 0 && (

Techniques ({techniqueResults.length})

{techniqueResults.map((item) => ( ))}
)} {/* Key moments */} {momentResults.length > 0 && (

Key Moments ({momentResults.length})

{momentResults.map((item, i) => ( ))}
)}
); } function SearchResultCard({ item }: { item: SearchResultItem }) { return (
{item.title} {item.type === "technique_page" ? "Technique" : "Key Moment"}
{item.summary && (

{item.summary.length > 200 ? `${item.summary.slice(0, 200)}…` : item.summary}

)}
{item.creator_name && {item.creator_name}} {item.topic_category && ( <> · {item.topic_category} )} {item.topic_tags.length > 0 && ( {item.topic_tags.map((tag) => ( {tag} ))} )}
); }