Files
vynl/frontend/src/pages/PlaylistGenerator.tsx

236 lines
8.3 KiB
TypeScript

import { useState, useEffect } 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>(() => {
try {
const saved = sessionStorage.getItem('vynl_playlist_gen_results')
return saved ? JSON.parse(saved) : null
} catch { return null }
})
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (result) {
sessionStorage.setItem('vynl_playlist_gen_results', JSON.stringify(result))
}
}, [result])
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>
)
}