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
|
- Estimate: 30m
|
||||||
- Files: backend/schemas.py, backend/routers/search.py, backend/tests/test_search.py
|
- 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
|
- 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:
|
Steps:
|
||||||
1. Add `fetchSuggestions` function to `frontend/src/api/public-client.ts` with types `SuggestionItem` and `SuggestionsResponse`.
|
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);
|
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