- "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
263 lines
8.6 KiB
TypeScript
263 lines
8.6 KiB
TypeScript
/**
|
|
* Shared search autocomplete component.
|
|
*
|
|
* - On focus with empty input: shows popular suggestions from /api/v1/search/suggestions
|
|
* - On 2+ chars: shows debounced typeahead search results (top 5)
|
|
* - Escape / outside-click closes dropdown
|
|
* - Enter submits via onSearch callback
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import {
|
|
searchApi,
|
|
fetchSuggestions,
|
|
type SearchResultItem,
|
|
type SuggestionItem,
|
|
} from "../api";
|
|
|
|
interface SearchAutocompleteProps {
|
|
onSearch: (query: string) => void;
|
|
placeholder?: string;
|
|
/** @deprecated Use variant="hero" instead */
|
|
heroSize?: boolean;
|
|
variant?: 'hero' | 'inline' | 'nav';
|
|
initialQuery?: string;
|
|
autoFocus?: boolean;
|
|
/** When true, Ctrl+Shift+F focuses the input globally */
|
|
globalShortcut?: boolean;
|
|
}
|
|
|
|
export default function SearchAutocomplete({
|
|
onSearch,
|
|
placeholder = "Search techniques…",
|
|
heroSize = false,
|
|
variant: variantProp,
|
|
initialQuery = "",
|
|
autoFocus = false,
|
|
globalShortcut = false,
|
|
}: SearchAutocompleteProps) {
|
|
// Resolve variant: explicit prop wins, then legacy heroSize, then default 'inline'
|
|
const variant = variantProp ?? (heroSize ? 'hero' : 'inline');
|
|
const [query, setQuery] = useState(initialQuery);
|
|
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
|
|
const [popularSuggestions, setPopularSuggestions] = useState<SuggestionItem[]>([]);
|
|
const [showDropdown, setShowDropdown] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const suggestionsLoadedRef = useRef(false);
|
|
|
|
// Sync initialQuery when URL changes (e.g. SearchResults page)
|
|
useEffect(() => {
|
|
setQuery(initialQuery);
|
|
}, [initialQuery]);
|
|
|
|
// Fetch popular suggestions once on mount
|
|
useEffect(() => {
|
|
if (suggestionsLoadedRef.current) return;
|
|
suggestionsLoadedRef.current = true;
|
|
void (async () => {
|
|
try {
|
|
const res = await fetchSuggestions();
|
|
setPopularSuggestions(res.suggestions);
|
|
} catch {
|
|
// Non-critical — autocomplete still works without popular suggestions
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
// Auto-focus
|
|
useEffect(() => {
|
|
if (autoFocus) inputRef.current?.focus();
|
|
}, [autoFocus]);
|
|
|
|
// Global Ctrl+Shift+F shortcut to focus input
|
|
useEffect(() => {
|
|
if (!globalShortcut) return;
|
|
function handleGlobalKey(e: KeyboardEvent) {
|
|
if (e.key === "F" && e.ctrlKey && e.shiftKey) {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
}
|
|
}
|
|
document.addEventListener("keydown", handleGlobalKey);
|
|
return () => document.removeEventListener("keydown", handleGlobalKey);
|
|
}, [globalShortcut]);
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setShowDropdown(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, []);
|
|
|
|
// Debounced typeahead search
|
|
const handleInputChange = useCallback((value: string) => {
|
|
setQuery(value);
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
|
|
if (value.length < 2) {
|
|
setSearchResults([]);
|
|
// Show popular suggestions if input is empty and we have them
|
|
if (value.length === 0 && popularSuggestions.length > 0) {
|
|
setShowDropdown(true);
|
|
} else {
|
|
setShowDropdown(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
debounceRef.current = setTimeout(() => {
|
|
void (async () => {
|
|
try {
|
|
const res = await searchApi(value, undefined, 5);
|
|
setSearchResults(res.items);
|
|
setShowDropdown(res.items.length > 0);
|
|
} catch {
|
|
setSearchResults([]);
|
|
setShowDropdown(false);
|
|
}
|
|
})();
|
|
}, 300);
|
|
}, [popularSuggestions.length]);
|
|
|
|
function handleFocus() {
|
|
if (query.length === 0 && popularSuggestions.length > 0) {
|
|
setShowDropdown(true);
|
|
} else if (query.length >= 2 && searchResults.length > 0) {
|
|
setShowDropdown(true);
|
|
}
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (query.trim()) {
|
|
setShowDropdown(false);
|
|
onSearch(query.trim());
|
|
}
|
|
}
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
if (e.key === "Escape") {
|
|
setShowDropdown(false);
|
|
}
|
|
}
|
|
|
|
function handleSuggestionClick(text: string) {
|
|
setShowDropdown(false);
|
|
setQuery(text);
|
|
onSearch(text);
|
|
}
|
|
|
|
const showPopular = query.length < 2 && popularSuggestions.length > 0;
|
|
const showSearch = query.length >= 2 && searchResults.length > 0;
|
|
|
|
const typeLabel: Record<string, string> = {
|
|
technique: "Technique",
|
|
topic: "Topic",
|
|
creator: "Creator",
|
|
technique_page: "Technique",
|
|
key_moment: "Key Moment",
|
|
technique_section: "Section",
|
|
};
|
|
|
|
return (
|
|
<div className={`search-container${variant === 'nav' ? ' search-container--nav' : ''}`} ref={dropdownRef}>
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className={`search-form search-form--${variant}`}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="search"
|
|
className={`search-input search-input--${variant}`}
|
|
placeholder={placeholder}
|
|
value={query}
|
|
onChange={(e) => handleInputChange(e.target.value)}
|
|
onFocus={handleFocus}
|
|
onKeyDown={handleKeyDown}
|
|
aria-label="Search techniques"
|
|
/>
|
|
{variant !== 'nav' && (
|
|
<button type="submit" className="btn btn--search">
|
|
Search
|
|
</button>
|
|
)}
|
|
{variant === 'nav' && (
|
|
<kbd className="search-nav__shortcut">Ctrl⇧F</kbd>
|
|
)}
|
|
</form>
|
|
|
|
{showDropdown && (showPopular || showSearch) && (
|
|
<div className="typeahead-dropdown">
|
|
{showPopular && (
|
|
<>
|
|
<div className="typeahead-suggestions-header">Popular</div>
|
|
{popularSuggestions.map((item) => (
|
|
<button
|
|
key={`${item.type}-${item.text}`}
|
|
className="typeahead-suggestion"
|
|
onClick={() => handleSuggestionClick(item.text)}
|
|
type="button"
|
|
>
|
|
<span className="typeahead-suggestion__text">{item.text}</span>
|
|
<span className={`typeahead-item__type typeahead-item__type--${item.type}`}>
|
|
{typeLabel[item.type] ?? item.type}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{showSearch && (
|
|
<>
|
|
{searchResults.map((item) => {
|
|
let linkTo = `/techniques/${item.slug}`;
|
|
if (item.type === "technique_section" && item.technique_page_slug) {
|
|
linkTo = `/techniques/${item.technique_page_slug}${item.section_anchor ? `#${item.section_anchor}` : ""}`;
|
|
} else if (item.type === "key_moment" && item.technique_page_slug) {
|
|
linkTo = `/techniques/${item.technique_page_slug}#km-${item.slug || item.title}`;
|
|
}
|
|
return (
|
|
<Link
|
|
key={`${item.type}-${item.slug}-${item.section_anchor ?? ""}`}
|
|
to={linkTo}
|
|
className="typeahead-item"
|
|
onClick={() => setShowDropdown(false)}
|
|
>
|
|
<span className="typeahead-item__title">{item.title}</span>
|
|
{item.type === "technique_section" && item.section_heading && (
|
|
<span className="typeahead-item__section">§ {item.section_heading}</span>
|
|
)}
|
|
<span className="typeahead-item__meta">
|
|
<span className={`typeahead-item__type typeahead-item__type--${item.type}`}>
|
|
{typeLabel[item.type] ?? item.type}
|
|
</span>
|
|
{item.creator_name && (
|
|
<span className="typeahead-item__creator">
|
|
{item.creator_name}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
<Link
|
|
to={`/search?q=${encodeURIComponent(query)}`}
|
|
className="typeahead-see-all"
|
|
onClick={() => setShowDropdown(false)}
|
|
>
|
|
See all results for “{query}”
|
|
</Link>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|