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

@@ -12,9 +12,11 @@ from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
from app.models.user import User from app.models.user import User
from app.models.playlist import Playlist
from app.models.track import Track
from app.models.recommendation import Recommendation from app.models.recommendation import Recommendation
from app.schemas.recommendation import RecommendationRequest, RecommendationResponse, RecommendationItem from app.schemas.recommendation import RecommendationRequest, RecommendationResponse, RecommendationItem
from app.services.recommender import generate_recommendations from app.services.recommender import generate_recommendations, build_taste_profile
router = APIRouter(prefix="/recommendations", tags=["recommendations"]) router = APIRouter(prefix="/recommendations", tags=["recommendations"])
@@ -172,6 +174,210 @@ Return ONLY the JSON object."""
) )
class ArtistDeepDiveRequest(BaseModel):
artist: str
class ArtistDeepDiveResponse(BaseModel):
artist: str
summary: str
why_they_matter: str
influences: list[str]
influenced: list[str]
start_with: str
start_with_reason: str
deep_cut: str
similar_artists: list[str]
genres: list[str]
@router.post("/artist-dive", response_model=ArtistDeepDiveResponse)
async def artist_deep_dive(
data: ArtistDeepDiveRequest,
user: User = Depends(get_current_user),
):
prompt = f"""You are Vynl, a music expert. Give a deep dive on this artist:
Artist: {data.artist}
Respond with a JSON object:
{{
"artist": "{data.artist}",
"summary": "2-3 sentences about who they are and their sound",
"why_they_matter": "Their cultural significance and impact on music",
"influences": ["artist1", "artist2", "artist3"],
"influenced": ["artist1", "artist2", "artist3"],
"start_with": "Album Name",
"start_with_reason": "Why this is the best entry point",
"deep_cut": "A hidden gem track title",
"similar_artists": ["artist1", "artist2", "artist3", "artist4", "artist5"],
"genres": ["genre1", "genre2"]
}}
Return ONLY the JSON object."""
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=2000,
messages=[{"role": "user", "content": prompt}],
)
response_text = message.content[0].text.strip()
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
response_text = response_text.rsplit("```", 1)[0]
try:
parsed = json.loads(response_text)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Failed to parse AI response")
return ArtistDeepDiveResponse(
artist=parsed.get("artist", data.artist),
summary=parsed.get("summary", ""),
why_they_matter=parsed.get("why_they_matter", ""),
influences=parsed.get("influences", []),
influenced=parsed.get("influenced", []),
start_with=parsed.get("start_with", ""),
start_with_reason=parsed.get("start_with_reason", ""),
deep_cut=parsed.get("deep_cut", ""),
similar_artists=parsed.get("similar_artists", []),
genres=parsed.get("genres", []),
)
class GeneratePlaylistRequest(BaseModel):
theme: str
count: int = 25
save: bool = False
class PlaylistTrack(BaseModel):
title: str
artist: str
album: str | None = None
reason: str
youtube_url: str | None = None
class GeneratedPlaylistResponse(BaseModel):
name: str
description: str
tracks: list[PlaylistTrack]
playlist_id: int | None = None
@router.post("/generate-playlist", response_model=GeneratedPlaylistResponse)
async def generate_playlist(
data: GeneratePlaylistRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if not data.theme.strip():
raise HTTPException(status_code=400, detail="Theme is required")
if data.count < 5 or data.count > 50:
raise HTTPException(status_code=400, detail="Count must be between 5 and 50")
# Build taste context from user's playlists
taste_context = ""
result = await db.execute(
select(Playlist).where(Playlist.user_id == user.id)
)
playlists = list(result.scalars().all())
all_tracks = []
for p in playlists:
track_result = await db.execute(select(Track).where(Track.playlist_id == p.id))
all_tracks.extend(track_result.scalars().all())
if all_tracks:
profile = build_taste_profile(all_tracks)
taste_context = f"\n\nThe user's taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}\n\nUse this to personalize the playlist to their taste while staying true to the theme."
prompt = f"""You are Vynl, a playlist curator. Create a cohesive playlist for this theme:
Theme: {data.theme}
{taste_context}
Generate a playlist of exactly {data.count} songs. The playlist should flow naturally — songs should be ordered for a great listening experience, not random.
Respond with a JSON object:
{{
"name": "A creative playlist name",
"description": "A 1-2 sentence description of the playlist vibe",
"tracks": [
{{"title": "...", "artist": "...", "album": "...", "reason": "Why this fits the playlist"}}
]
}}
Only recommend real songs that actually exist. Do not invent song titles.
Return ONLY the JSON object."""
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=4000,
messages=[{"role": "user", "content": prompt}],
)
response_text = message.content[0].text.strip()
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
response_text = response_text.rsplit("```", 1)[0]
try:
parsed = json.loads(response_text)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Failed to parse AI response")
playlist_name = parsed.get("name", f"Playlist: {data.theme}")
description = parsed.get("description", "")
tracks_data = parsed.get("tracks", [])
# Add YouTube Music search links
tracks = []
for t in tracks_data:
artist = t.get("artist", "Unknown")
title = t.get("title", "Unknown")
yt_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}"
tracks.append(PlaylistTrack(
title=title,
artist=artist,
album=t.get("album"),
reason=t.get("reason", ""),
youtube_url=yt_url,
))
# Optionally save as a playlist in the DB
playlist_id = None
if data.save:
new_playlist = Playlist(
user_id=user.id,
name=playlist_name,
platform_source="generated",
track_count=len(tracks),
)
db.add(new_playlist)
await db.flush()
for t in tracks:
track_record = Track(
playlist_id=new_playlist.id,
title=t.title,
artist=t.artist,
album=t.album,
)
db.add(track_record)
await db.flush()
playlist_id = new_playlist.id
return GeneratedPlaylistResponse(
name=playlist_name,
description=description,
tracks=tracks,
playlist_id=playlist_id,
)
@router.post("/{rec_id}/save") @router.post("/{rec_id}/save")
async def save_recommendation( async def save_recommendation(
rec_id: int, rec_id: int,

View File

@@ -0,0 +1,185 @@
import json
import logging
import anthropic
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.playlist import Playlist
from app.models.track import Track
from app.models.recommendation import Recommendation
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/profile", tags=["profile"])
class DecadeData(BaseModel):
decade: str
artists: list[str]
count: int
percentage: float
class TimelineResponse(BaseModel):
decades: list[DecadeData]
total_artists: int
dominant_era: str
insight: str
@router.get("/timeline", response_model=TimelineResponse)
async def get_timeline(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Build a music timeline showing which eras/decades define the user's taste."""
# Get all tracks from user's playlists
result = await db.execute(
select(Playlist).where(Playlist.user_id == user.id)
)
playlists = list(result.scalars().all())
all_artists: set[str] = set()
for p in playlists:
result = await db.execute(select(Track).where(Track.playlist_id == p.id))
tracks = result.scalars().all()
for t in tracks:
if t.artist:
all_artists.add(t.artist)
# Get artists from saved recommendations
result = await db.execute(
select(Recommendation).where(
Recommendation.user_id == user.id,
Recommendation.saved == True, # noqa: E712
)
)
saved_recs = result.scalars().all()
for r in saved_recs:
if r.artist:
all_artists.add(r.artist)
if not all_artists:
raise HTTPException(
status_code=404,
detail="No artists found. Import some playlists first.",
)
# Cap at 50 artists for the Claude call
artist_list = sorted(all_artists)[:50]
# Call Claude once to categorize all artists by era
client = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
prompt = f"""Categorize these artists by their primary era/decade. For each artist, pick the decade they are MOST associated with (when they were most active/influential).
Artists: {', '.join(artist_list)}
Respond with a JSON object with two keys:
1. "decades" - keys are decade strings, values are lists of artists from the input:
{{
"1960s": ["artist1"],
"1970s": ["artist2"],
"1980s": [],
"1990s": ["artist3"],
"2000s": ["artist4", "artist5"],
"2010s": ["artist6"],
"2020s": ["artist7"]
}}
2. "insight" - A single engaging sentence about their taste pattern across time, like "Your taste peaks in the 2000s indie explosion, with strong roots in 90s alternative." Make it specific to the actual artists and eras present.
Return ONLY a valid JSON object with "decades" and "insight" keys. No other text."""
try:
message = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
response_text = message.content[0].text.strip()
# Try to extract JSON if wrapped in markdown code blocks
if response_text.startswith("```"):
lines = response_text.split("\n")
json_lines = []
in_block = False
for line in lines:
if line.startswith("```") and not in_block:
in_block = True
continue
elif line.startswith("```") and in_block:
break
elif in_block:
json_lines.append(line)
response_text = "\n".join(json_lines)
parsed = json.loads(response_text)
decades_data = parsed.get("decades", parsed)
insight = parsed.get("insight", "")
except (json.JSONDecodeError, KeyError, IndexError) as e:
logger.error(f"Failed to parse Claude timeline response: {e}")
raise HTTPException(
status_code=500,
detail="Failed to analyze your music timeline. Please try again.",
)
except anthropic.APIError as e:
logger.error(f"Claude API error in timeline: {e}")
raise HTTPException(
status_code=502,
detail="AI service unavailable. Please try again later.",
)
# Build the response
total_categorized = 0
decade_results: list[DecadeData] = []
all_decades = ["1960s", "1970s", "1980s", "1990s", "2000s", "2010s", "2020s"]
for decade in all_decades:
artists = decades_data.get(decade, [])
if isinstance(artists, list):
total_categorized += len(artists)
dominant_decade = ""
max_count = 0
for decade in all_decades:
artists = decades_data.get(decade, [])
if not isinstance(artists, list):
artists = []
count = len(artists)
percentage = round((count / total_categorized * 100), 1) if total_categorized > 0 else 0.0
if count > max_count:
max_count = count
dominant_decade = decade
decade_results.append(
DecadeData(
decade=decade,
artists=artists,
count=count,
percentage=percentage,
)
)
if not insight:
insight = f"Your music taste is centered around the {dominant_decade}."
return TimelineResponse(
decades=decade_results,
total_artists=len(all_artists),
dominant_era=dominant_decade,
insight=insight,
)

