236 lines
8.3 KiB
TypeScript
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>
|
|
)
|
|
}
|