From 7abec6de7c9f9839041854f410bfe57ddf49125b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 31 Mar 2026 18:50:23 -0500 Subject: [PATCH] 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 --- backend/app/api/endpoints/recommendations.py | 208 +++++++++++++++- backend/app/api/endpoints/timeline.py | 185 +++++++++++++++ backend/app/main.py | 3 +- frontend/src/App.tsx | 33 +++ frontend/src/components/Layout.tsx | 4 +- .../src/components/RecommendationCard.tsx | 7 +- frontend/src/lib/api.ts | 54 +++++ frontend/src/pages/ArtistDive.tsx | 217 +++++++++++++++++ frontend/src/pages/PlaylistGenerator.tsx | 224 ++++++++++++++++++ frontend/src/pages/Timeline.tsx | 171 +++++++++++++ 10 files changed, 1102 insertions(+), 4 deletions(-) create mode 100644 backend/app/api/endpoints/timeline.py create mode 100644 frontend/src/pages/ArtistDive.tsx create mode 100644 frontend/src/pages/PlaylistGenerator.tsx create mode 100644 frontend/src/pages/Timeline.tsx diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 5d79251..eefa93e 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -12,9 +12,11 @@ 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 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"]) @@ -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") async def save_recommendation( rec_id: int, diff --git a/backend/app/api/endpoints/timeline.py b/backend/app/api/endpoints/timeline.py new file mode 100644 index 0000000..6e66e0c --- /dev/null +++ b/backend/app/api/endpoints/timeline.py @@ -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, + ) diff --git a/backend/app/main.py b/backend/app/main.py index c173906..764dcee 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse 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) @@ -29,6 +29,7 @@ app.include_router(manual_import.router, prefix="/api") app.include_router(lastfm.router, prefix="/api") app.include_router(bandcamp.router, prefix="/api") app.include_router(profile.router, prefix="/api") +app.include_router(timeline.router, prefix="/api") logger = logging.getLogger("app") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7b2d679..116ed39 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index b264fab..4216c99 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 }, diff --git a/frontend/src/components/RecommendationCard.tsx b/frontend/src/components/RecommendationCard.tsx index 2e51c09..af1bc78 100644 --- a/frontend/src/components/RecommendationCard.tsx +++ b/frontend/src/components/RecommendationCard.tsx @@ -59,7 +59,12 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis {recommendation.title}

- {recommendation.artist} + {recommendation.album && ( · {recommendation.album} )} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5726fbf..643866a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -224,6 +224,42 @@ export interface AnalyzeResponse { export const analyzeSong = (artist: string, title: string) => api.post('/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('/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('/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('/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('/profile/timeline').then((r) => r.data) + // Admin export interface AdminStats { users: { total: number; pro: number; free: number } diff --git a/frontend/src/pages/ArtistDive.tsx b/frontend/src/pages/ArtistDive.tsx new file mode 100644 index 0000000..70af49a --- /dev/null +++ b/frontend/src/pages/ArtistDive.tsx @@ -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(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 ( +

+ {/* Header */} +
+
+ +
+
+

Artist Deep Dive

+

Explore any artist's story, influences, and legacy

+
+
+ + {/* Search */} +
+ 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" + /> + +
+ + {error && ( +
{error}
+ )} + + {loading && ( +
+ +

Diving deep into {artist}...

+
+ )} + + {result && !loading && ( +
+ {/* Artist Header */} +
+

{result.artist}

+
+ {result.genres.map((genre) => ( + + {genre} + + ))} +
+
+ +
+ {/* Summary */} +
+

{result.summary}

+
+ + {/* Why They Matter */} +
+
+ +

Why They Matter

+
+

{result.why_they_matter}

+
+ + {/* Start Here */} +
+
+ +

Start Here

+
+

{result.start_with}

+

{result.start_with_reason}

+
+ + {/* Hidden Gem */} +
+
+ +

Hidden Gem

+
+

{result.deep_cut}

+
+ + {/* Influences */} +
+
+

+ + Influenced By +

+
+ {result.influences.map((name) => ( + + ))} +
+
+
+

+ + Influenced +

+
+ {result.influenced.map((name) => ( + + ))} +
+
+
+ + {/* Similar Artists */} +
+

Similar Artists

+
+ {result.similar_artists.map((name) => ( + + ))} +
+
+ + {/* YouTube Music Link */} + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/PlaylistGenerator.tsx b/frontend/src/pages/PlaylistGenerator.tsx new file mode 100644 index 0000000..51cec1e --- /dev/null +++ b/frontend/src/pages/PlaylistGenerator.tsx @@ -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(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 ( +
+ {/* Header */} +
+
+ +
+
+

Playlist Generator

+

Describe a vibe and get a full playlist

+
+
+ + {/* Input Section */} +
+ + 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 */} +
+ +
+ {COUNT_OPTIONS.map((n) => ( + + ))} +
+
+ + {/* Generate Button */} + +
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Results */} + {result && ( +
+ {/* Playlist Header */} +
+

{result.name}

+

{result.description}

+

{result.tracks.length} tracks

+
+ + {/* Actions */} +
+ + +
+ + {/* Track List */} +
+ {result.tracks.map((track, index) => ( +
+ + {index + 1} + +
+
+ {track.title} + + {track.artist} + {track.youtube_url && ( + + + + )} +
+

{track.reason}

+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Timeline.tsx b/frontend/src/pages/Timeline.tsx new file mode 100644 index 0000000..df7c2a9 --- /dev/null +++ b/frontend/src/pages/Timeline.tsx @@ -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 = { + '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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+ +

Analyzing your music across the decades...

+
+ ) + } + + if (error || !timeline) { + return ( +
+ +

{error || 'Could not load your music timeline.'}

+
+ ) + } + + const maxPercentage = Math.max(...timeline.decades.map((d) => d.percentage), 1) + + return ( +
+ {/* Header */} +
+

+ + Your Music Timeline +

+

+ How your taste spans the decades, based on {timeline.total_artists} artist{timeline.total_artists !== 1 ? 's' : ''} in your library +

+
+ + {/* AI Insight */} +
+
+
+ +
+
+

+ Timeline Insight +

+

+ {timeline.insight} +

+
+
+
+ + {/* Dominant Era Badge */} +
+ + + Dominant Era: {timeline.dominant_era} + + + + {timeline.total_artists} artists analyzed + +
+ + {/* Timeline Bar Chart */} +
+

Decades Breakdown

+ +
+ {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 ( +
+ {/* Percentage label */} + 0 ? 'text-charcoal' : 'text-charcoal-muted/40'}`}> + {decade.percentage > 0 ? `${decade.percentage}%` : ''} + + + {/* Bar */} +
0 ? '20px' : '4px' }} + /> + + {/* Decade label */} + + {decade.decade} + +
+ ) + })} +
+
+ + {/* Artists by Decade */} +
+ {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 ( +
+
+
+ +

{decade.decade}

+ {isDominant && ( + + Dominant + + )} +
+ + {decade.count} artist{decade.count !== 1 ? 's' : ''} ({decade.percentage}%) + +
+
+ {decade.artists.map((artist) => ( + + {artist} + + ))} +
+
+ ) + })} +
+
+ ) +}