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:
@@ -15,6 +15,9 @@ import Analyze from './pages/Analyze'
|
||||
import BandcampDiscover from './pages/BandcampDiscover'
|
||||
import Admin from './pages/Admin'
|
||||
import SharedView from './pages/SharedView'
|
||||
import ArtistDive from './pages/ArtistDive'
|
||||
import PlaylistGenerator from './pages/PlaylistGenerator'
|
||||
import Timeline from './pages/Timeline'
|
||||
|
||||
function RootRedirect() {
|
||||
const { user, loading } = useAuth()
|
||||
@@ -136,6 +139,36 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/artist-dive"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<ArtistDive />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/generate-playlist"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<PlaylistGenerator />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/timeline"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Timeline />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/shared/:recId/:token" element={<SharedView />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Disc3, LayoutDashboard, Fingerprint, ListMusic, Compass, Lightbulb, Store, Heart, Crown, Shield, Menu, X, LogOut, User } from 'lucide-react'
|
||||
import { Disc3, LayoutDashboard, Fingerprint, Clock, ListMusic, ListPlus, Compass, Lightbulb, Store, Heart, Crown, Shield, Menu, X, LogOut, User } from 'lucide-react'
|
||||
import { useAuth } from '../lib/auth'
|
||||
|
||||
const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
|
||||
@@ -8,9 +8,11 @@ const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
|
||||
const baseNavItems = [
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/profile', label: 'My Taste', icon: Fingerprint },
|
||||
{ path: '/timeline', label: 'Timeline', icon: Clock },
|
||||
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||
{ path: '/discover', label: 'Discover', icon: Compass },
|
||||
{ path: '/analyze', label: 'Analyze', icon: Lightbulb },
|
||||
{ path: '/generate-playlist', label: 'Create Playlist', icon: ListPlus },
|
||||
{ path: '/bandcamp', label: 'Bandcamp', icon: Store },
|
||||
{ path: '/saved', label: 'Saved', icon: Heart },
|
||||
{ path: '/billing', label: 'Pro', icon: Crown },
|
||||
|
||||
@@ -59,7 +59,12 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
|
||||
{recommendation.title}
|
||||
</h3>
|
||||
<p className="text-charcoal-muted text-sm truncate">
|
||||
{recommendation.artist}
|
||||
<button
|
||||
onClick={() => navigate(`/artist-dive?artist=${encodeURIComponent(recommendation.artist)}`)}
|
||||
className="hover:text-purple hover:underline transition-colors cursor-pointer bg-transparent border-none p-0 font-inherit text-inherit text-sm"
|
||||
>
|
||||
{recommendation.artist}
|
||||
</button>
|
||||
{recommendation.album && (
|
||||
<span className="text-charcoal-muted/60"> · {recommendation.album}</span>
|
||||
)}
|
||||
|
||||
@@ -224,6 +224,42 @@ export interface AnalyzeResponse {
|
||||
export const analyzeSong = (artist: string, title: string) =>
|
||||
api.post<AnalyzeResponse>('/recommendations/analyze', { artist, title }).then((r) => r.data)
|
||||
|
||||
// Artist Deep Dive
|
||||
export interface ArtistDeepDiveResponse {
|
||||
artist: string
|
||||
summary: string
|
||||
why_they_matter: string
|
||||
influences: string[]
|
||||
influenced: string[]
|
||||
start_with: string
|
||||
start_with_reason: string
|
||||
deep_cut: string
|
||||
similar_artists: string[]
|
||||
genres: string[]
|
||||
}
|
||||
|
||||
export const artistDeepDive = (artist: string) =>
|
||||
api.post<ArtistDeepDiveResponse>('/recommendations/artist-dive', { artist }).then((r) => r.data)
|
||||
|
||||
// Playlist Generator
|
||||
export interface PlaylistTrackItem {
|
||||
title: string
|
||||
artist: string
|
||||
album: string | null
|
||||
reason: string
|
||||
youtube_url: string | null
|
||||
}
|
||||
|
||||
export interface GeneratedPlaylistResponse {
|
||||
name: string
|
||||
description: string
|
||||
tracks: PlaylistTrackItem[]
|
||||
playlist_id: number | null
|
||||
}
|
||||
|
||||
export const generatePlaylist = (theme: string, count: number = 25, save: boolean = false) =>
|
||||
api.post<GeneratedPlaylistResponse>('/recommendations/generate-playlist', { theme, count, save }).then((r) => r.data)
|
||||
|
||||
// YouTube Music Import
|
||||
export interface YouTubeTrackResult {
|
||||
title: string
|
||||
@@ -323,6 +359,24 @@ export const fixPlaylist = (playlistId: string) =>
|
||||
export const getTasteProfile = () =>
|
||||
api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data)
|
||||
|
||||
// Timeline
|
||||
export interface TimelineDecade {
|
||||
decade: string
|
||||
artists: string[]
|
||||
count: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
export interface TimelineResponse {
|
||||
decades: TimelineDecade[]
|
||||
total_artists: number
|
||||
dominant_era: string
|
||||
insight: string
|
||||
}
|
||||
|
||||
export const getTimeline = () =>
|
||||
api.get<TimelineResponse>('/profile/timeline').then((r) => r.data)
|
||||
|
||||
// Admin
|
||||
export interface AdminStats {
|
||||
users: { total: number; pro: number; free: number }
|
||||
|
||||
217
frontend/src/pages/ArtistDive.tsx
Normal file
217
frontend/src/pages/ArtistDive.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { Mic2, Disc3, Sparkles, Music, ExternalLink, ArrowRight, Quote } from 'lucide-react'
|
||||
import { artistDeepDive, type ArtistDeepDiveResponse } from '../lib/api'
|
||||
|
||||
export default function ArtistDive() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [artist, setArtist] = useState(searchParams.get('artist') || '')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<ArtistDeepDiveResponse | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleDive = async (artistName?: string) => {
|
||||
const name = artistName || artist
|
||||
if (!name.trim()) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setResult(null)
|
||||
try {
|
||||
const data = await artistDeepDive(name.trim())
|
||||
setResult(data)
|
||||
setArtist(name.trim())
|
||||
navigate(`/artist-dive?artist=${encodeURIComponent(name.trim())}`, { replace: true })
|
||||
} catch {
|
||||
setError('Failed to get artist deep dive. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const initialArtist = searchParams.get('artist')
|
||||
if (initialArtist) {
|
||||
setArtist(initialArtist)
|
||||
handleDive(initialArtist)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleArtistClick = (name: string) => {
|
||||
setArtist(name)
|
||||
handleDive(name)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="w-12 h-12 bg-purple/10 rounded-2xl flex items-center justify-center">
|
||||
<Mic2 className="w-6 h-6 text-purple" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-charcoal">Artist Deep Dive</h1>
|
||||
<p className="text-charcoal-muted text-sm">Explore any artist's story, influences, and legacy</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex gap-3 mb-8">
|
||||
<input
|
||||
type="text"
|
||||
value={artist}
|
||||
onChange={(e) => setArtist(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleDive()}
|
||||
placeholder="Enter an artist name..."
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-purple-100 bg-white text-charcoal placeholder:text-charcoal-muted/50 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleDive()}
|
||||
disabled={loading || !artist.trim()}
|
||||
className="px-6 py-3 bg-purple text-white font-semibold rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer border-none flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<Disc3 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-5 h-5" />
|
||||
)}
|
||||
Dive In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-xl mb-6">{error}</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<Disc3 className="w-12 h-12 text-purple animate-spin" />
|
||||
<p className="text-charcoal-muted">Diving deep into {artist}...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !loading && (
|
||||
<div className="bg-white rounded-2xl border border-purple-100 shadow-sm overflow-hidden">
|
||||
{/* Artist Header */}
|
||||
<div className="bg-gradient-to-r from-[#7C3AED] to-[#6D28D9] p-8">
|
||||
<h2 className="text-3xl font-bold text-white mb-3">{result.artist}</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.genres.map((genre) => (
|
||||
<span
|
||||
key={genre}
|
||||
className="px-3 py-1 bg-white/20 text-white text-sm rounded-full backdrop-blur-sm"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<p className="text-charcoal leading-relaxed text-base">{result.summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Why They Matter */}
|
||||
<div className="bg-[#FFF7ED] rounded-xl p-5 border-l-4 border-[#7C3AED]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Quote className="w-5 h-5 text-purple" />
|
||||
<h3 className="font-semibold text-charcoal">Why They Matter</h3>
|
||||
</div>
|
||||
<p className="text-charcoal/80 leading-relaxed italic">{result.why_they_matter}</p>
|
||||
</div>
|
||||
|
||||
{/* Start Here */}
|
||||
<div className="bg-purple-50 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Disc3 className="w-5 h-5 text-purple" />
|
||||
<h3 className="font-semibold text-charcoal">Start Here</h3>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-[#7C3AED] mb-1">{result.start_with}</p>
|
||||
<p className="text-charcoal-muted text-sm leading-relaxed">{result.start_with_reason}</p>
|
||||
</div>
|
||||
|
||||
{/* Hidden Gem */}
|
||||
<div className="bg-[#1C1917] rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Music className="w-5 h-5 text-[#7C3AED]" />
|
||||
<h3 className="font-semibold text-white">Hidden Gem</h3>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-[#FFF7ED]">{result.deep_cut}</p>
|
||||
</div>
|
||||
|
||||
{/* Influences */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-charcoal mb-3 flex items-center gap-2">
|
||||
<ArrowRight className="w-4 h-4 text-purple rotate-180" />
|
||||
Influenced By
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.influences.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => handleArtistClick(name)}
|
||||
className="px-3 py-1.5 bg-purple-50 text-[#7C3AED] text-sm rounded-full hover:bg-purple-100 transition-colors cursor-pointer border-none font-medium"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-charcoal mb-3 flex items-center gap-2">
|
||||
<ArrowRight className="w-4 h-4 text-purple" />
|
||||
Influenced
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.influenced.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => handleArtistClick(name)}
|
||||
className="px-3 py-1.5 bg-purple-50 text-[#7C3AED] text-sm rounded-full hover:bg-purple-100 transition-colors cursor-pointer border-none font-medium"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similar Artists */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-charcoal mb-3">Similar Artists</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.similar_artists.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => handleArtistClick(name)}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-50 to-purple-100 text-[#7C3AED] rounded-xl hover:from-purple-100 hover:to-purple-200 transition-colors cursor-pointer border border-purple-200 font-medium text-sm"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* YouTube Music Link */}
|
||||
<div className="pt-4 border-t border-purple-100">
|
||||
<a
|
||||
href={`https://music.youtube.com/search?q=${encodeURIComponent(result.artist)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-colors font-medium text-sm no-underline"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Listen on YouTube Music
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
171
frontend/src/pages/Timeline.tsx
Normal file
171
frontend/src/pages/Timeline.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Clock, Loader2, Sparkles, Users } from 'lucide-react'
|
||||
import { getTimeline, type TimelineResponse } from '../lib/api'
|
||||
|
||||
const decadeColors: Record<string, { bar: string; bg: string }> = {
|
||||
'1960s': { bar: 'bg-[#DDD6FE]', bg: 'bg-[#F5F3FF]' },
|
||||
'1970s': { bar: 'bg-[#C4ABFD]', bg: 'bg-[#F0EAFF]' },
|
||||
'1980s': { bar: 'bg-[#A78BFA]', bg: 'bg-[#EDE9FE]' },
|
||||
'1990s': { bar: 'bg-[#8B5CF6]', bg: 'bg-[#E8DFFE]' },
|
||||
'2000s': { bar: 'bg-[#7C3AED]', bg: 'bg-[#DDD6FE]' },
|
||||
'2010s': { bar: 'bg-[#6D28D9]', bg: 'bg-[#DDD6FE]' },
|
||||
'2020s': { bar: 'bg-[#5B21B6]', bg: 'bg-[#DDD6FE]' },
|
||||
}
|
||||
|
||||
export default function Timeline() {
|
||||
const [timeline, setTimeline] = useState<TimelineResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getTimeline()
|
||||
.then(setTimeline)
|
||||
.catch((err) => {
|
||||
const msg = err.response?.data?.detail || 'Failed to load timeline.'
|
||||
setError(msg)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<Loader2 className="w-8 h-8 text-purple animate-spin" />
|
||||
<p className="text-charcoal-muted text-sm">Analyzing your music across the decades...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !timeline) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<Clock className="w-12 h-12 text-purple-300 mx-auto mb-4" />
|
||||
<p className="text-charcoal-muted">{error || 'Could not load your music timeline.'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const maxPercentage = Math.max(...timeline.decades.map((d) => d.percentage), 1)
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-charcoal flex items-center gap-3">
|
||||
<Clock className="w-8 h-8 text-purple" />
|
||||
Your Music Timeline
|
||||
</h1>
|
||||
<p className="text-charcoal-muted mt-1">
|
||||
How your taste spans the decades, based on {timeline.total_artists} artist{timeline.total_artists !== 1 ? 's' : ''} in your library
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AI Insight */}
|
||||
<div className="bg-gradient-to-br from-purple to-purple-dark rounded-2xl p-6 text-white">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-white/15 flex items-center justify-center flex-shrink-0">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-purple-200 text-xs font-semibold uppercase tracking-wider mb-1">
|
||||
Timeline Insight
|
||||
</p>
|
||||
<p className="text-white text-base leading-relaxed">
|
||||
{timeline.insight}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dominant Era Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-purple text-white text-sm font-semibold">
|
||||
<Clock className="w-4 h-4" />
|
||||
Dominant Era: {timeline.dominant_era}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white border border-purple-100 text-charcoal text-sm font-medium">
|
||||
<Users className="w-4 h-4 text-purple" />
|
||||
{timeline.total_artists} artists analyzed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline Bar Chart */}
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6">
|
||||
<h2 className="text-lg font-semibold text-charcoal mb-6">Decades Breakdown</h2>
|
||||
|
||||
<div className="flex items-end gap-3 sm:gap-4 justify-center mb-6" style={{ height: '220px' }}>
|
||||
{timeline.decades.map((decade) => {
|
||||
const colors = decadeColors[decade.decade] || { bar: 'bg-purple', bg: 'bg-purple-50' }
|
||||
const barHeight = decade.percentage > 0
|
||||
? Math.max((decade.percentage / maxPercentage) * 100, 8)
|
||||
: 4
|
||||
const isDominant = decade.decade === timeline.dominant_era
|
||||
|
||||
return (
|
||||
<div key={decade.decade} className="flex flex-col items-center gap-2 flex-1 h-full justify-end">
|
||||
{/* Percentage label */}
|
||||
<span className={`text-xs font-bold ${decade.count > 0 ? 'text-charcoal' : 'text-charcoal-muted/40'}`}>
|
||||
{decade.percentage > 0 ? `${decade.percentage}%` : ''}
|
||||
</span>
|
||||
|
||||
{/* Bar */}
|
||||
<div
|
||||
className={`w-full rounded-t-lg transition-all duration-700 ease-out ${colors.bar} ${isDominant ? 'ring-2 ring-purple ring-offset-2' : ''}`}
|
||||
style={{ height: `${barHeight}%`, minHeight: decade.count > 0 ? '20px' : '4px' }}
|
||||
/>
|
||||
|
||||
{/* Decade label */}
|
||||
<span className={`text-xs font-semibold ${isDominant ? 'text-purple' : 'text-charcoal-muted'}`}>
|
||||
{decade.decade}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Artists by Decade */}
|
||||
<div className="space-y-3">
|
||||
{timeline.decades
|
||||
.filter((d) => d.count > 0)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((decade) => {
|
||||
const colors = decadeColors[decade.decade] || { bar: 'bg-purple', bg: 'bg-purple-50' }
|
||||
const isDominant = decade.decade === timeline.dominant_era
|
||||
|
||||
return (
|
||||
<div
|
||||
key={decade.decade}
|
||||
className={`bg-white rounded-xl border p-5 ${isDominant ? 'border-purple-300 ring-1 ring-purple-100' : 'border-purple-100'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`inline-block w-3 h-3 rounded-full ${colors.bar}`} />
|
||||
<h3 className="text-base font-semibold text-charcoal">{decade.decade}</h3>
|
||||
{isDominant && (
|
||||
<span className="text-xs font-semibold text-purple bg-purple-50 px-2 py-0.5 rounded-full">
|
||||
Dominant
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-charcoal-muted">
|
||||
{decade.count} artist{decade.count !== 1 ? 's' : ''} ({decade.percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{decade.artists.map((artist) => (
|
||||
<span
|
||||
key={artist}
|
||||
className={`text-sm px-3 py-1 rounded-full ${colors.bg} text-charcoal font-medium`}
|
||||
>
|
||||
{artist}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user