Initial MVP: full-stack music discovery app

Backend (FastAPI):
- User auth with email/password and Spotify OAuth
- Spotify playlist import with audio feature extraction
- AI recommendation engine using Claude API with taste profiling
- Save/bookmark recommendations
- Rate limiting for free tier (10 recs/day, 1 playlist)
- PostgreSQL models with Alembic migrations
- Redis-ready configuration

Frontend (React 19 + TypeScript + Vite + Tailwind):
- Landing page, auth flows (email + Spotify OAuth)
- Dashboard with stats and quick discover
- Playlist management and import from Spotify
- Discover page with custom query support
- Recommendation cards with explanations and save toggle
- Taste profile visualization
- Responsive layout with mobile navigation
- PWA-ready configuration

Infrastructure:
- Docker Compose with PostgreSQL, Redis, backend, frontend
- Environment-based configuration
This commit is contained in:
root
2026-03-30 15:53:39 -05:00
commit 155cbd1bbf
62 changed files with 7536 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
import { Heart, ExternalLink, Music } from 'lucide-react'
import type { RecommendationItem } from '../lib/api'
interface Props {
recommendation: RecommendationItem
onToggleSave: (id: string) => void
saving?: boolean
}
export default function RecommendationCard({ recommendation, onToggleSave, saving }: Props) {
return (
<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">
{/* Album Art */}
<div className="w-20 h-20 rounded-xl bg-gradient-to-br from-purple-200 to-purple-400 flex-shrink-0 flex items-center justify-center overflow-hidden">
{recommendation.image_url ? (
<img
src={recommendation.image_url}
alt={`${recommendation.title} cover`}
className="w-full h-full object-cover"
/>
) : (
<Music className="w-8 h-8 text-white/80" />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-charcoal text-base truncate">
{recommendation.title}
</h3>
<p className="text-charcoal-muted text-sm truncate">
{recommendation.artist}
{recommendation.album && (
<span className="text-charcoal-muted/60"> &middot; {recommendation.album}</span>
)}
</p>
{/* Reason */}
<p className="text-sm text-charcoal-muted mt-2 line-clamp-2 leading-relaxed">
{recommendation.reason}
</p>
</div>
{/* Actions */}
<div className="flex flex-col items-center gap-2 flex-shrink-0">
<button
onClick={() => onToggleSave(recommendation.id)}
disabled={saving}
className={`p-2 rounded-full transition-colors cursor-pointer border-none ${
recommendation.saved
? 'bg-red-50 text-red-500 hover:bg-red-100'
: 'bg-purple-50 text-purple-400 hover:bg-purple-100 hover:text-purple'
} ${saving ? 'opacity-50 cursor-not-allowed' : ''}`}
title={recommendation.saved ? 'Remove from saved' : 'Save recommendation'}
>
<Heart
className="w-5 h-5"
fill={recommendation.saved ? 'currentColor' : 'none'}
/>
</button>
{recommendation.spotify_url && (
<a
href={recommendation.spotify_url}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-full bg-green-50 text-green-600 hover:bg-green-100 transition-colors"
title="Open in Spotify"
>
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
</div>
</div>
)
}