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:
root
2026-03-31 18:50:23 -05:00
parent 0b82149b97
commit 7abec6de7c
10 changed files with 1102 additions and 4 deletions

View File

@@ -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>

View File

@@ -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 },

View File

@@ -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"> &middot; {recommendation.album}</span>
)}

View File

@@ -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 }

View 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>
)
}

View 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>
)
}

View 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>
)
}