Add Tier 2 features: Playlist Generator, Artist Deep Dive, Music Timeline
- Playlist Generator: describe a vibe, get a 15-30 song playlist, save or copy as text - Artist Deep Dive: click any artist name for influences, best album, hidden gems, similar artists - Music Timeline: visual decade breakdown of your taste with AI insight - Nav updates: Create Playlist, Timeline links
This commit is contained in:
224
frontend/src/pages/PlaylistGenerator.tsx
Normal file
224
frontend/src/pages/PlaylistGenerator.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useState } from 'react'
|
||||
import { ListMusic, Loader2, Save, Copy, Check, ExternalLink } from 'lucide-react'
|
||||
import { generatePlaylist, type GeneratedPlaylistResponse } from '../lib/api'
|
||||
|
||||
const COUNT_OPTIONS = [15, 20, 25, 30]
|
||||
|
||||
export default function PlaylistGenerator() {
|
||||
const [theme, setTheme] = useState('')
|
||||
const [count, setCount] = useState(25)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [result, setResult] = useState<GeneratedPlaylistResponse | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!theme.trim()) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setResult(null)
|
||||
setSaved(false)
|
||||
|
||||
try {
|
||||
const data = await generatePlaylist(theme.trim(), count)
|
||||
setResult(data)
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.detail || err.message || 'Unknown error'
|
||||
setError(`Error: ${msg}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!theme.trim() || saving || saved) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = await generatePlaylist(theme.trim(), count, true)
|
||||
setResult(data)
|
||||
setSaved(true)
|
||||
} catch (err: any) {
|
||||
setError('Failed to save playlist')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyText = () => {
|
||||
if (!result) return
|
||||
const text = result.tracks
|
||||
.map((t, i) => `${i + 1}. ${t.artist} - ${t.title}`)
|
||||
.join('\n')
|
||||
navigator.clipboard.writeText(`${result.name}\n\n${text}`)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple to-purple-700 flex items-center justify-center">
|
||||
<ListMusic className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-charcoal">Playlist Generator</h1>
|
||||
<p className="text-sm text-charcoal-muted">Describe a vibe and get a full playlist</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Section */}
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6 mb-6">
|
||||
<label className="block text-sm font-medium text-charcoal mb-2">
|
||||
What's the vibe?
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !loading && handleGenerate()}
|
||||
placeholder="Road trip through the desert, Rainy day reading, 90s nostalgia party..."
|
||||
className="w-full px-4 py-3 rounded-xl border border-purple-200 focus:border-purple focus:ring-2 focus:ring-purple/20 outline-none text-charcoal placeholder:text-charcoal-muted/50 bg-cream/30"
|
||||
/>
|
||||
|
||||
{/* Count Selector */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-charcoal mb-2">
|
||||
Number of tracks
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{COUNT_OPTIONS.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setCount(n)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors cursor-pointer border-none ${
|
||||
count === n
|
||||
? 'bg-purple text-white'
|
||||
: 'bg-purple-50 text-purple hover:bg-purple-100'
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!theme.trim() || loading}
|
||||
className="mt-5 w-full py-3 rounded-xl font-semibold text-white bg-gradient-to-r from-purple to-purple-700 hover:from-purple-700 hover:to-purple-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all cursor-pointer border-none text-base"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Crafting your playlist...
|
||||
</span>
|
||||
) : (
|
||||
'Generate Playlist'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6">
|
||||
{/* Playlist Header */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-charcoal">{result.name}</h2>
|
||||
<p className="text-charcoal-muted mt-1">{result.description}</p>
|
||||
<p className="text-xs text-charcoal-muted/60 mt-2">{result.tracks.length} tracks</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 mb-6">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || saved}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors cursor-pointer border-none ${
|
||||
saved
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-purple-50 text-purple hover:bg-purple-100'
|
||||
}`}
|
||||
>
|
||||
{saved ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
Saved
|
||||
</>
|
||||
) : saving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Save to My Playlists
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyText}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-purple-50 text-purple hover:bg-purple-100 transition-colors cursor-pointer border-none"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4" />
|
||||
Copy as Text
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Track List */}
|
||||
<div className="space-y-1">
|
||||
{result.tracks.map((track, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-3 p-3 rounded-xl hover:bg-cream/50 transition-colors group"
|
||||
>
|
||||
<span className="text-sm font-mono text-charcoal-muted/50 w-6 text-right pt-0.5 shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-charcoal truncate">{track.title}</span>
|
||||
<span className="text-charcoal-muted">—</span>
|
||||
<span className="text-charcoal-muted truncate">{track.artist}</span>
|
||||
{track.youtube_url && (
|
||||
<a
|
||||
href={track.youtube_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
title="Search on YouTube Music"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 text-purple" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-charcoal-muted/70 mt-0.5">{track.reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user