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:
@@ -19,6 +19,7 @@ import ArtistDive from './pages/ArtistDive'
|
||||
import PlaylistGenerator from './pages/PlaylistGenerator'
|
||||
import Timeline from './pages/Timeline'
|
||||
import Compatibility from './pages/Compatibility'
|
||||
import CrateDigger from './pages/CrateDigger'
|
||||
|
||||
function RootRedirect() {
|
||||
const { user, loading } = useAuth()
|
||||
@@ -170,6 +171,16 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/crate"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<CrateDigger />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/timeline"
|
||||
element={
|
||||
|
||||
@@ -13,6 +13,7 @@ const baseNavItems = [
|
||||
{ path: '/discover', label: 'Discover', icon: Compass },
|
||||
{ path: '/analyze', label: 'Analyze', icon: Lightbulb },
|
||||
{ path: '/generate-playlist', label: 'Create Playlist', icon: ListPlus },
|
||||
{ path: '/crate', label: 'Crate Dig', icon: Disc3 },
|
||||
{ path: '/bandcamp', label: 'Bandcamp', icon: Store },
|
||||
{ path: '/compatibility', label: 'Taste Match', icon: Users },
|
||||
{ path: '/saved', label: 'Saved', icon: Heart },
|
||||
|
||||
@@ -260,6 +260,21 @@ export interface GeneratedPlaylistResponse {
|
||||
export const generatePlaylist = (theme: string, count: number = 25, save: boolean = false) =>
|
||||
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
|
||||
export interface YouTubeTrackResult {
|
||||
title: string
|
||||
@@ -413,4 +428,39 @@ export interface LogEntry {
|
||||
export const getAdminLogs = (level: string = 'ALL', limit: number = 100) =>
|
||||
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
|
||||
|
||||
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