Add Crate Digger feature for swipe-based music discovery
- POST /api/recommendations/crate endpoint generates diverse crate of discoveries - POST /api/recommendations/crate-save endpoint saves individual picks - CrateDigger.tsx page with card UI, pass/save buttons, slide animations - Progress bar, save counter, and end-of-crate stats summary - Added to nav, routing, and API client
This commit is contained in:
@@ -378,6 +378,228 @@ Return ONLY the JSON object."""
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CrateRequest(BaseModel):
|
||||||
|
count: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
class CrateItem(BaseModel):
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
album: str | None = None
|
||||||
|
reason: str
|
||||||
|
youtube_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crate", response_model=list[CrateItem])
|
||||||
|
async def fill_crate(
|
||||||
|
data: CrateRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if data.count < 1 or data.count > 50:
|
||||||
|
raise HTTPException(status_code=400, detail="Count must be between 1 and 50")
|
||||||
|
|
||||||
|
# Build taste context from user's playlists
|
||||||
|
taste_context = "No listening history yet — give a diverse mix of great music across genres and eras."
|
||||||
|
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"The user's taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}"
|
||||||
|
|
||||||
|
prompt = f"""You are Vynl, filling a vinyl crate for a music lover to dig through.
|
||||||
|
|
||||||
|
{taste_context}
|
||||||
|
|
||||||
|
Fill a crate with {data.count} diverse music discoveries. Mix it up:
|
||||||
|
- Some familiar-adjacent picks they'll instantly love
|
||||||
|
- Some wildcards from genres they haven't explored
|
||||||
|
- Some deep cuts and rarities
|
||||||
|
- Some brand new artists
|
||||||
|
- Some classics they may have missed
|
||||||
|
|
||||||
|
Make each pick interesting and varied. This should feel like flipping through records at a great shop.
|
||||||
|
|
||||||
|
Respond with a JSON array of objects with: title, artist, album, reason (1 sentence why it's in the crate).
|
||||||
|
Only recommend real songs. Return ONLY the JSON array."""
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for rec in parsed:
|
||||||
|
artist = rec.get("artist", "Unknown")
|
||||||
|
title = rec.get("title", "Unknown")
|
||||||
|
youtube_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}"
|
||||||
|
items.append(CrateItem(
|
||||||
|
title=title,
|
||||||
|
artist=artist,
|
||||||
|
album=rec.get("album"),
|
||||||
|
reason=rec.get("reason", ""),
|
||||||
|
youtube_url=youtube_url,
|
||||||
|
))
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
class CrateSaveRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
album: str | None = None
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crate-save")
|
||||||
|
async def crate_save(
|
||||||
|
data: CrateSaveRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
youtube_url = f"https://music.youtube.com/search?q={quote_plus(f'{data.artist} {data.title}')}"
|
||||||
|
r = Recommendation(
|
||||||
|
user_id=user.id,
|
||||||
|
title=data.title,
|
||||||
|
artist=data.artist,
|
||||||
|
album=data.album,
|
||||||
|
reason=data.reason,
|
||||||
|
saved=True,
|
||||||
|
youtube_url=youtube_url,
|
||||||
|
query="crate-digger",
|
||||||
|
)
|
||||||
|
db.add(r)
|
||||||
|
await db.flush()
|
||||||
|
return {"id": r.id, "saved": True}
|
||||||
|
|
||||||
|
|
||||||
|
class RabbitHoleStep(BaseModel):
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
album: str | None = None
|
||||||
|
reason: str
|
||||||
|
connection: str # How this connects to the previous step
|
||||||
|
youtube_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RabbitHoleResponse(BaseModel):
|
||||||
|
theme: str
|
||||||
|
steps: list[RabbitHoleStep]
|
||||||
|
|
||||||
|
|
||||||
|
class RabbitHoleRequest(BaseModel):
|
||||||
|
seed_artist: str | None = None
|
||||||
|
seed_title: str | None = None
|
||||||
|
steps: int = 8
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rabbit-hole", response_model=RabbitHoleResponse)
|
||||||
|
async def rabbit_hole(
|
||||||
|
data: RabbitHoleRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Generate a musical rabbit hole — a chain of connected songs."""
|
||||||
|
if data.steps < 3 or data.steps > 15:
|
||||||
|
raise HTTPException(status_code=400, detail="Steps must be between 3 and 15")
|
||||||
|
|
||||||
|
# Build taste context
|
||||||
|
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)}"
|
||||||
|
|
||||||
|
seed_info = ""
|
||||||
|
if data.seed_artist:
|
||||||
|
seed_info = f"Starting from: {data.seed_artist}"
|
||||||
|
if data.seed_title:
|
||||||
|
seed_info += f" - {data.seed_title}"
|
||||||
|
|
||||||
|
prompt = f"""You are Vynl, a music guide taking someone on a journey. Create a musical rabbit hole — a chain of connected songs where each one leads naturally to the next through a shared quality.
|
||||||
|
|
||||||
|
{seed_info}
|
||||||
|
{taste_context}
|
||||||
|
|
||||||
|
Create a {data.steps}-step rabbit hole. Each step should connect to the previous one through ONE specific quality — maybe the same producer, a shared influence, a similar guitar tone, a lyrical theme, a tempo shift, etc. The connections should feel like "if you liked THAT about the last song, wait until you hear THIS."
|
||||||
|
|
||||||
|
Respond with JSON:
|
||||||
|
{{
|
||||||
|
"theme": "A fun 1-sentence description of where this rabbit hole goes",
|
||||||
|
"steps": [
|
||||||
|
{{
|
||||||
|
"title": "...",
|
||||||
|
"artist": "...",
|
||||||
|
"album": "...",
|
||||||
|
"reason": "Why this song is great",
|
||||||
|
"connection": "How this connects to the previous song (leave empty for first step)"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
Only use real songs. Return ONLY the JSON."""
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
theme = parsed.get("theme", "A musical journey")
|
||||||
|
steps_data = parsed.get("steps", [])
|
||||||
|
|
||||||
|
steps = []
|
||||||
|
for s in steps_data:
|
||||||
|
artist = s.get("artist", "Unknown")
|
||||||
|
title = s.get("title", "Unknown")
|
||||||
|
yt_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}"
|
||||||
|
steps.append(RabbitHoleStep(
|
||||||
|
title=title,
|
||||||
|
artist=artist,
|
||||||
|
album=s.get("album"),
|
||||||
|
reason=s.get("reason", ""),
|
||||||
|
connection=s.get("connection", ""),
|
||||||
|
youtube_url=yt_url,
|
||||||
|
))
|
||||||
|
|
||||||
|
return RabbitHoleResponse(theme=theme, steps=steps)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{rec_id}/save")
|
@router.post("/{rec_id}/save")
|
||||||
async def save_recommendation(
|
async def save_recommendation(
|
||||||
rec_id: int,
|
rec_id: int,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import ArtistDive from './pages/ArtistDive'
|
|||||||
import PlaylistGenerator from './pages/PlaylistGenerator'
|
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'
|
||||||
|
|
||||||
function RootRedirect() {
|
function RootRedirect() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
@@ -170,6 +171,16 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/crate"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<CrateDigger />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/timeline"
|
path="/timeline"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const baseNavItems = [
|
|||||||
{ 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: '/generate-playlist', label: 'Create Playlist', icon: ListPlus },
|
||||||
|
{ path: '/crate', label: 'Crate Dig', icon: Disc3 },
|
||||||
{ 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 },
|
||||||
|
|||||||
@@ -260,6 +260,21 @@ export interface GeneratedPlaylistResponse {
|
|||||||
export const generatePlaylist = (theme: string, count: number = 25, save: boolean = false) =>
|
export const generatePlaylist = (theme: string, count: number = 25, save: boolean = false) =>
|
||||||
api.post<GeneratedPlaylistResponse>('/recommendations/generate-playlist', { theme, count, save }).then((r) => r.data)
|
api.post<GeneratedPlaylistResponse>('/recommendations/generate-playlist', { theme, count, save }).then((r) => r.data)
|
||||||
|
|
||||||
|
// Crate Digger
|
||||||
|
export interface CrateItem {
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
album: string | null
|
||||||
|
reason: string
|
||||||
|
youtube_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fillCrate = (count: number = 20) =>
|
||||||
|
api.post<CrateItem[]>('/recommendations/crate', { count }).then((r) => r.data)
|
||||||
|
|
||||||
|
export const crateSave = (title: string, artist: string, album: string | null, reason: string) =>
|
||||||
|
api.post<{ id: string; saved: boolean }>('/recommendations/crate-save', { title, artist, album, reason }).then((r) => r.data)
|
||||||
|
|
||||||
// YouTube Music Import
|
// YouTube Music Import
|
||||||
export interface YouTubeTrackResult {
|
export interface YouTubeTrackResult {
|
||||||
title: string
|
title: string
|
||||||
@@ -413,4 +428,39 @@ export interface LogEntry {
|
|||||||
export const getAdminLogs = (level: string = 'ALL', limit: number = 100) =>
|
export const getAdminLogs = (level: string = 'ALL', limit: number = 100) =>
|
||||||
api.get<{ logs: LogEntry[]; total: number }>('/admin/logs', { params: { level, limit } }).then((r) => r.data)
|
api.get<{ logs: LogEntry[]; total: number }>('/admin/logs', { params: { level, limit } }).then((r) => r.data)
|
||||||
|
|
||||||
|
// Concerts
|
||||||
|
export interface ConcertEvent {
|
||||||
|
date: string
|
||||||
|
venue: string
|
||||||
|
city: string
|
||||||
|
region: string
|
||||||
|
country: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findConcerts = (artist: string) =>
|
||||||
|
api.get<{ artist: string; events: ConcertEvent[] }>('/concerts', { params: { artist } }).then((r) => r.data)
|
||||||
|
|
||||||
|
// Rabbit Hole
|
||||||
|
export interface RabbitHoleStep {
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
album: string | null
|
||||||
|
reason: string
|
||||||
|
connection: string
|
||||||
|
youtube_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RabbitHoleResponse {
|
||||||
|
theme: string
|
||||||
|
steps: RabbitHoleStep[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateRabbitHole = (seedArtist?: string, seedTitle?: string, steps: number = 8) =>
|
||||||
|
api.post<RabbitHoleResponse>('/recommendations/rabbit-hole', {
|
||||||
|
seed_artist: seedArtist || undefined,
|
||||||
|
seed_title: seedTitle || undefined,
|
||||||
|
steps,
|
||||||
|
}).then((r) => r.data)
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
250
frontend/src/pages/CrateDigger.tsx
Normal file
250
frontend/src/pages/CrateDigger.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { Disc3, X, Heart, ExternalLink, Loader2, RotateCcw } from 'lucide-react'
|
||||||
|
import { fillCrate, crateSave, type CrateItem } from '../lib/api'
|
||||||
|
|
||||||
|
type CardState = 'visible' | 'saving' | 'passing'
|
||||||
|
|
||||||
|
export default function CrateDigger() {
|
||||||
|
const [crate, setCrate] = useState<CrateItem[]>([])
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [cardState, setCardState] = useState<CardState>('visible')
|
||||||
|
const [savedCount, setSavedCount] = useState(0)
|
||||||
|
const [crateSize, setCrateSize] = useState(0)
|
||||||
|
const [finished, setFinished] = useState(false)
|
||||||
|
|
||||||
|
const loadCrate = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
setFinished(false)
|
||||||
|
setSavedCount(0)
|
||||||
|
setCurrentIndex(0)
|
||||||
|
setCardState('visible')
|
||||||
|
try {
|
||||||
|
const items = await fillCrate(20)
|
||||||
|
setCrate(items)
|
||||||
|
setCrateSize(items.length)
|
||||||
|
} catch {
|
||||||
|
setError('Failed to fill the crate. Try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const advanceCard = useCallback(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setCardState('visible')
|
||||||
|
if (currentIndex + 1 >= crate.length) {
|
||||||
|
setFinished(true)
|
||||||
|
} else {
|
||||||
|
setCurrentIndex((i) => i + 1)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}, [currentIndex, crate.length])
|
||||||
|
|
||||||
|
const handlePass = useCallback(() => {
|
||||||
|
if (cardState !== 'visible') return
|
||||||
|
setCardState('passing')
|
||||||
|
advanceCard()
|
||||||
|
}, [cardState, advanceCard])
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (cardState !== 'visible') return
|
||||||
|
const item = crate[currentIndex]
|
||||||
|
setCardState('saving')
|
||||||
|
setSavedCount((c) => c + 1)
|
||||||
|
try {
|
||||||
|
await crateSave(item.title, item.artist, item.album, item.reason)
|
||||||
|
} catch {
|
||||||
|
// Still advance even if save fails
|
||||||
|
}
|
||||||
|
advanceCard()
|
||||||
|
}, [cardState, crate, currentIndex, advanceCard])
|
||||||
|
|
||||||
|
const currentItem = crate[currentIndex]
|
||||||
|
|
||||||
|
// Empty state — no crate loaded yet
|
||||||
|
if (crate.length === 0 && !loading && !finished) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto text-center py-16">
|
||||||
|
<div className="w-20 h-20 mx-auto mb-6 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<Disc3 className="w-10 h-10 text-purple" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-charcoal mb-3">Crate Digger</h1>
|
||||||
|
<p className="text-charcoal-muted mb-8">
|
||||||
|
Dig through a crate of hand-picked discoveries. Save the ones that speak to you, pass on the rest.
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-600 text-sm mb-4">{error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={loadCrate}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-2 px-8 py-4 bg-purple text-white rounded-xl font-semibold text-lg hover:bg-purple-700 transition-colors disabled:opacity-50 cursor-pointer border-none"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Filling Crate...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Disc3 className="w-5 h-5" />
|
||||||
|
Fill My Crate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto text-center py-16">
|
||||||
|
<Loader2 className="w-12 h-12 text-purple animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-charcoal-muted text-lg">Filling your crate with discoveries...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finished state
|
||||||
|
if (finished) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto text-center py-16">
|
||||||
|
<div className="w-20 h-20 mx-auto mb-6 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<Heart className="w-10 h-10 text-purple" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-charcoal mb-3">Crate Empty!</h2>
|
||||||
|
<p className="text-charcoal-muted text-lg mb-2">
|
||||||
|
You saved <span className="font-bold text-purple">{savedCount}</span> out of <span className="font-bold">{crateSize}</span> records.
|
||||||
|
</p>
|
||||||
|
<p className="text-charcoal-muted mb-8">
|
||||||
|
{savedCount === 0
|
||||||
|
? 'Tough crowd! Try another crate for different picks.'
|
||||||
|
: savedCount <= crateSize / 4
|
||||||
|
? 'Selective taste. Check your saved recommendations!'
|
||||||
|
: savedCount <= crateSize / 2
|
||||||
|
? 'Nice haul! Some real gems in there.'
|
||||||
|
: 'You loved most of them! Great crate.'}
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-red-600 text-sm mb-4">{error}</p>}
|
||||||
|
<button
|
||||||
|
onClick={loadCrate}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-2 px-8 py-4 bg-purple text-white rounded-xl font-semibold text-lg hover:bg-purple-700 transition-colors disabled:opacity-50 cursor-pointer border-none"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-5 h-5" />
|
||||||
|
Fill Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main crate digging view
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto py-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-1">
|
||||||
|
<Disc3 className="w-6 h-6 text-purple" />
|
||||||
|
<h1 className="text-2xl font-bold text-charcoal">Crate Digger</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-charcoal-muted text-sm">Dig through the crate</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="flex items-center justify-between mb-4 px-2">
|
||||||
|
<span className="text-sm text-charcoal-muted">
|
||||||
|
{currentIndex + 1} of {crate.length}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-charcoal-muted">
|
||||||
|
<Heart className="w-3.5 h-3.5 inline text-purple mr-1" />
|
||||||
|
{savedCount} saved
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full h-1.5 bg-purple-100 rounded-full mb-6 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-purple rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${((currentIndex + 1) / crate.length) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
{currentItem && (
|
||||||
|
<div
|
||||||
|
className={`bg-white rounded-2xl shadow-lg border border-purple-100 overflow-hidden transition-all duration-300 ${
|
||||||
|
cardState === 'saving'
|
||||||
|
? 'opacity-0 translate-x-24'
|
||||||
|
: cardState === 'passing'
|
||||||
|
? 'opacity-0 -translate-x-24'
|
||||||
|
: 'opacity-100 translate-x-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Album art placeholder */}
|
||||||
|
<div className="h-48 bg-gradient-to-br from-purple-600 via-purple-500 to-purple-800 flex items-center justify-center relative">
|
||||||
|
<Disc3 className="w-20 h-20 text-white/30" />
|
||||||
|
<div className="absolute bottom-3 right-3">
|
||||||
|
{currentItem.youtube_url && (
|
||||||
|
<a
|
||||||
|
href={currentItem.youtube_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 bg-white/20 backdrop-blur-sm rounded-lg text-white text-xs font-medium hover:bg-white/30 transition-colors no-underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
Listen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track info */}
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-bold text-charcoal mb-1 leading-tight">
|
||||||
|
{currentItem.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-purple font-semibold mb-1">{currentItem.artist}</p>
|
||||||
|
{currentItem.album && (
|
||||||
|
<p className="text-charcoal-muted text-sm mb-4">{currentItem.album}</p>
|
||||||
|
)}
|
||||||
|
{!currentItem.album && <div className="mb-4" />}
|
||||||
|
<div className="bg-cream rounded-xl p-4">
|
||||||
|
<p className="text-charcoal text-sm leading-relaxed italic">
|
||||||
|
"{currentItem.reason}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center justify-center gap-8 mt-8">
|
||||||
|
<button
|
||||||
|
onClick={handlePass}
|
||||||
|
disabled={cardState !== 'visible'}
|
||||||
|
className="w-16 h-16 rounded-full bg-white border-2 border-red-300 flex items-center justify-center hover:bg-red-50 hover:border-red-400 transition-all cursor-pointer disabled:opacity-50 shadow-md hover:shadow-lg active:scale-95"
|
||||||
|
title="Pass"
|
||||||
|
>
|
||||||
|
<X className="w-7 h-7 text-red-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={cardState !== 'visible'}
|
||||||
|
className="w-16 h-16 rounded-full bg-white border-2 border-green-300 flex items-center justify-center hover:bg-green-50 hover:border-green-400 transition-all cursor-pointer disabled:opacity-50 shadow-md hover:shadow-lg active:scale-95"
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<Heart className="w-7 h-7 text-green-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard hints */}
|
||||||
|
<p className="text-center text-charcoal-muted text-xs mt-4">
|
||||||
|
Tap the buttons to pass or save
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user