Add Concert Finder and Rabbit Hole features
- Concert Finder: Bandsintown API integration to find upcoming shows for recommended artists, with expandable tour dates section on recommendation cards - Rabbit Hole: Multi-step guided discovery journey where each song connects to the next through a shared musical quality (producer, influence, tone, etc.) - New /rabbit-hole route with seed input, step count selector, and vertical chain visualization of connected songs - Added concerts endpoint, rabbit hole endpoint, and corresponding frontend API functions and navigation
This commit is contained in:
42
backend/app/api/endpoints/concerts.py
Normal file
42
backend/app/api/endpoints/concerts.py
Normal file
@@ -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]
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import PlaylistGenerator from './pages/PlaylistGenerator'
|
|||||||
import Timeline from './pages/Timeline'
|
import Timeline from './pages/Timeline'
|
||||||
import Compatibility from './pages/Compatibility'
|
import Compatibility from './pages/Compatibility'
|
||||||
import CrateDigger from './pages/CrateDigger'
|
import CrateDigger from './pages/CrateDigger'
|
||||||
|
import RabbitHole from './pages/RabbitHole'
|
||||||
|
|
||||||
function RootRedirect() {
|
function RootRedirect() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
@@ -171,6 +172,16 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/rabbit-hole"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<RabbitHole />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/crate"
|
path="/crate"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -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, Clock, ListMusic, ListPlus, Compass, Lightbulb, Store, Users, Heart, Crown, Shield, Menu, X, LogOut, User } from 'lucide-react'
|
import { Disc3, LayoutDashboard, Fingerprint, Clock, ListMusic, ListPlus, Compass, Lightbulb, Store, Users, ArrowDownCircle, 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'
|
||||||
@@ -14,6 +14,7 @@ const baseNavItems = [
|
|||||||
{ path: '/analyze', label: 'Analyze', icon: Lightbulb },
|
{ path: '/analyze', label: 'Analyze', icon: Lightbulb },
|
||||||
{ path: '/generate-playlist', label: 'Create Playlist', icon: ListPlus },
|
{ path: '/generate-playlist', label: 'Create Playlist', icon: ListPlus },
|
||||||
{ path: '/crate', label: 'Crate Dig', icon: Disc3 },
|
{ path: '/crate', label: 'Crate Dig', icon: Disc3 },
|
||||||
|
{ path: '/rabbit-hole', label: 'Rabbit Hole', icon: ArrowDownCircle },
|
||||||
{ path: '/bandcamp', label: 'Bandcamp', icon: Store },
|
{ path: '/bandcamp', label: 'Bandcamp', icon: Store },
|
||||||
{ path: '/compatibility', label: 'Taste Match', icon: Users },
|
{ path: '/compatibility', label: 'Taste Match', icon: Users },
|
||||||
{ path: '/saved', label: 'Saved', icon: Heart },
|
{ path: '/saved', label: 'Saved', icon: Heart },
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Heart, ExternalLink, Music, ThumbsDown, Repeat, Share2, Check } from 'lucide-react'
|
import { Heart, ExternalLink, Music, ThumbsDown, Repeat, Share2, Check, Calendar, MapPin, Ticket, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import type { RecommendationItem } from '../lib/api'
|
import type { RecommendationItem, ConcertEvent } from '../lib/api'
|
||||||
import { shareRecommendation } from '../lib/api'
|
import { shareRecommendation, findConcerts } from '../lib/api'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
recommendation: RecommendationItem
|
recommendation: RecommendationItem
|
||||||
@@ -16,6 +16,9 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [sharing, setSharing] = useState(false)
|
const [sharing, setSharing] = useState(false)
|
||||||
const [shared, setShared] = useState(false)
|
const [shared, setShared] = useState(false)
|
||||||
|
const [concertsOpen, setConcertsOpen] = useState(false)
|
||||||
|
const [concerts, setConcerts] = useState<ConcertEvent[] | null>(null)
|
||||||
|
const [concertsLoading, setConcertsLoading] = useState(false)
|
||||||
|
|
||||||
const handleMoreLikeThis = () => {
|
const handleMoreLikeThis = () => {
|
||||||
const q = `find songs similar to ${recommendation.artist} - ${recommendation.title}`
|
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 (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-purple-100 shadow-sm hover:shadow-md transition-shadow overflow-hidden">
|
<div className="bg-white rounded-2xl border border-purple-100 shadow-sm hover:shadow-md transition-shadow overflow-hidden">
|
||||||
<div className="flex gap-4 p-5">
|
<div className="flex gap-4 p-5">
|
||||||
@@ -144,6 +177,19 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
|
|||||||
<Repeat className="w-4 h-4" />
|
<Repeat className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleConcerts}
|
||||||
|
disabled={concertsLoading}
|
||||||
|
className={`p-2 rounded-full transition-colors cursor-pointer border-none ${
|
||||||
|
concertsOpen
|
||||||
|
? 'bg-amber-50 text-amber-600'
|
||||||
|
: 'bg-orange-50 text-orange-500 hover:bg-orange-100 hover:text-orange-600'
|
||||||
|
} ${concertsLoading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
title="Tour dates"
|
||||||
|
>
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
{recommendation.youtube_url && (
|
{recommendation.youtube_url && (
|
||||||
<a
|
<a
|
||||||
href={recommendation.youtube_url}
|
href={recommendation.youtube_url}
|
||||||
@@ -157,6 +203,56 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Concert section */}
|
||||||
|
{concertsOpen && (
|
||||||
|
<div className="border-t border-purple-50 px-5 py-3 bg-orange-50/30">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-semibold text-orange-700 uppercase tracking-wide flex items-center gap-1">
|
||||||
|
<Ticket className="w-3 h-3" />
|
||||||
|
Upcoming Shows
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setConcertsOpen(false)}
|
||||||
|
className="p-1 rounded hover:bg-orange-100 transition-colors cursor-pointer bg-transparent border-none text-orange-400"
|
||||||
|
>
|
||||||
|
<ChevronUp className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{concertsLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-charcoal-muted py-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-orange-300 border-t-transparent rounded-full animate-spin" />
|
||||||
|
Finding shows...
|
||||||
|
</div>
|
||||||
|
) : concerts && concerts.length > 0 ? (
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
{concerts.map((event, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={event.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-start gap-3 p-2 rounded-lg hover:bg-orange-100/50 transition-colors no-underline group"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-orange-600 whitespace-nowrap mt-0.5">
|
||||||
|
{formatConcertDate(event.date)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-charcoal truncate">{event.venue}</p>
|
||||||
|
<p className="text-xs text-charcoal-muted flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3 flex-shrink-0" />
|
||||||
|
{[event.city, event.region, event.country].filter(Boolean).join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="w-3 h-3 text-orange-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 mt-1" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-charcoal-muted py-1">No upcoming shows found</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
212
frontend/src/pages/RabbitHole.tsx
Normal file
212
frontend/src/pages/RabbitHole.tsx
Normal file
@@ -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<RabbitHoleStep[]>([])
|
||||||
|
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 (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-purple-100 flex items-center justify-center">
|
||||||
|
<ArrowDownCircle className="w-7 h-7 text-purple" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-charcoal">Rabbit Hole</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-charcoal-muted text-lg">Follow the music wherever it leads</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-white rounded-2xl border border-purple-100 shadow-sm p-6 mb-8">
|
||||||
|
<p className="text-sm text-charcoal-muted mb-4">
|
||||||
|
Start from a specific song or leave blank and let the AI choose a starting point.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-charcoal mb-1">Artist (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={seedArtist}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-charcoal mb-1">Song title (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={seedTitle}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-charcoal">Steps:</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{STEP_OPTIONS.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => setStepCount(n)}
|
||||||
|
className={`px-3.5 py-1.5 rounded-full text-sm font-medium transition-colors cursor-pointer border-none ${
|
||||||
|
stepCount === n
|
||||||
|
? 'bg-purple text-white'
|
||||||
|
: 'bg-purple-50 text-purple-600 hover:bg-purple-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-6 py-2.5 bg-purple text-white rounded-xl font-medium hover:bg-purple-700 transition-colors cursor-pointer border-none disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
Digging...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowDownCircle className="w-4 h-4" />
|
||||||
|
Go Down the Rabbit Hole
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6 text-center">
|
||||||
|
<p className="text-red-700 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{theme && (
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 bg-purple-50 text-purple-700 px-5 py-2.5 rounded-full text-sm font-medium">
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
{theme}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{steps.length > 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Vertical line */}
|
||||||
|
<div className="absolute left-6 top-6 bottom-6 w-0.5 bg-purple-200 hidden sm:block" style={{ borderLeft: '2px dashed #d8b4fe' }} />
|
||||||
|
|
||||||
|
<div className="space-y-0">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div key={i} className="relative">
|
||||||
|
{/* Step card */}
|
||||||
|
<div className="flex gap-4 sm:pl-14 py-3">
|
||||||
|
{/* Step number bubble (desktop) */}
|
||||||
|
<div className="hidden sm:flex absolute left-0 w-12 h-12 rounded-full bg-purple text-white items-center justify-center text-sm font-bold shadow-md z-10 flex-shrink-0"
|
||||||
|
style={{ top: '12px' }}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 bg-white rounded-2xl border border-purple-100 shadow-sm hover:shadow-md transition-shadow p-5">
|
||||||
|
{/* Connection text (not for first step) */}
|
||||||
|
{i > 0 && step.connection && (
|
||||||
|
<div className="flex items-start gap-2 mb-3 pb-3 border-b border-purple-50">
|
||||||
|
<Link2 className="w-4 h-4 text-purple-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-purple-600 italic leading-relaxed">{step.connection}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Mobile step number */}
|
||||||
|
<div className="sm:hidden w-8 h-8 rounded-full bg-purple text-white flex items-center justify-center text-xs font-bold flex-shrink-0">
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-semibold text-charcoal text-base truncate">{step.title}</h3>
|
||||||
|
<p className="text-sm text-charcoal-muted">
|
||||||
|
{step.artist}
|
||||||
|
{step.album && <span className="text-charcoal-muted/60"> · {step.album}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step.youtube_url && (
|
||||||
|
<a
|
||||||
|
href={step.youtube_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors text-xs font-medium no-underline flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Music className="w-3 h-3" />
|
||||||
|
Listen
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-charcoal-muted mt-2 leading-relaxed">{step.reason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!loading && steps.length === 0 && !error && (
|
||||||
|
<div className="text-center py-12 text-charcoal-muted">
|
||||||
|
<ArrowDownCircle className="w-16 h-16 mx-auto mb-4 text-purple-200" />
|
||||||
|
<p className="text-lg font-medium text-charcoal mb-2">Ready to go deep?</p>
|
||||||
|
<p className="text-sm">Each step connects to the next through a shared musical quality.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user