feat: Extracted inline typeahead from Home.tsx into shared SearchAutoco…
- "frontend/src/components/SearchAutocomplete.tsx" - "frontend/src/api/public-client.ts" - "frontend/src/pages/Home.tsx" - "frontend/src/pages/SearchResults.tsx" - "frontend/src/App.css" GSD-Task: S04/T02
This commit is contained in:
parent
9107323a66
commit
d0bdc6f516
6 changed files with 307 additions and 164 deletions
|
|
@ -1131,6 +1131,59 @@ a.app-footer__repo:hover {
|
||||||
background: var(--color-bg-surface-hover);
|
background: var(--color-bg-surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Popular suggestions (shown on empty focus) */
|
||||||
|
.typeahead-suggestions-header {
|
||||||
|
padding: 0.5rem 1rem 0.25rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeahead-suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeahead-suggestion:hover {
|
||||||
|
background: var(--color-bg-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeahead-suggestion__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeahead-item__type--technique {
|
||||||
|
background: var(--color-badge-technique-bg, rgba(34, 211, 238, 0.15));
|
||||||
|
color: var(--color-badge-technique-text, #22d3ee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeahead-item__type--topic {
|
||||||
|
background: var(--color-badge-topic-bg, rgba(168, 85, 247, 0.15));
|
||||||
|
color: var(--color-badge-topic-text, #a855f7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeahead-item__type--creator {
|
||||||
|
background: var(--color-badge-creator-bg, rgba(251, 146, 60, 0.15));
|
||||||
|
color: var(--color-badge-creator-text, #fb923c);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Navigation cards ─────────────────────────────────────────────────────── */
|
/* ── Navigation cards ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.nav-cards {
|
.nav-cards {
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,19 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
|
||||||
// ── Search ───────────────────────────────────────────────────────────────────
|
// ── Search ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SuggestionItem {
|
||||||
|
text: string;
|
||||||
|
type: "topic" | "technique" | "creator";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestionsResponse {
|
||||||
|
suggestions: SuggestionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSuggestions(): Promise<SuggestionsResponse> {
|
||||||
|
return request<SuggestionsResponse>(`${BASE}/search/suggestions`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchApi(
|
export async function searchApi(
|
||||||
q: string,
|
q: string,
|
||||||
scope?: string,
|
scope?: string,
|
||||||
|
|
|
||||||
225
frontend/src/components/SearchAutocomplete.tsx
Normal file
225
frontend/src/components/SearchAutocomplete.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
/**
|
||||||
|
* 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/public-client";
|
||||||
|
|
||||||
|
interface SearchAutocompleteProps {
|
||||||
|
onSearch: (query: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
heroSize?: boolean;
|
||||||
|
initialQuery?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchAutocomplete({
|
||||||
|
onSearch,
|
||||||
|
placeholder = "Search techniques…",
|
||||||
|
heroSize = false,
|
||||||
|
initialQuery = "",
|
||||||
|
autoFocus = false,
|
||||||
|
}: SearchAutocompleteProps) {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="search-container" ref={dropdownRef}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className={`search-form ${heroSize ? "search-form--hero" : "search-form--inline"}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="search"
|
||||||
|
className={`search-input ${heroSize ? "search-input--hero" : "search-input--inline"}`}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => handleInputChange(e.target.value)}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-label="Search techniques"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn--search">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</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) => (
|
||||||
|
<Link
|
||||||
|
key={`${item.type}-${item.slug}`}
|
||||||
|
to={`/techniques/${item.slug}`}
|
||||||
|
className="typeahead-item"
|
||||||
|
onClick={() => setShowDropdown(false)}
|
||||||
|
>
|
||||||
|
<span className="typeahead-item__title">{item.title}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,33 +6,21 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IconTopics, IconCreators } from "../components/CategoryIcons";
|
import { IconTopics, IconCreators } from "../components/CategoryIcons";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import SearchAutocomplete from "../components/SearchAutocomplete";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
searchApi,
|
|
||||||
fetchTechniques,
|
fetchTechniques,
|
||||||
fetchTopics,
|
fetchTopics,
|
||||||
type SearchResultItem,
|
|
||||||
type TechniqueListItem,
|
type TechniqueListItem,
|
||||||
} from "../api/public-client";
|
} from "../api/public-client";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [suggestions, setSuggestions] = useState<SearchResultItem[]>([]);
|
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
|
||||||
const [featured, setFeatured] = useState<TechniqueListItem | null>(null);
|
const [featured, setFeatured] = useState<TechniqueListItem | null>(null);
|
||||||
const [recent, setRecent] = useState<TechniqueListItem[]>([]);
|
const [recent, setRecent] = useState<TechniqueListItem[]>([]);
|
||||||
const [recentLoading, setRecentLoading] = useState(true);
|
const [recentLoading, setRecentLoading] = useState(true);
|
||||||
const [popularTopics, setPopularTopics] = useState<{name: string; count: number}[]>([]);
|
const [popularTopics, setPopularTopics] = useState<{name: string; count: number}[]>([]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Auto-focus search on mount
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load featured technique (random)
|
// Load featured technique (random)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -88,63 +76,6 @@ export default function Home() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 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
|
|
||||||
const handleInputChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setQuery(value);
|
|
||||||
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
|
|
||||||
if (value.length < 2) {
|
|
||||||
setSuggestions([]);
|
|
||||||
setShowDropdown(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const res = await searchApi(value, undefined, 5);
|
|
||||||
setSuggestions(res.items);
|
|
||||||
setShowDropdown(res.items.length > 0);
|
|
||||||
} catch {
|
|
||||||
setSuggestions([]);
|
|
||||||
setShowDropdown(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, 300);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (query.trim()) {
|
|
||||||
setShowDropdown(false);
|
|
||||||
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setShowDropdown(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
{/* Hero search */}
|
{/* Hero search */}
|
||||||
|
|
@ -154,58 +85,11 @@ export default function Home() {
|
||||||
Search techniques, key moments, and creators
|
Search techniques, key moments, and creators
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="search-container" ref={dropdownRef}>
|
<SearchAutocomplete
|
||||||
<form onSubmit={handleSubmit} className="search-form search-form--hero">
|
heroSize
|
||||||
<input
|
autoFocus
|
||||||
ref={inputRef}
|
onSearch={(q) => navigate(`/search?q=${encodeURIComponent(q)}`)}
|
||||||
type="search"
|
|
||||||
className="search-input search-input--hero"
|
|
||||||
placeholder="Search techniques…"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => handleInputChange(e.target.value)}
|
|
||||||
onFocus={() => {
|
|
||||||
if (suggestions.length > 0) setShowDropdown(true);
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
aria-label="Search techniques"
|
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn--search">
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{showDropdown && suggestions.length > 0 && (
|
|
||||||
<div className="typeahead-dropdown">
|
|
||||||
{suggestions.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={`${item.type}-${item.slug}`}
|
|
||||||
to={`/techniques/${item.slug}`}
|
|
||||||
className="typeahead-item"
|
|
||||||
onClick={() => setShowDropdown(false)}
|
|
||||||
>
|
|
||||||
<span className="typeahead-item__title">{item.title}</span>
|
|
||||||
<span className="typeahead-item__meta">
|
|
||||||
<span className={`typeahead-item__type typeahead-item__type--${item.type}`}>
|
|
||||||
{item.type === "technique_page" ? "Technique" : "Key Moment"}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<p className="home-hero__value-prop">
|
<p className="home-hero__value-prop">
|
||||||
Real music production techniques extracted from creator tutorials.
|
Real music production techniques extracted from creator tutorials.
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@
|
||||||
* keyword search was used.
|
* keyword search was used.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Link, useSearchParams, useNavigate } from "react-router-dom";
|
import { Link, useSearchParams, useNavigate } from "react-router-dom";
|
||||||
import { searchApi, type SearchResultItem } from "../api/public-client";
|
import { searchApi, type SearchResultItem } from "../api/public-client";
|
||||||
import { catSlug } from "../utils/catSlug";
|
import { catSlug } from "../utils/catSlug";
|
||||||
|
import SearchAutocomplete from "../components/SearchAutocomplete";
|
||||||
|
|
||||||
export default function SearchResults() {
|
export default function SearchResults() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
@ -19,8 +20,6 @@ export default function SearchResults() {
|
||||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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) => {
|
const doSearch = useCallback(async (query: string) => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
|
|
@ -43,33 +42,9 @@ export default function SearchResults() {
|
||||||
|
|
||||||
// Search when URL param changes
|
// Search when URL param changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalQuery(q);
|
|
||||||
if (q) void doSearch(q);
|
if (q) void doSearch(q);
|
||||||
}, [q, doSearch]);
|
}, [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
|
// Group results by type
|
||||||
const techniqueResults = results.filter((r) => r.type === "technique_page");
|
const techniqueResults = results.filter((r) => r.type === "technique_page");
|
||||||
const momentResults = results.filter((r) => r.type === "key_moment");
|
const momentResults = results.filter((r) => r.type === "key_moment");
|
||||||
|
|
@ -77,19 +52,12 @@ export default function SearchResults() {
|
||||||
return (
|
return (
|
||||||
<div className="search-results-page">
|
<div className="search-results-page">
|
||||||
{/* Inline search bar */}
|
{/* Inline search bar */}
|
||||||
<form onSubmit={handleSubmit} className="search-form search-form--inline">
|
<SearchAutocomplete
|
||||||
<input
|
initialQuery={q}
|
||||||
type="search"
|
onSearch={(newQ) =>
|
||||||
className="search-input search-input--inline"
|
navigate(`/search?q=${encodeURIComponent(newQ)}`, { replace: true })
|
||||||
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 */}
|
{/* Status */}
|
||||||
{loading && <div className="loading">Searching…</div>}
|
{loading && <div className="loading">Searching…</div>}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts"],"version":"5.6.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts"],"version":"5.6.3"}
|
||||||
Loading…
Add table
Reference in a new issue