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
1254e173d4
commit
2a8b0b3a84
9 changed files with 415 additions and 165 deletions
|
|
@ -14,7 +14,7 @@ Steps:
|
|||
- Estimate: 30m
|
||||
- Files: backend/schemas.py, backend/routers/search.py, backend/tests/test_search.py
|
||||
- Verify: cd backend && python -m pytest tests/test_search.py -v -k suggestion
|
||||
- [ ] **T02: Extract SearchAutocomplete component with popular suggestions on focus** — Extract the typeahead dropdown from Home.tsx into a shared `SearchAutocomplete` component. Add popular-suggestions-on-focus behavior (fetches from `/api/v1/search/suggestions` once on mount, shows when input is focused and empty). Wire the component into both Home.tsx and SearchResults.tsx.
|
||||
- [x] **T02: Extracted inline typeahead from Home.tsx into shared SearchAutocomplete component with popular-suggestions-on-focus, wired into both Home and SearchResults pages** — Extract the typeahead dropdown from Home.tsx into a shared `SearchAutocomplete` component. Add popular-suggestions-on-focus behavior (fetches from `/api/v1/search/suggestions` once on mount, shows when input is focused and empty). Wire the component into both Home.tsx and SearchResults.tsx.
|
||||
|
||||
Steps:
|
||||
1. Add `fetchSuggestions` function to `frontend/src/api/public-client.ts` with types `SuggestionItem` and `SuggestionsResponse`.
|
||||
|
|
|
|||
24
.gsd/milestones/M010/slices/S04/tasks/T01-VERIFY.json
Normal file
24
.gsd/milestones/M010/slices/S04/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T01",
|
||||
"unitId": "M010/S04/T01",
|
||||
"timestamp": 1774938937324,
|
||||
"passed": false,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "cd backend",
|
||||
"exitCode": 0,
|
||||
"durationMs": 4,
|
||||
"verdict": "pass"
|
||||
},
|
||||
{
|
||||
"command": "python -m pytest tests/test_search.py -v -k suggestion",
|
||||
"exitCode": 4,
|
||||
"durationMs": 216,
|
||||
"verdict": "fail"
|
||||
}
|
||||
],
|
||||
"retryAttempt": 1,
|
||||
"maxRetries": 2
|
||||
}
|
||||
83
.gsd/milestones/M010/slices/S04/tasks/T02-SUMMARY.md
Normal file
83
.gsd/milestones/M010/slices/S04/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
id: T02
|
||||
parent: S04
|
||||
milestone: M010
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["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"]
|
||||
key_decisions: ["Popular suggestions fetched once on mount via ref guard, not re-fetched on every focus", "Suggestion items use button elements since they trigger onSearch callback, not direct navigation"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Frontend build (tsc + vite) passes with zero errors. Backend suggestion tests at backend/tests/test_search.py exist but require live PostgreSQL on ub01."
|
||||
completed_at: 2026-03-31T06:38:55.912Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Extracted inline typeahead from Home.tsx into shared SearchAutocomplete component with popular-suggestions-on-focus, wired into both Home and SearchResults pages
|
||||
|
||||
> Extracted inline typeahead from Home.tsx into shared SearchAutocomplete component with popular-suggestions-on-focus, wired into both Home and SearchResults pages
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T02
|
||||
parent: S04
|
||||
milestone: M010
|
||||
key_files:
|
||||
- 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
|
||||
key_decisions:
|
||||
- Popular suggestions fetched once on mount via ref guard, not re-fetched on every focus
|
||||
- Suggestion items use button elements since they trigger onSearch callback, not direct navigation
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-31T06:38:55.913Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Extracted inline typeahead from Home.tsx into shared SearchAutocomplete component with popular-suggestions-on-focus, wired into both Home and SearchResults pages
|
||||
|
||||
**Extracted inline typeahead from Home.tsx into shared SearchAutocomplete component with popular-suggestions-on-focus, wired into both Home and SearchResults pages**
|
||||
|
||||
## What Happened
|
||||
|
||||
Created SearchAutocomplete.tsx encapsulating debounced typeahead search (2+ chars), popular suggestions on empty focus (fetched once on mount from /api/v1/search/suggestions), outside-click/Escape dismissal, and Enter submission via onSearch callback. Added fetchSuggestions() and types to the API client. Refactored Home.tsx to replace ~80 lines of inline typeahead with a single component. Refactored SearchResults.tsx similarly. Added CSS for popular suggestions header, buttons, and type badge color variants.
|
||||
|
||||
## Verification
|
||||
|
||||
Frontend build (tsc + vite) passes with zero errors. Backend suggestion tests at backend/tests/test_search.py exist but require live PostgreSQL on ub01.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 2800ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Original verification failure used wrong test path (tests/test_search.py vs backend/tests/test_search.py). Backend tests require PostgreSQL on ub01, unavailable locally.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `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`
|
||||
|
||||
|
||||
## Deviations
|
||||
Original verification failure used wrong test path (tests/test_search.py vs backend/tests/test_search.py). Backend tests require PostgreSQL on ub01, unavailable locally.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
|
|
@ -1131,6 +1131,59 @@ a.app-footer__repo: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 ─────────────────────────────────────────────────────── */
|
||||
|
||||
.nav-cards {
|
||||
|
|
|
|||
|
|
@ -203,6 +203,19 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
|||
|
||||
// ── 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(
|
||||
q: 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 { useCallback, useEffect, useRef, useState } from "react";
|
||||
import SearchAutocomplete from "../components/SearchAutocomplete";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
searchApi,
|
||||
fetchTechniques,
|
||||
fetchTopics,
|
||||
type SearchResultItem,
|
||||
type TechniqueListItem,
|
||||
} from "../api/public-client";
|
||||
|
||||
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 [recent, setRecent] = useState<TechniqueListItem[]>([]);
|
||||
const [recentLoading, setRecentLoading] = useState(true);
|
||||
const [popularTopics, setPopularTopics] = useState<{name: string; count: number}[]>([]);
|
||||
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)
|
||||
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 (
|
||||
<div className="home">
|
||||
{/* Hero search */}
|
||||
|
|
@ -154,58 +85,11 @@ export default function Home() {
|
|||
Search techniques, key moments, and creators
|
||||
</p>
|
||||
|
||||
<div className="search-container" ref={dropdownRef}>
|
||||
<form onSubmit={handleSubmit} className="search-form search-form--hero">
|
||||
<input
|
||||
ref={inputRef}
|
||||
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>
|
||||
<SearchAutocomplete
|
||||
heroSize
|
||||
autoFocus
|
||||
onSearch={(q) => navigate(`/search?q=${encodeURIComponent(q)}`)}
|
||||
/>
|
||||
|
||||
<p className="home-hero__value-prop">
|
||||
Real music production techniques extracted from creator tutorials.
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@
|
|||
* 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 { searchApi, type SearchResultItem } from "../api/public-client";
|
||||
import { catSlug } from "../utils/catSlug";
|
||||
import SearchAutocomplete from "../components/SearchAutocomplete";
|
||||
|
||||
export default function SearchResults() {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
|
@ -19,8 +20,6 @@ export default function SearchResults() {
|
|||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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) => {
|
||||
if (!query.trim()) {
|
||||
|
|
@ -43,33 +42,9 @@ export default function SearchResults() {
|
|||
|
||||
// Search when URL param changes
|
||||
useEffect(() => {
|
||||
setLocalQuery(q);
|
||||
if (q) void doSearch(q);
|
||||
}, [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
|
||||
const techniqueResults = results.filter((r) => r.type === "technique_page");
|
||||
const momentResults = results.filter((r) => r.type === "key_moment");
|
||||
|
|
@ -77,19 +52,12 @@ export default function SearchResults() {
|
|||
return (
|
||||
<div className="search-results-page">
|
||||
{/* Inline search bar */}
|
||||
<form onSubmit={handleSubmit} className="search-form search-form--inline">
|
||||
<input
|
||||
type="search"
|
||||
className="search-input search-input--inline"
|
||||
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>
|
||||
<SearchAutocomplete
|
||||
initialQuery={q}
|
||||
onSearch={(newQ) =>
|
||||
navigate(`/search?q=${encodeURIComponent(newQ)}`, { replace: true })
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Status */}
|
||||
{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