chrysopedia/frontend/src/pages/SearchResults.tsx
jlightner 3a7f10005b feat: Built frontend search flow: typed public API client, landing page…
- "frontend/src/api/public-client.ts"
- "frontend/src/pages/Home.tsx"
- "frontend/src/pages/SearchResults.tsx"
- "frontend/src/pages/TechniquePage.tsx"
- "frontend/src/App.tsx"
- "frontend/src/App.css"

GSD-Task: S05/T03
2026-03-30 00:09:08 +00:00

184 lines
5.6 KiB
TypeScript

/**
* 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<SearchResultItem[]>([]);
const [fallbackUsed, setFallbackUsed] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [localQuery, setLocalQuery] = useState(q);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="search-results-page">
{/* Inline search bar */}
<form onSubmit={handleSubmit} className="search-form search-form--inline">
<input
type="search"
className="search-input search-input--inline"
placeholder="Search techniques…"
value={localQuery}
onChange={(e) => handleInputChange(e.target.value)}
aria-label="Refine search"
/>
<button type="submit" className="btn btn--search">
Search
</button>
</form>
{/* Status */}
{loading && <div className="loading">Searching</div>}
{error && <div className="loading error-text">Error: {error}</div>}
{/* Fallback banner */}
{!loading && fallbackUsed && results.length > 0 && (
<div className="search-fallback-banner">
Showing keyword results semantic search unavailable
</div>
)}
{/* No results */}
{!loading && !error && q && results.length === 0 && (
<div className="empty-state">
<p>No results found for "{q}"</p>
</div>
)}
{/* Technique pages */}
{techniqueResults.length > 0 && (
<section className="search-group">
<h3 className="search-group__title">
Techniques ({techniqueResults.length})
</h3>
<div className="search-group__list">
{techniqueResults.map((item) => (
<SearchResultCard key={`tp-${item.slug}`} item={item} />
))}
</div>
</section>
)}
{/* Key moments */}
{momentResults.length > 0 && (
<section className="search-group">
<h3 className="search-group__title">
Key Moments ({momentResults.length})
</h3>
<div className="search-group__list">
{momentResults.map((item, i) => (
<SearchResultCard key={`km-${item.slug}-${i}`} item={item} />
))}
</div>
</section>
)}
</div>
);
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
return (
<Link
to={`/techniques/${item.slug}`}
className="search-result-card"
>
<div className="search-result-card__header">
<span className="search-result-card__title">{item.title}</span>
<span className={`badge badge--type badge--type-${item.type}`}>
{item.type === "technique_page" ? "Technique" : "Key Moment"}
</span>
</div>
{item.summary && (
<p className="search-result-card__summary">
{item.summary.length > 200
? `${item.summary.slice(0, 200)}`
: item.summary}
</p>
)}
<div className="search-result-card__meta">
{item.creator_name && <span>{item.creator_name}</span>}
{item.topic_category && (
<>
<span className="queue-card__separator">·</span>
<span>{item.topic_category}</span>
</>
)}
{item.topic_tags.length > 0 && (
<span className="search-result-card__tags">
{item.topic_tags.map((tag) => (
<span key={tag} className="pill">
{tag}
</span>
))}
</span>
)}
</div>
</Link>
);
}