Add Bandcamp discovery via public API (no scraping) - browse new releases by genre tag
This commit is contained in:
@@ -11,6 +11,7 @@ import Discover from './pages/Discover'
|
||||
import Recommendations from './pages/Recommendations'
|
||||
import Billing from './pages/Billing'
|
||||
import TasteProfilePage from './pages/TasteProfilePage'
|
||||
import BandcampDiscover from './pages/BandcampDiscover'
|
||||
|
||||
function RootRedirect() {
|
||||
const { user, loading } = useAuth()
|
||||
@@ -82,6 +83,16 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/bandcamp"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<BandcampDiscover />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/saved"
|
||||
element={
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Disc3, LayoutDashboard, Fingerprint, ListMusic, Compass, Heart, Crown, Menu, X, LogOut, User } from 'lucide-react'
|
||||
import { Disc3, LayoutDashboard, Fingerprint, ListMusic, Compass, Store, Heart, Crown, Menu, X, LogOut, User } from 'lucide-react'
|
||||
import { useAuth } from '../lib/auth'
|
||||
|
||||
const navItems = [
|
||||
@@ -8,6 +8,7 @@ const navItems = [
|
||||
{ path: '/profile', label: 'My Taste', icon: Fingerprint },
|
||||
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||
{ path: '/discover', label: 'Discover', icon: Compass },
|
||||
{ path: '/bandcamp', label: 'Bandcamp', icon: Store },
|
||||
{ path: '/saved', label: 'Saved', icon: Heart },
|
||||
{ path: '/billing', label: 'Pro', icon: Crown },
|
||||
]
|
||||
|
||||
@@ -256,30 +256,20 @@ export const getBillingStatus = () =>
|
||||
api.get<BillingStatusResponse>('/billing/status').then((r) => r.data)
|
||||
|
||||
// Bandcamp
|
||||
export interface BandcampResult {
|
||||
export interface BandcampRelease {
|
||||
title: string
|
||||
artist: string
|
||||
art_url: string | null
|
||||
bandcamp_url: string
|
||||
genre: string
|
||||
item_type: string
|
||||
}
|
||||
|
||||
export interface BandcampEmbed {
|
||||
embed_url: string
|
||||
title: string
|
||||
artist: string
|
||||
art_url: string | null
|
||||
}
|
||||
export const discoverBandcamp = (tags: string, sort: string = 'new', page: number = 1) =>
|
||||
api.get<BandcampRelease[]>('/bandcamp/discover', { params: { tags, sort, page } }).then((r) => r.data)
|
||||
|
||||
export async function searchBandcamp(query: string, type: string = 't'): Promise<BandcampResult[]> {
|
||||
const { data } = await api.get('/bandcamp/search', { params: { q: query, type } })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getBandcampEmbed(url: string): Promise<BandcampEmbed> {
|
||||
const { data } = await api.get('/bandcamp/embed', { params: { url } })
|
||||
return data
|
||||
}
|
||||
export const getBandcampTags = () =>
|
||||
api.get<string[]>('/bandcamp/tags').then((r) => r.data)
|
||||
|
||||
// Playlist Fix
|
||||
export interface OutlierTrack {
|
||||
|
||||
206
frontend/src/pages/BandcampDiscover.tsx
Normal file
206
frontend/src/pages/BandcampDiscover.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Disc3, Music, ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { discoverBandcamp, getBandcampTags, BandcampRelease } from '../lib/api'
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'new', label: 'New Releases' },
|
||||
{ value: 'rec', label: 'Recommended' },
|
||||
{ value: 'pop', label: 'Popular' },
|
||||
]
|
||||
|
||||
export default function BandcampDiscover() {
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([])
|
||||
const [sort, setSort] = useState('new')
|
||||
const [releases, setReleases] = useState<BandcampRelease[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [tagsLoading, setTagsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
getBandcampTags()
|
||||
.then(setTags)
|
||||
.catch(() => setTags(['indie-rock', 'electronic', 'shoegaze', 'ambient', 'punk', 'experimental', 'hip-hop', 'jazz', 'folk', 'metal', 'post-punk', 'synthwave']))
|
||||
.finally(() => setTagsLoading(false))
|
||||
}, [])
|
||||
|
||||
const fetchReleases = async (newPage: number, append: boolean = false) => {
|
||||
if (selectedTags.length === 0) return
|
||||
append ? setLoadingMore(true) : setLoading(true)
|
||||
try {
|
||||
const data = await discoverBandcamp(selectedTags.join(','), sort, newPage)
|
||||
setReleases(append ? (prev) => [...prev, ...data] : data)
|
||||
setPage(newPage)
|
||||
} catch {
|
||||
// silently handle
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTags.length > 0) {
|
||||
fetchReleases(1)
|
||||
} else {
|
||||
setReleases([])
|
||||
setPage(1)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTags, sort])
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh]">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Disc3 className="w-8 h-8 text-purple" />
|
||||
<h1 className="text-3xl font-bold text-charcoal">Bandcamp Discovery</h1>
|
||||
</div>
|
||||
<p className="text-charcoal-muted">Browse new independent releases</p>
|
||||
</div>
|
||||
|
||||
{/* Tag Selector */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-semibold text-charcoal-muted uppercase tracking-wider mb-3">Genres</h2>
|
||||
{tagsLoading ? (
|
||||
<div className="flex items-center gap-2 text-charcoal-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">Loading tags...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => {
|
||||
const selected = selectedTags.includes(tag)
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-all cursor-pointer border ${
|
||||
selected
|
||||
? 'bg-purple text-white border-purple shadow-md'
|
||||
: 'bg-white text-charcoal border-purple-200 hover:border-purple hover:text-purple'
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Toggle */}
|
||||
<div className="mb-8">
|
||||
<div className="flex gap-2">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setSort(opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all cursor-pointer border ${
|
||||
sort === opt.value
|
||||
? 'bg-charcoal text-white border-charcoal'
|
||||
: 'bg-white text-charcoal-muted border-purple-100 hover:border-charcoal hover:text-charcoal'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{selectedTags.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<Disc3 className="w-16 h-16 text-purple-200 mx-auto mb-4" />
|
||||
<p className="text-charcoal-muted text-lg">Select some genres to start digging</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-10 h-10 text-purple animate-spin" />
|
||||
</div>
|
||||
) : releases.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<Music className="w-16 h-16 text-purple-200 mx-auto mb-4" />
|
||||
<p className="text-charcoal-muted text-lg">No releases found for these tags</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-5">
|
||||
{releases.map((release, i) => (
|
||||
<div
|
||||
key={`${release.bandcamp_url}-${i}`}
|
||||
className="bg-white rounded-xl overflow-hidden shadow-md hover:shadow-xl transition-shadow group border border-purple-50"
|
||||
style={{ boxShadow: '0 4px 20px rgba(124, 58, 237, 0.08)' }}
|
||||
>
|
||||
{/* Album Art */}
|
||||
<div className="aspect-square relative overflow-hidden">
|
||||
{release.art_url ? (
|
||||
<img
|
||||
src={release.art_url}
|
||||
alt={`${release.title} by ${release.artist}`}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-purple to-purple-800 flex items-center justify-center">
|
||||
<Music className="w-12 h-12 text-white/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3">
|
||||
<h3 className="font-bold text-charcoal text-sm leading-tight truncate" title={release.title}>
|
||||
{release.title}
|
||||
</h3>
|
||||
<p className="text-charcoal-muted text-xs mt-1 truncate" title={release.artist}>
|
||||
{release.artist}
|
||||
</p>
|
||||
{release.genre && (
|
||||
<span className="inline-block mt-2 text-[10px] font-medium text-purple bg-purple-50 px-2 py-0.5 rounded-full">
|
||||
{release.genre}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={release.bandcamp_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 flex items-center justify-center gap-1.5 w-full py-2 rounded-lg text-xs font-medium bg-charcoal text-white hover:bg-charcoal/80 transition-colors no-underline"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Listen on Bandcamp
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Load More */}
|
||||
<div className="flex justify-center mt-10 mb-4">
|
||||
<button
|
||||
onClick={() => fetchReleases(page + 1, true)}
|
||||
disabled={loadingMore}
|
||||
className="px-8 py-3 rounded-xl bg-purple text-white font-medium hover:bg-purple-700 transition-colors cursor-pointer border-none disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load More'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user