- "frontend/src/api/client.ts" - "frontend/src/api/index.ts" - "frontend/src/api/search.ts" - "frontend/src/api/techniques.ts" - "frontend/src/api/creators.ts" - "frontend/src/api/topics.ts" - "frontend/src/api/stats.ts" - "frontend/src/api/reports.ts" GSD-Task: S05/T01
285 lines
9.5 KiB
TypeScript
285 lines
9.5 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, useState } from "react";
|
||
import { Link, useSearchParams, useNavigate } from "react-router-dom";
|
||
import { searchApi, type SearchResultItem } from "../api";
|
||
import { catSlug } from "../utils/catSlug";
|
||
import SearchAutocomplete from "../components/SearchAutocomplete";
|
||
import SortDropdown from "../components/SortDropdown";
|
||
import TagList from "../components/TagList";
|
||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||
import { useSortPreference } from "../hooks/useSortPreference";
|
||
|
||
const SEARCH_SORT_OPTIONS = [
|
||
{ value: "relevance", label: "Relevance" },
|
||
{ value: "newest", label: "Newest" },
|
||
{ value: "oldest", label: "Oldest" },
|
||
{ value: "alpha", label: "A–Z" },
|
||
{ value: "creator", label: "Creator" },
|
||
];
|
||
|
||
export default function SearchResults() {
|
||
const [searchParams] = useSearchParams();
|
||
const navigate = useNavigate();
|
||
const q = searchParams.get("q") ?? "";
|
||
const [sort, setSort] = useSortPreference("relevance");
|
||
|
||
useDocumentTitle(q ? `Search: ${q} — Chrysopedia` : "Search — Chrysopedia");
|
||
|
||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||
const [partialMatches, setPartialMatches] = useState<SearchResultItem[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const doSearch = useCallback(async (query: string, sortBy: string) => {
|
||
if (!query.trim()) {
|
||
setResults([]);
|
||
setPartialMatches([]);
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await searchApi(query.trim(), undefined, undefined, sortBy);
|
||
setResults(res.items);
|
||
setPartialMatches(res.partial_matches ?? []);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Search failed");
|
||
setResults([]);
|
||
setPartialMatches([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// Search when URL param or sort changes
|
||
useEffect(() => {
|
||
if (q) void doSearch(q, sort);
|
||
}, [q, sort, doSearch]);
|
||
|
||
// Group results by type
|
||
const techniqueResults = results.filter((r) => r.type === "technique_page");
|
||
const sectionResults = results.filter((r) => r.type === "technique_section");
|
||
const momentResults = results.filter((r) => r.type === "key_moment");
|
||
|
||
return (
|
||
<div className="search-results-page">
|
||
<h1 className="sr-only">Search Results</h1>
|
||
{/* Inline search bar */}
|
||
<SearchAutocomplete
|
||
variant="inline"
|
||
initialQuery={q}
|
||
onSearch={(newQ) =>
|
||
navigate(`/search?q=${encodeURIComponent(newQ)}`, { replace: true })
|
||
}
|
||
/>
|
||
|
||
{/* Sort control */}
|
||
{q && (
|
||
<SortDropdown
|
||
options={SEARCH_SORT_OPTIONS}
|
||
value={sort}
|
||
onChange={setSort}
|
||
/>
|
||
)}
|
||
|
||
{/* Status */}
|
||
{loading && <div className="loading">Searching…</div>}
|
||
{error && <div className="loading error-text">Error: {error}</div>}
|
||
|
||
{/* No exact results — partial match fallback */}
|
||
{!loading && !error && q && results.length === 0 && partialMatches.length > 0 && (
|
||
<>
|
||
<div className="partial-match-banner">
|
||
<p>No exact matches for all terms in "{q}"</p>
|
||
</div>
|
||
<PartialMatchResults items={partialMatches} />
|
||
</>
|
||
)}
|
||
|
||
{/* No results at all */}
|
||
{!loading && !error && q && results.length === 0 && partialMatches.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, i) => (
|
||
<SearchResultCard key={`tp-${item.slug}`} item={item} staggerIndex={i} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Technique sections */}
|
||
{sectionResults.length > 0 && (
|
||
<section className="search-group">
|
||
<h3 className="search-group__title">
|
||
Sections ({sectionResults.length})
|
||
</h3>
|
||
<div className="search-group__list">
|
||
{sectionResults.map((item, i) => (
|
||
<SearchResultCard key={`ts-${item.slug}-${item.section_anchor}-${i}`} item={item} staggerIndex={i} />
|
||
))}
|
||
</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} staggerIndex={i} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getSearchResultLink(item: SearchResultItem): string {
|
||
if (item.type === "technique_section") {
|
||
if (item.technique_page_slug && item.section_anchor) {
|
||
return `/techniques/${item.technique_page_slug}#${item.section_anchor}`;
|
||
}
|
||
if (item.technique_page_slug) {
|
||
return `/techniques/${item.technique_page_slug}`;
|
||
}
|
||
return `/search?q=${encodeURIComponent(item.title)}`;
|
||
}
|
||
if (item.type === "key_moment") {
|
||
if (item.technique_page_slug) {
|
||
return `/techniques/${item.technique_page_slug}#km-${item.slug || item.title}`;
|
||
}
|
||
// Graceful fallback — re-search instead of 404
|
||
return `/search?q=${encodeURIComponent(item.title)}`;
|
||
}
|
||
return `/techniques/${item.slug}`;
|
||
}
|
||
|
||
function SearchResultCard({ item, staggerIndex }: { item: SearchResultItem; staggerIndex: number }) {
|
||
const typeLabels: Record<string, string> = {
|
||
technique_page: "Technique",
|
||
key_moment: "Key Moment",
|
||
technique_section: "Section",
|
||
};
|
||
|
||
return (
|
||
<Link
|
||
to={getSearchResultLink(item)}
|
||
className="search-result-card card-stagger"
|
||
style={{ '--stagger-index': staggerIndex } as React.CSSProperties}
|
||
>
|
||
<div className="search-result-card__header">
|
||
<span className="search-result-card__title">{item.title}</span>
|
||
<span className={`badge badge--type badge--type-${item.type}`}>
|
||
{typeLabels[item.type] ?? item.type}
|
||
</span>
|
||
</div>
|
||
{item.type === "technique_section" && item.section_heading && (
|
||
<div className="search-result-card__section-context">
|
||
§ {item.section_heading}
|
||
</div>
|
||
)}
|
||
{item.match_context && (
|
||
<div className="search-result-card__match-context">
|
||
<span className="match-context__icon">⚡</span>
|
||
<span className="match-context__text">{item.match_context}</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 className="search-result-card__creator">{item.creator_name}</span>}
|
||
{item.topic_category && (
|
||
<>
|
||
<span className="queue-card__separator">·</span>
|
||
<span className={`badge badge--cat-${catSlug(item.topic_category)}`}>{item.topic_category}</span>
|
||
</>
|
||
)}
|
||
{item.topic_tags.length > 0 && (
|
||
<span className="search-result-card__tags">
|
||
<TagList tags={item.topic_tags} />
|
||
</span>
|
||
)}
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
function PartialMatchResults({ items }: { items: SearchResultItem[] }) {
|
||
const techniqueResults = items.filter((r) => r.type === "technique_page");
|
||
const sectionResults = items.filter((r) => r.type === "technique_section");
|
||
const momentResults = items.filter((r) => r.type === "key_moment");
|
||
|
||
return (
|
||
<div className="partial-match-results">
|
||
<h3 className="partial-match-results__header">
|
||
Results matching some of your terms
|
||
</h3>
|
||
|
||
{techniqueResults.length > 0 && (
|
||
<section className="search-group">
|
||
<h4 className="search-group__title">
|
||
Techniques ({techniqueResults.length})
|
||
</h4>
|
||
<div className="search-group__list">
|
||
{techniqueResults.map((item, i) => (
|
||
<SearchResultCard key={`partial-tp-${item.slug}`} item={item} staggerIndex={i} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{sectionResults.length > 0 && (
|
||
<section className="search-group">
|
||
<h4 className="search-group__title">
|
||
Sections ({sectionResults.length})
|
||
</h4>
|
||
<div className="search-group__list">
|
||
{sectionResults.map((item, i) => (
|
||
<SearchResultCard key={`partial-ts-${item.slug}-${item.section_anchor}-${i}`} item={item} staggerIndex={i} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{momentResults.length > 0 && (
|
||
<section className="search-group">
|
||
<h4 className="search-group__title">
|
||
Key Moments ({momentResults.length})
|
||
</h4>
|
||
<div className="search-group__list">
|
||
{momentResults.map((item, i) => (
|
||
<SearchResultCard key={`partial-km-${item.slug}-${i}`} item={item} staggerIndex={i} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|