From 8dd7ac428950c82ead2fe8193ebba8190cd32676 Mon Sep 17 00:00:00 2001 From: RawGroundBeef Date: Sat, 14 Feb 2026 09:53:55 -0500 Subject: [PATCH] feat(search): add search functionality for tracks and artists - Add SearchBar component with autocomplete dropdown - Add SearchPage with full search results and filters - Support searching tracks by title, artist, genre - Support searching artists by username, display name - Add tabs to filter between tracks/artists/all - Navigate to track/artist pages on selection - Press Enter to view full search results page --- web/src/App.tsx | 3 + web/src/components/SearchBar.tsx | 276 ++++++++++++++++++++++++ web/src/layouts/RadioLayout.tsx | 2 + web/src/pages/SearchPage.tsx | 353 +++++++++++++++++++++++++++++++ 4 files changed, 634 insertions(+) create mode 100644 web/src/components/SearchBar.tsx create mode 100644 web/src/pages/SearchPage.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 589412e..405e9b5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import { TrackPage, WalletTrackPage, LegacyTrackRedirect } from './pages/TrackPa import { RoyaltiesPage } from './pages/RoyaltiesPage' import { NotFoundPage } from './pages/NotFoundPage' import { FavoritesPage } from './pages/FavoritesPage' +import { SearchPage } from './pages/SearchPage' export default function App() { return ( @@ -21,6 +22,8 @@ export default function App() { } /> {/* Favorites page */} } /> + {/* Search page */} + } /> {/* Wallet-based track page (must be before wallet profile) */} } /> {/* Wallet lookup (no username) */} diff --git a/web/src/components/SearchBar.tsx b/web/src/components/SearchBar.tsx new file mode 100644 index 0000000..15ad728 --- /dev/null +++ b/web/src/components/SearchBar.tsx @@ -0,0 +1,276 @@ +import { useState, useEffect, useRef } from 'react' +import { useNavigate } from 'react-router' +import { API_URL } from '../lib/constants' + +interface SearchResult { + type: 'track' | 'artist' + id: string + title?: string + slug?: string + username?: string + displayName?: string + wallet?: string + coverUrl?: string | null + avatarUrl?: string | null + genre?: string +} + +export function SearchBar() { + const navigate = useNavigate() + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isOpen, setIsOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const inputRef = useRef(null) + const containerRef = useRef(null) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + // Search API call + useEffect(() => { + if (!query.trim()) { + setResults([]) + return + } + + const timeoutId = setTimeout(async () => { + setIsLoading(true) + try { + const res = await fetch(`${API_URL}/api/search?q=${encodeURIComponent(query)}&limit=8`) + if (res.ok) { + const data = await res.json() + setResults(data.results || []) + setIsOpen(true) + } + } catch (e) { + console.error('Search failed:', e) + } finally { + setIsLoading(false) + } + }, 150) // Debounce 150ms + + return () => clearTimeout(timeoutId) + }, [query]) + + const handleSelect = (result: SearchResult) => { + setIsOpen(false) + setQuery('') + if (result.type === 'track') { + navigate(`/${result.username}/${result.slug}`) + } else { + navigate(`/${result.username}`) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && query.trim()) { + navigate(`/search?q=${encodeURIComponent(query)}`) + setIsOpen(false) + setQuery('') + } + } + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => query.trim() && setIsOpen(true)} + placeholder="Search tracks & artists..." + style={{ + width: '200px', + padding: '6px 12px 6px 32px', + borderRadius: '20px', + border: '1px solid var(--border)', + background: 'var(--bg-card)', + color: 'var(--text-primary)', + fontSize: '13px', + outline: 'none', + }} + /> + + + + + {isLoading && ( +
+
+
+ )} +
+ + {/* Dropdown results */} + {isOpen && results.length > 0 && ( +
+ {results.map((result) => ( + + ))} +
+ )} + + +
+ ) +} diff --git a/web/src/layouts/RadioLayout.tsx b/web/src/layouts/RadioLayout.tsx index 3f262ab..30a0160 100644 --- a/web/src/layouts/RadioLayout.tsx +++ b/web/src/layouts/RadioLayout.tsx @@ -9,6 +9,7 @@ import { ReconnectingIndicator } from '../components/ReconnectingIndicator' import { WalletButton } from '../components/WalletButton' import { ConfettiCelebration } from '../components/ConfettiCelebration' import { WhatIsThisModal } from '../components/WhatIsThisModal' +import { SearchBar } from '../components/SearchBar' import { Toaster } from 'sonner' export function RadioLayout() { @@ -55,6 +56,7 @@ export function RadioLayout() {
+ + ))} +
+ )} + + {/* Loading */} + {isLoading && ( +
+
+

Searching...

+ +
+ )} + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Results */} + {!isLoading && !error && filteredResults.length === 0 && query && ( +
+
🔍
+

+ No results found +

+

+ Try searching for a different track title, artist name, or genre +

+
+ )} + + {/* Results grid */} + {!isLoading && filteredResults.length > 0 && ( +
+ {filteredResults.map((result) => ( + { + e.currentTarget.style.background = 'var(--bg-card-hover)' + e.currentTarget.style.transform = 'translateY(-2px)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'var(--bg-card)' + e.currentTarget.style.transform = 'translateY(0)' + }} + > + {/* Image */} + {result.type === 'track' ? ( + result.coverUrl ? ( + + ) : ( +
+ 🎵 +
+ ) + ) : result.avatarUrl ? ( + + ) : ( +
+ 👤 +
+ )} + + {/* Info */} +
+
+ {result.type === 'track' ? result.title : result.displayName || result.username} +
+
+ {result.type === 'track' ? ( + <> + 🎵 Track + + {result.genre} + {result.plays !== undefined && ( + <> + + {result.plays.toLocaleString()} plays + + )} + + ) : ( + <> + 👤 Artist + + @{result.username} + {result.followerCount !== undefined && ( + <> + + {result.followerCount.toLocaleString()} followers + + )} + + )} +
+
+ + {/* Arrow */} + + + + + ))} +
+ )} +
+ ) +}