Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -21,6 +22,8 @@ export default function App() {
<Route path="royalties" element={<RoyaltiesPage />} />
{/* Favorites page */}
<Route path="favorites" element={<FavoritesPage />} />
{/* Search page */}
<Route path="search" element={<SearchPage />} />
{/* Wallet-based track page (must be before wallet profile) */}
<Route path="w/:wallet/:trackSlug" element={<WalletTrackPage />} />
{/* Wallet lookup (no username) */}
Expand Down
276 changes: 276 additions & 0 deletions web/src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -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<SearchResult[]>([])
const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} style={{ position: 'relative' }}>
<div style={{ position: 'relative' }}>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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',
}}
/>
<svg
style={{
position: 'absolute',
left: '10px',
top: '50%',
transform: 'translateY(-50%)',
width: '14px',
height: '14px',
color: 'var(--text-muted)',
}}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
{isLoading && (
<div
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
}}
>
<div
style={{
width: '12px',
height: '12px',
border: '2px solid var(--border)',
borderTopColor: 'var(--accent)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
/>
</div>
)}
</div>

{/* Dropdown results */}
{isOpen && results.length > 0 && (
<div
style={{
position: 'absolute',
top: 'calc(100% + 8px)',
left: 0,
right: 0,
minWidth: '280px',
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '12px',
boxShadow: '0 4px 20px rgba(0,0,0,0.3)',
zIndex: 100,
maxHeight: '400px',
overflowY: 'auto',
}}
>
{results.map((result) => (
<button
key={`${result.type}-${result.id}`}
onClick={() => handleSelect(result)}
style={{
width: '100%',
padding: '10px 14px',
display: 'flex',
alignItems: 'center',
gap: '12px',
background: 'transparent',
border: 'none',
borderBottom: '1px solid var(--border-subtle)',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-card-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
}}
>
{/* Icon or image */}
{result.type === 'track' ? (
result.coverUrl ? (
<img
src={result.coverUrl}
alt=""
style={{
width: '36px',
height: '36px',
borderRadius: '4px',
objectFit: 'cover',
}}
/>
) : (
<div
style={{
width: '36px',
height: '36px',
borderRadius: '4px',
background: 'var(--bg-muted)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ fontSize: '16px' }}>🎵</span>
</div>
)
) : result.avatarUrl ? (
<img
src={result.avatarUrl}
alt=""
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
objectFit: 'cover',
}}
/>
) : (
<div
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
background: 'var(--bg-muted)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ fontSize: '16px' }}>👤</span>
</div>
)}

{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{result.type === 'track' ? result.title : result.displayName || result.username}
</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-secondary)',
}}
>
{result.type === 'track' ? `🎵 ${result.artist} • ${result.genre}` : `@${result.username}`}
</div>
</div>
</button>
))}
</div>
)}

<style>{`
@keyframes spin {
to { transform: translateY(-50%) rotate(360deg); }
}
`}</style>
</div>
)
}
2 changes: 2 additions & 0 deletions web/src/layouts/RadioLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -55,6 +56,7 @@ export function RadioLayout() {
</span>
</Link>
<div className="flex items-center" style={{ gap: '16px' }}>
<SearchBar />
<button
onClick={openModal}
className="transition-colors"
Expand Down
Loading