- "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
184 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|