View File

@@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.core.config import settings from app.core.config import settings
from app.api.endpoints import admin, auth, bandcamp, billing, lastfm, manual_import, playlist_fix, playlists, profile, recommendations, youtube_music from app.api.endpoints import admin, auth, bandcamp, billing, lastfm, manual_import, playlist_fix, playlists, profile, recommendations, timeline, youtube_music
app = FastAPI(title="Vynl API", version="1.0.0", redirect_slashes=False) app = FastAPI(title="Vynl API", version="1.0.0", redirect_slashes=False)
@@ -29,6 +29,7 @@ app.include_router(manual_import.router, prefix="/api")
app.include_router(lastfm.router, prefix="/api") app.include_router(lastfm.router, prefix="/api")
app.include_router(bandcamp.router, prefix="/api") app.include_router(bandcamp.router, prefix="/api")
app.include_router(profile.router, prefix="/api") app.include_router(profile.router, prefix="/api")
app.include_router(timeline.router, prefix="/api")
logger = logging.getLogger("app") logger = logging.getLogger("app")

View File

@@ -15,6 +15,9 @@ import Analyze from './pages/Analyze'
import BandcampDiscover from './pages/BandcampDiscover' import BandcampDiscover from './pages/BandcampDiscover'
import Admin from './pages/Admin' import Admin from './pages/Admin'
import SharedView from './pages/SharedView' import SharedView from './pages/SharedView'
import ArtistDive from './pages/ArtistDive'
import PlaylistGenerator from './pages/PlaylistGenerator'
import Timeline from './pages/Timeline'
function RootRedirect() { function RootRedirect() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
@@ -136,6 +139,36 @@ function AppRoutes() {
</ProtectedRoute> </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="/shared/:recId/:token" element={<SharedView />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom' 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' import { useAuth } from '../lib/auth'
const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com' const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
@@ -8,9 +8,11 @@ const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
const baseNavItems = [ const baseNavItems = [
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/profile', label: 'My Taste', icon: Fingerprint }, { path: '/profile', label: 'My Taste', icon: Fingerprint },
{ path: '/timeline', label: 'Timeline', icon: Clock },
{ path: '/playlists', label: 'Playlists', icon: ListMusic }, { path: '/playlists', label: 'Playlists', icon: ListMusic },
{ path: '/discover', label: 'Discover', icon: Compass }, { path: '/discover', label: 'Discover', icon: Compass },
{ path: '/analyze', label: 'Analyze', icon: Lightbulb }, { path: '/analyze', label: 'Analyze', icon: Lightbulb },
{ path: '/generate-playlist', label: 'Create Playlist', icon: ListPlus },
{ path: '/bandcamp', label: 'Bandcamp', icon: Store }, { path: '/bandcamp', label: 'Bandcamp', icon: Store },
{ path: '/saved', label: 'Saved', icon: Heart }, { path: '/saved', label: 'Saved', icon: Heart },
{ path: '/billing', label: 'Pro', icon: Crown }, { path: '/billing', label: 'Pro', icon: Crown },

View File

@@ -59,7 +59,12 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
{recommendation.title} {recommendation.title}
</h3> </h3>
<p className="text-charcoal-muted text-sm truncate"> <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 && ( {recommendation.album && (
<span className="text-charcoal-muted/60"> &middot; {recommendation.album}</span> <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) => export const analyzeSong = (artist: string, title: string) =>
api.post<AnalyzeResponse>('/recommendations/analyze', { artist, title }).then((r) => r.data) 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 // YouTube Music Import
export interface YouTubeTrackResult { export interface YouTubeTrackResult {
title: string title: string
@@ -323,6 +359,24 @@ export const fixPlaylist = (playlistId: string) =>
export const getTasteProfile = () => export const getTasteProfile = () =>
api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data) 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 // Admin
export interface AdminStats { export interface AdminStats {
users: { total: number; pro: number; free: number } 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>
)
}