333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import { useSearchParams } from 'react-router-dom'
|
|
import { Compass, Sparkles, Loader2, ListMusic, Search, Users, Clock, Disc3, TrendingUp } from 'lucide-react'
|
|
import { useAuth } from '../lib/auth'
|
|
import { getPlaylists, generateRecommendations, toggleSaveRecommendation, dislikeRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api'
|
|
import RecommendationCard from '../components/RecommendationCard'
|
|
|
|
const DISCOVERY_MODES = [
|
|
{ id: 'discover', label: 'Discover', icon: Compass, description: 'General recommendations' },
|
|
{ id: 'sonic_twin', label: 'Sonic Twin', icon: Users, description: 'Underground artists who sound like your favorites' },
|
|
{ id: 'era_bridge', label: 'Era Bridge', icon: Clock, description: 'Classic artists who inspired your favorites' },
|
|
{ id: 'deep_cuts', label: 'Deep Cuts', icon: Disc3, description: 'B-sides and rarities from artists you know' },
|
|
{ id: 'rising', label: 'Rising', icon: TrendingUp, description: 'Under 50K listeners who fit your profile' },
|
|
] as const
|
|
|
|
const ADVENTUROUSNESS_LABELS: Record<number, string> = {
|
|
1: 'Safe',
|
|
2: 'Familiar',
|
|
3: 'Balanced',
|
|
4: 'Exploring',
|
|
5: 'Adventurous',
|
|
}
|
|
|
|
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 [bandcampMode, setBandcampMode] = useState(false)
|
|
const [savingIds, setSavingIds] = useState<Set<string>>(new Set())
|
|
const [dislikingIds, setDislikingIds] = useState<Set<string>>(new Set())
|
|
const [mode, setMode] = useState('discover')
|
|
const [adventurousness, setAdventurousness] = useState(3)
|
|
const [excludeGenres, setExcludeGenres] = useState('')
|
|
const [count, setCount] = useState(5)
|
|
const resultsRef = useRef<HTMLDivElement>(null)
|
|
|
|
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,
|
|
bandcampMode,
|
|
mode,
|
|
adventurousness,
|
|
excludeGenres.trim() || undefined,
|
|
count,
|
|
)
|
|
const recs = response.recommendations || []
|
|
setResults(recs)
|
|
setRemaining(response.remaining_this_week ?? null)
|
|
// Scroll to results after render
|
|
if (recs.length > 0) {
|
|
setTimeout(() => resultsRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
|
|
}
|
|
} catch (err: any) {
|
|
setError(
|
|
err.response?.data?.detail || 'Failed to generate recommendations. Please try again.'
|
|
)
|
|
} finally {
|
|
setDiscovering(false)
|
|
}
|
|
}
|
|
|
|
const handleToggleSave = async (id: string) => {
|
|
const sid = String(id)
|
|
setSavingIds((prev) => new Set(prev).add(sid))
|
|
try {
|
|
const { saved } = await toggleSaveRecommendation(sid)
|
|
setResults((prev) =>
|
|
prev.map((r) => (String(r.id) === sid ? { ...r, saved } : r))
|
|
)
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
setSavingIds((prev) => {
|
|
const next = new Set(prev)
|
|
next.delete(sid)
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleDislike = async (id: string) => {
|
|
const sid = String(id)
|
|
setDislikingIds((prev) => new Set(prev).add(sid))
|
|
try {
|
|
const { disliked } = await dislikeRecommendation(sid)
|
|
setResults((prev) =>
|
|
prev.map((r) => (String(r.id) === sid ? { ...r, disliked } : r))
|
|
)
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
setDislikingIds((prev) => {
|
|
const next = new Set(prev)
|
|
next.delete(sid)
|
|
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-2xl sm: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 Modes */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{DISCOVERY_MODES.map(({ id, label, icon: Icon, description }) => (
|
|
<button
|
|
key={id}
|
|
onClick={() => setMode(id)}
|
|
className={`inline-flex items-center gap-1.5 px-3 py-2 sm:px-4 sm:py-2.5 rounded-full text-xs sm:text-sm font-medium transition-all cursor-pointer border ${
|
|
mode === id
|
|
? 'bg-purple text-white border-purple shadow-md shadow-purple/20'
|
|
: 'bg-white text-charcoal-muted border-purple-100 hover:border-purple/30 hover:text-charcoal'
|
|
}`}
|
|
title={description}
|
|
>
|
|
<Icon className="w-4 h-4" />
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Discovery Form */}
|
|
<div className="bg-white rounded-2xl border border-purple-100 p-4 sm:p-6 space-y-4 sm: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", "Dreamy synth-pop for late night drives", or just type artists/songs like "Radiohead, Tame Impala"'
|
|
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>
|
|
|
|
{/* Discovery Dial */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-charcoal mb-3">
|
|
Discovery dial
|
|
<span className="ml-2 text-charcoal-muted font-normal">
|
|
— {ADVENTUROUSNESS_LABELS[adventurousness]}
|
|
</span>
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type="range"
|
|
min={1}
|
|
max={5}
|
|
step={1}
|
|
value={adventurousness}
|
|
onChange={(e) => setAdventurousness(Number(e.target.value))}
|
|
className="w-full h-2 bg-purple-100 rounded-full appearance-none cursor-pointer accent-purple"
|
|
/>
|
|
<div className="flex justify-between mt-1.5 px-0.5">
|
|
<span className="text-xs text-charcoal-muted">Safe</span>
|
|
<span className="text-xs text-charcoal-muted">Balanced</span>
|
|
<span className="text-xs text-charcoal-muted">Adventurous</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recommendation Count */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-charcoal mb-3">
|
|
How many recommendations
|
|
</label>
|
|
<div className="flex gap-2">
|
|
{[5, 10, 15, 20].map((n) => (
|
|
<button
|
|
key={n}
|
|
onClick={() => setCount(n)}
|
|
className={`px-4 py-2 rounded-full text-sm font-medium transition-all cursor-pointer border ${
|
|
count === n
|
|
? 'bg-purple text-white border-purple shadow-md shadow-purple/20'
|
|
: 'bg-white text-charcoal-muted border-purple-100 hover:border-purple/30 hover:text-charcoal'
|
|
}`}
|
|
>
|
|
{n}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Block Genres */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-charcoal mb-2">
|
|
Exclude genres / moods
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={excludeGenres}
|
|
onChange={(e) => setExcludeGenres(e.target.value)}
|
|
placeholder="country, sad songs, metal"
|
|
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"
|
|
/>
|
|
</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 ?? 5} discoveries remaining this week
|
|
</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 ref={resultsRef}>
|
|
<h2 className="text-lg font-semibold text-charcoal mb-4">
|
|
Your Recommendations ({results.length})
|
|
</h2>
|
|
<div className="space-y-3">
|
|
{results.map((rec) => (
|
|
<RecommendationCard
|
|
key={rec.id}
|
|
recommendation={rec}
|
|
onToggleSave={handleToggleSave}
|
|
onDislike={handleDislike}
|
|
saving={savingIds.has(String(rec.id))}
|
|
disliking={dislikingIds.has(String(rec.id))}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!discovering && results.length === 0 && !error && (selectedPlaylist || query.trim()) && (
|
|
<p className="text-charcoal-muted text-sm text-center py-4">
|
|
Click "Discover Music" to get recommendations
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|