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:
211
frontend/src/pages/Playlists.tsx
Normal file
211
frontend/src/pages/Playlists.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ListMusic, Plus, Loader2, Music, ChevronRight, Download, X } from 'lucide-react'
|
||||
import { getPlaylists, getSpotifyPlaylists, importSpotifyPlaylist, type PlaylistResponse, type SpotifyPlaylistItem } from '../lib/api'
|
||||
|
||||
export default function Playlists() {
|
||||
const [playlists, setPlaylists] = useState<PlaylistResponse[]>([])
|
||||
const [spotifyPlaylists, setSpotifyPlaylists] = useState<SpotifyPlaylistItem[]>([])
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
const [importing, setImporting] = useState<string | null>(null)
|
||||
const [loadingSpotify, setLoadingSpotify] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadPlaylists()
|
||||
}, [])
|
||||
|
||||
const loadPlaylists = async () => {
|
||||
try {
|
||||
const data = await getPlaylists()
|
||||
setPlaylists(data)
|
||||
} catch {
|
||||
setError('Failed to load playlists')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openImportModal = async () => {
|
||||
setShowImport(true)
|
||||
setLoadingSpotify(true)
|
||||
try {
|
||||
const data = await getSpotifyPlaylists()
|
||||
setSpotifyPlaylists(data)
|
||||
} catch {
|
||||
setError('Failed to load Spotify playlists. Make sure your Spotify account is connected.')
|
||||
} finally {
|
||||
setLoadingSpotify(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async (playlistId: string) => {
|
||||
setImporting(playlistId)
|
||||
try {
|
||||
const imported = await importSpotifyPlaylist(playlistId)
|
||||
setPlaylists((prev) => [...prev, imported])
|
||||
setSpotifyPlaylists((prev) => prev.filter((p) => p.id !== playlistId))
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to import playlist')
|
||||
} finally {
|
||||
setImporting(null)
|
||||
}
|
||||
}
|
||||
|
||||
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 className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-charcoal">Playlists</h1>
|
||||
<p className="text-charcoal-muted mt-1">Manage your imported playlists</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openImportModal}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-purple text-white font-medium rounded-xl hover:bg-purple-dark transition-colors cursor-pointer border-none text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Import from Spotify
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playlist Grid */}
|
||||
{playlists.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">
|
||||
<ListMusic className="w-8 h-8 text-purple" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-charcoal mb-2">No playlists yet</h2>
|
||||
<p className="text-charcoal-muted mb-6 max-w-md mx-auto">
|
||||
Import your Spotify playlists to start getting personalized music recommendations
|
||||
</p>
|
||||
<button
|
||||
onClick={openImportModal}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-purple text-white font-medium rounded-xl hover:bg-purple-dark transition-colors cursor-pointer border-none text-sm"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Import your first playlist
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{playlists.map((playlist) => (
|
||||
<Link
|
||||
key={playlist.id}
|
||||
to={`/playlists/${playlist.id}`}
|
||||
className="bg-white rounded-2xl border border-purple-100 p-5 hover:shadow-md transition-shadow no-underline group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-purple-200 to-purple-400 flex-shrink-0 flex items-center justify-center overflow-hidden">
|
||||
{playlist.image_url ? (
|
||||
<img
|
||||
src={playlist.image_url}
|
||||
alt={playlist.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-7 h-7 text-white/80" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-charcoal truncate group-hover:text-purple transition-colors">
|
||||
{playlist.name}
|
||||
</h3>
|
||||
<p className="text-sm text-charcoal-muted mt-0.5">
|
||||
{playlist.track_count} tracks
|
||||
</p>
|
||||
<span className="inline-block mt-2 px-2 py-0.5 bg-purple-50 text-purple text-xs font-medium rounded-full">
|
||||
{playlist.source}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-charcoal-muted/30 group-hover:text-purple transition-colors flex-shrink-0 mt-1" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Modal */}
|
||||
{showImport && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-purple-100">
|
||||
<h2 className="text-lg font-semibold text-charcoal">Import from Spotify</h2>
|
||||
<button
|
||||
onClick={() => setShowImport(false)}
|
||||
className="p-1.5 rounded-lg hover:bg-purple-50 transition-colors cursor-pointer bg-transparent border-none"
|
||||
>
|
||||
<X className="w-5 h-5 text-charcoal-muted" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[60vh] p-4">
|
||||
{loadingSpotify ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-purple animate-spin" />
|
||||
</div>
|
||||
) : spotifyPlaylists.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-charcoal-muted">No playlists found on Spotify</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{spotifyPlaylists.map((sp) => (
|
||||
<div
|
||||
key={sp.id}
|
||||
className="flex items-center gap-3 p-3 rounded-xl hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-purple-200 to-purple-400 flex-shrink-0 flex items-center justify-center overflow-hidden">
|
||||
{sp.image_url ? (
|
||||
<img
|
||||
src={sp.image_url}
|
||||
alt={sp.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-5 h-5 text-white/80" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-charcoal text-sm truncate">{sp.name}</p>
|
||||
<p className="text-xs text-charcoal-muted">
|
||||
{sp.track_count} tracks · {sp.owner}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleImport(sp.id)}
|
||||
disabled={importing === sp.id}
|
||||
className="px-4 py-2 bg-purple text-white text-xs font-medium rounded-lg hover:bg-purple-dark transition-colors cursor-pointer border-none disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
{importing === sp.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-3 h-3" />
|
||||
)}
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user