chrysopedia/frontend/src/pages/SearchResults.tsx
jlightner 39e169b4ce feat: Split 945-line public-client.ts into 10 domain API modules with s…
- "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
2026-04-03 23:04:56 +00:00

285 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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: "AZ" },
{ 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>
);
}