Add mood scanner and surprise me features to discover page
Add mood_energy and mood_valence sliders that inject mood context into AI recommendation prompts. Add "Surprise Me" button that generates recommendations from a creative, unexpected angle without requiring any user input. Includes backend endpoints, schema updates, and full frontend UI integration.
This commit is contained in:
@@ -177,6 +177,8 @@ export const generateRecommendations = (
|
||||
adventurousness?: number,
|
||||
exclude?: string,
|
||||
count?: number,
|
||||
moodEnergy?: number,
|
||||
moodValence?: number,
|
||||
) =>
|
||||
api.post<RecommendationResponse>('/recommendations/generate', {
|
||||
playlist_id: playlistId,
|
||||
@@ -186,8 +188,13 @@ export const generateRecommendations = (
|
||||
adventurousness: adventurousness ?? 3,
|
||||
exclude: exclude || undefined,
|
||||
count: count ?? 5,
|
||||
mood_energy: moodEnergy || undefined,
|
||||
mood_valence: moodValence || undefined,
|
||||
}).then((r) => r.data)
|
||||
|
||||
export const surpriseMe = () =>
|
||||
api.post<RecommendationResponse>('/recommendations/surprise').then((r) => r.data)
|
||||
|
||||
export const getRecommendationHistory = () =>
|
||||
api.get<RecommendationItem[]>('/recommendations/history').then((r) => r.data)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { Compass, Sparkles, Loader2, ListMusic, Search, Users, Clock, Disc3, TrendingUp, Shuffle, X } from 'lucide-react'
|
||||
import { useAuth } from '../lib/auth'
|
||||
import { getPlaylists, generateRecommendations, toggleSaveRecommendation, dislikeRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api'
|
||||
import { getPlaylists, generateRecommendations, surpriseMe, toggleSaveRecommendation, dislikeRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api'
|
||||
import RecommendationCard from '../components/RecommendationCard'
|
||||
|
||||
const DISCOVERY_MODES = [
|
||||
@@ -39,6 +39,10 @@ export default function Discover() {
|
||||
const [adventurousness, setAdventurousness] = useState(3)
|
||||
const [excludeGenres, setExcludeGenres] = useState('')
|
||||
const [count, setCount] = useState(5)
|
||||
const [moodEnergy, setMoodEnergy] = useState<number | null>(null)
|
||||
const [moodValence, setMoodValence] = useState<number | null>(null)
|
||||
const [showMood, setShowMood] = useState(false)
|
||||
const [surprising, setSurprising] = useState(false)
|
||||
const resultsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,6 +83,8 @@ export default function Discover() {
|
||||
adventurousness,
|
||||
excludeGenres.trim() || undefined,
|
||||
count,
|
||||
moodEnergy ?? undefined,
|
||||
moodValence ?? undefined,
|
||||
)
|
||||
const recs = response.recommendations || []
|
||||
setResults(recs)
|
||||
@@ -136,6 +142,30 @@ export default function Discover() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSurprise = async () => {
|
||||
setSurprising(true)
|
||||
setError('')
|
||||
setResults([])
|
||||
|
||||
try {
|
||||
const response = await surpriseMe()
|
||||
const recs = response.recommendations || []
|
||||
setResults(recs)
|
||||
setRemaining(response.remaining_this_week ?? null)
|
||||
if (recs.length === 0) {
|
||||
setError('No surprise recommendations returned.')
|
||||
}
|
||||
if (recs.length > 0) {
|
||||
setTimeout(() => resultsRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.detail || err.message || 'Unknown error'
|
||||
setError(`Error: ${msg} (status: ${err.response?.status || 'none'})`)
|
||||
} finally {
|
||||
setSurprising(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -156,6 +186,25 @@ export default function Discover() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Surprise Me */}
|
||||
<button
|
||||
onClick={handleSurprise}
|
||||
disabled={surprising || discovering}
|
||||
className="w-full py-4 bg-gradient-to-r from-purple via-purple-dark to-purple text-white font-semibold rounded-2xl hover:shadow-xl hover:shadow-purple/30 transition-all disabled:opacity-50 cursor-pointer border-none text-base flex items-center justify-center gap-3"
|
||||
>
|
||||
{surprising ? (
|
||||
<>
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
Cooking up a surprise...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shuffle className="w-6 h-6" />
|
||||
Surprise Me
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Discovery Modes */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DISCOVERY_MODES.map(({ id, label, icon: Icon, description }) => (
|
||||
@@ -238,6 +287,86 @@ export default function Discover() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mood Scanner */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMood(!showMood)
|
||||
if (showMood) {
|
||||
setMoodEnergy(null)
|
||||
setMoodValence(null)
|
||||
}
|
||||
}}
|
||||
className="text-sm font-medium text-charcoal hover:text-purple transition-colors cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
{showMood ? 'Hide mood' : 'Set mood'}
|
||||
<span className="text-charcoal-muted text-xs font-normal">(optional)</span>
|
||||
</button>
|
||||
|
||||
{showMood && (
|
||||
<div className="mt-3 space-y-4 p-4 bg-cream/30 rounded-xl border border-purple-100/50">
|
||||
{/* Energy Slider */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-charcoal">Energy</label>
|
||||
{moodEnergy !== null && (
|
||||
<button
|
||||
onClick={() => setMoodEnergy(null)}
|
||||
className="text-xs text-charcoal-muted hover:text-purple transition-colors cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
step={1}
|
||||
value={moodEnergy ?? 3}
|
||||
onChange={(e) => setMoodEnergy(Number(e.target.value))}
|
||||
className={`w-full h-2 rounded-full appearance-none cursor-pointer accent-purple ${moodEnergy !== null ? 'bg-purple-100' : 'bg-gray-200 opacity-50'}`}
|
||||
/>
|
||||
<div className="flex justify-between mt-1.5 px-0.5">
|
||||
<span className="text-xs text-charcoal-muted">Chill</span>
|
||||
<span className="text-xs text-charcoal-muted">Energetic</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mood/Valence Slider */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-charcoal">Mood</label>
|
||||
{moodValence !== null && (
|
||||
<button
|
||||
onClick={() => setMoodValence(null)}
|
||||
className="text-xs text-charcoal-muted hover:text-purple transition-colors cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
step={1}
|
||||
value={moodValence ?? 3}
|
||||
onChange={(e) => setMoodValence(Number(e.target.value))}
|
||||
className={`w-full h-2 rounded-full appearance-none cursor-pointer accent-purple ${moodValence !== null ? 'bg-purple-100' : 'bg-gray-200 opacity-50'}`}
|
||||
/>
|
||||
<div className="flex justify-between mt-1.5 px-0.5">
|
||||
<span className="text-xs text-charcoal-muted">Dark</span>
|
||||
<span className="text-xs text-charcoal-muted">Happy</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recommendation Count */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-3">
|
||||
@@ -291,7 +420,7 @@ export default function Discover() {
|
||||
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering || (!selectedPlaylist && !query.trim())}
|
||||
disabled={discovering || surprising || (!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 ? (
|
||||
|
||||
Reference in New Issue
Block a user