diff --git a/frontend/src/App.css b/frontend/src/App.css index d375724..7d890c4 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -772,6 +772,34 @@ a.app-footer__repo:hover { font-size: 0.875rem; } +/* ── Partial match fallback ─────────────────────────────────────────── */ + +.partial-match-banner { + text-align: center; + padding: 1rem; + margin-bottom: 1rem; + color: var(--color-text-muted); + font-size: 0.875rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md, 8px); + background: var(--color-surface-raised, var(--color-surface)); +} + +.partial-match-results__header { + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.partial-match-results .search-result-card { + opacity: 0.85; +} + .error-text { color: var(--color-error); } diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts index 1148f5d..7addf57 100644 --- a/frontend/src/api/public-client.ts +++ b/frontend/src/api/public-client.ts @@ -22,6 +22,7 @@ export interface SearchResultItem { export interface SearchResponse { items: SearchResultItem[]; + partial_matches: SearchResultItem[]; total: number; query: string; fallback_used: boolean; diff --git a/frontend/src/pages/SearchResults.tsx b/frontend/src/pages/SearchResults.tsx index 5713990..f769ac6 100644 --- a/frontend/src/pages/SearchResults.tsx +++ b/frontend/src/pages/SearchResults.tsx @@ -22,12 +22,14 @@ export default function SearchResults() { useDocumentTitle(q ? `Search: ${q} — Chrysopedia` : "Search — Chrysopedia"); const [results, setResults] = useState([]); + const [partialMatches, setPartialMatches] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const doSearch = useCallback(async (query: string) => { if (!query.trim()) { setResults([]); + setPartialMatches([]); return; } @@ -36,9 +38,11 @@ export default function SearchResults() { try { const res = await searchApi(query.trim()); setResults(res.items); + setPartialMatches(res.partial_matches ?? []); } catch (err) { setError(err instanceof Error ? err.message : "Search failed"); setResults([]); + setPartialMatches([]); } finally { setLoading(false); } @@ -69,8 +73,18 @@ export default function SearchResults() { {loading &&
Searching…
} {error &&
Error: {error}
} - {/* No results */} - {!loading && !error && q && results.length === 0 && ( + {/* No exact results — partial match fallback */} + {!loading && !error && q && results.length === 0 && partialMatches.length > 0 && ( + <> +
+

No exact matches for all terms in "{q}"

+
+ + + )} + + {/* No results at all */} + {!loading && !error && q && results.length === 0 && partialMatches.length === 0 && (

No results found for "{q}"

@@ -155,3 +169,42 @@ function SearchResultCard({ item, staggerIndex }: { item: SearchResultItem; stag ); } + +function PartialMatchResults({ items }: { items: SearchResultItem[] }) { + const techniqueResults = items.filter((r) => r.type === "technique_page"); + const momentResults = items.filter((r) => r.type === "key_moment"); + + return ( +
+

+ Results matching some of your terms +

+ + {techniqueResults.length > 0 && ( +
+

+ Techniques ({techniqueResults.length}) +

+
+ {techniqueResults.map((item, i) => ( + + ))} +
+
+ )} + + {momentResults.length > 0 && ( +
+

+ Key Moments ({momentResults.length}) +

+
+ {momentResults.map((item, i) => ( + + ))} +
+
+ )} +
+ ); +}