Initial MVP: full-stack music discovery app
Backend (FastAPI): - User auth with email/password and Spotify OAuth - Spotify playlist import with audio feature extraction - AI recommendation engine using Claude API with taste profiling - Save/bookmark recommendations - Rate limiting for free tier (10 recs/day, 1 playlist) - PostgreSQL models with Alembic migrations - Redis-ready configuration Frontend (React 19 + TypeScript + Vite + Tailwind): - Landing page, auth flows (email + Spotify OAuth) - Dashboard with stats and quick discover - Playlist management and import from Spotify - Discover page with custom query support - Recommendation cards with explanations and save toggle - Taste profile visualization - Responsive layout with mobile navigation - PWA-ready configuration Infrastructure: - Docker Compose with PostgreSQL, Redis, backend, frontend - Environment-based configuration
This commit is contained in:
189
frontend/src/pages/Discover.tsx
Normal file
189
frontend/src/pages/Discover.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Compass, Sparkles, Loader2, ListMusic, Search } from 'lucide-react'
|
||||
import { useAuth } from '../lib/auth'
|
||||
import { getPlaylists, generateRecommendations, toggleSaveRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api'
|
||||
import RecommendationCard from '../components/RecommendationCard'
|
||||
|
||||
export default function Discover() {
|
||||
const { user } = useAuth()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [playlists, setPlaylists] = useState<PlaylistResponse[]>([])
|
||||
const [selectedPlaylist, setSelectedPlaylist] = useState<string>('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<RecommendationItem[]>([])
|
||||
const [remaining, setRemaining] = useState<number | null>(null)
|
||||
const [discovering, setDiscovering] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [savingIds, setSavingIds] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await getPlaylists()
|
||||
setPlaylists(data)
|
||||
const preselected = searchParams.get('playlist')
|
||||
if (preselected && data.some((p) => p.id === preselected)) {
|
||||
setSelectedPlaylist(preselected)
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [searchParams])
|
||||
|
||||
const handleDiscover = async () => {
|
||||
if (!selectedPlaylist && !query.trim()) return
|
||||
setDiscovering(true)
|
||||
setError('')
|
||||
setResults([])
|
||||
|
||||
try {
|
||||
const response = await generateRecommendations(
|
||||
selectedPlaylist || undefined,
|
||||
query.trim() || undefined
|
||||
)
|
||||
setResults(response.recommendations)
|
||||
setRemaining(response.remaining_today)
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err.response?.data?.detail || 'Failed to generate recommendations. Please try again.'
|
||||
)
|
||||
} finally {
|
||||
setDiscovering(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleSave = async (id: string) => {
|
||||
setSavingIds((prev) => new Set(prev).add(id))
|
||||
try {
|
||||
const { saved } = await toggleSaveRecommendation(id)
|
||||
setResults((prev) =>
|
||||
prev.map((r) => (r.id === id ? { ...r, saved } : r))
|
||||
)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setSavingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-purple animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-charcoal flex items-center gap-3">
|
||||
<Compass className="w-8 h-8 text-purple" />
|
||||
Discover
|
||||
</h1>
|
||||
<p className="text-charcoal-muted mt-1">
|
||||
Find new music based on your taste or a specific vibe
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Discovery Form */}
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6 space-y-5">
|
||||
{/* Playlist Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-2">
|
||||
<ListMusic className="w-4 h-4 inline mr-1.5" />
|
||||
Based on a playlist
|
||||
</label>
|
||||
<select
|
||||
value={selectedPlaylist}
|
||||
onChange={(e) => setSelectedPlaylist(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">Select a playlist (optional)</option>
|
||||
{playlists.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.track_count} tracks)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Custom Query */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-2">
|
||||
<Search className="w-4 h-4 inline mr-1.5" />
|
||||
Or describe what you want
|
||||
</label>
|
||||
<textarea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder='e.g., "Upbeat indie rock with jangly guitars" or "Dreamy synth-pop for late night drives"'
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remaining count */}
|
||||
{!user?.is_pro && (
|
||||
<p className="text-xs text-charcoal-muted flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3 text-amber-500" />
|
||||
{remaining !== null ? remaining : user?.daily_recommendations_remaining ?? 10} recommendations remaining today
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering || (!selectedPlaylist && !query.trim())}
|
||||
className="w-full py-3.5 bg-gradient-to-r from-purple to-purple-dark text-white font-semibold rounded-xl hover:shadow-lg hover:shadow-purple/25 transition-all disabled:opacity-50 cursor-pointer border-none text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
{discovering ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Discovering new music...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
Discover Music
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-charcoal mb-4">
|
||||
Your Recommendations
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{results.map((rec) => (
|
||||
<RecommendationCard
|
||||
key={rec.id}
|
||||
recommendation={rec}
|
||||
onToggleSave={handleToggleSave}
|
||||
saving={savingIds.has(rec.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user