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
155 lines
5.1 KiB
TypeScript
155 lines
5.1 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { Loader2, Clock, Heart, Sparkles } from 'lucide-react'
|
|
import { useAuth } from '../lib/auth'
|
|
import { getRecommendationHistory, getSavedRecommendations, toggleSaveRecommendation, type RecommendationItem } from '../lib/api'
|
|
import RecommendationCard from '../components/RecommendationCard'
|
|
|
|
type Tab = 'saved' | 'history'
|
|
|
|
export default function Recommendations() {
|
|
const { user } = useAuth()
|
|
const [tab, setTab] = useState<Tab>('saved')
|
|
const [saved, setSaved] = useState<RecommendationItem[]>([])
|
|
const [history, setHistory] = useState<RecommendationItem[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [savingIds, setSavingIds] = useState<Set<string>>(new Set())
|
|
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
try {
|
|
const [savedData, historyData] = await Promise.all([
|
|
getSavedRecommendations(),
|
|
getRecommendationHistory(),
|
|
])
|
|
setSaved(savedData)
|
|
setHistory(historyData)
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
load()
|
|
}, [])
|
|
|
|
const handleToggleSave = async (id: string) => {
|
|
setSavingIds((prev) => new Set(prev).add(id))
|
|
try {
|
|
const { saved: isSaved } = await toggleSaveRecommendation(id)
|
|
const updater = (items: RecommendationItem[]) =>
|
|
items.map((r) => (r.id === id ? { ...r, saved: isSaved } : r))
|
|
setSaved(updater)
|
|
setHistory(updater)
|
|
|
|
// If unsaved, remove from saved tab
|
|
if (!isSaved) {
|
|
setSaved((prev) => prev.filter((r) => r.id !== id))
|
|
}
|
|
// If saved, add to saved tab if not already there
|
|
if (isSaved) {
|
|
const item = history.find((r) => r.id === id)
|
|
if (item) {
|
|
setSaved((prev) =>
|
|
prev.some((r) => r.id === id) ? prev : [...prev, { ...item, saved: true }]
|
|
)
|
|
}
|
|
}
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
setSavingIds((prev) => {
|
|
const next = new Set(prev)
|
|
next.delete(id)
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
const items = tab === 'saved' ? saved : history
|
|
|
|
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">Recommendations</h1>
|
|
<p className="text-charcoal-muted mt-1">Your discovered music</p>
|
|
</div>
|
|
|
|
{/* Daily remaining */}
|
|
{!user?.is_pro && (
|
|
<div className="flex items-center gap-2 px-4 py-3 bg-amber-50 border border-amber-200 rounded-xl text-sm">
|
|
<Sparkles className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
|
<span className="text-amber-700">
|
|
<strong>{user?.daily_recommendations_remaining ?? 0}</strong> recommendations remaining today (free tier)
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 bg-purple-50 p-1 rounded-xl w-fit">
|
|
<button
|
|
onClick={() => setTab('saved')}
|
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors cursor-pointer border-none ${
|
|
tab === 'saved'
|
|
? 'bg-white text-purple shadow-sm'
|
|
: 'bg-transparent text-charcoal-muted hover:text-charcoal'
|
|
}`}
|
|
>
|
|
<Heart className="w-4 h-4" />
|
|
Saved ({saved.length})
|
|
</button>
|
|
<button
|
|
onClick={() => setTab('history')}
|
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors cursor-pointer border-none ${
|
|
tab === 'history'
|
|
? 'bg-white text-purple shadow-sm'
|
|
: 'bg-transparent text-charcoal-muted hover:text-charcoal'
|
|
}`}
|
|
>
|
|
<Clock className="w-4 h-4" />
|
|
History ({history.length})
|
|
</button>
|
|
</div>
|
|
|
|
{/* List */}
|
|
{items.length === 0 ? (
|
|
<div className="bg-white rounded-2xl border border-purple-100 p-12 text-center">
|
|
<div className="w-16 h-16 rounded-2xl bg-purple-50 flex items-center justify-center mx-auto mb-4">
|
|
{tab === 'saved' ? (
|
|
<Heart className="w-8 h-8 text-purple" />
|
|
) : (
|
|
<Clock className="w-8 h-8 text-purple" />
|
|
)}
|
|
</div>
|
|
<h2 className="text-xl font-semibold text-charcoal mb-2">
|
|
{tab === 'saved' ? 'No saved recommendations' : 'No recommendations yet'}
|
|
</h2>
|
|
<p className="text-charcoal-muted max-w-md mx-auto">
|
|
{tab === 'saved'
|
|
? 'Tap the heart icon on recommendations to save them here'
|
|
: 'Head to the Discover page to get your first recommendations'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{items.map((rec) => (
|
|
<RecommendationCard
|
|
key={rec.id}
|
|
recommendation={rec}
|
|
onToggleSave={handleToggleSave}
|
|
saving={savingIds.has(rec.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|