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:
jlightner 2026-03-31 06:39:01 +00:00
parent 9107323a66
commit d0bdc6f516
6 changed files with 307 additions and 164 deletions

View file

@ -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 {

View file

@ -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,

View 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 &ldquo;{query}&rdquo;
</Link>
</>
)}
</div>
)}
</div>
);
}

View file

@ -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.

View file

@ -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>}

View file

@ -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"}