Add discovery modes, personalization controls, taste profile page, updated pricing
- Discovery modes: Sonic Twin, Era Bridge, Deep Cuts, Rising Artists - Discovery dial (Safe to Adventurous slider) - Block genres/moods exclusion - Thumbs down/dislike on recommendations - My Taste page with Genre DNA breakdown, audio feature meters, listening personality - Updated pricing: Free (5/week), Premium ($6.99/mo), Family ($12.99/mo coming soon) - Weekly rate limiting instead of daily - Alembic migration for new fields
This commit is contained in:
@@ -1,10 +1,26 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Compass, Sparkles, Loader2, ListMusic, Search } from 'lucide-react'
|
||||
import { Compass, Sparkles, Loader2, ListMusic, Search, Users, Clock, Disc3, TrendingUp } from 'lucide-react'
|
||||
import { useAuth } from '../lib/auth'
|
||||
import { getPlaylists, generateRecommendations, toggleSaveRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api'
|
||||
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()
|
||||
@@ -18,6 +34,10 @@ export default function Discover() {
|
||||
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('')
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -47,10 +67,13 @@ export default function Discover() {
|
||||
const response = await generateRecommendations(
|
||||
selectedPlaylist || undefined,
|
||||
query.trim() || undefined,
|
||||
bandcampMode
|
||||
bandcampMode,
|
||||
mode,
|
||||
adventurousness,
|
||||
excludeGenres.trim() || undefined,
|
||||
)
|
||||
setResults(response.recommendations)
|
||||
setRemaining(response.remaining_today)
|
||||
setRemaining(response.remaining_this_week)
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err.response?.data?.detail || 'Failed to generate recommendations. Please try again.'
|
||||
@@ -78,6 +101,24 @@ export default function Discover() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDislike = async (id: string) => {
|
||||
setDislikingIds((prev) => new Set(prev).add(id))
|
||||
try {
|
||||
const { disliked } = await dislikeRecommendation(id)
|
||||
setResults((prev) =>
|
||||
prev.map((r) => (r.id === id ? { ...r, disliked } : r))
|
||||
)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setDislikingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -98,6 +139,25 @@ export default function Discover() {
|
||||
</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-2 px-4 py-2.5 rounded-full 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-6 space-y-5">
|
||||
{/* Playlist Selector */}
|
||||
@@ -135,6 +195,46 @@ export default function Discover() {
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Bandcamp Mode Toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
@@ -160,7 +260,7 @@ export default function Discover() {
|
||||
{!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
|
||||
{remaining !== null ? remaining : user?.daily_recommendations_remaining ?? 5} discoveries remaining this week
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -201,7 +301,9 @@ export default function Discover() {
|
||||
key={rec.id}
|
||||
recommendation={rec}
|
||||
onToggleSave={handleToggleSave}
|
||||
onDislike={handleDislike}
|
||||
saving={savingIds.has(rec.id)}
|
||||
disliking={dislikingIds.has(rec.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user