diff --git a/backend/app/api/endpoints/concerts.py b/backend/app/api/endpoints/concerts.py new file mode 100644 index 0000000..9d3c0b5 --- /dev/null +++ b/backend/app/api/endpoints/concerts.py @@ -0,0 +1,42 @@ +from urllib.parse import quote + +import httpx +from fastapi import APIRouter, Depends, Query + +from app.core.security import get_current_user +from app.models.user import User + +router = APIRouter(prefix="/concerts", tags=["concerts"]) + + +@router.get("") +async def find_concerts( + artist: str = Query(...), + user: User = Depends(get_current_user), +): + """Find upcoming concerts for an artist using Bandsintown API.""" + url = f"https://rest.bandsintown.com/artists/{quote(artist)}/events" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(url, params={"app_id": "vynl", "date": "upcoming"}) + + if resp.status_code != 200: + return {"events": []} + + events = resp.json() + if not isinstance(events, list): + return {"events": []} + + return { + "artist": artist, + "events": [ + { + "date": e.get("datetime", ""), + "venue": e.get("venue", {}).get("name", ""), + "city": e.get("venue", {}).get("city", ""), + "region": e.get("venue", {}).get("region", ""), + "country": e.get("venue", {}).get("country", ""), + "url": e.get("url", ""), + } + for e in events[:10] + ], + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4867178..8e8e24a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import PlaylistGenerator from './pages/PlaylistGenerator' import Timeline from './pages/Timeline' import Compatibility from './pages/Compatibility' import CrateDigger from './pages/CrateDigger' +import RabbitHole from './pages/RabbitHole' function RootRedirect() { const { user, loading } = useAuth() @@ -171,6 +172,16 @@ function AppRoutes() { } /> + + + + + + } + /> (null) + const [concertsLoading, setConcertsLoading] = useState(false) const handleMoreLikeThis = () => { const q = `find songs similar to ${recommendation.artist} - ${recommendation.title}` @@ -48,6 +51,36 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis } } + const handleConcerts = async () => { + if (concertsOpen) { + setConcertsOpen(false) + return + } + if (concerts !== null) { + setConcertsOpen(true) + return + } + setConcertsLoading(true) + setConcertsOpen(true) + try { + const data = await findConcerts(recommendation.artist) + setConcerts(data.events) + } catch { + setConcerts([]) + } finally { + setConcertsLoading(false) + } + } + + const formatConcertDate = (dateStr: string) => { + try { + const d = new Date(dateStr) + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + } catch { + return dateStr + } + } + return (
@@ -144,6 +177,19 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis + + {recommendation.youtube_url && (
+ + {/* Concert section */} + {concertsOpen && ( +
+
+ + + Upcoming Shows + + +
+ {concertsLoading ? ( +
+
+ Finding shows... +
+ ) : concerts && concerts.length > 0 ? ( +
+ ) : ( +

No upcoming shows found

+ )} +
+ )}
) } diff --git a/frontend/src/pages/RabbitHole.tsx b/frontend/src/pages/RabbitHole.tsx new file mode 100644 index 0000000..27d1d9e --- /dev/null +++ b/frontend/src/pages/RabbitHole.tsx @@ -0,0 +1,212 @@ +import { useState } from 'react' +import { ArrowDownCircle, ExternalLink, Link2, Play, Music } from 'lucide-react' +import type { RabbitHoleStep } from '../lib/api' +import { generateRabbitHole } from '../lib/api' + +const STEP_OPTIONS = [5, 8, 12] + +export default function RabbitHole() { + const [seedArtist, setSeedArtist] = useState('') + const [seedTitle, setSeedTitle] = useState('') + const [stepCount, setStepCount] = useState(8) + const [loading, setLoading] = useState(false) + const [theme, setTheme] = useState('') + const [steps, setSteps] = useState([]) + const [error, setError] = useState('') + + const handleGenerate = async () => { + setLoading(true) + setError('') + setTheme('') + setSteps([]) + try { + const data = await generateRabbitHole( + seedArtist.trim() || undefined, + seedTitle.trim() || undefined, + stepCount, + ) + setTheme(data.theme) + setSteps(data.steps) + } catch { + setError('Failed to generate rabbit hole. Try again.') + } finally { + setLoading(false) + } + } + + return ( +
+ {/* Header */} +
+
+
+ +
+

Rabbit Hole

+
+

Follow the music wherever it leads

+
+ + {/* Controls */} +
+

+ Start from a specific song or leave blank and let the AI choose a starting point. +

+ +
+
+ + setSeedArtist(e.target.value)} + placeholder="e.g. Radiohead" + className="w-full px-4 py-2.5 rounded-xl border border-purple-200 focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent text-sm bg-cream" + /> +
+
+ + setSeedTitle(e.target.value)} + placeholder="e.g. Everything In Its Right Place" + className="w-full px-4 py-2.5 rounded-xl border border-purple-200 focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent text-sm bg-cream" + /> +
+
+ +
+
+ Steps: +
+ {STEP_OPTIONS.map((n) => ( + + ))} +
+
+ + +
+
+ + {error && ( +
+

{error}

+
+ )} + + {/* Results */} + {theme && ( +
+
+ + {theme} +
+
+ )} + + {steps.length > 0 && ( +
+ {/* Vertical line */} +
+ +
+ {steps.map((step, i) => ( +
+ {/* Step card */} +
+ {/* Step number bubble (desktop) */} +
+ {i + 1} +
+ +
+ {/* Connection text (not for first step) */} + {i > 0 && step.connection && ( +
+ +

{step.connection}

+
+ )} + +
+ {/* Mobile step number */} +
+ {i + 1} +
+ +
+
+
+

{step.title}

+

+ {step.artist} + {step.album && · {step.album}} +

+
+ + {step.youtube_url && ( + + + Listen + + + )} +
+ +

{step.reason}

+
+
+
+
+
+ ))} +
+
+ )} + + {/* Empty state */} + {!loading && steps.length === 0 && !error && ( +
+ +

Ready to go deep?

+

Each step connects to the next through a shared musical quality.

+
+ )} +
+ ) +}