Compare commits

58 Commits
master ... dev

Author SHA1 Message Date
root
dcff90289c Redesign landing page with all features: 6 discovery modes, 8 tools, taste match 2026-04-03 21:18:14 -05:00
root
f2b8dadbf8 Add API cost tracking to admin dashboard
Track estimated Anthropic API costs per request across all Claude API
call sites (recommender, analyze, artist-dive, generate-playlist, crate,
rabbit-hole, playlist-fix, timeline, compatibility). Log token usage and
estimated cost to the app logger. Aggregate costs in admin stats endpoint
and display total/today costs and token usage in the admin dashboard.
2026-03-31 20:51:51 -05:00
root
0ee8f9a144 Add public shareable taste profiles
Users can generate a share link for their taste profile via the
"Share My Taste" button. The link opens a public page showing
listening personality, genre breakdown, audio features, and top
artists with a CTA to register. Token-based URL prevents enumeration.
2026-03-31 20:51:12 -05:00
root
db2767bfda Add user settings page and PWA install support
- Add profile update, password change, and account deletion endpoints
- Create Settings page with profile editing, password change, and danger zone
- Add Settings link to user dropdown menu in Layout
- Add /settings route to App.tsx
- Add API functions for profile management
- Create PWA manifest.json and add meta tags to index.html
2026-03-31 20:49:57 -05:00
root
5215e8c792 Add playlist export and SEO meta tags
Add text/CSV export endpoints for playlists and saved recommendations.
Add export buttons to PlaylistDetail and Recommendations pages.
Add Open Graph and Twitter meta tags to index.html for better SEO.
2026-03-31 20:49:07 -05:00
root
957a66bbd0 Reset onboarding for all existing users (v2 key) 2026-03-31 20:43:46 -05:00
root
d1ee78fc27 Add first-time user onboarding walkthrough
Multi-step modal overlay that guides new users through key features:
Welcome, Import Music, Discover, Features, and Get Started. Shows
once on first login via localStorage flag, with animated step
transitions, progress dots, and navigation to import/discover pages.
2026-03-31 20:41:29 -05:00
root
cb6de2f43e Add pre-release checklist for things that need manual setup 2026-03-31 20:08:04 -05:00
root
4b4f383f48 Split models: Sonnet for discoveries (accuracy), Haiku for everything else (cost) 2026-03-31 19:43:14 -05:00
root
2cb6f4d6b2 Switch to Sonnet for better accuracy, stronger anti-hallucination prompt 2026-03-31 19:41:13 -05:00
root
5773870c91 Exclude all previously recommended songs from new discoveries - no more repeats 2026-03-31 19:29:52 -05:00
root
34eabf0fae Add touch swipe gestures + keyboard arrows to Crate Digger 2026-03-31 19:26:50 -05:00
root
2b56d0c06b Persist results to sessionStorage on all pages - prevents data loss on external link clicks 2026-03-31 19:13:26 -05:00
root
88e7bc9c30 Add inline YouTube player on recommendation cards, fetch real video IDs via ytmusicapi 2026-03-31 19:09:12 -05:00
root
086b9e4e71 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
2026-03-31 19:01:22 -05:00
root
aeadf722cb 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
2026-03-31 18:58:02 -05:00
root
5b603f4acc Add Taste Match compatibility feature for comparing music taste between users 2026-03-31 18:57:33 -05:00
root
53ab59f0fc Fix share button: add clipboard fallback for HTTP/mobile 2026-03-31 18:54:26 -05:00
root
7abec6de7c Add Tier 2 features: Playlist Generator, Artist Deep Dive, Music Timeline
- Playlist Generator: describe a vibe, get a 15-30 song playlist, save or copy as text
- Artist Deep Dive: click any artist name for influences, best album, hidden gems, similar artists
- Music Timeline: visual decade breakdown of your taste with AI insight
- Nav updates: Create Playlist, Timeline links
2026-03-31 18:50:23 -05:00
root
0b82149b97 Add mood scanner and surprise me features to discover page
Add mood_energy and mood_valence sliders that inject mood context into
AI recommendation prompts. Add "Surprise Me" button that generates
recommendations from a creative, unexpected angle without requiring
any user input. Includes backend endpoints, schema updates, and
full frontend UI integration.
2026-03-31 18:31:35 -05:00
root
da94df01da Add "More Like This" button and "Why Do I Like This?" analyze feature
- Add Repeat button to RecommendationCard that navigates to Discover
  with a pre-filled query for similar songs
- Read q param on Discover page to pre-fill the query field
- Add POST /api/recommendations/analyze endpoint that uses Claude to
  explain why a user likes a song and suggest similar music
- Create Analyze page with artist/title inputs, analysis card,
  quality tags, and recommendation cards
- Add Analyze nav item and /analyze route
2026-03-31 18:28:05 -05:00
root
2e26aa03c4 Add share discoveries feature with public share links
- Add single and batch share endpoints with signed URL tokens
- Add public view endpoints (no auth required) for shared recommendations
- Add share button with clipboard copy to RecommendationCard
- Create SharedView page with Vynl branding and registration CTA
- Add /shared/:recId/:token public route in App.tsx
- Add shareRecommendation and getSharedRecommendation API functions
2026-03-31 18:20:43 -05:00
root
3bab0b5911 Add feature roadmap prioritized by impact and effort 2026-03-31 18:15:10 -05:00
root
99ca2ff7cc Remove MusicBrainz/YT verification (async greenlet conflict), use direct YouTube Music search links 2026-03-31 16:14:42 -05:00
root
a0d9f1f9d9 Add live logs to admin dashboard with level filtering and error middleware 2026-03-31 15:54:21 -05:00
root
40322e8861 Add admin dashboard page with usage stats, user breakdown, admin-only nav 2026-03-31 15:43:58 -05:00
root
cc8bb0dd09 Add admin stats endpoint for usage tracking 2026-03-31 13:49:18 -05:00
root
fcca23e3ca Exclude queried artists from recommendations - only suggest new artists 2026-03-31 10:41:28 -05:00
root
51040e3723 Fix Discover: auto-scroll to results, improve mobile layout 2026-03-31 10:34:49 -05:00
root
75ca5fff64 Fix Discover page: stringify IDs for Set comparisons, show result count 2026-03-31 10:22:07 -05:00
root
85d4210a21 Switch to MusicBrainz for song verification, YouTube Music for playback links 2026-03-31 10:15:32 -05:00
root
9f9f9581d6 Verify all recommendations against YouTube Music - no more hallucinated songs, direct YT Music links 2026-03-31 10:12:37 -05:00
root
152f217675 Add Bandcamp discovery via public API (no scraping) - browse new releases by genre tag 2026-03-31 09:58:28 -05:00
root
be30a47bbb Remove Bandcamp scraping (TOS violation), use YouTube links for all recommendations 2026-03-31 09:51:20 -05:00
root
ccb49aa693 Fix login redirect: load user before navigating to dashboard 2026-03-31 09:47:32 -05:00
root
240032d972 Add Bandcamp/YouTube links on all recs, Fix My Playlist, configurable rec count
- Bandcamp: match artist+song, fall back to artist-only page
- YouTube fallback: if not on Bandcamp, link to YouTube music video search
- Fix My Playlist: AI analyzes playlist, finds outliers, suggests replacements
- User chooses recommendation count (5, 10, 15, 20)
2026-03-31 08:45:02 -05:00
root
47ab3dd847 Add Fix My Playlist feature
New endpoint POST /api/playlists/{id}/fix that analyzes a playlist
using Claude AI to identify outlier tracks that don't match the
overall vibe, and suggests better-fitting replacements.

Frontend shows results with warm amber cards for outliers and green
cards for replacements, with dismissible suggestions and visual
highlighting of flagged tracks in the track list.
2026-03-31 08:43:20 -05:00
root
cf2d8019bb Fix playlists API: disable redirect_slashes, fix route path for Vite proxy 2026-03-31 01:08:45 -05:00
root
018f5f23cb Fix YouTube Music import: isolate sync ytmusicapi from async DB session 2026-03-31 00:58:01 -05:00
root
50e9b492d5 Fix YouTube Music import (run sync ytmusicapi in thread), remove all Spotify UI 2026-03-31 00:30:55 -05:00
root
1eea237c08 Add discovery modes, personalization controls, taste profile page, updated pricing
- Discovery modes: Sonic Twin, Era Bridge, Deep Cuts, Rising Artists
- Discovery dial (Safe to Adventurous slider)
- Block genres/moods exclusion
- Thumbs down/dislike on recommendations
- My Taste page with Genre DNA breakdown, audio feature meters, listening personality
- Updated pricing: Free (5/week), Premium ($6.99/mo), Family ($12.99/mo coming soon)
- Weekly rate limiting instead of daily
- Alembic migration for new fields
2026-03-31 00:21:58 -05:00
root
789de25c1a Remove Listen page, tighten Bandcamp matching to 75%+ artist similarity 2026-03-31 00:10:15 -05:00
root
1efa5cd628 Verify Bandcamp results match the actual artist/track before including 2026-03-30 23:56:17 -05:00
root
c6a82cf9d9 Bandcamp mode: only return artists verified on Bandcamp, request 15 and filter to 5 2026-03-30 23:50:02 -05:00
root
44eab20614 Make Bandcamp mode opt-in toggle on Discover page 2026-03-30 23:44:04 -05:00
root
37fccc6eef Wire Bandcamp into AI recommendations - prioritize indie artists, attach Bandcamp links to results 2026-03-30 23:42:03 -05:00
root
dd4df6a070 Add Bandcamp search and Listening Room page
Implement Bandcamp search service with autocomplete API and HTML
scraping fallback. Add /api/bandcamp/search and /api/bandcamp/embed
endpoints. Create Listening Room page with search, embedded player,
and queue management. Add navigation entry and Bandcamp link on
recommendation cards.
2026-03-30 23:38:14 -05:00
root
3303cd1507 Fix Docker Compose: add PYTHONPATH for Alembic, use port 8100, fix Vite proxy for container networking 2026-03-30 23:06:52 -05:00
root
90945932ad Add Last.fm import support
Users can import their top tracks from Last.fm by entering their username.
No OAuth required - uses the public Last.fm API with user.getTopTracks and
user.getInfo endpoints. Includes a preview feature to verify the username
and see sample tracks before importing. Supports configurable time periods
(all time, 7 days, 1/3/6/12 months). Free tier playlist limit enforced.
2026-03-30 22:49:13 -05:00
root
d0ab1755bb Add paste-your-songs manual import feature
Users can now paste a list of songs as text to create a playlist without
needing any service integration. Supports multiple formats: "Artist - Title",
"Title by Artist", "Artist: Title", and numbered lists. Includes a live
song count preview in the modal and free tier playlist limit enforcement.
2026-03-30 22:48:35 -05:00
root
f799a12ed5 Add local dev setup guide and .env, fix defaults for local development 2026-03-30 22:08:39 -05:00
root
234a914480 Add launch checklist with accounts, legal, and infrastructure steps 2026-03-30 22:06:13 -05:00
root
93c0ba81d3 Set deepcutsai.com as production domain 2026-03-30 21:59:58 -05:00
root
cef7d576d4 Add production deployment config, Alembic migration, switch to Haiku
- Production Docker Compose with Caddy reverse proxy, Gunicorn, Nginx
- Multi-stage frontend build for production
- Deploy script and automated database backup script
- Initial Alembic migration with all tables
- Switch recommendation model from Sonnet to Haiku for cost efficiency
2026-03-30 21:40:16 -05:00
root
b97955d004 Add Stripe subscription billing integration
- Add stripe_customer_id and stripe_subscription_id fields to User model
- Add Stripe config settings (secret key, publishable key, price ID, webhook secret)
- Create billing API endpoints: checkout session, webhook handler, portal, status
- Add frontend Billing page with upgrade/manage subscription UI
- Add billing route and Pro nav link
- Add stripe dependency to requirements
2026-03-30 21:38:40 -05:00
root
58c17498be Add YouTube Music playlist import support
- Add ytmusicapi dependency for fetching public YouTube Music playlists
- Create youtube_music service with playlist fetching and track search
- Add /api/youtube-music/import and /api/youtube-music/search endpoints
- Add importYouTubePlaylist and searchYouTubeMusic API client functions
- Update Playlists page with YouTube Music import button and URL input modal
2026-03-30 21:33:27 -05:00
root
cd88ed2983 Revise architecture doc to reflect actual data pipeline (Spotify audio features + LLM) 2026-03-30 16:09:13 -05:00
root
32f7dca1c9 Add audio analysis architecture doc with Essentia pipeline design 2026-03-30 16:06:57 -05:00
77 changed files with 9062 additions and 280 deletions

93
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,93 @@
# Vynl - Recommendation Architecture
## Data Sources
### Spotify Audio Features API (already integrated)
Pre-computed by Spotify for every track:
- **Tempo** (BPM)
- **Energy** (0.01.0, intensity/activity)
- **Danceability** (0.01.0)
- **Valence** (0.01.0, musical positivity)
- **Acousticness** (0.01.0)
- **Instrumentalness** (0.01.0)
- **Key** and **Mode** (major/minor)
- **Loudness** (dB)
- **Speechiness** (0.01.0)
### Metadata (from Spotify + supplementary APIs)
- Artist name, album, release date
- Genres and tags
- Popularity score
- Related artists
### Supplementary APIs (to add)
- **MusicBrainz** — artist relationships, detailed genre/tag taxonomy, release info
- **Last.fm** — similar artists, user-generated tags, listener overlap stats
## Recommendation Pipeline
```
User imports playlist
Spotify API ──→ Track metadata + audio features
Build taste profile:
- Genre distribution
- Average energy/danceability/valence/tempo
- Mood tendencies
- Sample artists and tracks
LLM (cheap model) receives:
- Structured taste profile
- User's specific request/query
- List of tracks already in library (to exclude)
Returns recommendations with
"why you'll like this" explanations
```
## Model Choice
The LLM reasons over structured audio feature data + metadata. It needs broad music knowledge but not heavy reasoning. Cheapest model wins:
| Model | Cost (per 1M tokens) | Notes |
|-------|---------------------|-------|
| Claude Haiku 4.5 | $0.25 in / $1.25 out | Best value, great music knowledge |
| GPT-4o-mini | $0.15 in / $0.60 out | Cheapest option |
| Gemini 2.5 Flash | $0.15 in / $0.60 out | Also cheap, good quality |
| Claude Sonnet | $3 in / $15 out | Overkill for this task |
## Taste Profile Structure
Built from a user's imported tracks:
```json
{
"top_genres": [{"name": "indie rock", "count": 12}, ...],
"avg_energy": 0.65,
"avg_danceability": 0.55,
"avg_valence": 0.42,
"avg_tempo": 118.5,
"track_count": 47,
"sample_artists": ["Radiohead", "Tame Impala", ...],
"sample_tracks": ["Radiohead - Everything In Its Right Place", ...]
}
```
The LLM uses this profile to understand what the user gravitates toward sonically (high energy? melancholy? upbeat?) and find new music that matches or intentionally contrasts those patterns.
## Platform Support
### Currently Implemented
- Spotify (OAuth + playlist import + audio features)
### Planned
- YouTube Music (via `ytmusicapi`, unofficial Python library)
- Apple Music (MusicKit API, requires Apple Developer account)
- Last.fm (scrobble history import + similar artist data)
- Tidal (official API)
- Manual entry / CSV upload (fallback for any platform)

View File

@@ -33,6 +33,10 @@ See `DESIGN.md` for full product spec.
- **Colors**: Deep purple (#7C3AED) + warm cream (#FFF7ED) + charcoal (#1C1917)
- **Vibe**: Warm analog nostalgia meets modern AI
## Domain
- **deepcutsai.com** (hosted on Hostinger)
- App name remains **Vynl**
## Git
- Repo: To be created on Gitea (chrisryn/vynl)
- Repo: Gitea (chrisryn/vynl)
- Branches: master + dev (standard workflow)

19
Caddyfile Normal file
View File

@@ -0,0 +1,19 @@
{$DOMAIN:localhost} {
# Frontend
handle {
reverse_proxy frontend:80
}
# API
handle /api/* {
reverse_proxy backend:8000
}
# Security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
-Server
}
}

BIN
FEATURES.pdf Normal file

Binary file not shown.

51
LAUNCH_CHECKLIST.md Normal file
View File

@@ -0,0 +1,51 @@
# Vynl Launch Checklist
## Accounts to Create
- [ ] **Anthropic API account** — separate account for Vynl at console.anthropic.com, get API key
- [ ] **Spotify Developer App** — developer.spotify.com, create app, get client ID + secret, add `https://deepcutsai.com/auth/spotify/callback` as redirect URI
- [ ] **Stripe account** — stripe.com, create Product ("Vynl Pro") with $4.99/mo Price, get API keys + webhook secret
- [ ] **Cloudflare account** — free, point deepcutsai.com nameservers there for DNS + CDN + DDoS protection
- [ ] **Resend account** — resend.com, for transactional emails (signup, password reset), verify domain
- [ ] **Email receiving** — Cloudflare email routing (free) to forward hello@deepcutsai.com to personal inbox
## Legal (required for SaaS)
- [ ] **Privacy Policy** — collecting emails, Spotify data, listening habits. Use Termly (free) or iubenda
- [ ] **Terms of Service** — liability, acceptable use, refund policy
- [ ] **Cookie banner** — required for EU users
- [ ] **Stripe requirements** — refund policy and business address on site
## Business
- [ ] **Business entity** — sole proprietorship to start, LLC ($50-200) for protection
- [ ] **Business bank account** — where Stripe deposits go (personal works to start)
- [ ] **Stripe tax settings** — configure sales tax collection
## Infrastructure Setup
- [ ] **Hostinger VPS** — provision server
- [ ] **Point DNS** — A record for deepcutsai.com to VPS IP (via Cloudflare)
- [ ] **Clone repo on VPS** — pull from Gitea
- [ ] **Fill in backend/.env** — all real API keys
- [ ] **Run deploy.sh** — Caddy handles SSL automatically
- [ ] **Set up DB backups** — cron job running backup.sh
## Before Launch
- [ ] **Test full flow** — register, connect Spotify, import playlist, get recommendations, upgrade to Pro
- [ ] **Spotify quota extension** — new apps limited to 25 users in Development Mode, submit extension request to go public. **Takes 2-6 weeks — do this ASAP**
- [ ] **Set Anthropic spend limit** — cap monthly spend in console
- [ ] **Set Stripe webhook URL** — point to https://deepcutsai.com/api/billing/webhook
## Nice to Have for Launch
- [ ] **Landing page copy** — real marketing copy
- [ ] **Open Graph / social meta tags** — for sharing on Twitter/Discord
- [ ] **Analytics** — Google Analytics or Plausible
- [ ] **Error tracking** — Sentry free tier
- [ ] **Uptime monitoring** — UptimeRobot (free) or similar
## Critical Path
The **Spotify quota extension** is the longest lead time item (2-6 weeks). Start that application immediately, even before everything else is ready. Without it, only 25 users can connect Spotify.

105
LOCAL_SETUP.md Normal file
View File

@@ -0,0 +1,105 @@
# Vynl - Local Development Setup
## Prerequisites
- Docker and Docker Compose installed
- Spotify Developer account (for playlist import)
- Anthropic API key (for recommendations)
## 1. Configure Environment
Edit `backend/.env` and fill in your API keys:
```
SPOTIFY_CLIENT_ID=your-client-id
SPOTIFY_CLIENT_SECRET=your-client-secret
ANTHROPIC_API_KEY=your-api-key
```
Stripe keys are optional for local dev — billing will just not work without them.
## 2. Start Everything
```bash
docker compose up --build
```
This starts:
- **PostgreSQL** on port 5432
- **Redis** on port 6379
- **Backend API** on http://localhost:8000
- **Frontend** on http://localhost:5173
The backend runs Alembic migrations automatically on startup.
## 3. Open the App
Go to http://localhost:5173
- Register with email/password
- Or connect Spotify (must configure Spotify Developer App first)
## 4. Spotify Developer App Setup
1. Go to https://developer.spotify.com/dashboard
2. Create an app
3. Set redirect URI to: `http://localhost:5173/auth/spotify/callback`
4. Copy Client ID and Client Secret to `backend/.env`
Note: Spotify apps start in Development Mode (25 user limit). This is fine for testing.
## 5. Test the Flow
1. Register an account
2. Connect Spotify
3. Import a playlist
4. Go to Discover, select the playlist, click "Discover"
5. Save some recommendations
---
## External Access for Testers
To let others test without deploying to a server, use a **Cloudflare Tunnel** (free).
### Setup Cloudflare Tunnel
```bash
# Install cloudflared
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared
chmod +x /usr/local/bin/cloudflared
# Login (one time)
cloudflared tunnel login
# Create a tunnel
cloudflared tunnel create vynl-test
# Run the tunnel (points to your local frontend)
cloudflared tunnel --url http://localhost:5173 run vynl-test
```
This gives you a URL like `https://vynl-test-xxxxx.cfargotunnel.com` that anyone can access.
### Or use the quick method (no account needed)
```bash
cloudflared tunnel --url http://localhost:5173
```
This creates a temporary public URL instantly. Share it with testers. It expires when you stop the command.
### Important for external access
When using a tunnel, update `backend/.env`:
```
FRONTEND_URL=https://your-tunnel-url.cfargotunnel.com
SPOTIFY_REDIRECT_URI=https://your-tunnel-url.cfargotunnel.com/auth/spotify/callback
```
And add the tunnel URL as a redirect URI in your Spotify Developer App settings.
Then restart the backend:
```bash
docker compose restart backend
```

54
PRE_RELEASE_TODO.md Normal file
View File

@@ -0,0 +1,54 @@
# Pre-Release TODO
Things YOU need to do before we can build the final release tiers. None of these can be automated.
## Accounts & Services
- [ ] **Hostinger VPS** — provision a KVM 2 (2 vCPU, 8GB RAM, ~$8.50/mo), Ubuntu 24.04, US East
- [ ] **Point DNS** — log into your domain registrar, point deepcutsai.com nameservers to Cloudflare
- [ ] **Cloudflare account** — free, add deepcutsai.com, set up DNS A record to Hostinger VPS IP
- [ ] **Cloudflare email routing** — forward hello@deepcutsai.com to your personal inbox (free, 2 min setup)
- [ ] **Resend account** — sign up at resend.com, verify deepcutsai.com domain, get API key (for password reset & welcome emails)
- [ ] **Stripe account** — sign up, create a Product ("Vynl Premium" at $6.99/mo), get API keys + webhook secret + price ID
- [ ] **Separate Anthropic account** — you have a key already but rotate it since you shared it in chat
- [ ] **Last.fm API key** — free, instant at last.fm/api/account/create (for Last.fm import feature)
## Legal (required for SaaS taking payments)
- [ ] **Privacy Policy** — generate at termly.io (free), covers email, listening data, Stripe
- [ ] **Terms of Service** — generate at termly.io, covers refund policy, acceptable use
- [ ] **Business address** — Stripe requires one displayed on your site (can be a PO Box or registered agent)
- [ ] **Decide on business entity** — sole proprietorship works to start, LLC for liability protection ($50-200)
## Content & Branding
- [ ] **Landing page copy** — write real marketing copy or tell me to generate it
- [ ] **App Store description** — 1-2 paragraph description for sharing on social media
- [ ] **Social accounts** — Twitter/X, maybe Instagram for @deepcutsai or @vynlmusic
- [ ] **OG image** — a 1200x630 image for social sharing previews (or I can generate a placeholder)
## Testing
- [ ] **Test every feature end to end** — register, import playlist, discover, save, share, all pages
- [ ] **Test on mobile** — iPhone + Android, check all pages render properly
- [ ] **Test payment flow** — Stripe test mode checkout → upgrade to Pro → manage subscription
- [ ] **Get 3-5 beta testers** — real users giving honest feedback (you have 4 already!)
- [ ] **Collect beta feedback** — what works, what's confusing, what's missing
## Give Me When Ready
Once you have these, give me:
1. Hostinger VPS IP address
2. Resend API key
3. Stripe secret key, publishable key, price ID, webhook secret
4. Last.fm API key
5. Fresh Anthropic API key (rotate the one shared in chat)
Then I'll build:
- Production deployment to Hostinger
- Password reset + welcome emails via Resend
- Stripe payment flow (end to end)
- Privacy/Terms pages
- SEO meta tags + OG images
- Final mobile responsive pass
- Error tracking (Sentry)

92
ROADMAP.md Normal file
View File

@@ -0,0 +1,92 @@
# Vynl Feature Roadmap
Ordered by impact + ease of implementation.
## Tier 1 — Big Wins, Easy to Build
### 1. "More Like This" Button
- Button on every recommendation card
- Uses that single song as seed for a new discovery
- Just pre-fills the Discover query with "find songs similar to [artist] - [title]"
- **Effort: 30 min** — frontend-only, no backend changes
### 2. "Why Do I Like This?" Reverse Analysis
- User pastes a song, AI explains what draws them to it
- Then finds more songs with those same qualities
- New mode on Discover page or standalone input
- **Effort: 1 hour** — new prompt template, reuse existing UI
### 3. Mood Scanner
- Simple mood sliders: happy/sad, energetic/chill
- Injects mood context into the recommendation prompt
- "I'm feeling chill and melancholy — find me music for that"
- **Effort: 1-2 hours** — UI sliders + prompt modification
### 4. Share Discoveries
- "Share" button generates a public link like deepcutsai.com/share/abc123
- Shows recommendations without requiring login
- Great for viral growth on social media
- **Effort: 2-3 hours** — new endpoint + public page
### 5. "Surprise Me" Mode
- One button, no input needed
- AI picks a random obscure angle from user's taste profile
- "You seem to like songs in D minor with fingerpicked guitar — here's a rabbit hole"
- **Effort: 1-2 hours** — new discovery mode, creative prompt
## Tier 2 — Medium Effort, High Value
### 6. Playlist Generator
- Generate a full 20-30 song playlist around a theme
- "Road trip playlist", "Sunday morning cooking", "90s nostalgia"
- Save as a playlist in the app, export as text
- **Effort: 3-4 hours** — new endpoint, playlist creation, UI
### 7. Daily Digest Email
- Automated daily/weekly email with 5 personalized recommendations
- Brings users back without them having to open the app
- Requires Resend integration (already planned)
- **Effort: 4-5 hours** — Resend setup, email template, Celery scheduled task
### 8. Artist Deep Dive
- Click any artist name to get an AI card
- Shows: why they matter, influences, best album to start with, similar artists
- Modal or dedicated page
- **Effort: 3-4 hours** — new endpoint + UI component
### 9. Song Preview Embeds
- Inline YouTube Music player in recommendation cards
- Users can listen without leaving the app
- YouTube iframe embed with the video ID
- **Effort: 2-3 hours** — need to fetch YouTube video IDs (ytmusicapi), embed iframe
### 10. Music Timeline
- Visual timeline of when recommended/liked artists released music
- Shows patterns in user's taste ("you love 2008-2012 indie")
- Uses MusicBrainz release dates
- **Effort: 4-5 hours** — data fetching + timeline visualization
## Tier 3 — Bigger Builds, Major Differentiators
### 11. Collaborative Discovery / Taste Compatibility
- Two users compare taste profiles
- Compatibility percentage score
- Shared "you'd both love this" recommendations
- **Effort: 6-8 hours** — friend system, profile comparison logic, shared rec generation
### 12. Vinyl Crate Simulator
- Gamified discovery: swipe through 50 random albums
- Tinder-style UI: swipe right to save, left to pass
- Rapid feedback builds taste profile faster
- **Effort: 8-10 hours** — swipe UI, album data source, feedback loop
### 13. Concert Finder
- After recommending an artist, check if they're touring nearby
- Link to tickets (Songkick API or Bandsintown API)
- Potential affiliate revenue
- **Effort: 6-8 hours** — external API integration, location input, UI
### 14. "Surprise Me" Advanced — Rabbit Holes
- Multi-step guided journey: "Start here → then try this → go deeper"
- Each step builds on the last, taking the user on a curated path
- **Effort: 8-10 hours** — multi-step UI, state management, chained prompts

View File

@@ -4,6 +4,11 @@ DATABASE_URL_SYNC=postgresql://vynl:vynl@db:5432/vynl
REDIS_URL=redis://redis:6379/0
SPOTIFY_CLIENT_ID=your-spotify-client-id
SPOTIFY_CLIENT_SECRET=your-spotify-client-secret
SPOTIFY_REDIRECT_URI=http://localhost:5173/auth/spotify/callback
SPOTIFY_REDIRECT_URI=https://deepcutsai.com/auth/spotify/callback
ANTHROPIC_API_KEY=your-anthropic-api-key
FRONTEND_URL=http://localhost:5173
STRIPE_SECRET_KEY=sk_test_your-stripe-secret-key
STRIPE_PUBLISHABLE_KEY=pk_test_your-stripe-publishable-key
STRIPE_PRICE_ID=price_your-pro-plan-price-id
STRIPE_WEBHOOK_SECRET=whsec_your-webhook-signing-secret
LASTFM_API_KEY=your-lastfm-api-key
FRONTEND_URL=https://deepcutsai.com

16
backend/Dockerfile.prod Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn
COPY . .
RUN alembic upgrade head 2>/dev/null || true
EXPOSE 8000
CMD ["sh", "-c", "alembic upgrade head && gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000"]

View File

@@ -0,0 +1,90 @@
"""Initial schema
Revision ID: 001
Revises:
Create Date: 2026-03-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(255), unique=True, index=True, nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("hashed_password", sa.String(255), nullable=True),
sa.Column("is_pro", sa.Boolean(), default=False, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("spotify_id", sa.String(255), unique=True, nullable=True),
sa.Column("spotify_access_token", sa.Text(), nullable=True),
sa.Column("spotify_refresh_token", sa.Text(), nullable=True),
sa.Column("stripe_customer_id", sa.String(255), unique=True, nullable=True),
sa.Column("stripe_subscription_id", sa.String(255), nullable=True),
)
op.create_table(
"playlists",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False),
sa.Column("name", sa.String(500), nullable=False),
sa.Column("platform_source", sa.String(50), nullable=False),
sa.Column("external_id", sa.String(255), nullable=True),
sa.Column("track_count", sa.Integer(), default=0, nullable=False),
sa.Column("taste_profile", sa.JSON(), nullable=True),
sa.Column("imported_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"tracks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("playlist_id", sa.Integer(), sa.ForeignKey("playlists.id", ondelete="CASCADE"), index=True, nullable=False),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("artist", sa.String(500), nullable=False),
sa.Column("album", sa.String(500), nullable=True),
sa.Column("spotify_id", sa.String(255), nullable=True),
sa.Column("isrc", sa.String(20), nullable=True),
sa.Column("preview_url", sa.String(500), nullable=True),
sa.Column("image_url", sa.String(500), nullable=True),
sa.Column("tempo", sa.Float(), nullable=True),
sa.Column("energy", sa.Float(), nullable=True),
sa.Column("danceability", sa.Float(), nullable=True),
sa.Column("valence", sa.Float(), nullable=True),
sa.Column("acousticness", sa.Float(), nullable=True),
sa.Column("instrumentalness", sa.Float(), nullable=True),
sa.Column("genres", sa.JSON(), nullable=True),
)
op.create_table(
"recommendations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False),
sa.Column("playlist_id", sa.Integer(), sa.ForeignKey("playlists.id", ondelete="SET NULL"), nullable=True),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("artist", sa.String(500), nullable=False),
sa.Column("album", sa.String(500), nullable=True),
sa.Column("spotify_id", sa.String(255), nullable=True),
sa.Column("preview_url", sa.String(500), nullable=True),
sa.Column("image_url", sa.String(500), nullable=True),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("score", sa.Float(), nullable=True),
sa.Column("query", sa.Text(), nullable=True),
sa.Column("saved", sa.Boolean(), default=False, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
op.drop_table("recommendations")
op.drop_table("tracks")
op.drop_table("playlists")
op.drop_table("users")

View File

@@ -0,0 +1,24 @@
"""Add bandcamp_url to recommendations
Revision ID: 002
Revises: 001
Create Date: 2026-03-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("recommendations", sa.Column("bandcamp_url", sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column("recommendations", "bandcamp_url")

View File

@@ -0,0 +1,28 @@
"""Add dislike and personalization preferences
Revision ID: 003
Revises: 002
Create Date: 2026-03-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("recommendations", sa.Column("disliked", sa.Boolean(), default=False, nullable=False, server_default="false"))
op.add_column("users", sa.Column("blocked_genres", sa.Text(), nullable=True))
op.add_column("users", sa.Column("adventurousness", sa.Integer(), default=3, nullable=False, server_default="3"))
def downgrade() -> None:
op.drop_column("recommendations", "disliked")
op.drop_column("users", "blocked_genres")
op.drop_column("users", "adventurousness")

View File

@@ -0,0 +1,24 @@
"""Add youtube_url to recommendations
Revision ID: 004
Revises: 003
Create Date: 2026-03-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "004"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("recommendations", sa.Column("youtube_url", sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column("recommendations", "youtube_url")

View File

@@ -0,0 +1,180 @@
import logging
import io
import re
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.playlist import Playlist
from app.models.track import Track
from app.models.recommendation import Recommendation
router = APIRouter(prefix="/admin", tags=["admin"])
ADMIN_EMAILS = ["chris.ryan@deepcutsai.com"]
# In-memory log buffer for admin viewing
LOG_BUFFER_SIZE = 500
_log_buffer: list[dict] = []
class AdminLogHandler(logging.Handler):
"""Captures log records into an in-memory buffer for the admin UI."""
def emit(self, record):
entry = {
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": self.format(record),
}
_log_buffer.append(entry)
if len(_log_buffer) > LOG_BUFFER_SIZE:
_log_buffer.pop(0)
# Attach handler to root logger and uvicorn
_handler = AdminLogHandler()
_handler.setLevel(logging.INFO)
_handler.setFormatter(logging.Formatter("%(message)s"))
logging.getLogger().addHandler(_handler)
logging.getLogger("uvicorn.access").addHandler(_handler)
logging.getLogger("uvicorn.error").addHandler(_handler)
logging.getLogger("app").addHandler(_handler)
# Create app logger for explicit logging
app_logger = logging.getLogger("app")
app_logger.setLevel(logging.INFO)
@router.get("/stats")
async def get_stats(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if user.email not in ADMIN_EMAILS:
raise HTTPException(status_code=403, detail="Admin only")
now = datetime.now(timezone.utc)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
# User stats
total_users = (await db.execute(select(func.count(User.id)))).scalar() or 0
pro_users = (await db.execute(select(func.count(User.id)).where(User.is_pro == True))).scalar() or 0
# Playlist stats
total_playlists = (await db.execute(select(func.count(Playlist.id)))).scalar() or 0
total_tracks = (await db.execute(select(func.count(Track.id)))).scalar() or 0
# Recommendation stats
total_recs = (await db.execute(select(func.count(Recommendation.id)))).scalar() or 0
recs_today = (await db.execute(
select(func.count(Recommendation.id)).where(Recommendation.created_at >= today)
)).scalar() or 0
recs_this_week = (await db.execute(
select(func.count(Recommendation.id)).where(Recommendation.created_at >= week_ago)
)).scalar() or 0
recs_this_month = (await db.execute(
select(func.count(Recommendation.id)).where(Recommendation.created_at >= month_ago)
)).scalar() or 0
saved_recs = (await db.execute(
select(func.count(Recommendation.id)).where(Recommendation.saved == True)
)).scalar() or 0
disliked_recs = (await db.execute(
select(func.count(Recommendation.id)).where(Recommendation.disliked == True)
)).scalar() or 0
# Per-user breakdown
user_stats_result = await db.execute(
select(
User.id, User.name, User.email, User.is_pro, User.created_at,
func.count(Recommendation.id).label("rec_count"),
)
.outerjoin(Recommendation, Recommendation.user_id == User.id)
.group_by(User.id)
.order_by(User.id)
)
user_breakdown = [
{
"id": row.id,
"name": row.name,
"email": row.email,
"is_pro": row.is_pro,
"created_at": row.created_at.isoformat() if row.created_at else None,
"recommendation_count": row.rec_count,
}
for row in user_stats_result.all()
]
# Calculate API costs from log buffer
total_cost = 0.0
today_cost = 0.0
total_tokens_in = 0
total_tokens_out = 0
for log in _log_buffer:
if 'API_COST' in log.get('message', ''):
msg = log['message']
cost_match = re.search(r'cost=\$([0-9.]+)', msg)
if cost_match:
c = float(cost_match.group(1))
total_cost += c
# Check if today
if log['timestamp'][:10] == now.strftime('%Y-%m-%d'):
today_cost += c
input_match = re.search(r'input=(\d+)', msg)
output_match = re.search(r'output=(\d+)', msg)
if input_match:
total_tokens_in += int(input_match.group(1))
if output_match:
total_tokens_out += int(output_match.group(1))
return {
"users": {
"total": total_users,
"pro": pro_users,
"free": total_users - pro_users,
},
"playlists": {
"total": total_playlists,
"total_tracks": total_tracks,
},
"recommendations": {
"total": total_recs,
"today": recs_today,
"this_week": recs_this_week,
"this_month": recs_this_month,
"saved": saved_recs,
"disliked": disliked_recs,
},
"api_costs": {
"total_estimated": round(total_cost, 4),
"today_estimated": round(today_cost, 4),
"total_input_tokens": total_tokens_in,
"total_output_tokens": total_tokens_out,
},
"user_breakdown": user_breakdown,
}
@router.get("/logs")
async def get_logs(
user: User = Depends(get_current_user),
level: str = Query("ALL", description="Filter by level: ALL, ERROR, WARNING, INFO"),
limit: int = Query(100, description="Number of log entries"),
):
if user.email not in ADMIN_EMAILS:
raise HTTPException(status_code=403, detail="Admin only")
logs = _log_buffer[-limit:]
if level != "ALL":
logs = [l for l in logs if l["level"] == level.upper()]
return {"logs": list(reversed(logs)), "total": len(_log_buffer)}

View File

@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import hash_password, verify_password, create_access_token, get_current_user
from app.models.user import User
from pydantic import BaseModel, EmailStr
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse
from app.services.spotify import get_spotify_auth_url, exchange_spotify_code, get_spotify_user
@@ -29,6 +30,42 @@ async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
return TokenResponse(access_token=create_access_token(user.id))
class UpdateProfileRequest(BaseModel):
name: str | None = None
email: EmailStr | None = None
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
@router.put("/me")
async def update_profile(data: UpdateProfileRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
if data.name:
user.name = data.name
if data.email and data.email != user.email:
existing = await db.execute(select(User).where(User.email == data.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already in use")
user.email = data.email
return UserResponse(id=user.id, email=user.email, name=user.name, is_pro=user.is_pro, spotify_connected=user.spotify_id is not None)
@router.post("/change-password")
async def change_password(data: ChangePasswordRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
if not user.hashed_password or not verify_password(data.current_password, user.hashed_password):
raise HTTPException(status_code=400, detail="Current password is incorrect")
user.hashed_password = hash_password(data.new_password)
return {"ok": True}
@router.delete("/me")
async def delete_account(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
await db.delete(user)
return {"ok": True}
@router.post("/login", response_model=TokenResponse)
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == data.email))

View File

@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, Query
from app.core.security import get_current_user
from app.models.user import User
from app.services.bandcamp import discover_by_tag, get_trending_tags
router = APIRouter(prefix="/bandcamp", tags=["bandcamp"])
@router.get("/discover")
async def bandcamp_discover(
tags: str = Query(..., description="Comma-separated tags, e.g. 'indie-rock,shoegaze'"),
sort: str = Query("new", description="Sort: new, rec, or pop"),
page: int = Query(1),
user: User = Depends(get_current_user),
):
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
if not tag_list:
return []
return await discover_by_tag(tag_list, sort=sort, page=page)
@router.get("/tags")
async def bandcamp_tags(user: User = Depends(get_current_user)):
return await get_trending_tags()

View File

@@ -0,0 +1,152 @@
import stripe
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
router = APIRouter(prefix="/billing", tags=["billing"])
stripe.api_key = settings.STRIPE_SECRET_KEY
@router.post("/create-checkout")
async def create_checkout(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if user.is_pro:
raise HTTPException(status_code=400, detail="Already subscribed to Pro")
# Create Stripe customer if needed
if not user.stripe_customer_id:
customer = stripe.Customer.create(
email=user.email,
name=user.name,
metadata={"vynl_user_id": str(user.id)},
)
user.stripe_customer_id = customer.id
await db.flush()
session = stripe.checkout.Session.create(
customer=user.stripe_customer_id,
mode="subscription",
line_items=[{"price": settings.STRIPE_PRICE_ID, "quantity": 1}],
success_url=f"{settings.FRONTEND_URL}/billing?success=true",
cancel_url=f"{settings.FRONTEND_URL}/billing?canceled=true",
metadata={"vynl_user_id": str(user.id)},
)
return {"url": session.url}
@router.post("/webhook")
async def stripe_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
):
payload = await request.body()
sig_header = request.headers.get("stripe-signature", "")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid payload")
except stripe.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
event_type = event["type"]
data = event["data"]["object"]
if event_type == "checkout.session.completed":
customer_id = data.get("customer")
subscription_id = data.get("subscription")
if customer_id:
result = await db.execute(
select(User).where(User.stripe_customer_id == customer_id)
)
user = result.scalar_one_or_none()
if user:
user.is_pro = True
user.stripe_subscription_id = subscription_id
await db.flush()
elif event_type == "customer.subscription.deleted":
customer_id = data.get("customer")
if customer_id:
result = await db.execute(
select(User).where(User.stripe_customer_id == customer_id)
)
user = result.scalar_one_or_none()
if user:
user.is_pro = False
user.stripe_subscription_id = None
await db.flush()
elif event_type == "customer.subscription.updated":
customer_id = data.get("customer")
sub_status = data.get("status")
if customer_id:
result = await db.execute(
select(User).where(User.stripe_customer_id == customer_id)
)
user = result.scalar_one_or_none()
if user:
user.is_pro = sub_status in ("active", "trialing")
user.stripe_subscription_id = data.get("id")
await db.flush()
elif event_type == "invoice.payment_failed":
customer_id = data.get("customer")
if customer_id:
result = await db.execute(
select(User).where(User.stripe_customer_id == customer_id)
)
user = result.scalar_one_or_none()
if user:
user.is_pro = False
await db.flush()
return {"status": "ok"}
@router.post("/portal")
async def create_portal(
user: User = Depends(get_current_user),
):
if not user.stripe_customer_id:
raise HTTPException(status_code=400, detail="No billing account found")
session = stripe.billing_portal.Session.create(
customer=user.stripe_customer_id,
return_url=f"{settings.FRONTEND_URL}/billing",
)
return {"url": session.url}
@router.get("/status")
async def billing_status(
user: User = Depends(get_current_user),
):
subscription_status = None
current_period_end = None
if user.stripe_subscription_id:
try:
sub = stripe.Subscription.retrieve(user.stripe_subscription_id)
subscription_status = sub.status
current_period_end = sub.current_period_end
except stripe.StripeError:
pass
return {
"is_pro": user.is_pro,
"subscription_status": subscription_status,
"current_period_end": current_period_end,
}

View File

@@ -0,0 +1,225 @@
import json
import logging
import anthropic
api_logger = logging.getLogger("app")
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.playlist import Playlist
from app.models.track import Track
from app.services.recommender import build_taste_profile
router = APIRouter(prefix="/profile", tags=["profile"])
class CompatibilityRequest(BaseModel):
friend_email: str
class CompatibilityResponse(BaseModel):
friend_name: str
compatibility_score: int
shared_genres: list[str]
unique_to_you: list[str]
unique_to_them: list[str]
shared_artists: list[str]
insight: str
recommendations: list[dict]
async def _get_user_tracks(db: AsyncSession, user_id: int) -> list[Track]:
"""Load all tracks across all playlists for a user."""
result = await db.execute(
select(Playlist).where(Playlist.user_id == user_id)
)
playlists = list(result.scalars().all())
all_tracks = []
for p in playlists:
result = await db.execute(select(Track).where(Track.playlist_id == p.id))
all_tracks.extend(result.scalars().all())
return all_tracks
def _extract_genres(tracks: list[Track]) -> set[str]:
"""Get the set of genres from a user's tracks."""
genres = set()
for t in tracks:
if t.genres:
for g in t.genres:
genres.add(g)
return genres
def _extract_artists(tracks: list[Track]) -> set[str]:
"""Get the set of artists from a user's tracks."""
return {t.artist for t in tracks}
def _audio_feature_avg(tracks: list[Track], attr: str) -> float:
"""Calculate the average of an audio feature across tracks."""
vals = [getattr(t, attr) for t in tracks if getattr(t, attr) is not None]
return sum(vals) / len(vals) if vals else 0.0
def _calculate_compatibility(
my_tracks: list[Track],
their_tracks: list[Track],
) -> tuple[int, list[str], list[str], list[str], list[str]]:
"""Calculate a weighted compatibility score between two users.
Returns (score, shared_genres, unique_to_you, unique_to_them, shared_artists).
"""
my_genres = _extract_genres(my_tracks)
their_genres = _extract_genres(their_tracks)
my_artists = _extract_artists(my_tracks)
their_artists = _extract_artists(their_tracks)
shared_genres = sorted(my_genres & their_genres)
unique_to_you = sorted(my_genres - their_genres)
unique_to_them = sorted(their_genres - my_genres)
shared_artists = sorted(my_artists & their_artists)
# Genre overlap (40% weight)
all_genres = my_genres | their_genres
genre_score = (len(shared_genres) / len(all_genres) * 100) if all_genres else 0
# Shared artists (30% weight)
all_artists = my_artists | their_artists
artist_score = (len(shared_artists) / len(all_artists) * 100) if all_artists else 0
# Audio feature similarity (30% weight)
feature_diffs = []
for attr in ("energy", "valence", "danceability"):
my_avg = _audio_feature_avg(my_tracks, attr)
their_avg = _audio_feature_avg(their_tracks, attr)
feature_diffs.append(abs(my_avg - their_avg))
avg_diff = sum(feature_diffs) / len(feature_diffs) if feature_diffs else 0
feature_score = max(0, (1 - avg_diff) * 100)
score = int(genre_score * 0.4 + artist_score * 0.3 + feature_score * 0.3)
score = max(0, min(100, score))
return score, shared_genres, unique_to_you, unique_to_them, shared_artists
async def _generate_ai_insight(
profile1: dict,
profile2: dict,
score: int,
shared_genres: list[str],
shared_artists: list[str],
) -> tuple[str, list[dict]]:
"""Call Claude to generate an insight and shared recommendations."""
prompt = f"""Two music lovers want to know their taste compatibility.
User 1 taste profile:
{json.dumps(profile1, indent=2)}
User 2 taste profile:
{json.dumps(profile2, indent=2)}
Their compatibility score is {score}%.
Shared genres: {", ".join(shared_genres) if shared_genres else "None"}
Shared artists: {", ".join(shared_artists) if shared_artists else "None"}
Respond with JSON:
{{
"insight": "A fun 2-3 sentence description of their musical relationship",
"recommendations": [
{{"title": "...", "artist": "...", "reason": "Why both would love this"}}
]
}}
Return ONLY the JSON. Include exactly 5 recommendations."""
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
# Track API cost (Haiku: $0.80/M input, $4/M output)
input_tokens = message.usage.input_tokens
output_tokens = message.usage.output_tokens
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user=system|endpoint=compatibility")
try:
text = message.content[0].text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip()
data = json.loads(text)
return data.get("insight", ""), data.get("recommendations", [])
except (json.JSONDecodeError, IndexError, KeyError):
return "These two listeners have an interesting musical connection!", []
@router.post("/compatibility", response_model=CompatibilityResponse)
async def check_compatibility(
data: CompatibilityRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Compare your taste profile with another user."""
if data.friend_email.lower() == user.email.lower():
raise HTTPException(status_code=400, detail="You can't compare with yourself!")
# Look up the friend
result = await db.execute(
select(User).where(User.email == data.friend_email.lower())
)
friend = result.scalar_one_or_none()
if not friend:
raise HTTPException(
status_code=404,
detail="No user found with that email. They need to have a Vynl account first!",
)
# Load tracks for both users
my_tracks = await _get_user_tracks(db, user.id)
their_tracks = await _get_user_tracks(db, friend.id)
if not my_tracks:
raise HTTPException(
status_code=400,
detail="You need to import some playlists first!",
)
if not their_tracks:
raise HTTPException(
status_code=400,
detail="Your friend hasn't imported any playlists yet!",
)
# Calculate compatibility
score, shared_genres, unique_to_you, unique_to_them, shared_artists = (
_calculate_compatibility(my_tracks, their_tracks)
)
# Build taste profiles for AI
profile1 = build_taste_profile(my_tracks)
profile2 = build_taste_profile(their_tracks)
# Generate AI insight and recommendations
insight, recommendations = await _generate_ai_insight(
profile1, profile2, score, shared_genres[:10], shared_artists[:10]
)
return CompatibilityResponse(
friend_name=friend.name,
compatibility_score=score,
shared_genres=shared_genres[:15],
unique_to_you=unique_to_you[:10],
unique_to_them=unique_to_them[:10],
shared_artists=shared_artists[:15],
insight=insight,
recommendations=recommendations,
)

View 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]
],
}

View File

@@ -0,0 +1,147 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.playlist import Playlist
from app.models.track import Track
from app.services.lastfm import get_user_info, get_top_tracks
from app.services.recommender import build_taste_profile
from app.schemas.playlist import PlaylistDetailResponse
router = APIRouter(prefix="/lastfm", tags=["lastfm"])
class ImportLastfmRequest(BaseModel):
username: str
period: str = "overall"
class LastfmPreviewTrack(BaseModel):
title: str
artist: str
playcount: int
image_url: str | None = None
class LastfmPreviewResponse(BaseModel):
display_name: str
track_count: int
sample_tracks: list[LastfmPreviewTrack]
@router.get("/preview", response_model=LastfmPreviewResponse)
async def preview_lastfm(
username: str = Query(..., min_length=1),
user: User = Depends(get_current_user),
):
"""Preview a Last.fm user's top tracks without importing."""
if not settings.LASTFM_API_KEY:
raise HTTPException(status_code=500, detail="Last.fm API key not configured")
info = await get_user_info(username.strip())
if not info:
raise HTTPException(status_code=404, detail="Last.fm user not found")
try:
tracks = await get_top_tracks(username.strip(), period="overall", limit=50)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
sample = tracks[:5]
return LastfmPreviewResponse(
display_name=info["display_name"],
track_count=len(tracks),
sample_tracks=[LastfmPreviewTrack(**t) for t in sample],
)
@router.post("/import", response_model=PlaylistDetailResponse)
async def import_lastfm(
data: ImportLastfmRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Import top tracks from a Last.fm user as a playlist."""
if not settings.LASTFM_API_KEY:
raise HTTPException(status_code=500, detail="Last.fm API key not configured")
username = data.username.strip()
if not username:
raise HTTPException(status_code=400, detail="Username is required")
# Free tier limit
if not user.is_pro:
result = await db.execute(
select(Playlist).where(Playlist.user_id == user.id)
)
existing = list(result.scalars().all())
if len(existing) >= settings.FREE_MAX_PLAYLISTS:
raise HTTPException(
status_code=403,
detail="Free tier limited to 1 playlist. Upgrade to Pro for unlimited.",
)
# Verify user exists
info = await get_user_info(username)
if not info:
raise HTTPException(status_code=404, detail="Last.fm user not found")
# Fetch top tracks
try:
raw_tracks = await get_top_tracks(username, period=data.period, limit=50)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not raw_tracks:
raise HTTPException(
status_code=400,
detail="No top tracks found for this user and time period.",
)
# Build playlist name from period
period_labels = {
"overall": "All Time",
"7day": "Last 7 Days",
"1month": "Last Month",
"3month": "Last 3 Months",
"6month": "Last 6 Months",
"12month": "Last Year",
}
period_label = period_labels.get(data.period, "All Time")
playlist_name = f"{info['display_name']}'s Top Tracks ({period_label})"
# Create playlist
playlist = Playlist(
user_id=user.id,
name=playlist_name,
platform_source="lastfm",
external_id=f"lastfm:{username}:{data.period}",
track_count=len(raw_tracks),
)
db.add(playlist)
await db.flush()
# Create tracks (no audio features from Last.fm)
tracks = []
for rt in raw_tracks:
track = Track(
playlist_id=playlist.id,
title=rt["title"],
artist=rt["artist"],
image_url=rt.get("image_url"),
)
db.add(track)
tracks.append(track)
await db.flush()
# Build taste profile
playlist.taste_profile = build_taste_profile(tracks)
playlist.tracks = tracks
return playlist

View File

@@ -0,0 +1,112 @@
import re
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.playlist import Playlist
from app.models.track import Track
from app.services.recommender import build_taste_profile
from app.schemas.playlist import PlaylistDetailResponse
router = APIRouter(prefix="/import", tags=["import"])
class PasteImportRequest(BaseModel):
name: str
text: str
def parse_song_line(line: str) -> dict | None:
line = line.strip()
if not line:
return None
# Strip leading numbering: "1.", "2)", "1 -", "01.", etc.
line = re.sub(r"^\d+[\.\)\-\:]\s*", "", line).strip()
if not line:
return None
# Try "Artist - Title" (most common)
if " - " in line:
parts = line.split(" - ", 1)
return {"artist": parts[0].strip(), "title": parts[1].strip()}
# Try "Title by Artist"
if " by " in line.lower():
idx = line.lower().index(" by ")
return {"title": line[:idx].strip(), "artist": line[idx + 4 :].strip()}
# Try "Artist: Title"
if ": " in line:
parts = line.split(": ", 1)
return {"artist": parts[0].strip(), "title": parts[1].strip()}
# Fallback: treat whole line as title with unknown artist
return {"title": line, "artist": "Unknown"}
@router.post("/paste", response_model=PlaylistDetailResponse)
async def import_pasted_songs(
data: PasteImportRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if not data.name.strip():
raise HTTPException(status_code=400, detail="Playlist name is required")
if not data.text.strip():
raise HTTPException(status_code=400, detail="No songs provided")
# Free tier limit
if not user.is_pro:
result = await db.execute(
select(Playlist).where(Playlist.user_id == user.id)
)
existing = list(result.scalars().all())
if len(existing) >= settings.FREE_MAX_PLAYLISTS:
raise HTTPException(
status_code=403,
detail="Free tier limited to 1 playlist. Upgrade to Pro for unlimited.",
)
# Parse lines
lines = data.text.strip().splitlines()
parsed = [parse_song_line(line) for line in lines]
parsed = [p for p in parsed if p is not None]
if not parsed:
raise HTTPException(status_code=400, detail="Could not parse any songs from the text")
# Create playlist
playlist = Playlist(
user_id=user.id,
name=data.name.strip(),
platform_source="manual",
track_count=len(parsed),
)
db.add(playlist)
await db.flush()
# Create tracks
tracks = []
for p in parsed:
track = Track(
playlist_id=playlist.id,
title=p["title"],
artist=p["artist"],
)
db.add(track)
tracks.append(track)
await db.flush()
# Build taste profile
playlist.taste_profile = build_taste_profile(tracks)
playlist.tracks = tracks
return playlist

View File

@@ -0,0 +1,141 @@
import json
import logging
import anthropic
api_logger = logging.getLogger("app")
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.playlist import Playlist
from app.models.track import Track
from app.models.user import User
from app.services.recommender import build_taste_profile
router = APIRouter(prefix="/playlists", tags=["playlists"])
class PlaylistFixRequest(BaseModel):
count: int = 5
class OutlierTrack(BaseModel):
track_number: int
artist: str
title: str
reason: str
class ReplacementTrack(BaseModel):
title: str
artist: str
album: str | None = None
reason: str
class PlaylistFixResponse(BaseModel):
playlist_vibe: str
outliers: list[OutlierTrack]
replacements: list[ReplacementTrack]
@router.post("/{playlist_id}/fix", response_model=PlaylistFixResponse)
async def fix_playlist(
playlist_id: int,
data: PlaylistFixRequest = PlaylistFixRequest(),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Load playlist
result = await db.execute(
select(Playlist).where(Playlist.id == playlist_id, Playlist.user_id == user.id)
)
playlist = result.scalar_one_or_none()
if not playlist:
raise HTTPException(status_code=404, detail="Playlist not found")
# Load tracks
result = await db.execute(
select(Track).where(Track.playlist_id == playlist.id)
)
tracks = list(result.scalars().all())
if not tracks:
raise HTTPException(status_code=400, detail="Playlist has no tracks")
# Build taste profile
taste_profile = build_taste_profile(tracks)
# Build numbered track list
track_list = "\n".join(
f"{i + 1}. {t.artist} - {t.title}" for i, t in enumerate(tracks)
)
count = min(max(data.count, 1), 10)
prompt = f"""You are Vynl, a music playlist curator. Analyze this playlist and identify tracks that don't fit the overall vibe.
Playlist: {playlist.name}
Taste profile: {json.dumps(taste_profile, indent=2)}
Tracks:
{track_list}
Analyze the playlist and respond with a JSON object:
{{
"playlist_vibe": "A 1-2 sentence description of the overall playlist vibe/mood",
"outliers": [
{{
"track_number": 1,
"artist": "...",
"title": "...",
"reason": "Why this track doesn't fit the playlist vibe"
}}
],
"replacements": [
{{
"title": "...",
"artist": "...",
"album": "...",
"reason": "Why this fits better"
}}
]
}}
Identify up to {count} outlier tracks. For each outlier, suggest a replacement that fits the playlist vibe better. Focus on maintaining sonic cohesion — same energy, tempo range, and mood.
Return ONLY the JSON object."""
# Call Claude API
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=2000,
messages=[{"role": "user", "content": prompt}],
)
# Track API cost (Haiku: $0.80/M input, $4/M output)
input_tokens = message.usage.input_tokens
output_tokens = message.usage.output_tokens
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=playlist_fix")
response_text = message.content[0].text.strip()
# Handle potential markdown code blocks
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
response_text = response_text.rsplit("```", 1)[0]
try:
fix_data = json.loads(response_text)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Failed to parse AI response")
return PlaylistFixResponse(
playlist_vibe=fix_data.get("playlist_vibe", ""),
outliers=[OutlierTrack(**o) for o in fix_data.get("outliers", [])],
replacements=[ReplacementTrack(**r) for r in fix_data.get("replacements", [])],
)

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import PlainTextResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -21,7 +22,7 @@ from app.services.recommender import build_taste_profile
router = APIRouter(prefix="/playlists", tags=["playlists"])
@router.get("/", response_model=list[PlaylistResponse])
@router.get("", response_model=list[PlaylistResponse])
async def list_playlists(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
@@ -49,6 +50,39 @@ async def get_playlist(
return playlist
@router.get("/{playlist_id}/export")
async def export_playlist(
playlist_id: int,
format: str = Query("text", pattern="^(text|csv)$"),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Playlist).options(selectinload(Playlist.tracks))
.where(Playlist.id == playlist_id, Playlist.user_id == user.id)
)
playlist = result.scalar_one_or_none()
if not playlist:
raise HTTPException(status_code=404, detail="Playlist not found")
if format == "csv":
lines = ["Title,Artist,Album"]
for t in playlist.tracks:
# Escape commas in CSV
title = f'"{t.title}"' if ',' in t.title else t.title
artist = f'"{t.artist}"' if ',' in t.artist else t.artist
album = f'"{t.album}"' if t.album and ',' in t.album else (t.album or '')
lines.append(f"{title},{artist},{album}")
return PlainTextResponse("\n".join(lines), media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{playlist.name}.csv"'})
else:
lines = [f"{playlist.name}", "=" * len(playlist.name), ""]
for i, t in enumerate(playlist.tracks, 1):
lines.append(f"{i}. {t.artist} - {t.title}")
return PlainTextResponse("\n".join(lines), media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="{playlist.name}.txt"'})
@router.delete("/{playlist_id}")
async def delete_playlist(
playlist_id: int,

View File

@@ -0,0 +1,364 @@
import hashlib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.playlist import Playlist
from app.models.track import Track
router = APIRouter(prefix="/profile", tags=["profile"])
def _determine_personality(
genre_count: int,
avg_energy: float,
avg_valence: float,
avg_acousticness: float,
energy_variance: float,
valence_variance: float,
) -> dict:
"""Assign a listening personality based on taste data."""
# High variance in energy/valence = Mood Listener
if energy_variance > 0.06 and valence_variance > 0.06:
return {
"label": "Mood Listener",
"description": "Your music shifts with your emotions. You have playlists for every feeling and aren't afraid to go from euphoric highs to contemplative lows.",
"icon": "drama",
}
# Many different genres = Genre Explorer
if genre_count >= 8:
return {
"label": "Genre Explorer",
"description": "You refuse to be put in a box. Your library is a world tour of sounds, spanning genres most listeners never discover.",
"icon": "globe",
}
# High energy = Energy Seeker
if avg_energy > 0.7:
return {
"label": "Energy Seeker",
"description": "You crave intensity. Whether it's driving beats, soaring guitars, or thundering bass, your music keeps the adrenaline flowing.",
"icon": "zap",
}
# Low energy + high acousticness = Chill Master
if avg_energy < 0.4 and avg_acousticness > 0.5:
return {
"label": "Chill Master",
"description": "You've mastered the art of the vibe. Acoustic textures and mellow grooves define your sonic world — your playlists are a warm blanket.",
"icon": "cloud",
}
# Very consistent taste, low variance = Comfort Listener
if energy_variance < 0.03 and valence_variance < 0.03:
return {
"label": "Comfort Listener",
"description": "You know exactly what you like and you lean into it. Your taste is refined, consistent, and deeply personal.",
"icon": "heart",
}
# Default: Catalog Diver
return {
"label": "Catalog Diver",
"description": "You dig deeper than the singles. Album tracks, B-sides, and deep cuts are your territory — you appreciate the full artistic vision.",
"icon": "layers",
}
@router.get("/taste")
async def get_taste_profile(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Aggregate all user playlists/tracks into a full taste profile."""
result = await db.execute(
select(Playlist).where(Playlist.user_id == user.id)
)
playlists = list(result.scalars().all())
all_tracks = []
for p in playlists:
result = await db.execute(select(Track).where(Track.playlist_id == p.id))
all_tracks.extend(result.scalars().all())
if not all_tracks:
return {
"genre_breakdown": [],
"audio_features": {
"energy": 0,
"danceability": 0,
"valence": 0,
"acousticness": 0,
"avg_tempo": 0,
},
"personality": {
"label": "New Listener",
"description": "Import some playlists to discover your listening personality!",
"icon": "music",
},
"top_artists": [],
"track_count": 0,
"playlist_count": len(playlists),
}
# Genre breakdown
genres_count: dict[str, int] = {}
for t in all_tracks:
if t.genres:
for g in t.genres:
genres_count[g] = genres_count.get(g, 0) + 1
total_genre_mentions = sum(genres_count.values()) or 1
top_genres = sorted(genres_count.items(), key=lambda x: x[1], reverse=True)[:10]
genre_breakdown = [
{"name": g, "percentage": round((c / total_genre_mentions) * 100, 1)}
for g, c in top_genres
]
# Audio features averages + variance
energies = []
danceabilities = []
valences = []
acousticnesses = []
tempos = []
for t in all_tracks:
if t.energy is not None:
energies.append(t.energy)
if t.danceability is not None:
danceabilities.append(t.danceability)
if t.valence is not None:
valences.append(t.valence)
if t.acousticness is not None:
acousticnesses.append(t.acousticness)
if t.tempo is not None:
tempos.append(t.tempo)
def avg(lst: list[float]) -> float:
return round(sum(lst) / len(lst), 3) if lst else 0
def variance(lst: list[float]) -> float:
if len(lst) < 2:
return 0
m = sum(lst) / len(lst)
return sum((x - m) ** 2 for x in lst) / len(lst)
avg_energy = avg(energies)
avg_danceability = avg(danceabilities)
avg_valence = avg(valences)
avg_acousticness = avg(acousticnesses)
avg_tempo = round(avg(tempos), 0)
# Personality
personality = _determine_personality(
genre_count=len(genres_count),
avg_energy=avg_energy,
avg_valence=avg_valence,
avg_acousticness=avg_acousticness,
energy_variance=variance(energies),
valence_variance=variance(valences),
)
# Top artists
artist_count: dict[str, int] = {}
for t in all_tracks:
artist_count[t.artist] = artist_count.get(t.artist, 0) + 1
top_artists_sorted = sorted(artist_count.items(), key=lambda x: x[1], reverse=True)[:8]
# Find a representative genre for each top artist
artist_genres: dict[str, str] = {}
for t in all_tracks:
if t.artist in dict(top_artists_sorted) and t.genres and t.artist not in artist_genres:
artist_genres[t.artist] = t.genres[0]
top_artists = [
{
"name": name,
"track_count": count,
"genre": artist_genres.get(name, ""),
}
for name, count in top_artists_sorted
]
return {
"genre_breakdown": genre_breakdown,
"audio_features": {
"energy": round(avg_energy * 100),
"danceability": round(avg_danceability * 100),
"valence": round(avg_valence * 100),
"acousticness": round(avg_acousticness * 100),
"avg_tempo": avg_tempo,
},
"personality": personality,
"top_artists": top_artists,
"track_count": len(all_tracks),
"playlist_count": len(playlists),
}
async def _build_taste_profile(user_id: int, db: AsyncSession) -> dict:
"""Build a taste profile dict for the given user_id (shared logic)."""
result = await db.execute(
select(Playlist).where(Playlist.user_id == user_id)
)
playlists = list(result.scalars().all())
all_tracks = []
for p in playlists:
result = await db.execute(select(Track).where(Track.playlist_id == p.id))
all_tracks.extend(result.scalars().all())
if not all_tracks:
return {
"genre_breakdown": [],
"audio_features": {
"energy": 0,
"danceability": 0,
"valence": 0,
"acousticness": 0,
"avg_tempo": 0,
},
"personality": {
"label": "New Listener",
"description": "Import some playlists to discover your listening personality!",
"icon": "music",
},
"top_artists": [],
"track_count": 0,
"playlist_count": len(playlists),
}
# Genre breakdown
genres_count: dict[str, int] = {}
for t in all_tracks:
if t.genres:
for g in t.genres:
genres_count[g] = genres_count.get(g, 0) + 1
total_genre_mentions = sum(genres_count.values()) or 1
top_genres = sorted(genres_count.items(), key=lambda x: x[1], reverse=True)[:10]
genre_breakdown = [
{"name": g, "percentage": round((c / total_genre_mentions) * 100, 1)}
for g, c in top_genres
]
# Audio features averages + variance
energies = []
danceabilities = []
valences = []
acousticnesses = []
tempos = []
for t in all_tracks:
if t.energy is not None:
energies.append(t.energy)
if t.danceability is not None:
danceabilities.append(t.danceability)
if t.valence is not None:
valences.append(t.valence)
if t.acousticness is not None:
acousticnesses.append(t.acousticness)
if t.tempo is not None:
tempos.append(t.tempo)
def avg(lst: list[float]) -> float:
return round(sum(lst) / len(lst), 3) if lst else 0
def variance(lst: list[float]) -> float:
if len(lst) < 2:
return 0
m = sum(lst) / len(lst)
return sum((x - m) ** 2 for x in lst) / len(lst)
avg_energy = avg(energies)
avg_danceability = avg(danceabilities)
avg_valence = avg(valences)
avg_acousticness = avg(acousticnesses)
avg_tempo = round(avg(tempos), 0)
# Personality
personality = _determine_personality(
genre_count=len(genres_count),
avg_energy=avg_energy,
avg_valence=avg_valence,
avg_acousticness=avg_acousticness,
energy_variance=variance(energies),
valence_variance=variance(valences),
)
# Top artists
artist_count: dict[str, int] = {}
for t in all_tracks:
artist_count[t.artist] = artist_count.get(t.artist, 0) + 1
top_artists_sorted = sorted(artist_count.items(), key=lambda x: x[1], reverse=True)[:8]
artist_genres: dict[str, str] = {}
for t in all_tracks:
if t.artist in dict(top_artists_sorted) and t.genres and t.artist not in artist_genres:
artist_genres[t.artist] = t.genres[0]
top_artists = [
{
"name": name,
"track_count": count,
"genre": artist_genres.get(name, ""),
}
for name, count in top_artists_sorted
]
return {
"genre_breakdown": genre_breakdown,
"audio_features": {
"energy": round(avg_energy * 100),
"danceability": round(avg_danceability * 100),
"valence": round(avg_valence * 100),
"acousticness": round(avg_acousticness * 100),
"avg_tempo": avg_tempo,
},
"personality": personality,
"top_artists": top_artists,
"track_count": len(all_tracks),
"playlist_count": len(playlists),
}
def _generate_profile_token(user_id: int) -> str:
"""Generate a deterministic share token for a user's profile."""
return hashlib.sha256(
f"profile:{user_id}:{settings.SECRET_KEY}".encode()
).hexdigest()[:16]
@router.get("/share-link")
async def get_profile_share_link(user: User = Depends(get_current_user)):
"""Generate a share link for the user's taste profile."""
token = _generate_profile_token(user.id)
return {"share_url": f"{settings.FRONTEND_URL}/taste/{user.id}/{token}"}
@router.get("/public/{user_id}/{token}")
async def get_public_profile(
user_id: int,
token: str,
db: AsyncSession = Depends(get_db),
):
"""Public taste profile — no auth required."""
expected = _generate_profile_token(user_id)
if token != expected:
raise HTTPException(status_code=404, detail="Invalid profile link")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="Profile not found")
profile = await _build_taste_profile(user_id, db)
profile["name"] = user.name.split()[0] # First name only for privacy
return profile

View File

@@ -1,13 +1,26 @@
import hashlib
import json
import logging
from urllib.parse import quote_plus
import anthropic
api_logger = logging.getLogger("app")
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.playlist import Playlist
from app.models.track import Track
from app.models.recommendation import Recommendation
from app.schemas.recommendation import RecommendationRequest, RecommendationResponse, RecommendationItem
from app.services.recommender import generate_recommendations
from app.services.recommender import generate_recommendations, build_taste_profile
router = APIRouter(prefix="/recommendations", tags=["recommendations"])
@@ -22,15 +35,35 @@ async def generate(
raise HTTPException(status_code=400, detail="Provide a playlist_id or query")
recs, remaining = await generate_recommendations(
db, user, playlist_id=data.playlist_id, query=data.query
db, user, playlist_id=data.playlist_id, query=data.query, bandcamp_mode=data.bandcamp_mode,
mode=data.mode, adventurousness=data.adventurousness, exclude=data.exclude, count=data.count,
mood_energy=data.mood_energy, mood_valence=data.mood_valence,
)
if not recs and remaining == 0:
raise HTTPException(status_code=429, detail="Daily recommendation limit reached. Upgrade to Pro for unlimited.")
raise HTTPException(status_code=429, detail="Weekly recommendation limit reached. Upgrade to Premium for unlimited.")
return RecommendationResponse(
recommendations=[RecommendationItem.model_validate(r) for r in recs],
remaining_today=remaining,
remaining_this_week=remaining,
)
@router.post("/surprise", response_model=RecommendationResponse)
async def surprise(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
recs, remaining = await generate_recommendations(
db, user, query=None, mode="surprise", count=5
)
if not recs and remaining == 0:
raise HTTPException(status_code=429, detail="Weekly recommendation limit reached. Upgrade to Premium for unlimited.")
return RecommendationResponse(
recommendations=[RecommendationItem.model_validate(r) for r in recs],
remaining_this_week=remaining,
)
@@ -48,6 +81,24 @@ async def history(
return result.scalars().all()
@router.get("/saved/export")
async def export_saved(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Recommendation).where(Recommendation.user_id == user.id, Recommendation.saved == True)
.order_by(Recommendation.created_at.desc())
)
recs = result.scalars().all()
lines = ["My Saved Discoveries - Vynl", "=" * 30, ""]
for i, r in enumerate(recs, 1):
lines.append(f"{i}. {r.artist} - {r.title}")
if r.youtube_url:
lines.append(f" {r.youtube_url}")
lines.append(f" {r.reason}")
lines.append("")
return PlainTextResponse("\n".join(lines), media_type="text/plain",
headers={"Content-Disposition": 'attachment; filename="vynl-discoveries.txt"'})
@router.get("/saved", response_model=list[RecommendationItem])
async def saved(
user: User = Depends(get_current_user),
@@ -61,6 +112,546 @@ async def saved(
return result.scalars().all()
class AnalyzeRequest(BaseModel):
artist: str
title: str
class AnalyzeResponse(BaseModel):
analysis: str
qualities: list[str]
recommendations: list[RecommendationItem]
@router.post("/analyze", response_model=AnalyzeResponse)
async def analyze_song(
data: AnalyzeRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
prompt = f"""You are Vynl, a music analysis expert. The user wants to understand why they love this song:
Artist: {data.artist}
Title: {data.title}
Respond with a JSON object:
{{
"analysis": "A warm, insightful 3-4 sentence explanation of what makes this song special and why someone would be drawn to it. Reference specific sonic qualities, production choices, lyrical themes, and emotional resonance.",
"qualities": ["quality1", "quality2", ...],
"recommendations": [
{{"title": "...", "artist": "...", "album": "...", "reason": "...", "score": 0.9}}
]
}}
For "qualities", list 4-6 specific musical qualities (e.g., "warm analog production", "introspective lyrics about loss", "driving bass line with syncopated rhythm").
For "recommendations", suggest 5 songs that share these same qualities. Only suggest songs that actually exist.
Return ONLY the JSON object."""
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=2000,
messages=[{"role": "user", "content": prompt}],
)
# Track API cost (Haiku: $0.80/M input, $4/M output)
input_tokens = message.usage.input_tokens
output_tokens = message.usage.output_tokens
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=analyze")
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")
analysis = parsed.get("analysis", "")
qualities = parsed.get("qualities", [])
recs_data = parsed.get("recommendations", [])
recommendations = []
for rec in recs_data[:5]:
artist = rec.get("artist", "Unknown")
title = rec.get("title", "Unknown")
youtube_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}"
r = Recommendation(
user_id=user.id,
title=title,
artist=artist,
album=rec.get("album"),
reason=rec.get("reason", ""),
score=rec.get("score"),
query=f"analyze: {data.artist} - {data.title}",
youtube_url=youtube_url,
)
db.add(r)
recommendations.append(r)
await db.flush()
return AnalyzeResponse(
analysis=analysis,
qualities=qualities,
recommendations=[RecommendationItem.model_validate(r) for r in recommendations],
)
class ArtistDeepDiveRequest(BaseModel):
artist: str
class ArtistDeepDiveResponse(BaseModel):
artist: str
summary: str
why_they_matter: str
influences: list[str]
influenced: list[str]
start_with: str
start_with_reason: str
deep_cut: str
similar_artists: list[str]
genres: list[str]
@router.post("/artist-dive", response_model=ArtistDeepDiveResponse)
async def artist_deep_dive(
data: ArtistDeepDiveRequest,
user: User = Depends(get_current_user),
):
prompt = f"""You are Vynl, a music expert. Give a deep dive on this artist:
Artist: {data.artist}
Respond with a JSON object:
{{
"artist": "{data.artist}",
"summary": "2-3 sentences about who they are and their sound",
"why_they_matter": "Their cultural significance and impact on music",
"influences": ["artist1", "artist2", "artist3"],
"influenced": ["artist1", "artist2", "artist3"],
"start_with": "Album Name",
"start_with_reason": "Why this is the best entry point",
"deep_cut": "A hidden gem track title",
"similar_artists": ["artist1", "artist2", "artist3", "artist4", "artist5"],
"genres": ["genre1", "genre2"]
}}
Return ONLY the JSON object."""
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=2000,
messages=[{"role": "user", "content": prompt}],
)
# Track API cost (Haiku: $0.80/M input, $4/M output)
input_tokens = message.usage.input_tokens
output_tokens = message.usage.output_tokens
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=artist_dive")
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")
return ArtistDeepDiveResponse(
artist=parsed.get("artist", data.artist),
summary=parsed.get("summary", ""),
why_they_matter=parsed.get("why_they_matter", ""),
influences=parsed.get("influences", []),
influenced=parsed.get("influenced", []),
start_with=parsed.get("start_with", ""),
start_with_reason=parsed.get("start_with_reason", ""),
deep_cut=parsed.get("deep_cut", ""),
similar_artists=parsed.get("similar_artists", []),
genres=parsed.get("genres", []),
)
class GeneratePlaylistRequest(BaseModel):
theme: str
count: int = 25
save: bool = False
class PlaylistTrack(BaseModel):
title: str
artist: str
album: str | None = None
reason: str
youtube_url: str | None = None
class GeneratedPlaylistResponse(BaseModel):
name: str
description: str
tracks: list[PlaylistTrack]
playlist_id: int | None = None
@router.post("/generate-playlist", response_model=GeneratedPlaylistResponse)
async def generate_playlist(
data: GeneratePlaylistRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if not data.theme.strip():
raise HTTPException(status_code=400, detail="Theme is required")
if data.count < 5 or data.count > 50:
raise HTTPException(status_code=400, detail="Count must be between 5 and 50")
# Build taste context from user's playlists
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)}\n\nUse this to personalize the playlist to their taste while staying true to the theme."
prompt = f"""You are Vynl, a playlist curator. Create a cohesive playlist for this theme:
Theme: {data.theme}
{taste_context}
Generate a playlist of exactly {data.count} songs. The playlist should flow naturally — songs should be ordered for a great listening experience, not random.
Respond with a JSON object:
{{
"name": "A creative playlist name",
"description": "A 1-2 sentence description of the playlist vibe",
"tracks": [
{{"title": "...", "artist": "...", "album": "...", "reason": "Why this fits the playlist"}}
]
}}
Only recommend real songs that actually exist. Do not invent song titles.
Return ONLY the JSON object."""
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}],
)
# Track API cost (Haiku: $0.80/M input, $4/M output)
input_tokens = message.usage.input_tokens
output_tokens = message.usage.output_tokens
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=generate_playlist")
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")
playlist_name = parsed.get("name", f"Playlist: {data.theme}")
description = parsed.get("description", "")
tracks_data = parsed.get("tracks", [])
# Add YouTube Music search links
tracks = []
for t in tracks_data:
artist = t.get("artist", "Unknown")
title = t.get("title", "Unknown")
yt_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}"
tracks.append(PlaylistTrack(
title=title,
artist=artist,
album=t.get("album"),
reason=t.get("reason", ""),
youtube_url=yt_url,
))
# Optionally save as a playlist in the DB
playlist_id = None
if data.save:
new_playlist = Playlist(
user_id=user.id,
name=playlist_name,
platform_source="generated",
track_count=len(tracks),
)
db.add(new_playlist)
await db.flush()
for t in tracks:
track_record = Track(
playlist_id=new_playlist.id,
title=t.title,
artist=t.artist,
album=t.album,
)
db.add(track_record)
await db.flush()
playlist_id = new_playlist.id
return GeneratedPlaylistResponse(
name=playlist_name,
description=description,
tracks=tracks,
playlist_id=playlist_id,
)
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}],
)
# Track API cost (Haiku: $0.80/M input, $4/M output)
input_tokens = message.usage.input_tokens
output_tokens = message.usage.output_tokens
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=crate")
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}],
)
# Track API cost (Haiku: $0.80/M input, $4/M output)
input_tokens = message.usage.input_tokens
output_tokens = message.usage.output_tokens
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=rabbit_hole")
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")
async def save_recommendation(
rec_id: int,
@@ -75,3 +666,109 @@ async def save_recommendation(
raise HTTPException(status_code=404, detail="Recommendation not found")
rec.saved = not rec.saved
return {"saved": rec.saved}
@router.post("/{rec_id}/dislike")
async def dislike_recommendation(
rec_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Recommendation).where(Recommendation.id == rec_id, Recommendation.user_id == user.id)
)
rec = result.scalar_one_or_none()
if not rec:
raise HTTPException(status_code=404, detail="Recommendation not found")
rec.disliked = not rec.disliked
return {"disliked": rec.disliked}
@router.post("/{rec_id}/share")
async def share_recommendation(
rec_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Generate a share link for a recommendation."""
result = await db.execute(
select(Recommendation).where(Recommendation.id == rec_id, Recommendation.user_id == user.id)
)
rec = result.scalar_one_or_none()
if not rec:
raise HTTPException(status_code=404, detail="Recommendation not found")
token = hashlib.sha256(f"{rec_id}:{settings.SECRET_KEY}".encode()).hexdigest()[:16]
return {"share_url": f"{settings.FRONTEND_URL}/shared/{rec_id}/{token}"}
@router.get("/shared/{rec_id}/{token}")
async def get_shared_recommendation(
rec_id: int,
token: str,
db: AsyncSession = Depends(get_db),
):
"""View a shared recommendation (no auth required)."""
expected = hashlib.sha256(f"{rec_id}:{settings.SECRET_KEY}".encode()).hexdigest()[:16]
if token != expected:
raise HTTPException(status_code=404, detail="Invalid share link")
result = await db.execute(
select(Recommendation).where(Recommendation.id == rec_id)
)
rec = result.scalar_one_or_none()
if not rec:
raise HTTPException(status_code=404, detail="Recommendation not found")
return {
"title": rec.title,
"artist": rec.artist,
"album": rec.album,
"reason": rec.reason,
"youtube_url": rec.youtube_url,
"image_url": rec.image_url,
}
@router.post("/share-batch")
async def share_batch(
rec_ids: list[int],
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Generate a share link for multiple recommendations."""
ids_str = ",".join(str(i) for i in sorted(rec_ids))
token = hashlib.sha256(f"batch:{ids_str}:{settings.SECRET_KEY}".encode()).hexdigest()[:16]
return {"share_url": f"{settings.FRONTEND_URL}/shared/batch/{ids_str}/{token}"}
@router.get("/shared/batch/{ids_str}/{token}")
async def get_shared_batch(
ids_str: str,
token: str,
db: AsyncSession = Depends(get_db),
):
"""View shared recommendations (no auth required)."""
expected = hashlib.sha256(f"batch:{ids_str}:{settings.SECRET_KEY}".encode()).hexdigest()[:16]
if token != expected:
raise HTTPException(status_code=404, detail="Invalid share link")
rec_ids = [int(i) for i in ids_str.split(",")]
result = await db.execute(
select(Recommendation).where(Recommendation.id.in_(rec_ids))
)
recs = result.scalars().all()
return {
"recommendations": [
{
"title": r.title,
"artist": r.artist,
"album": r.album,
"reason": r.reason,
"youtube_url": r.youtube_url,
"image_url": r.image_url,
}
for r in recs
]
}

View File

@@ -0,0 +1,192 @@
import json
import logging
import anthropic
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.playlist import Playlist
from app.models.track import Track
from app.models.recommendation import Recommendation
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/profile", tags=["profile"])
class DecadeData(BaseModel):
decade: str
artists: list[str]
count: int
percentage: float
class TimelineResponse(BaseModel):
decades: list[DecadeData]
total_artists: int
dominant_era: str
insight: str
@router.get("/timeline", response_model=TimelineResponse)
async def get_timeline(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Build a music timeline showing which eras/decades define the user's taste."""
# Get all tracks from user's playlists
result = await db.execute(
select(Playlist).where(Playlist.user_id == user.id)
)
playlists = list(result.scalars().all())
all_artists: set[str] = set()
for p in playlists:
result = await db.execute(select(Track).where(Track.playlist_id == p.id))
tracks = result.scalars().all()
for t in tracks:
if t.artist:
all_artists.add(t.artist)
# Get artists from saved recommendations
result = await db.execute(
select(Recommendation).where(
Recommendation.user_id == user.id,
Recommendation.saved == True, # noqa: E712
)
)
saved_recs = result.scalars().all()
for r in saved_recs:
if r.artist:
all_artists.add(r.artist)
if not all_artists:
raise HTTPException(
status_code=404,
detail="No artists found. Import some playlists first.",
)
# Cap at 50 artists for the Claude call
artist_list = sorted(all_artists)[:50]
# Call Claude once to categorize all artists by era
client = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
prompt = f"""Categorize these artists by their primary era/decade. For each artist, pick the decade they are MOST associated with (when they were most active/influential).
Artists: {', '.join(artist_list)}
Respond with a JSON object with two keys:
1. "decades" - keys are decade strings, values are lists of artists from the input:
{{
"1960s": ["artist1"],
"1970s": ["artist2"],
"1980s": [],
"1990s": ["artist3"],
"2000s": ["artist4", "artist5"],
"2010s": ["artist6"],
"2020s": ["artist7"]
}}
2. "insight" - A single engaging sentence about their taste pattern across time, like "Your taste peaks in the 2000s indie explosion, with strong roots in 90s alternative." Make it specific to the actual artists and eras present.
Return ONLY a valid JSON object with "decades" and "insight" keys. No other text."""
try:
message = await client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
# Track API cost (Haiku: $0.80/M input, $4/M output)
api_logger = logging.getLogger("app")
input_tokens = message.usage.input_tokens
output_tokens = message.usage.output_tokens
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=timeline")
response_text = message.content[0].text.strip()
# Try to extract JSON if wrapped in markdown code blocks
if response_text.startswith("```"):
lines = response_text.split("\n")
json_lines = []
in_block = False
for line in lines:
if line.startswith("```") and not in_block:
in_block = True
continue
elif line.startswith("```") and in_block:
break
elif in_block:
json_lines.append(line)
response_text = "\n".join(json_lines)
parsed = json.loads(response_text)
decades_data = parsed.get("decades", parsed)
insight = parsed.get("insight", "")
except (json.JSONDecodeError, KeyError, IndexError) as e:
logger.error(f"Failed to parse Claude timeline response: {e}")
raise HTTPException(
status_code=500,
detail="Failed to analyze your music timeline. Please try again.",
)
except anthropic.APIError as e:
logger.error(f"Claude API error in timeline: {e}")
raise HTTPException(
status_code=502,
detail="AI service unavailable. Please try again later.",
)
# Build the response
total_categorized = 0
decade_results: list[DecadeData] = []
all_decades = ["1960s", "1970s", "1980s", "1990s", "2000s", "2010s", "2020s"]
for decade in all_decades:
artists = decades_data.get(decade, [])
if isinstance(artists, list):
total_categorized += len(artists)
dominant_decade = ""
max_count = 0
for decade in all_decades:
artists = decades_data.get(decade, [])
if not isinstance(artists, list):
artists = []
count = len(artists)
percentage = round((count / total_categorized * 100), 1) if total_categorized > 0 else 0.0
if count > max_count:
max_count = count
dominant_decade = decade
decade_results.append(
DecadeData(
decade=decade,
artists=artists,
count=count,
percentage=percentage,
)
)
if not insight:
insight = f"Your music taste is centered around the {dominant_decade}."
return TimelineResponse(
decades=decade_results,
total_artists=len(all_artists),
dominant_era=dominant_decade,
insight=insight,
)

View File

@@ -0,0 +1,153 @@
import asyncio
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import BaseModel
from sqlalchemy import select
from app.core.config import settings
from app.core.security import ALGORITHM
from app.core.database import async_session
from app.models.user import User
from app.models.playlist import Playlist
from app.models.track import Track
from app.services.youtube_music import get_playlist_tracks, search_track
from app.services.recommender import build_taste_profile
from app.schemas.playlist import PlaylistDetailResponse
router = APIRouter(prefix="/youtube-music", tags=["youtube-music"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
class ImportYouTubeRequest(BaseModel):
url: str
class SearchYouTubeRequest(BaseModel):
query: str
class YouTubeTrackResult(BaseModel):
title: str
artist: str
album: str | None = None
youtube_id: str | None = None
image_url: str | None = None
def _get_user_id_from_token(token: str) -> int:
"""Extract user ID from JWT without hitting the database."""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid credentials")
return int(user_id)
except JWTError:
raise HTTPException(status_code=401, detail="Invalid credentials")
@router.post("/import", response_model=PlaylistDetailResponse)
async def import_youtube_playlist(
data: ImportYouTubeRequest,
token: str = Depends(oauth2_scheme),
):
user_id = _get_user_id_from_token(token)
# Run sync ytmusicapi in a thread — NO DB connection open during this
try:
playlist_name, playlist_image, raw_tracks = await asyncio.to_thread(
get_playlist_tracks, data.url
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception:
raise HTTPException(status_code=400, detail="Failed to fetch playlist from YouTube Music. Make sure the URL is valid and the playlist is public.")
if not raw_tracks:
raise HTTPException(status_code=400, detail="Playlist is empty or could not be read.")
# Now do all DB work in a fresh session
async with async_session() as db:
try:
# Verify user exists
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found")
# Free tier limit
if not user.is_pro:
result = await db.execute(
select(Playlist).where(Playlist.user_id == user.id)
)
existing = list(result.scalars().all())
if len(existing) >= settings.FREE_MAX_PLAYLISTS:
raise HTTPException(
status_code=403,
detail="Free tier limited to 1 playlist. Upgrade to Premium for unlimited.",
)
playlist = Playlist(
user_id=user.id,
name=playlist_name,
platform_source="youtube_music",
external_id=data.url,
track_count=len(raw_tracks),
)
db.add(playlist)
await db.flush()
tracks = []
for rt in raw_tracks:
track = Track(
playlist_id=playlist.id,
title=rt["title"],
artist=rt["artist"],
album=rt.get("album"),
image_url=rt.get("image_url"),
)
db.add(track)
tracks.append(track)
await db.flush()
playlist.taste_profile = build_taste_profile(tracks)
await db.commit()
# Return response manually to avoid lazy-load issues
return PlaylistDetailResponse(
id=playlist.id,
name=playlist.name,
platform_source=playlist.platform_source,
track_count=playlist.track_count,
taste_profile=playlist.taste_profile,
imported_at=playlist.imported_at,
tracks=[],
)
except HTTPException:
await db.rollback()
raise
except Exception:
await db.rollback()
raise
@router.post("/search", response_model=list[YouTubeTrackResult])
async def search_youtube_music(
data: SearchYouTubeRequest,
token: str = Depends(oauth2_scheme),
):
_get_user_id_from_token(token) # Just verify auth
if not data.query.strip():
raise HTTPException(status_code=400, detail="Query cannot be empty")
try:
results = await asyncio.to_thread(search_track, data.query.strip())
except Exception:
raise HTTPException(status_code=500, detail="Failed to search YouTube Music")
return results

View File

@@ -22,11 +22,20 @@ class Settings(BaseSettings):
# Claude API
ANTHROPIC_API_KEY: str = ""
# Stripe
STRIPE_SECRET_KEY: str = ""
STRIPE_PUBLISHABLE_KEY: str = ""
STRIPE_PRICE_ID: str = ""
STRIPE_WEBHOOK_SECRET: str = ""
# Last.fm
LASTFM_API_KEY: str = ""
# Frontend
FRONTEND_URL: str = "http://localhost:5173"
# Rate limits (free tier)
FREE_DAILY_RECOMMENDATIONS: int = 10
FREE_WEEKLY_RECOMMENDATIONS: int = 5
FREE_MAX_PLAYLISTS: int = 1
model_config = {"env_file": ".env", "extra": "ignore"}

View File

@@ -1,10 +1,14 @@
from fastapi import FastAPI
import logging
import traceback
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.api.endpoints import auth, playlists, recommendations
from app.api.endpoints import admin, auth, bandcamp, billing, compatibility, concerts, lastfm, manual_import, playlist_fix, playlists, profile, recommendations, timeline, youtube_music
app = FastAPI(title="Vynl API", version="1.0.0")
app = FastAPI(title="Vynl API", version="1.0.0", redirect_slashes=False)
app.add_middleware(
CORSMiddleware,
@@ -14,9 +18,35 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(admin.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
app.include_router(billing.router, prefix="/api")
app.include_router(playlists.router, prefix="/api")
app.include_router(playlist_fix.router, prefix="/api")
app.include_router(recommendations.router, prefix="/api")
app.include_router(youtube_music.router, prefix="/api")
app.include_router(manual_import.router, prefix="/api")
app.include_router(lastfm.router, prefix="/api")
app.include_router(bandcamp.router, prefix="/api")
app.include_router(profile.router, prefix="/api")
app.include_router(compatibility.router, prefix="/api")
app.include_router(concerts.router, prefix="/api")
app.include_router(timeline.router, prefix="/api")
logger = logging.getLogger("app")
@app.middleware("http")
async def log_errors(request: Request, call_next):
try:
response = await call_next(request)
if response.status_code >= 400:
logger.warning(f"{request.method} {request.url.path} -> {response.status_code}")
return response
except Exception as e:
logger.error(f"{request.method} {request.url.path} -> 500: {e}\n{traceback.format_exc()}")
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@app.get("/api/health")

View File

@@ -20,6 +20,8 @@ class Recommendation(Base):
spotify_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
preview_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
image_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
bandcamp_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
youtube_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# AI explanation
reason: Mapped[str] = mapped_column(Text)
@@ -28,6 +30,7 @@ class Recommendation(Base):
# User interaction
saved: Mapped[bool] = mapped_column(Boolean, default=False)
disliked: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -18,6 +18,14 @@ class User(Base):
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
# Stripe
stripe_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
stripe_subscription_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Personalization
blocked_genres: Mapped[str | None] = mapped_column(Text, nullable=True)
adventurousness: Mapped[int] = mapped_column(default=3, server_default="3")
# Spotify OAuth
spotify_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
spotify_access_token: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -6,6 +6,13 @@ from pydantic import BaseModel
class RecommendationRequest(BaseModel):
playlist_id: int | None = None
query: str | None = None # Manual search/request
bandcamp_mode: bool = False # Prioritize Bandcamp/indie artists
mode: str = "discover" # discover, sonic_twin, era_bridge, deep_cuts, rising
adventurousness: int = 3 # 1-5
exclude: str | None = None # comma-separated genres to exclude
count: int = 5 # Number of recommendations (5, 10, 15, 20)
mood_energy: int | None = None # 1-5, 1=chill, 5=energetic
mood_valence: int | None = None # 1-5, 1=sad/dark, 5=happy/bright
class RecommendationItem(BaseModel):
@@ -16,9 +23,12 @@ class RecommendationItem(BaseModel):
spotify_id: str | None = None
preview_url: str | None = None
image_url: str | None = None
bandcamp_url: str | None = None
youtube_url: str | None = None
reason: str
score: float | None = None
saved: bool = False
disliked: bool = False
created_at: datetime
model_config = {"from_attributes": True}
@@ -26,7 +36,7 @@ class RecommendationItem(BaseModel):
class RecommendationResponse(BaseModel):
recommendations: list[RecommendationItem]
remaining_today: int | None = None # None = unlimited (pro)
remaining_this_week: int | None = None # None = unlimited (pro)
class TasteProfile(BaseModel):

View File

@@ -0,0 +1,73 @@
"""Bandcamp discovery using their public APIs (no scraping)."""
import httpx
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
}
DIG_DEEPER_URL = "https://bandcamp.com/api/hub/2/dig_deeper"
async def discover_by_tag(
tags: list[str],
sort: str = "new",
page: int = 1,
) -> list[dict]:
"""Discover new music on Bandcamp by tag using their public API.
Args:
tags: List of genre/tag strings (e.g. ["indie-rock", "shoegaze"])
sort: "new", "rec", or "pop" (new releases, recommended, popular)
page: Page number for pagination
Returns list of releases with: title, artist, art_url, bandcamp_url, genre, item_type
"""
async with httpx.AsyncClient(timeout=15, headers=HEADERS) as client:
resp = await client.post(
DIG_DEEPER_URL,
json={
"filters": {
"format": "all",
"location": 0,
"sort": sort,
"tags": tags,
},
"page": page,
},
)
if resp.status_code != 200:
return []
data = resp.json()
results = []
for item in data.get("items", []):
art_id = item.get("art_id")
art_url = f"https://f4.bcbits.com/img/a{art_id}_16.jpg" if art_id else None
tralbum_type = item.get("tralbum_type", "a")
type_path = "album" if tralbum_type == "a" else "track"
item_url = item.get("tralbum_url", "")
results.append({
"title": item.get("title", ""),
"artist": item.get("artist", ""),
"art_url": art_url,
"bandcamp_url": item_url,
"genre": ", ".join(tags),
"item_type": type_path,
})
return results
async def get_trending_tags() -> list[str]:
"""Return common Bandcamp genre tags for discovery."""
return [
"indie-rock", "electronic", "hip-hop-rap", "ambient", "punk",
"experimental", "folk", "jazz", "metal", "pop", "r-b-soul",
"shoegaze", "post-punk", "synthwave", "lo-fi", "dream-pop",
"indie-pop", "psychedelic", "garage-rock", "emo",
]

View File

@@ -0,0 +1,100 @@
import httpx
from app.core.config import settings
LASTFM_API_URL = "http://ws.audioscrobbler.com/2.0/"
async def get_user_info(username: str) -> dict | None:
"""Verify a Last.fm username exists and return basic info."""
params = {
"method": "user.getInfo",
"user": username,
"api_key": settings.LASTFM_API_KEY,
"format": "json",
}
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(LASTFM_API_URL, params=params)
if resp.status_code != 200:
return None
data = resp.json()
if "error" in data:
return None
user = data.get("user", {})
image_url = None
images = user.get("image", [])
for img in images:
if img.get("size") == "large" and img.get("#text"):
image_url = img["#text"]
return {
"name": user.get("name", username),
"display_name": user.get("realname") or user.get("name", username),
"playcount": int(user.get("playcount", 0)),
"image_url": image_url,
"url": user.get("url", ""),
}
async def get_top_tracks(
username: str, period: str = "overall", limit: int = 50
) -> list[dict]:
"""Fetch a user's top tracks from Last.fm.
Args:
username: Last.fm username.
period: Time period - overall, 7day, 1month, 3month, 6month, 12month.
limit: Max number of tracks to return (max 1000).
Returns:
List of dicts with: title, artist, playcount, image_url.
"""
valid_periods = {"overall", "7day", "1month", "3month", "6month", "12month"}
if period not in valid_periods:
period = "overall"
params = {
"method": "user.getTopTracks",
"user": username,
"period": period,
"limit": min(limit, 1000),
"api_key": settings.LASTFM_API_KEY,
"format": "json",
}
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(LASTFM_API_URL, params=params)
if resp.status_code != 200:
raise ValueError(f"Last.fm API returned status {resp.status_code}")
data = resp.json()
if "error" in data:
raise ValueError(data.get("message", "Last.fm API error"))
raw_tracks = data.get("toptracks", {}).get("track", [])
tracks = []
for t in raw_tracks:
image_url = None
images = t.get("image", [])
for img in images:
if img.get("size") == "large" and img.get("#text"):
image_url = img["#text"]
artist_name = ""
artist = t.get("artist")
if isinstance(artist, dict):
artist_name = artist.get("name", "")
elif isinstance(artist, str):
artist_name = artist
tracks.append({
"title": t.get("name", "Unknown"),
"artist": artist_name or "Unknown",
"playcount": int(t.get("playcount", 0)),
"image_url": image_url,
})
return tracks

View File

@@ -0,0 +1,104 @@
"""MusicBrainz API client for verifying songs exist."""
import httpx
MB_API_URL = "https://musicbrainz.org/ws/2"
HEADERS = {
"User-Agent": "Vynl/1.0 (chris.ryan@deepcutsai.com)",
"Accept": "application/json",
}
# MusicBrainz rate limit: 1 request per second
import time
_last_request_time = 0.0
def _rate_limit():
global _last_request_time
now = time.time()
elapsed = now - _last_request_time
if elapsed < 1.1:
time.sleep(1.1 - elapsed)
_last_request_time = time.time()
def verify_track(artist: str, title: str) -> dict | None:
"""Verify a track exists on MusicBrainz and return canonical data.
Returns dict with: artist, title, album, mb_id or None if not found.
"""
_rate_limit()
try:
resp = httpx.get(
f"{MB_API_URL}/recording",
params={
"query": f'artist:"{artist}" recording:"{title}"',
"fmt": "json",
"limit": 5,
},
headers=HEADERS,
timeout=10,
)
if resp.status_code != 200:
return None
data = resp.json()
recordings = data.get("recordings", [])
for rec in recordings:
score = rec.get("score", 0)
if score < 70:
continue
rec_title = rec.get("title", "")
rec_artists = rec.get("artist-credit", [])
rec_artist = rec_artists[0]["name"] if rec_artists else ""
# Get album from first release
album = None
releases = rec.get("releases", [])
if releases:
album = releases[0].get("title")
return {
"artist": rec_artist,
"title": rec_title,
"album": album,
"mb_id": rec.get("id"),
"score": score,
}
return None
except Exception:
return None
def search_artist(artist: str) -> dict | None:
"""Verify an artist exists on MusicBrainz."""
_rate_limit()
try:
resp = httpx.get(
f"{MB_API_URL}/artist",
params={
"query": f'artist:"{artist}"',
"fmt": "json",
"limit": 3,
},
headers=HEADERS,
timeout=10,
)
if resp.status_code != 200:
return None
data = resp.json()
artists = data.get("artists", [])
for a in artists:
if a.get("score", 0) >= 80:
return {
"name": a.get("name"),
"mb_id": a.get("id"),
"score": a.get("score"),
}
return None
except Exception:
return None

View File

@@ -1,5 +1,6 @@
import json
from datetime import datetime, timezone, timedelta
from urllib.parse import quote_plus
import anthropic
from sqlalchemy import select, func
@@ -50,31 +51,58 @@ def build_taste_profile(tracks: list[Track]) -> dict:
}
async def get_daily_rec_count(db: AsyncSession, user_id: int) -> int:
"""Count recommendations generated today for rate limiting."""
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
async def get_weekly_rec_count(db: AsyncSession, user_id: int) -> int:
"""Count recommendations generated this week (since Monday) for rate limiting."""
now = datetime.now(timezone.utc)
week_start = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
result = await db.execute(
select(func.count(Recommendation.id)).where(
Recommendation.user_id == user_id,
Recommendation.created_at >= today_start,
Recommendation.created_at >= week_start,
)
)
return result.scalar() or 0
MODE_PROMPTS = {
"discover": "Find music they'll love. Mix well-known and underground artists.",
"sonic_twin": "Find underground or lesser-known artists who sound nearly identical to their favorites. Focus on artists under 100K monthly listeners who share the same sonic qualities — similar vocal style, production approach, tempo, and energy.",
"era_bridge": "Suggest classic artists from earlier eras who directly inspired their current favorites. Trace musical lineage — if they love Tame Impala, suggest the 70s psych rock that influenced him. Bridge eras.",
"deep_cuts": "Find B-sides, album tracks, rarities, and lesser-known songs from artists already in their library. Focus on tracks they probably haven't heard even from artists they already know.",
"rising": "Find artists with under 50K monthly listeners who match their taste. Focus on brand new, up-and-coming artists who haven't broken through yet. Think artists who just released their debut album or EP.",
"surprise": "Be wildly creative. Pick ONE obscure, unexpected angle from their taste profile — maybe a specific production technique, a niche sub-genre, a particular era, or an unusual sonic quality — and build all recommendations around that single thread. Start your 'reason' for the first recommendation by explaining the angle you chose. Make it feel like a curated rabbit hole they never knew they wanted.",
}
def build_adventurousness_prompt(level: int) -> str:
if level <= 2:
return "Stick very close to their existing taste. Recommend artists who are very similar to what they already listen to."
elif level == 3:
return "Balance familiar and new. Mix artists similar to their taste with some that push boundaries."
else:
return "Be adventurous. Recommend artists that are different from their usual taste but share underlying qualities they'd appreciate. Push boundaries."
async def generate_recommendations(
db: AsyncSession,
user: User,
playlist_id: int | None = None,
query: str | None = None,
bandcamp_mode: bool = False,
mode: str = "discover",
adventurousness: int = 3,
exclude: str | None = None,
count: int = 5,
mood_energy: int | None = None,
mood_valence: int | None = None,
) -> tuple[list[Recommendation], int | None]:
"""Generate AI music recommendations using Claude."""
# Rate limit check for free users
remaining = None
if not user.is_pro:
used_today = await get_daily_rec_count(db, user.id)
remaining = max(0, settings.FREE_DAILY_RECOMMENDATIONS - used_today)
used_this_week = await get_weekly_rec_count(db, user.id)
remaining = max(0, settings.FREE_WEEKLY_RECOMMENDATIONS - used_this_week)
if remaining <= 0:
return [], 0
@@ -110,8 +138,64 @@ async def generate_recommendations(
profile = build_taste_profile(all_tracks)
taste_context = f"Taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}"
# Load ALL previously recommended songs to exclude duplicates
prev_recs_result = await db.execute(
select(Recommendation.artist, Recommendation.title).where(
Recommendation.user_id == user.id,
)
)
previously_recommended = {f"{r.artist} - {r.title}".lower() for r in prev_recs_result.all()}
# Load disliked artists to exclude
disliked_result = await db.execute(
select(Recommendation.artist).where(
Recommendation.user_id == user.id,
Recommendation.disliked == True,
)
)
disliked_artists = list({a for a in disliked_result.scalars().all()})
# Build prompt
user_request = query or "Find me music I'll love based on my taste profile. Prioritize lesser-known artists and hidden gems."
if mode == "surprise" and not query:
user_request = "Surprise me with something unexpected based on my taste profile. Pick a creative, unusual angle I wouldn't think of myself."
else:
user_request = query or "Find me music I'll love based on my taste profile. Prioritize lesser-known artists and hidden gems."
if bandcamp_mode:
focus_instruction = "IMPORTANT: Strongly prioritize independent and underground artists who release music on Bandcamp. Think DIY, indie labels, self-released artists, and the kind of music you'd find crate-digging on Bandcamp. Focus on artists who self-publish or release on small indie labels."
else:
focus_instruction = "Focus on discovery - prioritize lesser-known artists, deep cuts, and hidden gems over obvious popular choices."
# Mode-specific instruction
mode_instruction = MODE_PROMPTS.get(mode, MODE_PROMPTS["discover"])
# Adventurousness instruction
adventurousness_instruction = build_adventurousness_prompt(adventurousness)
# Mood instruction
mood_instruction = ""
if mood_energy is not None or mood_valence is not None:
energy_desc = {1: "very chill and calm", 2: "relaxed", 3: "moderate energy", 4: "upbeat and energetic", 5: "high energy and intense"}
valence_desc = {1: "dark, melancholy, or moody", 2: "introspective or bittersweet", 3: "neutral mood", 4: "positive and uplifting", 5: "happy, euphoric, or celebratory"}
parts = []
if mood_energy is not None:
parts.append(f"Energy: {energy_desc.get(mood_energy, 'moderate energy')}")
if mood_valence is not None:
parts.append(f"Mood: {valence_desc.get(mood_valence, 'neutral mood')}")
mood_instruction = f"\nMatch this mood: {'. '.join(parts)}"
# Exclude genres instruction
exclude_instruction = ""
combined_exclude = exclude or ""
if user.blocked_genres:
combined_exclude = f"{user.blocked_genres}, {combined_exclude}" if combined_exclude else user.blocked_genres
if combined_exclude.strip():
exclude_instruction = f"\nDo NOT recommend anything in these genres/moods: {combined_exclude}"
# Disliked artists exclusion
disliked_instruction = ""
if disliked_artists:
disliked_instruction = f"\nDo NOT recommend anything by these artists (user disliked them): {', '.join(disliked_artists[:30])}"
prompt = f"""You are Vynl, an AI music discovery assistant. You help people discover new music they'll love.
@@ -119,17 +203,31 @@ async def generate_recommendations(
User request: {user_request}
Discovery mode: {mode_instruction}
{adventurousness_instruction}
{mood_instruction}
IMPORTANT: If the user mentions specific artists or songs in their request, do NOT recommend anything BY those artists. The user already knows them — recommend music by OTHER artists that match the vibe. For example, if they say "I like Sublime", recommend artists similar to Sublime, but NEVER Sublime themselves.
Already in their library (do NOT recommend these):
{', '.join(list(existing_tracks)[:50]) if existing_tracks else 'None provided'}
Respond with exactly 5 music recommendations as a JSON array. Each item should have:
- "title": song title
- "artist": artist name
- "album": album name (if known)
ALREADY RECOMMENDED BEFORE (do NOT repeat any of these — every recommendation must be NEW):
{', '.join(list(previously_recommended)[:100]) if previously_recommended else 'None yet'}
{disliked_instruction}
{exclude_instruction}
Respond with exactly {count} music recommendations as a JSON array. Each item should have:
- "title": the EXACT real song title as it appears on streaming platforms
- "artist": the EXACT real artist name
- "album": the real album name (if known)
- "reason": A warm, personal 2-3 sentence explanation of WHY they'll love this track. Reference specific qualities from their taste profile. Be specific about sonic qualities, not generic.
- "score": confidence score 0.0-1.0
Focus on discovery - prioritize lesser-known artists, deep cuts, and hidden gems over obvious popular choices.
CRITICAL: Every song MUST be a real, verifiable song that exists on YouTube Music, Spotify, or Apple Music. Do NOT invent songs, guess titles, or combine wrong artists with wrong songs. If you are not 100% certain a song exists with that exact title by that exact artist, do NOT include it. It is better to recommend a well-known song you are certain about than to guess at an obscure one.
{focus_instruction}
Return ONLY the JSON array, no other text."""
# Call Claude API
@@ -140,6 +238,15 @@ Return ONLY the JSON array, no other text."""
messages=[{"role": "user", "content": prompt}],
)
# Track API cost
import logging
api_logger = logging.getLogger("app")
input_tokens = message.usage.input_tokens
output_tokens = message.usage.output_tokens
# Sonnet pricing: $3/M input, $15/M output
cost = (input_tokens * 3 / 1_000_000) + (output_tokens * 15 / 1_000_000)
api_logger.info(f"API_COST|model=claude-sonnet|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=generate_recommendations")
# Parse response
response_text = message.content[0].text.strip()
# Handle potential markdown code blocks
@@ -152,18 +259,56 @@ Return ONLY the JSON array, no other text."""
except json.JSONDecodeError:
return [], remaining
# Save to DB
# Look up YouTube video IDs for embeddable players
import asyncio
from app.services.youtube_music import search_track as yt_search
def get_youtube_links(tracks: list[dict]) -> list[dict]:
"""Get YouTube video IDs for a batch of tracks (sync, runs in thread)."""
results = []
for t in tracks:
artist = t.get("artist", "")
title = t.get("title", "")
try:
yt_results = yt_search(f"{artist} {title}")
if yt_results and yt_results[0].get("youtube_id"):
yt = yt_results[0]
results.append({
"youtube_url": f"https://music.youtube.com/watch?v={yt['youtube_id']}",
"image_url": yt.get("image_url"),
})
continue
except Exception:
pass
results.append({
"youtube_url": f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}",
"image_url": None,
})
return results
# Get YouTube data BEFORE any DB operations (avoids greenlet conflict)
tracks_to_lookup = [{"artist": r.get("artist", ""), "title": r.get("title", "")} for r in recs_data[:count]]
yt_data = await asyncio.to_thread(get_youtube_links, tracks_to_lookup)
# Save to DB with YouTube Music links
recommendations = []
for rec in recs_data[:5]:
for i, rec in enumerate(recs_data[:count]):
artist = rec.get("artist", "Unknown")
title = rec.get("title", "Unknown")
reason = rec.get("reason", "")
yt = yt_data[i] if i < len(yt_data) else {}
r = Recommendation(
user_id=user.id,
playlist_id=playlist_id,
title=rec.get("title", "Unknown"),
artist=rec.get("artist", "Unknown"),
title=title,
artist=artist,
album=rec.get("album"),
reason=rec.get("reason", ""),
image_url=yt.get("image_url"),
reason=reason,
score=rec.get("score"),
query=query,
youtube_url=yt.get("youtube_url", f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}"),
)
db.add(r)
recommendations.append(r)

View File

@@ -0,0 +1,87 @@
import re
from ytmusicapi import YTMusic
ytmusic = YTMusic()
def extract_playlist_id(url: str) -> str:
"""Extract playlist ID from a YouTube Music playlist URL."""
match = re.search(r"[?&]list=([A-Za-z0-9_-]+)", url)
if not match:
raise ValueError("Invalid YouTube Music playlist URL")
return match.group(1)
def get_playlist_tracks(playlist_url: str) -> tuple[str, str | None, list[dict]]:
"""Fetch playlist info and tracks from a public YouTube Music playlist.
Returns (playlist_name, playlist_image_url, tracks).
Each track dict has: title, artist, album, youtube_id, image_url.
"""
playlist_id = extract_playlist_id(playlist_url)
playlist = ytmusic.get_playlist(playlist_id, limit=None)
name = playlist.get("title", playlist_id)
image_url = None
thumbnails = playlist.get("thumbnails")
if thumbnails:
image_url = thumbnails[-1].get("url")
tracks = []
for item in playlist.get("tracks", []):
artists = item.get("artists") or []
artist_name = ", ".join(a.get("name", "") for a in artists if a.get("name"))
album_name = None
album = item.get("album")
if album:
album_name = album.get("name")
thumb_url = None
thumbs = item.get("thumbnails")
if thumbs:
thumb_url = thumbs[-1].get("url")
tracks.append({
"title": item.get("title", "Unknown"),
"artist": artist_name or "Unknown",
"album": album_name,
"youtube_id": item.get("videoId"),
"image_url": thumb_url,
})
return name, image_url, tracks
def search_track(query: str) -> list[dict]:
"""Search YouTube Music for tracks matching the query.
Returns a list of result dicts with: title, artist, album, youtube_id, image_url.
"""
results = ytmusic.search(query, filter="songs", limit=10)
tracks = []
for item in results:
artists = item.get("artists") or []
artist_name = ", ".join(a.get("name", "") for a in artists if a.get("name"))
album_name = None
album = item.get("album")
if album:
album_name = album.get("name")
thumb_url = None
thumbs = item.get("thumbnails")
if thumbs:
thumb_url = thumbs[-1].get("url")
tracks.append({
"title": item.get("title", "Unknown"),
"artist": artist_name or "Unknown",
"album": album_name,
"youtube_id": item.get("videoId"),
"image_url": thumb_url,
})
return tracks

View File

@@ -6,6 +6,7 @@ asyncpg==0.30.0
psycopg2-binary==2.9.10
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-multipart==0.0.20
pydantic[email]==2.10.4
pydantic-settings==2.7.1
@@ -14,4 +15,6 @@ celery==5.4.0
httpx==0.28.1
anthropic==0.42.0
spotipy==2.24.0
ytmusicapi==1.8.2
python-dotenv==1.0.1
stripe==11.4.1

20
backup.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
set -e
# Vynl database backup script
# Run via cron: 0 3 * * * /path/to/backup.sh
BACKUP_DIR="/backups/vynl"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
KEEP_DAYS=14
mkdir -p "$BACKUP_DIR"
# Dump database from running container
docker compose -f docker-compose.prod.yml exec -T db \
pg_dump -U vynl vynl | gzip > "${BACKUP_DIR}/vynl_${TIMESTAMP}.sql.gz"
# Remove backups older than KEEP_DAYS
find "$BACKUP_DIR" -name "vynl_*.sql.gz" -mtime +${KEEP_DAYS} -delete
echo "Backup complete: vynl_${TIMESTAMP}.sql.gz"

33
deploy.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
set -e
# Vynl production deployment script
# Usage: ./deploy.sh [domain]
# Example: ./deploy.sh deepcutsai.com
DOMAIN=${1:-deepcutsai.com}
echo "=== Deploying Vynl to ${DOMAIN} ==="
# Check .env exists
if [ ! -f backend/.env ]; then
echo "ERROR: backend/.env not found. Copy backend/.env.example and fill in your values."
exit 1
fi
# Set domain for Caddy
export DOMAIN
export DB_PASSWORD=$(grep POSTGRES_PASSWORD backend/.env 2>/dev/null || echo "vynl")
# Build and start
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up -d
echo ""
echo "=== Vynl deployed ==="
echo "URL: https://${DOMAIN}"
echo ""
echo "Useful commands:"
echo " docker compose -f docker-compose.prod.yml logs -f # View logs"
echo " docker compose -f docker-compose.prod.yml down # Stop"
echo " docker compose -f docker-compose.prod.yml restart # Restart"

51
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,51 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: vynl
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: vynl
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile.prod
env_file: ./backend/.env
depends_on:
- db
- redis
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
args:
VITE_API_URL: ${VITE_API_URL:-}
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- backend
- frontend
restart: unless-stopped
volumes:
pgdata:
caddy_data:
caddy_config:

View File

@@ -18,15 +18,17 @@ services:
backend:
build: ./backend
ports:
- "8000:8000"
- "8100:8000"
env_file: ./backend/.env
depends_on:
- db
- redis
working_dir: /app
environment:
- PYTHONPATH=/app
command: >
sh -c "
pip install alembic psycopg2-binary &&
cd /app &&
alembic upgrade head &&
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
"

View File

@@ -9,4 +9,4 @@ COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
CMD ["npx", "vite", "--host", "0.0.0.0"]

18
frontend/Dockerfile.prod Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -7,6 +7,25 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<meta name="description" content="Discover music you'll love with AI. Import your playlists, get personalized recommendations, and explore new artists." />
<meta name="keywords" content="music discovery, AI music, playlist analyzer, find new music, music recommendations" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Vynl - AI Music Discovery" />
<meta property="og:description" content="Dig deeper. Discover more. AI-powered music recommendations that actually understand your taste." />
<meta property="og:url" content="https://deepcutsai.com" />
<meta property="og:site_name" content="Vynl" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Vynl - AI Music Discovery" />
<meta name="twitter:description" content="Dig deeper. Discover more. AI-powered music recommendations that actually understand your taste." />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#7C3AED" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Vynl - AI Music Discovery</title>
</head>
<body>

14
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,14 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "Vynl - AI Music Discovery",
"short_name": "Vynl",
"description": "Discover music you'll love with AI",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#FFF7ED",
"theme_color": "#7C3AED",
"icons": [
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}

View File

@@ -4,12 +4,25 @@ import Layout from './components/Layout'
import Landing from './pages/Landing'
import Login from './pages/Login'
import Register from './pages/Register'
import SpotifyCallback from './pages/SpotifyCallback'
import Dashboard from './pages/Dashboard'
import Playlists from './pages/Playlists'
import PlaylistDetail from './pages/PlaylistDetail'
import Discover from './pages/Discover'
import Recommendations from './pages/Recommendations'
import Billing from './pages/Billing'
import TasteProfilePage from './pages/TasteProfilePage'
import Analyze from './pages/Analyze'
import BandcampDiscover from './pages/BandcampDiscover'
import Admin from './pages/Admin'
import SharedView from './pages/SharedView'
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'
import RabbitHole from './pages/RabbitHole'
import Settings from './pages/Settings'
import PublicProfile from './pages/PublicProfile'
function RootRedirect() {
const { user, loading } = useAuth()
@@ -31,7 +44,6 @@ function AppRoutes() {
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/auth/spotify/callback" element={<SpotifyCallback />} />
<Route
path="/dashboard"
element={
@@ -42,6 +54,16 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Layout>
<TasteProfilePage />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/playlists"
element={
@@ -72,6 +94,26 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/analyze"
element={
<ProtectedRoute>
<Layout>
<Analyze />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/bandcamp"
element={
<ProtectedRoute>
<Layout>
<BandcampDiscover />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/saved"
element={
@@ -82,6 +124,98 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/billing"
element={
<ProtectedRoute>
<Layout>
<Billing />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute>
<Layout>
<Admin />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/artist-dive"
element={
<ProtectedRoute>
<Layout>
<ArtistDive />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/generate-playlist"
element={
<ProtectedRoute>
<Layout>
<PlaylistGenerator />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/compatibility"
element={
<ProtectedRoute>
<Layout>
<Compatibility />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/rabbit-hole"
element={
<ProtectedRoute>
<Layout>
<RabbitHole />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/crate"
element={
<ProtectedRoute>
<Layout>
<CrateDigger />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/timeline"
element={
<ProtectedRoute>
<Layout>
<Timeline />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Layout>
<Settings />
</Layout>
</ProtectedRoute>
}
/>
<Route path="/shared/:recId/:token" element={<SharedView />} />
<Route path="/taste/:userId/:token" element={<PublicProfile />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View File

@@ -1,19 +1,34 @@
import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Disc3, LayoutDashboard, ListMusic, Compass, Heart, 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, Settings } from 'lucide-react'
import { useAuth } from '../lib/auth'
const navItems = [
const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
const baseNavItems = [
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/profile', label: 'My Taste', icon: Fingerprint },
{ path: '/timeline', label: 'Timeline', icon: Clock },
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
{ 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: '/rabbit-hole', label: 'Rabbit Hole', icon: ArrowDownCircle },
{ path: '/bandcamp', label: 'Bandcamp', icon: Store },
{ path: '/compatibility', label: 'Taste Match', icon: Users },
{ path: '/saved', label: 'Saved', icon: Heart },
{ path: '/billing', label: 'Pro', icon: Crown },
]
export default function Layout({ children }: { children: React.ReactNode }) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [userMenuOpen, setUserMenuOpen] = useState(false)
const { user, logout } = useAuth()
const navItems = user?.email === ADMIN_EMAIL
? [...baseNavItems, { path: '/admin', label: 'Admin', icon: Shield }]
: baseNavItems
const location = useLocation()
const navigate = useNavigate()
@@ -81,6 +96,14 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<p className="text-sm font-medium text-charcoal">{user?.name}</p>
<p className="text-xs text-charcoal-muted">{user?.email}</p>
</div>
<Link
to="/settings"
onClick={() => setUserMenuOpen(false)}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-charcoal hover:bg-purple-50 transition-colors no-underline"
>
<Settings className="w-4 h-4" />
Settings
</Link>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors cursor-pointer bg-transparent border-none text-left"
@@ -150,7 +173,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</nav>
{/* Main Content */}
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
<main className="max-w-6xl mx-auto px-3 sm:px-6 py-4 sm:py-8">
{children}
</main>
</div>

View File

@@ -0,0 +1,225 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Disc3, ListMusic, Compass, Sparkles, Rocket } from 'lucide-react'
interface OnboardingProps {
onComplete: () => void
}
interface Step {
icon: typeof Disc3
title: string
description: string
details?: string[]
buttons?: { label: string; path: string; primary?: boolean }[]
}
const steps: Step[] = [
{
icon: Disc3,
title: 'Welcome to Vynl!',
description:
'Your AI-powered music discovery companion. Let\u2019s show you around.',
},
{
icon: ListMusic,
title: 'Start by importing your music',
description:
'Paste a YouTube Music playlist URL, import from Last.fm, or just type in songs you love.',
details: ['YouTube Music playlists', 'Last.fm library sync', 'Manual song & artist entry'],
},
{
icon: Compass,
title: 'Discover new music',
description:
'Choose a discovery mode, set your mood, and let AI find songs you\u2019ll love. Every recommendation links to YouTube Music.',
details: [
'Sonic Twin \u2013 your musical doppelg\u00e4nger',
'Era Bridge \u2013 cross decades',
'Deep Cuts \u2013 hidden gems',
'Rising \u2013 emerging artists',
'Surprise Me \u2013 random delight',
],
},
{
icon: Sparkles,
title: 'Explore more',
description: 'Vynl is packed with ways to dig deeper into music.',
details: [
'Crate Digger \u2013 swipe through discoveries',
'Rabbit Hole \u2013 follow connected songs',
'Playlist Generator \u2013 AI-built playlists',
'Artist Deep Dive \u2013 click any artist name',
],
},
{
icon: Rocket,
title: 'You\u2019re all set!',
description:
'Import a playlist or just type what you\u2019re in the mood for on the Discover page.',
buttons: [
{ label: 'Import Music', path: '/playlists' },
{ label: 'Start Discovering', path: '/discover', primary: true },
],
},
]
export default function Onboarding({ onComplete }: OnboardingProps) {
const navigate = useNavigate()
const [currentStep, setCurrentStep] = useState(0)
const [direction, setDirection] = useState<'next' | 'prev'>('next')
const [animating, setAnimating] = useState(false)
const [visible, setVisible] = useState(false)
const isLastStep = currentStep === steps.length - 1
// Fade in on mount
useEffect(() => {
requestAnimationFrame(() => setVisible(true))
document.body.classList.add('overflow-hidden')
return () => document.body.classList.remove('overflow-hidden')
}, [])
const goTo = useCallback(
(next: number, dir: 'next' | 'prev') => {
if (animating) return
setDirection(dir)
setAnimating(true)
setTimeout(() => {
setCurrentStep(next)
setAnimating(false)
}, 200)
},
[animating],
)
const handleNext = () => {
if (isLastStep) {
onComplete()
return
}
goTo(currentStep + 1, 'next')
}
const handleCTA = (path: string) => {
onComplete()
navigate(path)
}
const step = steps[currentStep]
const Icon = step.icon
const translateClass = animating
? direction === 'next'
? 'opacity-0 translate-x-8'
: 'opacity-0 -translate-x-8'
: 'opacity-100 translate-x-0'
return (
<div
className={`fixed inset-0 z-[100] flex items-center justify-center p-4 transition-opacity duration-300 ${
visible ? 'opacity-100' : 'opacity-0'
}`}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60" onClick={onComplete} />
{/* Card */}
<div className="relative w-full max-w-lg bg-white rounded-2xl shadow-2xl overflow-hidden">
<div className="px-8 pt-10 pb-8 flex flex-col items-center text-center">
{/* Skip */}
{!isLastStep && (
<button
onClick={onComplete}
className="absolute top-4 right-5 text-sm text-charcoal-muted hover:text-charcoal transition-colors cursor-pointer bg-transparent border-none"
>
Skip
</button>
)}
{/* Step content with transition */}
<div
className={`flex flex-col items-center transition-all duration-200 ease-in-out ${translateClass}`}
>
{/* Icon */}
<div className="w-20 h-20 rounded-full bg-purple-50 flex items-center justify-center mb-6">
<Icon className="w-10 h-10 text-purple" strokeWidth={1.8} />
</div>
{/* Title */}
<h2 className="text-2xl font-bold text-charcoal mb-3">
{step.title}
</h2>
{/* Description */}
<p className="text-charcoal-muted leading-relaxed max-w-sm">
{step.description}
</p>
{/* Details list */}
{step.details && (
<ul className="mt-5 space-y-2 text-left w-full max-w-xs">
{step.details.map((item) => (
<li
key={item}
className="flex items-start gap-2 text-sm text-charcoal-muted"
>
<span className="mt-1 w-1.5 h-1.5 rounded-full bg-purple flex-shrink-0" />
{item}
</li>
))}
</ul>
)}
{/* CTA buttons on last step */}
{step.buttons && (
<div className="flex gap-3 mt-8 w-full max-w-xs">
{step.buttons.map((btn) => (
<button
key={btn.label}
onClick={() => handleCTA(btn.path)}
className={`flex-1 py-3 px-4 rounded-xl font-semibold text-sm transition-colors cursor-pointer border-none ${
btn.primary
? 'bg-purple text-white hover:bg-purple-dark'
: 'bg-purple-50 text-purple hover:bg-purple-100'
}`}
>
{btn.label}
</button>
))}
</div>
)}
</div>
{/* Navigation */}
{!isLastStep && (
<button
onClick={handleNext}
className="mt-8 w-full max-w-xs py-3 bg-purple text-white font-semibold rounded-xl hover:bg-purple-dark transition-colors cursor-pointer border-none text-sm"
>
{currentStep === 0 ? "Let's Go" : 'Next'}
</button>
)}
{/* Progress dots */}
<div className="flex items-center gap-2 mt-6">
{steps.map((_, i) => (
<button
key={i}
onClick={() => {
if (i !== currentStep) goTo(i, i > currentStep ? 'next' : 'prev')
}}
className={`rounded-full transition-all duration-300 cursor-pointer border-none p-0 ${
i === currentStep
? 'w-6 h-2 bg-purple'
: 'w-2 h-2 bg-purple-200 hover:bg-purple-300'
}`}
aria-label={`Go to step ${i + 1}`}
/>
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,13 +1,96 @@
import { Heart, ExternalLink, Music } from 'lucide-react'
import type { RecommendationItem } from '../lib/api'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Heart, ExternalLink, Music, ThumbsDown, Repeat, Share2, Check, Calendar, MapPin, Ticket, ChevronUp, Play, X } from 'lucide-react'
import type { RecommendationItem, ConcertEvent } from '../lib/api'
import { shareRecommendation, findConcerts } from '../lib/api'
interface Props {
recommendation: RecommendationItem
onToggleSave: (id: string) => void
onDislike?: (id: string) => void
saving?: boolean
disliking?: boolean
}
export default function RecommendationCard({ recommendation, onToggleSave, saving }: Props) {
export default function RecommendationCard({ recommendation, onToggleSave, onDislike, saving, disliking }: Props) {
const navigate = useNavigate()
const [sharing, setSharing] = 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 [playerOpen, setPlayerOpen] = useState(false)
// Extract YouTube video ID from URL if it's a direct link
const getYouTubeVideoId = (url: string | null): string | null => {
if (!url) return null
const match = url.match(/[?&]v=([a-zA-Z0-9_-]{11})/)
return match ? match[1] : null
}
const videoId = getYouTubeVideoId(recommendation.youtube_url)
const handleMoreLikeThis = () => {
const q = `find songs similar to ${recommendation.artist} - ${recommendation.title}`
navigate(`/discover?q=${encodeURIComponent(q)}`)
}
const handleShare = async () => {
if (sharing) return
setSharing(true)
try {
const { share_url } = await shareRecommendation(recommendation.id)
// Try clipboard first, fall back to prompt
try {
await navigator.clipboard.writeText(share_url)
} catch {
// Clipboard blocked (HTTP/mobile) — use fallback
const input = document.createElement('textarea')
input.value = share_url
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
}
setShared(true)
setTimeout(() => setShared(false), 2000)
} catch {
// API call failed
} finally {
setSharing(false)
}
}
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 (
<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">
@@ -30,7 +113,12 @@ export default function RecommendationCard({ recommendation, onToggleSave, savin
{recommendation.title}
</h3>
<p className="text-charcoal-muted text-sm truncate">
{recommendation.artist}
<button
onClick={() => navigate(`/artist-dive?artist=${encodeURIComponent(recommendation.artist)}`)}
className="hover:text-purple hover:underline transition-colors cursor-pointer bg-transparent border-none p-0 font-inherit text-inherit text-sm"
>
{recommendation.artist}
</button>
{recommendation.album && (
<span className="text-charcoal-muted/60"> &middot; {recommendation.album}</span>
)}
@@ -60,19 +148,151 @@ export default function RecommendationCard({ recommendation, onToggleSave, savin
/>
</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"
{onDislike && (
<button
onClick={() => onDislike(recommendation.id)}
disabled={disliking}
className={`p-2 rounded-full transition-colors cursor-pointer border-none ${
recommendation.disliked
? 'bg-charcoal-muted/20 text-charcoal'
: 'bg-gray-50 text-gray-400 hover:bg-gray-100 hover:text-charcoal-muted'
} ${disliking ? 'opacity-50 cursor-not-allowed' : ''}`}
title={recommendation.disliked ? 'Undo dislike' : 'Never recommend this type again'}
>
<ExternalLink className="w-4 h-4" />
</a>
<ThumbsDown
className="w-4 h-4"
fill={recommendation.disliked ? 'currentColor' : 'none'}
/>
</button>
)}
<button
onClick={handleShare}
disabled={sharing}
className={`p-2 rounded-full transition-colors cursor-pointer border-none ${
shared
? 'bg-green-50 text-green-600'
: 'bg-purple-50 text-purple-600 hover:bg-purple-100 hover:text-purple-700'
} ${sharing ? 'opacity-50 cursor-not-allowed' : ''}`}
title={shared ? 'Link copied!' : 'Share'}
>
{shared ? <Check className="w-4 h-4" /> : <Share2 className="w-4 h-4" />}
</button>
<button
onClick={handleMoreLikeThis}
className="p-2 rounded-full bg-purple-50 text-purple-600 hover:bg-purple-100 hover:text-purple-700 transition-colors cursor-pointer border-none"
title="More like this"
>
<Repeat className="w-4 h-4" />
</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 && (
videoId ? (
<button
onClick={() => setPlayerOpen(!playerOpen)}
className={`p-2 rounded-full transition-colors cursor-pointer border-none ${
playerOpen
? 'bg-red-100 text-red-700'
: 'bg-red-50 text-red-600 hover:bg-red-100'
}`}
title={playerOpen ? 'Close player' : 'Play'}
>
{playerOpen ? <X className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</button>
) : (
<a
href={recommendation.youtube_url}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-full bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
title="Search on YouTube Music"
>
<ExternalLink className="w-4 h-4" />
</a>
)
)}
</div>
</div>
{/* YouTube Player */}
{playerOpen && videoId && (
<div className="border-t border-purple-50 bg-charcoal">
<iframe
width="100%"
height="152"
src={`https://www.youtube.com/embed/${videoId}?autoplay=1`}
title={`${recommendation.artist} - ${recommendation.title}`}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="block"
/>
</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>
)
}

View File

@@ -96,14 +96,42 @@ export interface RecommendationItem {
album: string
image_url: string | null
spotify_url: string | null
bandcamp_url: string | null
youtube_url: string | null
reason: string
saved: boolean
disliked: boolean
created_at: string
}
export interface RecommendationResponse {
recommendations: RecommendationItem[]
remaining_today: number
remaining_this_week: number
}
export interface TasteProfileArtist {
name: string
track_count: number
genre: string
}
export interface TasteProfileResponse {
genre_breakdown: { name: string; percentage: number }[]
audio_features: {
energy: number
danceability: number
valence: number
acousticness: number
avg_tempo: number
}
personality: {
label: string
description: string
icon: string
}
top_artists: TasteProfileArtist[]
track_count: number
playlist_count: number
}
// Auth
@@ -116,6 +144,16 @@ export const login = (email: string, password: string) =>
export const getMe = () =>
api.get<UserResponse>('/auth/me').then((r) => r.data)
// Profile & Settings
export const updateProfile = (data: { name?: string; email?: string }) =>
api.put<UserResponse>('/auth/me', data).then((r) => r.data)
export const changePassword = (currentPassword: string, newPassword: string) =>
api.post('/auth/change-password', { current_password: currentPassword, new_password: newPassword })
export const deleteAccount = () =>
api.delete('/auth/me')
// Spotify OAuth
export const getSpotifyAuthUrl = () =>
api.get<{ url: string; state: string }>('/auth/spotify/authorize').then((r) => r.data)
@@ -141,12 +179,32 @@ export const importSpotifyPlaylist = (playlistId: string) =>
api.post<PlaylistDetailResponse>('/spotify/import', { playlist_id: playlistId }).then((r) => r.data)
// Recommendations
export const generateRecommendations = (playlistId?: string, query?: string) =>
export const generateRecommendations = (
playlistId?: string,
query?: string,
bandcampMode?: boolean,
mode?: string,
adventurousness?: number,
exclude?: string,
count?: number,
moodEnergy?: number,
moodValence?: number,
) =>
api.post<RecommendationResponse>('/recommendations/generate', {
playlist_id: playlistId,
query,
bandcamp_mode: bandcampMode || false,
mode: mode || 'discover',
adventurousness: adventurousness ?? 3,
exclude: exclude || undefined,
count: count ?? 5,
mood_energy: moodEnergy || undefined,
mood_valence: moodValence || undefined,
}).then((r) => r.data)
export const surpriseMe = () =>
api.post<RecommendationResponse>('/recommendations/surprise').then((r) => r.data)
export const getRecommendationHistory = () =>
api.get<RecommendationItem[]>('/recommendations/history').then((r) => r.data)
@@ -154,6 +212,292 @@ export const getSavedRecommendations = () =>
api.get<RecommendationItem[]>('/recommendations/saved').then((r) => r.data)
export const toggleSaveRecommendation = (id: string) =>
api.post<{ saved: boolean }>(`/recommendations/${id}/toggle-save`).then((r) => r.data)
api.post<{ saved: boolean }>(`/recommendations/${id}/save`).then((r) => r.data)
export const dislikeRecommendation = (id: string) =>
api.post<{ disliked: boolean }>(`/recommendations/${id}/dislike`).then((r) => r.data)
// Sharing
export const shareRecommendation = (id: string) =>
api.post<{ share_url: string }>(`/recommendations/${id}/share`).then((r) => r.data)
export const getSharedRecommendation = (recId: string, token: string) =>
api.get<{ title: string; artist: string; album: string | null; reason: string; youtube_url: string | null; image_url: string | null }>(`/recommendations/shared/${recId}/${token}`).then((r) => r.data)
// Analyze Song
export interface AnalyzeResponse {
analysis: string
qualities: string[]
recommendations: RecommendationItem[]
}
export const analyzeSong = (artist: string, title: string) =>
api.post<AnalyzeResponse>('/recommendations/analyze', { artist, title }).then((r) => r.data)
// Artist Deep Dive
export interface ArtistDeepDiveResponse {
artist: string
summary: string
why_they_matter: string
influences: string[]
influenced: string[]
start_with: string
start_with_reason: string
deep_cut: string
similar_artists: string[]
genres: string[]
}
export const artistDeepDive = (artist: string) =>
api.post<ArtistDeepDiveResponse>('/recommendations/artist-dive', { artist }).then((r) => r.data)
// Playlist Generator
export interface PlaylistTrackItem {
title: string
artist: string
album: string | null
reason: string
youtube_url: string | null
}
export interface GeneratedPlaylistResponse {
name: string
description: string
tracks: PlaylistTrackItem[]
playlist_id: number | null
}
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
artist: string
album: string | null
youtube_id: string | null
image_url: string | null
}
export const importYouTubePlaylist = (url: string) =>
api.post<PlaylistDetailResponse>('/youtube-music/import', { url }).then((r) => r.data)
export const searchYouTubeMusic = (query: string) =>
api.post<YouTubeTrackResult[]>('/youtube-music/search', { query }).then((r) => r.data)
// Last.fm Import
export interface LastfmPreviewTrack {
title: string
artist: string
playcount: number
image_url: string | null
}
export interface LastfmPreviewResponse {
display_name: string
track_count: number
sample_tracks: LastfmPreviewTrack[]
}
export const previewLastfm = (username: string) =>
api.get<LastfmPreviewResponse>('/lastfm/preview', { params: { username } }).then((r) => r.data)
export const importLastfm = (username: string, period: string) =>
api.post<PlaylistDetailResponse>('/lastfm/import', { username, period }).then((r) => r.data)
// Manual Import (paste songs)
export const importPastedSongs = (name: string, text: string) =>
api.post<PlaylistDetailResponse>('/import/paste', { name, text }).then((r) => r.data)
// Billing
export interface BillingStatusResponse {
is_pro: boolean
subscription_status: string | null
current_period_end: number | null
}
export const createCheckout = () =>
api.post<{ url: string }>('/billing/create-checkout').then((r) => r.data)
export const createBillingPortal = () =>
api.post<{ url: string }>('/billing/portal').then((r) => r.data)
export const getBillingStatus = () =>
api.get<BillingStatusResponse>('/billing/status').then((r) => r.data)
// Bandcamp
export interface BandcampRelease {
title: string
artist: string
art_url: string | null
bandcamp_url: string
genre: string
item_type: string
}
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 const getBandcampTags = () =>
api.get<string[]>('/bandcamp/tags').then((r) => r.data)
// Playlist Fix
export interface OutlierTrack {
track_number: number
artist: string
title: string
reason: string
}
export interface ReplacementTrack {
title: string
artist: string
album: string | null
reason: string
}
export interface PlaylistFixResponse {
playlist_vibe: string
outliers: OutlierTrack[]
replacements: ReplacementTrack[]
}
export const fixPlaylist = (playlistId: string) =>
api.post<PlaylistFixResponse>(`/playlists/${playlistId}/fix`).then((r) => r.data)
export const exportPlaylist = (id: string, format: string = 'text') =>
api.get(`/playlists/${id}/export`, { params: { format }, responseType: 'blob' }).then((r) => {
const url = URL.createObjectURL(r.data)
const a = document.createElement('a')
a.href = url
a.download = format === 'csv' ? 'playlist.csv' : 'playlist.txt'
a.click()
URL.revokeObjectURL(url)
})
export const exportSaved = () =>
api.get('/recommendations/saved/export', { responseType: 'blob' }).then((r) => {
const url = URL.createObjectURL(r.data)
const a = document.createElement('a')
a.href = url
a.download = 'vynl-discoveries.txt'
a.click()
URL.revokeObjectURL(url)
})
// Taste Profile
export const getTasteProfile = () =>
api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data)
export const getProfileShareLink = () =>
api.get<{ share_url: string }>('/profile/share-link').then((r) => r.data)
export const getPublicProfile = (userId: string, token: string) =>
api.get<TasteProfileResponse & { name: string }>(`/profile/public/${userId}/${token}`).then((r) => r.data)
// Taste Compatibility
export interface CompatibilityResponse {
friend_name: string
compatibility_score: number
shared_genres: string[]
unique_to_you: string[]
unique_to_them: string[]
shared_artists: string[]
insight: string
recommendations: { title: string; artist: string; reason: string }[]
}
export const checkCompatibility = (friendEmail: string) =>
api.post<CompatibilityResponse>('/profile/compatibility', { friend_email: friendEmail }).then((r) => r.data)
// Timeline
export interface TimelineDecade {
decade: string
artists: string[]
count: number
percentage: number
}
export interface TimelineResponse {
decades: TimelineDecade[]
total_artists: number
dominant_era: string
insight: string
}
export const getTimeline = () =>
api.get<TimelineResponse>('/profile/timeline').then((r) => r.data)
// Admin
export interface AdminStats {
users: { total: number; pro: number; free: number }
playlists: { total: number; total_tracks: number }
recommendations: { total: number; today: number; this_week: number; this_month: number; saved: number; disliked: number }
api_costs: { total_estimated: number; today_estimated: number; total_input_tokens: number; total_output_tokens: number }
user_breakdown: { id: number; name: string; email: string; is_pro: boolean; created_at: string; recommendation_count: number }[]
}
export const getAdminStats = () =>
api.get<AdminStats>('/admin/stats').then((r) => r.data)
export interface LogEntry {
timestamp: string
level: string
logger: string
message: string
}
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

View File

@@ -33,7 +33,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
useEffect(() => {
if (token) {
if (token && !user) {
loadUser().finally(() => setLoading(false))
} else {
setLoading(false)
@@ -43,6 +43,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const loginFn = async (newToken: string) => {
localStorage.setItem('vynl_token', newToken)
setTokenState(newToken)
// Load user immediately so ProtectedRoute sees them before navigate
try {
const userData = await getMe()
setUser(userData)
} catch {
// will retry via useEffect
}
}
const logout = () => {

View File

@@ -0,0 +1,267 @@
import { useEffect, useState } from 'react'
import { Shield, Users, ListMusic, Sparkles, Heart, ThumbsDown, ScrollText, RefreshCw, DollarSign } from 'lucide-react'
import { useAuth } from '../lib/auth'
import { getAdminStats, getAdminLogs, type AdminStats, type LogEntry } from '../lib/api'
const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
export default function Admin() {
const { user } = useAuth()
const [stats, setStats] = useState<AdminStats | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([])
const [logLevel, setLogLevel] = useState('ALL')
const [logTotal, setLogTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const isAdmin = user?.email === ADMIN_EMAIL
useEffect(() => {
if (!isAdmin) {
setLoading(false)
setError('Access denied. Admin privileges required.')
return
}
Promise.all([
getAdminStats().then(setStats),
getAdminLogs('ALL', 200).then((d) => { setLogs(d.logs); setLogTotal(d.total) }),
])
.catch((err) => {
setError(err.response?.status === 403 ? 'Access denied. Admin privileges required.' : 'Failed to load admin stats.')
})
.finally(() => setLoading(false))
}, [isAdmin])
const refreshLogs = () => {
getAdminLogs(logLevel, 200).then((d) => { setLogs(d.logs); setLogTotal(d.total) })
}
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<div className="w-12 h-12 border-4 border-purple border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error || !isAdmin) {
return (
<div className="flex flex-col items-center justify-center py-32 gap-4">
<Shield className="w-16 h-16 text-red-400" />
<h2 className="text-2xl font-bold text-charcoal">403 Forbidden</h2>
<p className="text-charcoal-muted">{error || 'Access denied. Admin privileges required.'}</p>
</div>
)
}
if (!stats) return null
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
const sortedUsers = [...stats.user_breakdown].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
return (
<div>
{/* Header */}
<div className="bg-charcoal rounded-2xl p-6 sm:p-8 mb-8">
<div className="flex items-center gap-3">
<Shield className="w-8 h-8 text-purple-300" />
<h1 className="text-2xl sm:text-3xl font-bold text-white">Admin Dashboard</h1>
</div>
</div>
{/* Top row - 3 stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div className="bg-white rounded-2xl p-6 border border-purple-100">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<Users className="w-5 h-5 text-purple" />
</div>
<h3 className="text-sm font-medium text-charcoal-muted">Users</h3>
</div>
<p className="text-4xl font-bold text-charcoal mb-2">{stats.users.total}</p>
<div className="flex gap-4 text-sm text-charcoal-muted">
<span><span className="font-semibold text-purple">{stats.users.pro}</span> Pro</span>
<span><span className="font-semibold text-charcoal">{stats.users.free}</span> Free</span>
</div>
</div>
<div className="bg-white rounded-2xl p-6 border border-purple-100">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<ListMusic className="w-5 h-5 text-purple" />
</div>
<h3 className="text-sm font-medium text-charcoal-muted">Playlists</h3>
</div>
<p className="text-4xl font-bold text-charcoal mb-2">{stats.playlists.total}</p>
<div className="text-sm text-charcoal-muted">
<span className="font-semibold text-charcoal">{stats.playlists.total_tracks}</span> total tracks
</div>
</div>
<div className="bg-white rounded-2xl p-6 border border-purple-100">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-purple" />
</div>
<h3 className="text-sm font-medium text-charcoal-muted">Recommendations</h3>
</div>
<p className="text-4xl font-bold text-charcoal mb-2">{stats.recommendations.total}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-charcoal-muted">
<span><span className="font-semibold text-charcoal">{stats.recommendations.today}</span> today</span>
<span><span className="font-semibold text-charcoal">{stats.recommendations.this_week}</span> this week</span>
<span><span className="font-semibold text-charcoal">{stats.recommendations.this_month}</span> this month</span>
</div>
</div>
</div>
{/* Middle row - 3 stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<div className="bg-white rounded-2xl p-6 border border-purple-100">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<Heart className="w-5 h-5 text-purple" />
</div>
<h3 className="text-sm font-medium text-charcoal-muted">Saved Recommendations</h3>
</div>
<p className="text-4xl font-bold text-charcoal">{stats.recommendations.saved}</p>
</div>
<div className="bg-white rounded-2xl p-6 border border-purple-100">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<ThumbsDown className="w-5 h-5 text-purple" />
</div>
<h3 className="text-sm font-medium text-charcoal-muted">Disliked Recommendations</h3>
</div>
<p className="text-4xl font-bold text-charcoal">{stats.recommendations.disliked}</p>
</div>
<div className="bg-white rounded-2xl p-6 border border-purple-100">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<DollarSign className="w-5 h-5 text-purple" />
</div>
<h3 className="text-sm font-medium text-charcoal-muted">API Costs</h3>
</div>
<p className="text-4xl font-bold text-charcoal">${stats.api_costs.total_estimated.toFixed(4)}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-charcoal-muted mt-2">
<span><span className="font-semibold text-charcoal">${stats.api_costs.today_estimated.toFixed(4)}</span> today</span>
<span><span className="font-semibold text-charcoal">{(stats.api_costs.total_input_tokens + stats.api_costs.total_output_tokens).toLocaleString()}</span> tokens</span>
</div>
</div>
</div>
{/* User breakdown table */}
<div className="bg-white rounded-2xl border border-purple-100 overflow-hidden">
<div className="p-6 border-b border-purple-100">
<h3 className="text-lg font-bold text-charcoal">User Breakdown</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-purple-100">
<th className="text-left text-sm font-medium text-charcoal-muted px-6 py-3">Name</th>
<th className="text-left text-sm font-medium text-charcoal-muted px-6 py-3">Email</th>
<th className="text-left text-sm font-medium text-charcoal-muted px-6 py-3">Status</th>
<th className="text-left text-sm font-medium text-charcoal-muted px-6 py-3">Joined</th>
<th className="text-right text-sm font-medium text-charcoal-muted px-6 py-3">Recommendations</th>
</tr>
</thead>
<tbody>
{sortedUsers.map((u, i) => (
<tr
key={u.id}
className={`border-b border-purple-50 ${i % 2 === 1 ? 'bg-purple-50/30' : ''}`}
>
<td className="px-6 py-4 text-sm font-medium text-charcoal">{u.name}</td>
<td className="px-6 py-4 text-sm text-charcoal-muted">{u.email}</td>
<td className="px-6 py-4">
{u.is_pro ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple text-white">
Pro
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-600">
Free
</span>
)}
</td>
<td className="px-6 py-4 text-sm text-charcoal-muted">{formatDate(u.created_at)}</td>
<td className="px-6 py-4 text-sm font-semibold text-charcoal text-right">{u.recommendation_count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Logs */}
<div className="bg-white rounded-2xl border border-purple-100 overflow-hidden mt-8">
<div className="p-6 border-b border-purple-100 flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<ScrollText className="w-5 h-5 text-purple" />
<h3 className="text-lg font-bold text-charcoal">Logs</h3>
<span className="text-xs text-charcoal-muted">({logTotal} total)</span>
</div>
<div className="flex items-center gap-2">
{['ALL', 'ERROR', 'WARNING', 'INFO'].map((lvl) => (
<button
key={lvl}
onClick={() => { setLogLevel(lvl); getAdminLogs(lvl, 200).then((d) => { setLogs(d.logs); setLogTotal(d.total) }) }}
className={`px-3 py-1 rounded-full text-xs font-medium cursor-pointer border transition-colors ${
logLevel === lvl
? 'bg-purple text-white border-purple'
: 'bg-white text-charcoal-muted border-purple-100 hover:border-purple/30'
}`}
>
{lvl}
</button>
))}
<button
onClick={refreshLogs}
className="p-1.5 rounded-lg hover:bg-purple-50 cursor-pointer border-none bg-transparent transition-colors"
title="Refresh logs"
>
<RefreshCw className="w-4 h-4 text-charcoal-muted" />
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto font-mono text-xs">
{logs.length === 0 ? (
<p className="p-6 text-charcoal-muted text-center text-sm">No logs found</p>
) : (
logs.map((log, i) => (
<div
key={i}
className={`px-4 py-2 border-b border-purple-50/50 flex gap-3 ${
log.level === 'ERROR' ? 'bg-red-50/50' : log.level === 'WARNING' ? 'bg-amber-50/50' : ''
}`}
>
<span className="text-charcoal-muted whitespace-nowrap flex-shrink-0">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span
className={`font-semibold flex-shrink-0 w-14 ${
log.level === 'ERROR' ? 'text-red-600' : log.level === 'WARNING' ? 'text-amber-600' : 'text-charcoal-muted'
}`}
>
{log.level}
</span>
<span className="text-charcoal break-all">{log.message}</span>
</div>
))
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,216 @@
import { useState, useEffect } from 'react'
import { Lightbulb, Loader2, Sparkles } from 'lucide-react'
import { analyzeSong, type AnalyzeResponse } from '../lib/api'
import RecommendationCard from '../components/RecommendationCard'
import { toggleSaveRecommendation, dislikeRecommendation } from '../lib/api'
export default function Analyze() {
const [artist, setArtist] = useState('')
const [title, setTitle] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState<AnalyzeResponse | null>(() => {
try {
const saved = sessionStorage.getItem('vynl_analyze_results')
return saved ? JSON.parse(saved) : null
} catch { return null }
})
const [savingIds, setSavingIds] = useState<Set<string>>(new Set())
const [dislikingIds, setDislikingIds] = useState<Set<string>>(new Set())
useEffect(() => {
if (result) {
sessionStorage.setItem('vynl_analyze_results', JSON.stringify(result))
}
}, [result])
const handleAnalyze = async () => {
if (!artist.trim() || !title.trim()) return
setLoading(true)
setError('')
setResult(null)
try {
const data = await analyzeSong(artist.trim(), title.trim())
setResult(data)
} catch (err: any) {
const msg = err.response?.data?.detail || err.message || 'Unknown error'
setError(`Error: ${msg}`)
} finally {
setLoading(false)
}
}
const handleToggleSave = async (id: string) => {
const sid = String(id)
setSavingIds((prev) => new Set(prev).add(sid))
try {
const { saved } = await toggleSaveRecommendation(sid)
setResult((prev) =>
prev
? {
...prev,
recommendations: prev.recommendations.map((r) =>
String(r.id) === sid ? { ...r, saved } : r
),
}
: prev
)
} catch {
// silent
} finally {
setSavingIds((prev) => {
const next = new Set(prev)
next.delete(sid)
return next
})
}
}
const handleDislike = async (id: string) => {
const sid = String(id)
setDislikingIds((prev) => new Set(prev).add(sid))
try {
const { disliked } = await dislikeRecommendation(sid)
setResult((prev) =>
prev
? {
...prev,
recommendations: prev.recommendations.map((r) =>
String(r.id) === sid ? { ...r, disliked } : r
),
}
: prev
)
} catch {
// silent
} finally {
setDislikingIds((prev) => {
const next = new Set(prev)
next.delete(sid)
return next
})
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-charcoal flex items-center gap-3">
<Lightbulb className="w-8 h-8 text-purple" />
Why Do I Like This?
</h1>
<p className="text-charcoal-muted mt-1">
Paste a song and discover what draws you to it, plus find similar music
</p>
</div>
{/* Input Form */}
<div className="bg-white rounded-2xl border border-purple-100 p-4 sm:p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-charcoal mb-2">
Artist
</label>
<input
type="text"
value={artist}
onChange={(e) => setArtist(e.target.value)}
placeholder="e.g., Radiohead"
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-charcoal mb-2">
Song Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Everything In Its Right Place"
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm"
/>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
{error}
</div>
)}
<button
onClick={handleAnalyze}
disabled={loading || !artist.trim() || !title.trim()}
className="w-full py-3.5 bg-gradient-to-r from-purple to-purple-dark text-white font-semibold rounded-xl hover:shadow-lg hover:shadow-purple/25 transition-all disabled:opacity-50 cursor-pointer border-none text-sm flex items-center justify-center gap-2"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Analyzing...
</>
) : (
<>
<Sparkles className="w-5 h-5" />
Analyze
</>
)}
</button>
</div>
{/* Results */}
{result && (
<div className="space-y-6">
{/* Analysis Card */}
<div className="bg-gradient-to-br from-purple-50 to-cream rounded-2xl border border-purple-200 p-5 sm:p-6">
<h2 className="text-lg font-semibold text-charcoal mb-3 flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-purple" />
Why You Love This
</h2>
<p className="text-charcoal leading-relaxed">{result.analysis}</p>
</div>
{/* Qualities */}
{result.qualities.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-charcoal mb-3">
Key Qualities
</h2>
<div className="flex flex-wrap gap-2">
{result.qualities.map((quality, i) => (
<span
key={i}
className="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-purple-100 text-purple-700"
>
{quality}
</span>
))}
</div>
</div>
)}
{/* Similar Recommendations */}
{result.recommendations.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-charcoal mb-4">
Songs With the Same Qualities
</h2>
<div className="space-y-3">
{result.recommendations.map((rec) => (
<RecommendationCard
key={rec.id}
recommendation={rec}
onToggleSave={handleToggleSave}
onDislike={handleDislike}
saving={savingIds.has(String(rec.id))}
disliking={dislikingIds.has(String(rec.id))}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,228 @@
import { useState, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { Mic2, Disc3, Sparkles, Music, ExternalLink, ArrowRight, Quote } from 'lucide-react'
import { artistDeepDive, type ArtistDeepDiveResponse } from '../lib/api'
export default function ArtistDive() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [artist, setArtist] = useState(searchParams.get('artist') || '')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<ArtistDeepDiveResponse | null>(() => {
try {
const saved = sessionStorage.getItem('vynl_artist_dive_results')
return saved ? JSON.parse(saved) : null
} catch { return null }
})
const [error, setError] = useState('')
useEffect(() => {
if (result) {
sessionStorage.setItem('vynl_artist_dive_results', JSON.stringify(result))
}
}, [result])
const handleDive = async (artistName?: string) => {
const name = artistName || artist
if (!name.trim()) return
setLoading(true)
setError('')
setResult(null)
try {
const data = await artistDeepDive(name.trim())
setResult(data)
setArtist(name.trim())
navigate(`/artist-dive?artist=${encodeURIComponent(name.trim())}`, { replace: true })
} catch {
setError('Failed to get artist deep dive. Please try again.')
} finally {
setLoading(false)
}
}
useEffect(() => {
const initialArtist = searchParams.get('artist')
if (initialArtist) {
setArtist(initialArtist)
handleDive(initialArtist)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleArtistClick = (name: string) => {
setArtist(name)
handleDive(name)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-8">
<div className="w-12 h-12 bg-purple/10 rounded-2xl flex items-center justify-center">
<Mic2 className="w-6 h-6 text-purple" />
</div>
<div>
<h1 className="text-2xl font-bold text-charcoal">Artist Deep Dive</h1>
<p className="text-charcoal-muted text-sm">Explore any artist's story, influences, and legacy</p>
</div>
</div>
{/* Search */}
<div className="flex gap-3 mb-8">
<input
type="text"
value={artist}
onChange={(e) => setArtist(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleDive()}
placeholder="Enter an artist name..."
className="flex-1 px-4 py-3 rounded-xl border border-purple-100 bg-white text-charcoal placeholder:text-charcoal-muted/50 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple"
/>
<button
onClick={() => handleDive()}
disabled={loading || !artist.trim()}
className="px-6 py-3 bg-purple text-white font-semibold rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer border-none flex items-center gap-2"
>
{loading ? (
<Disc3 className="w-5 h-5 animate-spin" />
) : (
<Sparkles className="w-5 h-5" />
)}
Dive In
</button>
</div>
{error && (
<div className="bg-red-50 text-red-600 p-4 rounded-xl mb-6">{error}</div>
)}
{loading && (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<Disc3 className="w-12 h-12 text-purple animate-spin" />
<p className="text-charcoal-muted">Diving deep into {artist}...</p>
</div>
)}
{result && !loading && (
<div className="bg-white rounded-2xl border border-purple-100 shadow-sm overflow-hidden">
{/* Artist Header */}
<div className="bg-gradient-to-r from-[#7C3AED] to-[#6D28D9] p-8">
<h2 className="text-3xl font-bold text-white mb-3">{result.artist}</h2>
<div className="flex flex-wrap gap-2">
{result.genres.map((genre) => (
<span
key={genre}
className="px-3 py-1 bg-white/20 text-white text-sm rounded-full backdrop-blur-sm"
>
{genre}
</span>
))}
</div>
</div>
<div className="p-6 space-y-8">
{/* Summary */}
<div>
<p className="text-charcoal leading-relaxed text-base">{result.summary}</p>
</div>
{/* Why They Matter */}
<div className="bg-[#FFF7ED] rounded-xl p-5 border-l-4 border-[#7C3AED]">
<div className="flex items-center gap-2 mb-3">
<Quote className="w-5 h-5 text-purple" />
<h3 className="font-semibold text-charcoal">Why They Matter</h3>
</div>
<p className="text-charcoal/80 leading-relaxed italic">{result.why_they_matter}</p>
</div>
{/* Start Here */}
<div className="bg-purple-50 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Disc3 className="w-5 h-5 text-purple" />
<h3 className="font-semibold text-charcoal">Start Here</h3>
</div>
<p className="text-lg font-bold text-[#7C3AED] mb-1">{result.start_with}</p>
<p className="text-charcoal-muted text-sm leading-relaxed">{result.start_with_reason}</p>
</div>
{/* Hidden Gem */}
<div className="bg-[#1C1917] rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Music className="w-5 h-5 text-[#7C3AED]" />
<h3 className="font-semibold text-white">Hidden Gem</h3>
</div>
<p className="text-lg font-bold text-[#FFF7ED]">{result.deep_cut}</p>
</div>
{/* Influences */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="font-semibold text-charcoal mb-3 flex items-center gap-2">
<ArrowRight className="w-4 h-4 text-purple rotate-180" />
Influenced By
</h3>
<div className="flex flex-wrap gap-2">
{result.influences.map((name) => (
<button
key={name}
onClick={() => handleArtistClick(name)}
className="px-3 py-1.5 bg-purple-50 text-[#7C3AED] text-sm rounded-full hover:bg-purple-100 transition-colors cursor-pointer border-none font-medium"
>
{name}
</button>
))}
</div>
</div>
<div>
<h3 className="font-semibold text-charcoal mb-3 flex items-center gap-2">
<ArrowRight className="w-4 h-4 text-purple" />
Influenced
</h3>
<div className="flex flex-wrap gap-2">
{result.influenced.map((name) => (
<button
key={name}
onClick={() => handleArtistClick(name)}
className="px-3 py-1.5 bg-purple-50 text-[#7C3AED] text-sm rounded-full hover:bg-purple-100 transition-colors cursor-pointer border-none font-medium"
>
{name}
</button>
))}
</div>
</div>
</div>
{/* Similar Artists */}
<div>
<h3 className="font-semibold text-charcoal mb-3">Similar Artists</h3>
<div className="flex flex-wrap gap-2">
{result.similar_artists.map((name) => (
<button
key={name}
onClick={() => handleArtistClick(name)}
className="px-4 py-2 bg-gradient-to-r from-purple-50 to-purple-100 text-[#7C3AED] rounded-xl hover:from-purple-100 hover:to-purple-200 transition-colors cursor-pointer border border-purple-200 font-medium text-sm"
>
{name}
</button>
))}
</div>
</div>
{/* YouTube Music Link */}
<div className="pt-4 border-t border-purple-100">
<a
href={`https://music.youtube.com/search?q=${encodeURIComponent(result.artist)}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-colors font-medium text-sm no-underline"
>
<ExternalLink className="w-4 h-4" />
Listen on YouTube Music
</a>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,228 @@
import { useState, useEffect } from 'react'
import { Disc3, Music, ExternalLink, Loader2 } from 'lucide-react'
import { discoverBandcamp, getBandcampTags, type 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[]>(() => {
try {
const saved = sessionStorage.getItem('vynl_bandcamp_tags')
return saved ? JSON.parse(saved) : []
} catch { return [] }
})
const [sort, setSort] = useState('new')
const [releases, setReleases] = useState<BandcampRelease[]>(() => {
try {
const saved = sessionStorage.getItem('vynl_bandcamp_results')
return saved ? JSON.parse(saved) : []
} catch { return [] }
})
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))
}, [])
useEffect(() => {
if (selectedTags.length > 0) {
sessionStorage.setItem('vynl_bandcamp_tags', JSON.stringify(selectedTags))
}
}, [selectedTags])
useEffect(() => {
if (releases.length > 0) {
sessionStorage.setItem('vynl_bandcamp_results', JSON.stringify(releases))
}
}, [releases])
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>
)
}

View File

@@ -0,0 +1,281 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Crown, Check, Loader2, ExternalLink, Sparkles, Music, Infinity, Download, Users, Fingerprint, X } from 'lucide-react'
import { useAuth } from '../lib/auth'
import { createCheckout, createBillingPortal, getBillingStatus } from '../lib/api'
interface BillingInfo {
is_pro: boolean
subscription_status: string | null
current_period_end: number | null
}
interface TierFeature {
text: string
included: boolean
}
const freeTierFeatures: TierFeature[] = [
{ text: '1 platform sync', included: true },
{ text: '5 discoveries per week', included: true },
{ text: 'Basic taste profile', included: true },
{ text: 'All platforms', included: false },
{ text: 'Unlimited discovery', included: false },
{ text: 'Full AI insights', included: false },
{ text: 'Export playlists', included: false },
]
const premiumTierFeatures: TierFeature[] = [
{ text: 'All platform syncs', included: true },
{ text: 'Unlimited discovery', included: true },
{ text: 'Full taste DNA profile', included: true },
{ text: 'Full AI insights & explanations', included: true },
{ text: 'Export to any platform', included: true },
{ text: 'All discovery modes', included: true },
{ text: 'Priority recommendations', included: true },
]
const familyTierFeatures: TierFeature[] = [
{ text: 'Everything in Premium', included: true },
{ text: 'Up to 5 profiles', included: true },
{ text: 'Family taste overlap feature', included: true },
{ text: 'Shared discovery feed', included: true },
]
export default function Billing() {
const { user, refreshUser } = useAuth()
const [searchParams] = useSearchParams()
const [billing, setBilling] = useState<BillingInfo | null>(null)
const [loading, setLoading] = useState(true)
const [checkoutLoading, setCheckoutLoading] = useState(false)
const [portalLoading, setPortalLoading] = useState(false)
const success = searchParams.get('success') === 'true'
const canceled = searchParams.get('canceled') === 'true'
useEffect(() => {
getBillingStatus()
.then(setBilling)
.catch(() => setBilling({ is_pro: user?.is_pro || false, subscription_status: null, current_period_end: null }))
.finally(() => setLoading(false))
}, [user?.is_pro])
useEffect(() => {
if (success && refreshUser) {
refreshUser()
}
}, [success, refreshUser])
const handleUpgrade = async () => {
setCheckoutLoading(true)
try {
const { url } = await createCheckout()
window.location.href = url
} catch {
setCheckoutLoading(false)
}
}
const handleManage = async () => {
setPortalLoading(true)
try {
const { url } = await createBillingPortal()
window.location.href = url
} catch {
setPortalLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-purple animate-spin" />
</div>
)
}
const isPro = billing?.is_pro || false
return (
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-charcoal">Plans & Pricing</h1>
<p className="text-charcoal-muted mt-1">Choose the plan that fits your music discovery journey</p>
</div>
{success && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl">
<p className="text-green-800 font-medium">Welcome to Vynl Premium! Your subscription is now active.</p>
</div>
)}
{canceled && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-amber-800 font-medium">Checkout was canceled. No charges were made.</p>
</div>
)}
{/* Active subscription banner */}
{isPro && billing?.subscription_status && (
<div className="mb-6 p-4 bg-purple-50 border border-purple-200 rounded-xl flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple flex items-center justify-center">
<Crown className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm font-semibold text-charcoal">Vynl Premium Active</p>
{billing.current_period_end && (
<p className="text-xs text-charcoal-muted">
Next billing: {new Date(billing.current_period_end * 1000).toLocaleDateString()}
</p>
)}
</div>
</div>
<button
onClick={handleManage}
disabled={portalLoading}
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-200 text-purple text-sm font-medium rounded-xl hover:bg-purple-50 transition-colors cursor-pointer disabled:opacity-50"
>
{portalLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<ExternalLink className="w-4 h-4" />
Manage
</>
)}
</button>
</div>
)}
{/* Pricing Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{/* Free Tier */}
<div className="bg-white rounded-2xl border border-purple-100 overflow-hidden flex flex-col">
<div className="p-6 border-b border-purple-50">
<div className="flex items-center gap-2 mb-3">
<Music className="w-5 h-5 text-charcoal-muted" />
<h3 className="text-lg font-semibold text-charcoal">Free</h3>
</div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-charcoal">$0</span>
<span className="text-sm text-charcoal-muted">/month</span>
</div>
<p className="text-sm text-charcoal-muted mt-2">
Get started with basic music discovery
</p>
</div>
<div className="p-6 flex-1">
<ul className="space-y-3">
{freeTierFeatures.map((f) => (
<li key={f.text} className="flex items-start gap-2.5">
{f.included ? (
<Check className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
) : (
<X className="w-4 h-4 text-charcoal-muted/30 mt-0.5 flex-shrink-0" />
)}
<span className={`text-sm ${f.included ? 'text-charcoal' : 'text-charcoal-muted/50'}`}>
{f.text}
</span>
</li>
))}
</ul>
</div>
<div className="p-6 pt-0">
<div className="w-full py-3 bg-cream text-charcoal-muted font-medium rounded-xl text-sm text-center">
{isPro ? 'Previous plan' : 'Current plan'}
</div>
</div>
</div>
{/* Premium Tier — Recommended */}
<div className="bg-white rounded-2xl border-2 border-purple shadow-lg shadow-purple/10 overflow-hidden flex flex-col relative">
<div className="absolute top-0 left-0 right-0 bg-purple text-white text-xs font-semibold text-center py-1.5 uppercase tracking-wider">
Recommended
</div>
<div className="p-6 border-b border-purple-50 pt-10">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-5 h-5 text-purple" />
<h3 className="text-lg font-semibold text-charcoal">Premium</h3>
</div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-charcoal">$6.99</span>
<span className="text-sm text-charcoal-muted">/month</span>
</div>
<p className="text-sm text-charcoal-muted mt-2">
Unlock the full power of AI music discovery
</p>
</div>
<div className="p-6 flex-1">
<ul className="space-y-3">
{premiumTierFeatures.map((f) => (
<li key={f.text} className="flex items-start gap-2.5">
<Check className="w-4 h-4 text-purple mt-0.5 flex-shrink-0" />
<span className="text-sm text-charcoal">{f.text}</span>
</li>
))}
</ul>
</div>
<div className="p-6 pt-0">
{isPro ? (
<div className="w-full py-3 bg-purple/10 text-purple font-semibold rounded-xl text-sm text-center flex items-center justify-center gap-2">
<Check className="w-4 h-4" />
Your current plan
</div>
) : (
<button
onClick={handleUpgrade}
disabled={checkoutLoading}
className="w-full flex items-center justify-center gap-2 py-3 bg-purple text-white font-semibold rounded-xl hover:bg-purple-dark transition-colors cursor-pointer disabled:opacity-50 border-none text-sm"
>
{checkoutLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<Crown className="w-4 h-4" />
Upgrade to Premium
</>
)}
</button>
)}
</div>
</div>
{/* Family Tier — Coming Soon */}
<div className="bg-white rounded-2xl border border-purple-100 overflow-hidden flex flex-col opacity-80">
<div className="p-6 border-b border-purple-50">
<div className="flex items-center gap-2 mb-3">
<Users className="w-5 h-5 text-charcoal-muted" />
<h3 className="text-lg font-semibold text-charcoal">Family</h3>
<span className="ml-auto px-2 py-0.5 bg-amber-50 text-amber-700 text-xs font-medium rounded-full">
Coming Soon
</span>
</div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-charcoal">$12.99</span>
<span className="text-sm text-charcoal-muted">/month</span>
</div>
<p className="text-sm text-charcoal-muted mt-2">
Share discovery with up to 5 family members
</p>
</div>
<div className="p-6 flex-1">
<ul className="space-y-3">
{familyTierFeatures.map((f) => (
<li key={f.text} className="flex items-start gap-2.5">
<Check className="w-4 h-4 text-charcoal-muted/50 mt-0.5 flex-shrink-0" />
<span className="text-sm text-charcoal-muted">{f.text}</span>
</li>
))}
</ul>
</div>
<div className="p-6 pt-0">
<div className="w-full py-3 bg-cream text-charcoal-muted font-medium rounded-xl text-sm text-center">
Coming soon
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,239 @@
import { useState, useEffect } from 'react'
import { Users, Loader2, Music, Sparkles, Heart } from 'lucide-react'
import { checkCompatibility, type CompatibilityResponse } from '../lib/api'
function ScoreCircle({ score }: { score: number }) {
const radius = 70
const circumference = 2 * Math.PI * radius
const offset = circumference - (score / 100) * circumference
const color =
score < 30 ? '#EF4444' : score < 60 ? '#EAB308' : '#22C55E'
const bgColor =
score < 30 ? 'text-red-100' : score < 60 ? 'text-yellow-100' : 'text-green-100'
const label =
score < 30 ? 'Different Wavelengths' : score < 60 ? 'Some Common Ground' : score < 80 ? 'Great Match' : 'Musical Soulmates'
return (
<div className="flex flex-col items-center gap-3">
<div className="relative w-44 h-44">
<svg className="w-full h-full -rotate-90" viewBox="0 0 160 160">
<circle
cx="80"
cy="80"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="10"
className={bgColor}
/>
<circle
cx="80"
cy="80"
r={radius}
fill="none"
stroke={color}
strokeWidth="10"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset 1s ease-out' }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-4xl font-bold text-charcoal">{score}%</span>
</div>
</div>
<span className="text-sm font-medium text-charcoal-muted">{label}</span>
</div>
)
}
function GenrePills({ genres, color }: { genres: string[]; color: string }) {
if (!genres.length) return <span className="text-sm text-charcoal-muted italic">None</span>
return (
<div className="flex flex-wrap gap-2">
{genres.map((g) => (
<span
key={g}
className={`px-3 py-1 rounded-full text-xs font-medium ${color}`}
>
{g}
</span>
))}
</div>
)
}
export default function Compatibility() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<CompatibilityResponse | null>(() => {
try {
const saved = sessionStorage.getItem('vynl_compatibility_results')
return saved ? JSON.parse(saved) : null
} catch { return null }
})
useEffect(() => {
if (result) {
sessionStorage.setItem('vynl_compatibility_results', JSON.stringify(result))
}
}, [result])
const handleCompare = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
setLoading(true)
setError(null)
setResult(null)
try {
const data = await checkCompatibility(email.trim())
setResult(data)
} catch (err: any) {
const msg = err.response?.data?.detail || 'Failed to check compatibility.'
setError(msg)
} finally {
setLoading(false)
}
}
return (
<div className="space-y-8">
{/* Header */}
<div className="text-center">
<div className="w-14 h-14 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Users className="w-7 h-7 text-purple" />
</div>
<h1 className="text-3xl font-bold text-charcoal">Taste Match</h1>
<p className="text-charcoal-muted mt-2">
See how your music taste compares with a friend
</p>
</div>
{/* Email input */}
<form onSubmit={handleCompare} className="max-w-md mx-auto flex gap-3">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your friend's email"
className="flex-1 px-4 py-3 rounded-xl border border-purple-200 bg-white text-charcoal placeholder-charcoal-muted/50 focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !email.trim()}
className="px-6 py-3 bg-purple text-white rounded-xl font-medium hover:bg-purple-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Compare'}
</button>
</form>
{/* Error */}
{error && (
<div className="max-w-md mx-auto bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700 text-center">
{error}
</div>
)}
{/* Loading */}
{loading && (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<Loader2 className="w-8 h-8 text-purple animate-spin" />
<p className="text-charcoal-muted text-sm">Analyzing your musical chemistry...</p>
</div>
)}
{/* Results */}
{result && !loading && (
<div className="space-y-8 max-w-2xl mx-auto">
{/* Score */}
<div className="bg-white rounded-2xl border border-purple-100 p-8 text-center">
<p className="text-sm text-charcoal-muted mb-4">
You & <span className="font-semibold text-charcoal">{result.friend_name}</span>
</p>
<ScoreCircle score={result.compatibility_score} />
</div>
{/* AI Insight */}
<div className="bg-gradient-to-br from-purple-50 to-purple-100/50 rounded-2xl border border-purple-200 p-6">
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 text-purple mt-0.5 flex-shrink-0" />
<p className="text-charcoal leading-relaxed">{result.insight}</p>
</div>
</div>
{/* Shared Genres */}
<div className="bg-white rounded-2xl border border-purple-100 p-6 space-y-4">
<h3 className="font-semibold text-charcoal flex items-center gap-2">
<Heart className="w-4 h-4 text-purple" />
Shared Genres
</h3>
<GenrePills genres={result.shared_genres} color="bg-purple-100 text-purple" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
<div>
<h4 className="text-sm font-medium text-charcoal-muted mb-2">Only You</h4>
<GenrePills genres={result.unique_to_you} color="bg-blue-100 text-blue-700" />
</div>
<div>
<h4 className="text-sm font-medium text-charcoal-muted mb-2">Only Them</h4>
<GenrePills genres={result.unique_to_them} color="bg-amber-100 text-amber-700" />
</div>
</div>
</div>
{/* Shared Artists */}
{result.shared_artists.length > 0 && (
<div className="bg-white rounded-2xl border border-purple-100 p-6">
<h3 className="font-semibold text-charcoal flex items-center gap-2 mb-3">
<Music className="w-4 h-4 text-purple" />
Shared Artists
</h3>
<div className="flex flex-wrap gap-2">
{result.shared_artists.map((a) => (
<span
key={a}
className="px-3 py-1.5 bg-cream rounded-lg text-sm text-charcoal font-medium"
>
{a}
</span>
))}
</div>
</div>
)}
{/* Recommendations */}
{result.recommendations.length > 0 && (
<div className="space-y-4">
<h3 className="font-semibold text-charcoal text-lg">
Songs You'd Both Love
</h3>
<div className="grid gap-3">
{result.recommendations.map((rec, i) => (
<div
key={i}
className="bg-white rounded-xl border border-purple-100 p-4 flex items-start gap-4"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center flex-shrink-0">
<Music className="w-5 h-5 text-purple" />
</div>
<div className="min-w-0">
<p className="font-medium text-charcoal truncate">{rec.title}</p>
<p className="text-sm text-charcoal-muted">{rec.artist}</p>
<p className="text-xs text-charcoal-muted/80 mt-1">{rec.reason}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,336 @@
import { useState, useCallback, useEffect, useRef } 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[]>(() => {
try {
const saved = sessionStorage.getItem('vynl_crate')
return saved ? JSON.parse(saved) : []
} catch { return [] }
})
const [currentIndex, setCurrentIndex] = useState(() => {
try { return Number(sessionStorage.getItem('vynl_crate_index') || '0') } catch { return 0 }
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [cardState, setCardState] = useState<CardState>('visible')
const [savedCount, setSavedCount] = useState(() => {
try { return Number(sessionStorage.getItem('vynl_crate_saved') || '0') } catch { return 0 }
})
const [crateSize, setCrateSize] = useState(() => {
try { return Number(sessionStorage.getItem('vynl_crate_size') || '0') } catch { return 0 }
})
const [finished, setFinished] = useState(false)
useEffect(() => { sessionStorage.setItem('vynl_crate_index', String(currentIndex)) }, [currentIndex])
useEffect(() => { sessionStorage.setItem('vynl_crate_saved', String(savedCount)) }, [savedCount])
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)
sessionStorage.setItem('vynl_crate', JSON.stringify(items))
sessionStorage.setItem('vynl_crate_size', String(items.length))
sessionStorage.setItem('vynl_crate_index', '0')
sessionStorage.setItem('vynl_crate_saved', '0')
} 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])
// Touch swipe handling
const touchStartX = useRef<number | null>(null)
const touchStartY = useRef<number | null>(null)
const [swipeOffset, setSwipeOffset] = useState(0)
const cardRef = useRef<HTMLDivElement>(null)
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX
touchStartY.current = e.touches[0].clientY
}, [])
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (touchStartX.current === null || cardState !== 'visible') return
const dx = e.touches[0].clientX - touchStartX.current
const dy = e.touches[0].clientY - (touchStartY.current || 0)
// Only swipe horizontally if horizontal movement > vertical
if (Math.abs(dx) > Math.abs(dy)) {
e.preventDefault()
setSwipeOffset(dx)
}
}, [cardState])
const handleTouchEnd = useCallback(() => {
if (touchStartX.current === null) return
const threshold = 80
if (swipeOffset > threshold) {
handleSave()
} else if (swipeOffset < -threshold) {
handlePass()
}
setSwipeOffset(0)
touchStartX.current = null
touchStartY.current = null
}, [swipeOffset, handleSave, handlePass])
// Keyboard support
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (cardState !== 'visible' || finished || crate.length === 0) return
if (e.key === 'ArrowLeft') handlePass()
if (e.key === 'ArrowRight') handleSave()
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [cardState, finished, crate.length, handlePass, handleSave])
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
ref={cardRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className={`bg-white rounded-2xl shadow-lg border border-purple-100 overflow-hidden select-none ${
cardState === 'saving'
? 'transition-all duration-300 opacity-0 translate-x-48 rotate-6'
: cardState === 'passing'
? 'transition-all duration-300 opacity-0 -translate-x-48 -rotate-6'
: swipeOffset === 0
? 'transition-all duration-300 opacity-100 translate-x-0 rotate-0'
: ''
}`}
style={swipeOffset !== 0 ? {
transform: `translateX(${swipeOffset}px) rotate(${swipeOffset * 0.05}deg)`,
opacity: Math.max(0.3, 1 - Math.abs(swipeOffset) / 300),
} : undefined}
>
{/* 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>
)}
{/* Swipe indicators */}
{swipeOffset !== 0 && (
<div className="flex justify-between px-4 mt-3">
<span className={`text-sm font-bold transition-opacity ${swipeOffset < -40 ? 'opacity-100 text-red-500' : 'opacity-0'}`}>
PASS
</span>
<span className={`text-sm font-bold transition-opacity ${swipeOffset > 40 ? 'opacity-100 text-green-500' : 'opacity-0'}`}>
SAVE
</span>
</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">
Swipe or use buttons · Arrow keys on desktop
</p>
</div>
)
}

View File

@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { ListMusic, Heart, Sparkles, Compass, Loader2, Music, CheckCircle2, XCircle } from 'lucide-react'
import { ListMusic, Heart, Sparkles, Compass, Loader2, Music } from 'lucide-react'
import { useAuth } from '../lib/auth'
import { getPlaylists, getRecommendationHistory, getSavedRecommendations, generateRecommendations, type RecommendationItem, type PlaylistResponse } from '../lib/api'
import RecommendationCard from '../components/RecommendationCard'
import Onboarding from '../components/Onboarding'
import { toggleSaveRecommendation } from '../lib/api'
export default function Dashboard() {
@@ -15,6 +16,14 @@ export default function Dashboard() {
const [query, setQuery] = useState('')
const [discovering, setDiscovering] = useState(false)
const [loading, setLoading] = useState(true)
const [showOnboarding, setShowOnboarding] = useState(
!localStorage.getItem('vynl_onboarding_v2')
)
const handleOnboardingComplete = () => {
localStorage.setItem('vynl_onboarding_v2', 'true')
setShowOnboarding(false)
}
useEffect(() => {
const load = async () => {
@@ -70,6 +79,8 @@ export default function Dashboard() {
return (
<div className="space-y-8">
{showOnboarding && <Onboarding onComplete={handleOnboardingComplete} />}
{/* Welcome */}
<div>
<h1 className="text-3xl font-bold text-charcoal">
@@ -119,10 +130,10 @@ export default function Dashboard() {
</div>
<div>
<p className="text-2xl font-bold text-charcoal">
{user?.daily_recommendations_remaining ?? 10}
{user?.daily_recommendations_remaining ?? 5}
</p>
<p className="text-sm text-charcoal-muted">
Recommendations left today
Discoveries left this week
</p>
</div>
</div>
@@ -162,27 +173,6 @@ export default function Dashboard() {
</div>
</div>
{/* Connected Accounts */}
<div className="bg-white rounded-2xl border border-purple-100 p-6">
<h2 className="text-lg font-semibold text-charcoal mb-4">Connected Accounts</h2>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 px-4 py-3 bg-cream rounded-xl flex-1">
<svg className="w-5 h-5 text-[#1DB954]" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
</svg>
<span className="text-sm font-medium text-charcoal">Spotify</span>
{user?.spotify_connected ? (
<CheckCircle2 className="w-4 h-4 text-green-500 ml-auto" />
) : (
<XCircle className="w-4 h-4 text-charcoal-muted/40 ml-auto" />
)}
<span className="text-xs text-charcoal-muted">
{user?.spotify_connected ? 'Connected' : 'Not connected'}
</span>
</div>
</div>
</div>
{/* Recent Recommendations */}
{recentRecs.length > 0 && (
<div>

View File

@@ -1,22 +1,66 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Compass, Sparkles, Loader2, ListMusic, Search } from 'lucide-react'
import { Compass, Sparkles, Loader2, ListMusic, Search, Users, Clock, Disc3, TrendingUp, Shuffle, X } from 'lucide-react'
import { useAuth } from '../lib/auth'
import { getPlaylists, generateRecommendations, toggleSaveRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api'
import { getPlaylists, generateRecommendations, surpriseMe, toggleSaveRecommendation, dislikeRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api'
import RecommendationCard from '../components/RecommendationCard'
const DISCOVERY_MODES = [
{ id: 'discover', label: 'Discover', icon: Compass, description: 'General recommendations' },
{ id: 'sonic_twin', label: 'Sonic Twin', icon: Users, description: 'Underground artists who sound like your favorites' },
{ id: 'era_bridge', label: 'Era Bridge', icon: Clock, description: 'Classic artists who inspired your favorites' },
{ id: 'deep_cuts', label: 'Deep Cuts', icon: Disc3, description: 'B-sides and rarities from artists you know' },
{ id: 'rising', label: 'Rising', icon: TrendingUp, description: 'Under 50K listeners who fit your profile' },
] as const
const ADVENTUROUSNESS_LABELS: Record<number, string> = {
1: 'Safe',
2: 'Familiar',
3: 'Balanced',
4: 'Exploring',
5: 'Adventurous',
}
export default function Discover() {
const { user } = useAuth()
const [searchParams] = useSearchParams()
const [playlists, setPlaylists] = useState<PlaylistResponse[]>([])
const [selectedPlaylist, setSelectedPlaylist] = useState<string>('')
const [query, setQuery] = useState('')
const [results, setResults] = useState<RecommendationItem[]>([])
const [results, setResults] = useState<RecommendationItem[]>(() => {
try {
const saved = sessionStorage.getItem('vynl_discover_results')
return saved ? JSON.parse(saved) : []
} catch { return [] }
})
const [remaining, setRemaining] = useState<number | null>(null)
const [discovering, setDiscovering] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [bandcampMode, setBandcampMode] = useState(false)
const [savingIds, setSavingIds] = useState<Set<string>>(new Set())
const [dislikingIds, setDislikingIds] = useState<Set<string>>(new Set())
const [mode, setMode] = useState('discover')
const [adventurousness, setAdventurousness] = useState(3)
const [excludeGenres, setExcludeGenres] = useState('')
const [count, setCount] = useState(5)
const [moodEnergy, setMoodEnergy] = useState<number | null>(null)
const [moodValence, setMoodValence] = useState<number | null>(null)
const [showMood, setShowMood] = useState(false)
const [surprising, setSurprising] = useState(false)
const resultsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const preQuery = searchParams.get('q')
if (preQuery) setQuery(preQuery)
}, [searchParams])
// Persist results to sessionStorage
useEffect(() => {
if (results.length > 0) {
sessionStorage.setItem('vynl_discover_results', JSON.stringify(results))
}
}, [results])
useEffect(() => {
const load = async () => {
@@ -45,37 +89,95 @@ export default function Discover() {
try {
const response = await generateRecommendations(
selectedPlaylist || undefined,
query.trim() || undefined
query.trim() || undefined,
false,
mode,
adventurousness,
excludeGenres.trim() || undefined,
count,
moodEnergy ?? undefined,
moodValence ?? undefined,
)
setResults(response.recommendations)
setRemaining(response.remaining_today)
const recs = response.recommendations || []
setResults(recs)
setRemaining(response.remaining_this_week ?? null)
if (recs.length === 0) {
setError(`Got 0 results back from API. Raw response keys: ${Object.keys(response).join(', ')}`)
}
// Scroll to results after render
if (recs.length > 0) {
setTimeout(() => resultsRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
}
} catch (err: any) {
setError(
err.response?.data?.detail || 'Failed to generate recommendations. Please try again.'
)
const msg = err.response?.data?.detail || err.message || 'Unknown error'
setError(`Error: ${msg} (status: ${err.response?.status || 'none'})`)
} finally {
setDiscovering(false)
}
}
const handleToggleSave = async (id: string) => {
setSavingIds((prev) => new Set(prev).add(id))
const sid = String(id)
setSavingIds((prev) => new Set(prev).add(sid))
try {
const { saved } = await toggleSaveRecommendation(id)
const { saved } = await toggleSaveRecommendation(sid)
setResults((prev) =>
prev.map((r) => (r.id === id ? { ...r, saved } : r))
prev.map((r) => (String(r.id) === sid ? { ...r, saved } : r))
)
} catch {
// silent
} finally {
setSavingIds((prev) => {
const next = new Set(prev)
next.delete(id)
next.delete(sid)
return next
})
}
}
const handleDislike = async (id: string) => {
const sid = String(id)
setDislikingIds((prev) => new Set(prev).add(sid))
try {
const { disliked } = await dislikeRecommendation(sid)
setResults((prev) =>
prev.map((r) => (String(r.id) === sid ? { ...r, disliked } : r))
)
} catch {
// silent
} finally {
setDislikingIds((prev) => {
const next = new Set(prev)
next.delete(sid)
return next
})
}
}
const handleSurprise = async () => {
setSurprising(true)
setError('')
setResults([])
try {
const response = await surpriseMe()
const recs = response.recommendations || []
setResults(recs)
setRemaining(response.remaining_this_week ?? null)
if (recs.length === 0) {
setError('No surprise recommendations returned.')
}
if (recs.length > 0) {
setTimeout(() => resultsRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
}
} catch (err: any) {
const msg = err.response?.data?.detail || err.message || 'Unknown error'
setError(`Error: ${msg} (status: ${err.response?.status || 'none'})`)
} finally {
setSurprising(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -87,7 +189,7 @@ export default function Discover() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-charcoal flex items-center gap-3">
<h1 className="text-2xl sm:text-3xl font-bold text-charcoal flex items-center gap-3">
<Compass className="w-8 h-8 text-purple" />
Discover
</h1>
@@ -96,8 +198,46 @@ export default function Discover() {
</p>
</div>
{/* Surprise Me */}
<button
onClick={handleSurprise}
disabled={surprising || discovering}
className="w-full py-4 bg-gradient-to-r from-purple via-purple-dark to-purple text-white font-semibold rounded-2xl hover:shadow-xl hover:shadow-purple/30 transition-all disabled:opacity-50 cursor-pointer border-none text-base flex items-center justify-center gap-3"
>
{surprising ? (
<>
<Loader2 className="w-6 h-6 animate-spin" />
Cooking up a surprise...
</>
) : (
<>
<Shuffle className="w-6 h-6" />
Surprise Me
</>
)}
</button>
{/* Discovery Modes */}
<div className="flex flex-wrap gap-2">
{DISCOVERY_MODES.map(({ id, label, icon: Icon, description }) => (
<button
key={id}
onClick={() => setMode(id)}
className={`inline-flex items-center gap-1.5 px-3 py-2 sm:px-4 sm:py-2.5 rounded-full text-xs sm:text-sm font-medium transition-all cursor-pointer border ${
mode === id
? 'bg-purple text-white border-purple shadow-md shadow-purple/20'
: 'bg-white text-charcoal-muted border-purple-100 hover:border-purple/30 hover:text-charcoal'
}`}
title={description}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
{/* Discovery Form */}
<div className="bg-white rounded-2xl border border-purple-100 p-6 space-y-5">
<div className="bg-white rounded-2xl border border-purple-100 p-4 sm:p-6 space-y-4 sm:space-y-5">
{/* Playlist Selector */}
<div>
<label className="block text-sm font-medium text-charcoal mb-2">
@@ -127,17 +267,160 @@ export default function Discover() {
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder='e.g., "Upbeat indie rock with jangly guitars" or "Dreamy synth-pop for late night drives"'
placeholder='e.g., "Upbeat indie rock with jangly guitars", "Dreamy synth-pop for late night drives", or just type artists/songs like "Radiohead, Tame Impala"'
rows={3}
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm resize-none"
/>
</div>
{/* Discovery Dial */}
<div>
<label className="block text-sm font-medium text-charcoal mb-3">
Discovery dial
<span className="ml-2 text-charcoal-muted font-normal">
{ADVENTUROUSNESS_LABELS[adventurousness]}
</span>
</label>
<div className="relative">
<input
type="range"
min={1}
max={5}
step={1}
value={adventurousness}
onChange={(e) => setAdventurousness(Number(e.target.value))}
className="w-full h-2 bg-purple-100 rounded-full appearance-none cursor-pointer accent-purple"
/>
<div className="flex justify-between mt-1.5 px-0.5">
<span className="text-xs text-charcoal-muted">Safe</span>
<span className="text-xs text-charcoal-muted">Balanced</span>
<span className="text-xs text-charcoal-muted">Adventurous</span>
</div>
</div>
</div>
{/* Mood Scanner */}
<div>
<button
type="button"
onClick={() => {
setShowMood(!showMood)
if (showMood) {
setMoodEnergy(null)
setMoodValence(null)
}
}}
className="text-sm font-medium text-charcoal hover:text-purple transition-colors cursor-pointer flex items-center gap-2"
>
{showMood ? 'Hide mood' : 'Set mood'}
<span className="text-charcoal-muted text-xs font-normal">(optional)</span>
</button>
{showMood && (
<div className="mt-3 space-y-4 p-4 bg-cream/30 rounded-xl border border-purple-100/50">
{/* Energy Slider */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-charcoal">Energy</label>
{moodEnergy !== null && (
<button
onClick={() => setMoodEnergy(null)}
className="text-xs text-charcoal-muted hover:text-purple transition-colors cursor-pointer flex items-center gap-1"
>
<X className="w-3 h-3" />
Clear
</button>
)}
</div>
<input
type="range"
min={1}
max={5}
step={1}
value={moodEnergy ?? 3}
onChange={(e) => setMoodEnergy(Number(e.target.value))}
className={`w-full h-2 rounded-full appearance-none cursor-pointer accent-purple ${moodEnergy !== null ? 'bg-purple-100' : 'bg-gray-200 opacity-50'}`}
/>
<div className="flex justify-between mt-1.5 px-0.5">
<span className="text-xs text-charcoal-muted">Chill</span>
<span className="text-xs text-charcoal-muted">Energetic</span>
</div>
</div>
{/* Mood/Valence Slider */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-charcoal">Mood</label>
{moodValence !== null && (
<button
onClick={() => setMoodValence(null)}
className="text-xs text-charcoal-muted hover:text-purple transition-colors cursor-pointer flex items-center gap-1"
>
<X className="w-3 h-3" />
Clear
</button>
)}
</div>
<input
type="range"
min={1}
max={5}
step={1}
value={moodValence ?? 3}
onChange={(e) => setMoodValence(Number(e.target.value))}
className={`w-full h-2 rounded-full appearance-none cursor-pointer accent-purple ${moodValence !== null ? 'bg-purple-100' : 'bg-gray-200 opacity-50'}`}
/>
<div className="flex justify-between mt-1.5 px-0.5">
<span className="text-xs text-charcoal-muted">Dark</span>
<span className="text-xs text-charcoal-muted">Happy</span>
</div>
</div>
</div>
)}
</div>
{/* Recommendation Count */}
<div>
<label className="block text-sm font-medium text-charcoal mb-3">
How many recommendations
</label>
<div className="flex gap-2">
{[5, 10, 15, 20].map((n) => (
<button
key={n}
onClick={() => setCount(n)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all cursor-pointer border ${
count === n
? 'bg-purple text-white border-purple shadow-md shadow-purple/20'
: 'bg-white text-charcoal-muted border-purple-100 hover:border-purple/30 hover:text-charcoal'
}`}
>
{n}
</button>
))}
</div>
</div>
{/* Block Genres */}
<div>
<label className="block text-sm font-medium text-charcoal mb-2">
Exclude genres / moods
</label>
<input
type="text"
value={excludeGenres}
onChange={(e) => setExcludeGenres(e.target.value)}
placeholder="country, sad songs, metal"
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm"
/>
</div>
{/* Remaining count */}
{!user?.is_pro && (
<p className="text-xs text-charcoal-muted flex items-center gap-1">
<Sparkles className="w-3 h-3 text-amber-500" />
{remaining !== null ? remaining : user?.daily_recommendations_remaining ?? 10} recommendations remaining today
{remaining !== null ? remaining : user?.daily_recommendations_remaining ?? 5} discoveries remaining this week
</p>
)}
@@ -149,7 +432,7 @@ export default function Discover() {
<button
onClick={handleDiscover}
disabled={discovering || (!selectedPlaylist && !query.trim())}
disabled={discovering || surprising || (!selectedPlaylist && !query.trim())}
className="w-full py-3.5 bg-gradient-to-r from-purple to-purple-dark text-white font-semibold rounded-xl hover:shadow-lg hover:shadow-purple/25 transition-all disabled:opacity-50 cursor-pointer border-none text-sm flex items-center justify-center gap-2"
>
{discovering ? (
@@ -168,9 +451,9 @@ export default function Discover() {
{/* Results */}
{results.length > 0 && (
<div>
<div ref={resultsRef}>
<h2 className="text-lg font-semibold text-charcoal mb-4">
Your Recommendations
Your Recommendations ({results.length})
</h2>
<div className="space-y-3">
{results.map((rec) => (
@@ -178,12 +461,20 @@ export default function Discover() {
key={rec.id}
recommendation={rec}
onToggleSave={handleToggleSave}
saving={savingIds.has(rec.id)}
onDislike={handleDislike}
saving={savingIds.has(String(rec.id))}
disliking={dislikingIds.has(String(rec.id))}
/>
))}
</div>
</div>
)}
{!discovering && results.length === 0 && !error && (selectedPlaylist || query.trim()) && (
<p className="text-charcoal-muted text-sm text-center py-4">
Click "Discover Music" to get recommendations
</p>
)}
</div>
)
}

View File

@@ -1,29 +1,49 @@
import { Link } from 'react-router-dom'
import { Disc3, Sparkles, ListMusic, Heart, ArrowRight } from 'lucide-react'
import { Disc3, Sparkles, ListMusic, Heart, ArrowRight, Compass, Users, Shuffle, Music, Repeat, Clock, TrendingUp, Lightbulb, ArrowDownCircle, Calendar, Share2, Fingerprint } from 'lucide-react'
const features = [
const howItWorks = [
{
icon: ListMusic,
title: 'Import Your Music',
description: 'Connect Spotify and import your playlists to build your taste profile.',
description: 'Paste a YouTube Music playlist, import from Last.fm, or just type in songs you love. No Spotify required.',
},
{
icon: Sparkles,
title: 'AI-Powered Discovery',
description: 'Our AI analyzes your taste and finds hidden gems you\'ll actually love.',
title: 'AI Analyzes Your Taste',
description: 'Our AI builds a deep profile of your musical DNA — genres, energy, mood, and the qualities that make you hit repeat.',
},
{
icon: Heart,
title: 'Understand Why',
description: 'Every recommendation comes with a personal explanation of why it fits your taste.',
title: 'Discover & Understand',
description: 'Get personalized recommendations with explanations of exactly WHY you\'ll love each track. Every song links to YouTube Music.',
},
]
const discoveryModes = [
{ icon: Compass, title: 'Discover', description: 'AI-curated picks based on your taste' },
{ icon: Users, title: 'Sonic Twin', description: 'Underground artists who sound like your favorites' },
{ icon: Clock, title: 'Era Bridge', description: 'Classic artists who inspired what you love now' },
{ icon: Disc3, title: 'Deep Cuts', description: 'B-sides and rarities from artists you know' },
{ icon: TrendingUp, title: 'Rising', description: 'Under 50K listeners who fit your profile' },
{ icon: Shuffle, title: 'Surprise Me', description: 'One button. AI picks a wild creative angle.' },
]
const features = [
{ icon: ArrowDownCircle, title: 'Rabbit Hole', description: 'Follow a chain of connected songs — each one leads to the next through a shared quality.' },
{ icon: Music, title: 'Crate Digger', description: 'Swipe through discoveries like Tinder for music. Save or pass — build your taste profile faster.' },
{ icon: Lightbulb, title: 'Why Do I Like This?', description: 'Paste any song. AI explains what draws you to it, then finds more with the same qualities.' },
{ icon: ListMusic, title: 'Playlist Generator', description: 'Describe a vibe — "rainy day reading" — and AI builds a full 25-song playlist.' },
{ icon: Repeat, title: 'Fix My Playlist', description: 'AI finds tracks that don\'t fit your playlist vibe and suggests better replacements.' },
{ icon: Calendar, title: 'Concert Finder', description: 'See upcoming tour dates for any recommended artist, with links to tickets.' },
{ icon: Fingerprint, title: 'Music DNA Profile', description: 'Visual breakdown of your taste — genre bars, mood meters, listening personality label.' },
{ icon: Share2, title: 'Share Discoveries', description: 'Share any recommendation or your full taste profile via a public link.' },
]
export default function Landing() {
return (
<div className="min-h-screen bg-cream">
{/* Header */}
<header className="px-6 py-5">
<header className="px-4 sm:px-6 py-5">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-2">
<Disc3 className="w-8 h-8 text-purple" strokeWidth={2.5} />
@@ -47,14 +67,13 @@ export default function Landing() {
</header>
{/* Hero */}
<section className="px-6 pt-16 pb-24 md:pt-24 md:pb-32">
<section className="px-4 sm:px-6 pt-12 pb-20 md:pt-20 md:pb-28">
<div className="max-w-4xl mx-auto text-center">
{/* Decorative vinyl */}
<div className="mb-8 inline-flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-purple to-purple-dark shadow-lg shadow-purple/25">
<Disc3 className="w-14 h-14 text-white animate-[spin_8s_linear_infinite]" />
</div>
<h1 className="text-5xl md:text-7xl font-extrabold text-charcoal leading-tight tracking-tight mb-6">
<h1 className="text-4xl sm:text-5xl md:text-7xl font-extrabold text-charcoal leading-tight tracking-tight mb-6">
Dig deeper.
<br />
<span className="text-purple">Discover more.</span>
@@ -62,8 +81,8 @@ export default function Landing() {
<p className="text-lg md:text-xl text-charcoal-muted max-w-2xl mx-auto mb-10 leading-relaxed">
Vynl uses AI to understand your unique music taste and uncover tracks
you never knew you needed. Like a friend with impeccable taste who
always knows what to play next.
you never knew you needed. Every recommendation comes with a reason WHY
and a link to listen instantly.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
@@ -83,38 +102,34 @@ export default function Landing() {
</div>
<p className="mt-6 text-sm text-charcoal-muted">
Free tier includes 10 recommendations per day
Free tier: 5 discoveries per week. No credit card required.
</p>
</div>
</section>
{/* Features */}
<section className="px-6 py-20 bg-white/50">
{/* How It Works */}
<section className="px-4 sm:px-6 py-16 bg-white/50">
<div className="max-w-5xl mx-auto">
<h2 className="text-3xl md:text-4xl font-bold text-charcoal text-center mb-4">
How it works
</h2>
<p className="text-charcoal-muted text-center mb-14 max-w-xl mx-auto">
Three simple steps to your next favorite song
<p className="text-charcoal-muted text-center mb-12 max-w-xl mx-auto">
Three steps to your next favorite song
</p>
<div className="grid md:grid-cols-3 gap-8">
{features.map((feature, i) => {
const Icon = feature.icon
<div className="grid md:grid-cols-3 gap-6">
{howItWorks.map((item, i) => {
const Icon = item.icon
return (
<div
key={i}
className="bg-white rounded-2xl p-8 border border-purple-100 shadow-sm hover:shadow-md transition-shadow"
className="bg-white rounded-2xl p-7 border border-purple-100 shadow-sm hover:shadow-md transition-shadow"
>
<div className="w-12 h-12 rounded-xl bg-purple-50 flex items-center justify-center mb-5">
<Icon className="w-6 h-6 text-purple" />
</div>
<h3 className="text-lg font-semibold text-charcoal mb-2">
{feature.title}
</h3>
<p className="text-charcoal-muted text-sm leading-relaxed">
{feature.description}
</p>
<h3 className="text-lg font-semibold text-charcoal mb-2">{item.title}</h3>
<p className="text-charcoal-muted text-sm leading-relaxed">{item.description}</p>
</div>
)
})}
@@ -122,14 +137,97 @@ export default function Landing() {
</div>
</section>
{/* Discovery Modes */}
<section className="px-4 sm:px-6 py-16">
<div className="max-w-5xl mx-auto">
<h2 className="text-3xl md:text-4xl font-bold text-charcoal text-center mb-4">
6 ways to discover
</h2>
<p className="text-charcoal-muted text-center mb-12 max-w-xl mx-auto">
Choose how adventurous you want to go
</p>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{discoveryModes.map((mode, i) => {
const Icon = mode.icon
return (
<div
key={i}
className="flex items-start gap-4 bg-white rounded-xl p-5 border border-purple-100 hover:border-purple/30 transition-colors"
>
<div className="w-10 h-10 rounded-lg bg-purple-50 flex items-center justify-center flex-shrink-0">
<Icon className="w-5 h-5 text-purple" />
</div>
<div>
<h3 className="font-semibold text-charcoal text-sm mb-1">{mode.title}</h3>
<p className="text-charcoal-muted text-xs leading-relaxed">{mode.description}</p>
</div>
</div>
)
})}
</div>
</div>
</section>
{/* All Features */}
<section className="px-4 sm:px-6 py-16 bg-white/50">
<div className="max-w-5xl mx-auto">
<h2 className="text-3xl md:text-4xl font-bold text-charcoal text-center mb-4">
Everything you get
</h2>
<p className="text-charcoal-muted text-center mb-12 max-w-xl mx-auto">
More than just recommendations
</p>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
{features.map((feature, i) => {
const Icon = feature.icon
return (
<div
key={i}
className="bg-white rounded-xl p-5 border border-purple-100 hover:shadow-md transition-shadow"
>
<Icon className="w-6 h-6 text-purple mb-3" />
<h3 className="font-semibold text-charcoal text-sm mb-1.5">{feature.title}</h3>
<p className="text-charcoal-muted text-xs leading-relaxed">{feature.description}</p>
</div>
)
})}
</div>
</div>
</section>
{/* Taste Compatibility */}
<section className="px-4 sm:px-6 py-16">
<div className="max-w-4xl mx-auto">
<div className="bg-gradient-to-br from-charcoal to-charcoal-light rounded-3xl p-8 md:p-12 text-center">
<Users className="w-12 h-12 text-purple-300 mx-auto mb-4" />
<h2 className="text-2xl md:text-3xl font-bold text-white mb-3">
Compare your taste with friends
</h2>
<p className="text-purple-200/80 mb-6 max-w-lg mx-auto">
Enter a friend's email and get a compatibility score, see what genres you share,
and discover songs you'd both love.
</p>
<Link
to="/register"
className="inline-flex items-center gap-2 px-6 py-3 bg-purple text-white font-semibold rounded-full hover:bg-purple-dark transition-colors no-underline text-sm"
>
Try Taste Match
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
</section>
{/* CTA */}
<section className="px-6 py-24">
<div className="max-w-3xl mx-auto text-center bg-gradient-to-br from-purple to-purple-dark rounded-3xl p-12 md:p-16 shadow-xl shadow-purple/20">
<section className="px-4 sm:px-6 py-20">
<div className="max-w-3xl mx-auto text-center bg-gradient-to-br from-purple to-purple-dark rounded-3xl p-10 md:p-16 shadow-xl shadow-purple/20">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Ready to find your next favorite song?
</h2>
<p className="text-purple-200 mb-8 text-lg">
Join Vynl today and let AI be your personal music curator.
Join thousands of music lovers discovering new artists every day.
</p>
<Link
to="/register"
@@ -142,8 +240,8 @@ export default function Landing() {
</section>
{/* Footer */}
<footer className="px-6 py-8 border-t border-purple-100">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<footer className="px-4 sm:px-6 py-8 border-t border-purple-100">
<div className="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-charcoal-muted">
<Disc3 className="w-5 h-5" />
<span className="text-sm font-medium">Vynl</span>

View File

@@ -2,7 +2,7 @@ import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Disc3, Mail, Lock, Loader2 } from 'lucide-react'
import { useAuth } from '../lib/auth'
import { login as apiLogin, getSpotifyAuthUrl } from '../lib/api'
import { login as apiLogin } from '../lib/api'
export default function Login() {
const [email, setEmail] = useState('')
@@ -28,15 +28,6 @@ export default function Login() {
}
}
const handleSpotifyLogin = async () => {
try {
const { url } = await getSpotifyAuthUrl()
window.location.href = url
} catch {
setError('Could not connect to Spotify')
}
}
return (
<div className="min-h-screen bg-cream flex flex-col">
{/* Header */}
@@ -113,22 +104,6 @@ export default function Login() {
</button>
</form>
<div className="my-6 flex items-center gap-3">
<div className="flex-1 h-px bg-purple-100" />
<span className="text-xs text-charcoal-muted uppercase tracking-wider">or</span>
<div className="flex-1 h-px bg-purple-100" />
</div>
<button
onClick={handleSpotifyLogin}
className="w-full py-3 bg-[#1DB954] text-white font-semibold rounded-xl hover:bg-[#1aa34a] transition-colors cursor-pointer border-none text-sm flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
</svg>
Continue with Spotify
</button>
<p className="text-center mt-6 text-sm text-charcoal-muted">
Don't have an account?{' '}
<Link to="/register" className="text-purple font-medium hover:underline">

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { ArrowLeft, Loader2, Music, Clock, Sparkles, Trash2 } from 'lucide-react'
import { getPlaylist, deletePlaylist, type PlaylistDetailResponse } from '../lib/api'
import { ArrowLeft, Loader2, Music, Clock, Sparkles, Trash2, Wand2, AlertTriangle, CheckCircle2, X, Download } from 'lucide-react'
import { getPlaylist, deletePlaylist, fixPlaylist, exportPlaylist, type PlaylistDetailResponse, type PlaylistFixResponse } from '../lib/api'
import TasteProfile from '../components/TasteProfile'
function formatDuration(ms: number): string {
@@ -17,6 +17,10 @@ export default function PlaylistDetail() {
const [loading, setLoading] = useState(true)
const [deleting, setDeleting] = useState(false)
const [showProfile, setShowProfile] = useState(false)
const [fixResult, setFixResult] = useState<PlaylistFixResponse | null>(null)
const [fixLoading, setFixLoading] = useState(false)
const [fixError, setFixError] = useState<string | null>(null)
const [dismissedOutliers, setDismissedOutliers] = useState<Set<number>>(new Set())
useEffect(() => {
if (!id) return
@@ -44,6 +48,26 @@ export default function PlaylistDetail() {
}
}
const handleFix = async () => {
if (!id) return
setFixLoading(true)
setFixError(null)
setFixResult(null)
setDismissedOutliers(new Set())
try {
const result = await fixPlaylist(id)
setFixResult(result)
} catch {
setFixError('Failed to analyze playlist. Please try again.')
} finally {
setFixLoading(false)
}
}
const dismissOutlier = (trackNumber: number) => {
setDismissedOutliers((prev) => new Set([...prev, trackNumber]))
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -94,6 +118,18 @@ export default function PlaylistDetail() {
<Sparkles className="w-4 h-4" />
Get Recommendations
</Link>
<button
onClick={handleFix}
disabled={fixLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-amber-50 text-amber-700 text-sm font-medium rounded-xl hover:bg-amber-100 transition-colors cursor-pointer border-none disabled:opacity-50"
>
{fixLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Wand2 className="w-4 h-4" />
)}
{fixLoading ? 'Analyzing...' : 'Fix My Playlist'}
</button>
{playlist.taste_profile && (
<button
onClick={() => setShowProfile(!showProfile)}
@@ -102,6 +138,20 @@ export default function PlaylistDetail() {
{showProfile ? 'Hide' : 'Show'} Taste Profile
</button>
)}
<button
onClick={() => id && exportPlaylist(id, 'text')}
className="flex items-center gap-2 px-4 py-2.5 bg-purple-50 text-purple text-sm font-medium rounded-xl hover:bg-purple-100 transition-colors cursor-pointer border-none"
>
<Download className="w-4 h-4" />
Export TXT
</button>
<button
onClick={() => id && exportPlaylist(id, 'csv')}
className="flex items-center gap-2 px-4 py-2.5 bg-purple-50 text-purple text-sm font-medium rounded-xl hover:bg-purple-100 transition-colors cursor-pointer border-none"
>
<Download className="w-4 h-4" />
Export CSV
</button>
<button
onClick={handleDelete}
disabled={deleting}
@@ -123,6 +173,121 @@ export default function PlaylistDetail() {
</div>
)}
{/* Fix My Playlist Loading */}
{fixLoading && (
<div className="bg-white rounded-2xl border border-amber-200 p-8 text-center">
<Loader2 className="w-8 h-8 text-amber-500 animate-spin mx-auto mb-3" />
<p className="text-charcoal font-medium">Analyzing your playlist...</p>
<p className="text-sm text-charcoal-muted mt-1">Looking for tracks that might not quite fit the vibe</p>
</div>
)}
{/* Fix My Playlist Error */}
{fixError && (
<div className="bg-white rounded-2xl border border-red-200 p-6">
<p className="text-red-600 text-sm">{fixError}</p>
</div>
)}
{/* Fix My Playlist Results */}
{fixResult && (
<div className="space-y-5">
{/* Playlist Vibe */}
<div className="bg-white rounded-2xl border border-purple-100 p-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-charcoal flex items-center gap-2">
<Wand2 className="w-5 h-5 text-purple" />
Playlist Vibe
</h2>
<button
onClick={() => setFixResult(null)}
className="p-1.5 text-charcoal-muted hover:text-charcoal rounded-lg hover:bg-purple-50 transition-colors cursor-pointer bg-transparent border-none"
title="Dismiss results"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-charcoal-muted leading-relaxed">{fixResult.playlist_vibe}</p>
</div>
{/* Outlier Tracks */}
{fixResult.outliers.length > 0 && (
<div className="bg-white rounded-2xl border border-purple-100 p-6">
<h2 className="text-lg font-semibold text-charcoal flex items-center gap-2 mb-1">
<AlertTriangle className="w-5 h-5 text-amber-500" />
Tracks Worth Reconsidering
</h2>
<p className="text-sm text-charcoal-muted mb-4">These tracks might not quite match the rest of your playlist's energy</p>
<div className="space-y-3">
{fixResult.outliers.map((outlier, i) => (
!dismissedOutliers.has(outlier.track_number) && (
<div
key={i}
className="flex items-start gap-4 p-4 rounded-xl bg-amber-50/60 border border-amber-100"
>
<span className="w-8 h-8 rounded-lg bg-amber-100 text-amber-700 flex items-center justify-center text-sm font-medium flex-shrink-0">
#{outlier.track_number}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-charcoal">
{outlier.artist} &mdash; {outlier.title}
</p>
<p className="text-sm text-charcoal-muted mt-1 leading-relaxed">
{outlier.reason}
</p>
</div>
<button
onClick={() => dismissOutlier(outlier.track_number)}
className="p-1.5 text-charcoal-muted/50 hover:text-charcoal-muted rounded-lg hover:bg-amber-100 transition-colors cursor-pointer bg-transparent border-none flex-shrink-0"
title="Dismiss suggestion"
>
<X className="w-4 h-4" />
</button>
</div>
)
))}
</div>
</div>
)}
{/* Suggested Replacements */}
{fixResult.replacements.length > 0 && (
<div className="bg-white rounded-2xl border border-purple-100 p-6">
<h2 className="text-lg font-semibold text-charcoal flex items-center gap-2 mb-1">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
Suggested Replacements
</h2>
<p className="text-sm text-charcoal-muted mb-4">These tracks would fit your playlist's vibe perfectly</p>
<div className="space-y-3">
{fixResult.replacements.map((replacement, i) => (
<div
key={i}
className="flex items-start gap-4 p-4 rounded-xl bg-emerald-50/60 border border-emerald-100"
>
<span className="w-8 h-8 rounded-lg bg-emerald-100 text-emerald-700 flex items-center justify-center text-sm font-medium flex-shrink-0">
{i + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-charcoal">
{replacement.artist} &mdash; {replacement.title}
</p>
{replacement.album && (
<p className="text-xs text-charcoal-muted mt-0.5">
from <span className="italic">{replacement.album}</span>
</p>
)}
<p className="text-sm text-charcoal-muted mt-1 leading-relaxed">
{replacement.reason}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Track List */}
<div className="bg-white rounded-2xl border border-purple-100 overflow-hidden">
<div className="px-6 py-4 border-b border-purple-50">
@@ -132,7 +297,11 @@ export default function PlaylistDetail() {
{playlist.tracks.map((track, index) => (
<div
key={track.id}
className="flex items-center gap-4 px-6 py-3 hover:bg-purple-50/50 transition-colors"
className={`flex items-center gap-4 px-6 py-3 hover:bg-purple-50/50 transition-colors ${
fixResult?.outliers.some((o) => o.track_number === index + 1) && !dismissedOutliers.has(index + 1)
? 'bg-amber-50/30 border-l-2 border-l-amber-300'
: ''
}`}
>
<span className="w-8 text-sm text-charcoal-muted/50 text-right flex-shrink-0">
{index + 1}

View File

@@ -0,0 +1,235 @@
import { useState, useEffect } from 'react'
import { ListMusic, Loader2, Save, Copy, Check, ExternalLink } from 'lucide-react'
import { generatePlaylist, type GeneratedPlaylistResponse } from '../lib/api'
const COUNT_OPTIONS = [15, 20, 25, 30]
export default function PlaylistGenerator() {
const [theme, setTheme] = useState('')
const [count, setCount] = useState(25)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState<GeneratedPlaylistResponse | null>(() => {
try {
const saved = sessionStorage.getItem('vynl_playlist_gen_results')
return saved ? JSON.parse(saved) : null
} catch { return null }
})
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (result) {
sessionStorage.setItem('vynl_playlist_gen_results', JSON.stringify(result))
}
}, [result])
const handleGenerate = async () => {
if (!theme.trim()) return
setLoading(true)
setError('')
setResult(null)
setSaved(false)
try {
const data = await generatePlaylist(theme.trim(), count)
setResult(data)
} catch (err: any) {
const msg = err.response?.data?.detail || err.message || 'Unknown error'
setError(`Error: ${msg}`)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
if (!theme.trim() || saving || saved) return
setSaving(true)
try {
const data = await generatePlaylist(theme.trim(), count, true)
setResult(data)
setSaved(true)
} catch (err: any) {
setError('Failed to save playlist')
} finally {
setSaving(false)
}
}
const handleCopyText = () => {
if (!result) return
const text = result.tracks
.map((t, i) => `${i + 1}. ${t.artist} - ${t.title}`)
.join('\n')
navigator.clipboard.writeText(`${result.name}\n\n${text}`)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple to-purple-700 flex items-center justify-center">
<ListMusic className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-charcoal">Playlist Generator</h1>
<p className="text-sm text-charcoal-muted">Describe a vibe and get a full playlist</p>
</div>
</div>
{/* Input Section */}
<div className="bg-white rounded-2xl border border-purple-100 p-6 mb-6">
<label className="block text-sm font-medium text-charcoal mb-2">
What's the vibe?
</label>
<input
type="text"
value={theme}
onChange={(e) => setTheme(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !loading && handleGenerate()}
placeholder="Road trip through the desert, Rainy day reading, 90s nostalgia party..."
className="w-full px-4 py-3 rounded-xl border border-purple-200 focus:border-purple focus:ring-2 focus:ring-purple/20 outline-none text-charcoal placeholder:text-charcoal-muted/50 bg-cream/30"
/>
{/* Count Selector */}
<div className="mt-4">
<label className="block text-sm font-medium text-charcoal mb-2">
Number of tracks
</label>
<div className="flex gap-2">
{COUNT_OPTIONS.map((n) => (
<button
key={n}
onClick={() => setCount(n)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors cursor-pointer border-none ${
count === n
? 'bg-purple text-white'
: 'bg-purple-50 text-purple hover:bg-purple-100'
}`}
>
{n}
</button>
))}
</div>
</div>
{/* Generate Button */}
<button
onClick={handleGenerate}
disabled={!theme.trim() || loading}
className="mt-5 w-full py-3 rounded-xl font-semibold text-white bg-gradient-to-r from-purple to-purple-700 hover:from-purple-700 hover:to-purple-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all cursor-pointer border-none text-base"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-5 h-5 animate-spin" />
Crafting your playlist...
</span>
) : (
'Generate Playlist'
)}
</button>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{/* Results */}
{result && (
<div className="bg-white rounded-2xl border border-purple-100 p-6">
{/* Playlist Header */}
<div className="mb-6">
<h2 className="text-2xl font-bold text-charcoal">{result.name}</h2>
<p className="text-charcoal-muted mt-1">{result.description}</p>
<p className="text-xs text-charcoal-muted/60 mt-2">{result.tracks.length} tracks</p>
</div>
{/* Actions */}
<div className="flex gap-3 mb-6">
<button
onClick={handleSave}
disabled={saving || saved}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors cursor-pointer border-none ${
saved
? 'bg-green-100 text-green-700'
: 'bg-purple-50 text-purple hover:bg-purple-100'
}`}
>
{saved ? (
<>
<Check className="w-4 h-4" />
Saved
</>
) : saving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4" />
Save to My Playlists
</>
)}
</button>
<button
onClick={handleCopyText}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-purple-50 text-purple hover:bg-purple-100 transition-colors cursor-pointer border-none"
>
{copied ? (
<>
<Check className="w-4 h-4" />
Copied!
</>
) : (
<>
<Copy className="w-4 h-4" />
Copy as Text
</>
)}
</button>
</div>
{/* Track List */}
<div className="space-y-1">
{result.tracks.map((track, index) => (
<div
key={index}
className="flex items-start gap-3 p-3 rounded-xl hover:bg-cream/50 transition-colors group"
>
<span className="text-sm font-mono text-charcoal-muted/50 w-6 text-right pt-0.5 shrink-0">
{index + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-charcoal truncate">{track.title}</span>
<span className="text-charcoal-muted">—</span>
<span className="text-charcoal-muted truncate">{track.artist}</span>
{track.youtube_url && (
<a
href={track.youtube_url}
target="_blank"
rel="noopener noreferrer"
className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
title="Search on YouTube Music"
>
<ExternalLink className="w-3.5 h-3.5 text-purple" />
</a>
)}
</div>
<p className="text-xs text-charcoal-muted/70 mt-0.5">{track.reason}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -1,16 +1,25 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { ListMusic, Plus, Loader2, Music, ChevronRight, Download, X } from 'lucide-react'
import { getPlaylists, getSpotifyPlaylists, importSpotifyPlaylist, type PlaylistResponse, type SpotifyPlaylistItem } from '../lib/api'
import { ListMusic, Loader2, Music, ChevronRight, Download, X, Play, Link2, ClipboardPaste } from 'lucide-react'
import { getPlaylists, importYouTubePlaylist, previewLastfm, importLastfm, importPastedSongs, type PlaylistResponse, type LastfmPreviewResponse } from '../lib/api'
export default function Playlists() {
const [playlists, setPlaylists] = useState<PlaylistResponse[]>([])
const [spotifyPlaylists, setSpotifyPlaylists] = useState<SpotifyPlaylistItem[]>([])
const [showImport, setShowImport] = useState(false)
const [importing, setImporting] = useState<string | null>(null)
const [loadingSpotify, setLoadingSpotify] = useState(false)
const [showYouTubeImport, setShowYouTubeImport] = useState(false)
const [youtubeUrl, setPlayUrl] = useState('')
const [importingYouTube, setImportingYouTube] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showLastfmImport, setShowLastfmImport] = useState(false)
const [lastfmUsername, setLastfmUsername] = useState('')
const [lastfmPeriod, setLastfmPeriod] = useState('overall')
const [lastfmPreview, setLastfmPreview] = useState<LastfmPreviewResponse | null>(null)
const [loadingLastfmPreview, setLoadingLastfmPreview] = useState(false)
const [importingLastfm, setImportingLastfm] = useState(false)
const [showPasteImport, setShowPasteImport] = useState(false)
const [pasteName, setPasteName] = useState('')
const [pasteText, setPasteText] = useState('')
const [importingPaste, setImportingPaste] = useState(false)
useEffect(() => {
loadPlaylists()
@@ -27,32 +36,75 @@ export default function Playlists() {
}
}
const openImportModal = async () => {
setShowImport(true)
setLoadingSpotify(true)
const handleYouTubeImport = async () => {
if (!youtubeUrl.trim()) return
setImportingYouTube(true)
setError('')
try {
const data = await getSpotifyPlaylists()
setSpotifyPlaylists(data)
} catch {
setError('Failed to load Spotify playlists. Make sure your Spotify account is connected.')
const imported = await importYouTubePlaylist(youtubeUrl.trim())
setPlaylists((prev) => [...prev, imported])
setPlayUrl('')
setShowYouTubeImport(false)
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to import YouTube Music playlist')
} finally {
setLoadingSpotify(false)
setImportingYouTube(false)
}
}
const handleImport = async (playlistId: string) => {
setImporting(playlistId)
const handleLastfmPreview = async () => {
if (!lastfmUsername.trim()) return
setLoadingLastfmPreview(true)
setError('')
setLastfmPreview(null)
try {
const imported = await importSpotifyPlaylist(playlistId)
setPlaylists((prev) => [...prev, imported])
setSpotifyPlaylists((prev) => prev.filter((p) => p.id !== playlistId))
const data = await previewLastfm(lastfmUsername.trim())
setLastfmPreview(data)
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to import playlist')
setError(err.response?.data?.detail || 'Last.fm user not found')
} finally {
setImporting(null)
setLoadingLastfmPreview(false)
}
}
const handleLastfmImport = async () => {
if (!lastfmUsername.trim()) return
setImportingLastfm(true)
setError('')
try {
const imported = await importLastfm(lastfmUsername.trim(), lastfmPeriod)
setPlaylists((prev) => [...prev, imported])
setLastfmUsername('')
setLastfmPreview(null)
setShowLastfmImport(false)
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to import from Last.fm')
} finally {
setImportingLastfm(false)
}
}
const handlePasteImport = async () => {
if (!pasteName.trim() || !pasteText.trim()) return
setImportingPaste(true)
setError('')
try {
const imported = await importPastedSongs(pasteName.trim(), pasteText.trim())
setPlaylists((prev) => [...prev, imported])
setPasteName('')
setPasteText('')
setShowPasteImport(false)
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to import pasted songs')
} finally {
setImportingPaste(false)
}
}
const parsedLineCount = pasteText
.split('\n')
.filter((line) => line.trim().length > 0).length
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -68,13 +120,29 @@ export default function Playlists() {
<h1 className="text-3xl font-bold text-charcoal">Playlists</h1>
<p className="text-charcoal-muted mt-1">Manage your imported playlists</p>
</div>
<button
onClick={openImportModal}
className="flex items-center gap-2 px-5 py-2.5 bg-purple text-white font-medium rounded-xl hover:bg-purple-dark transition-colors cursor-pointer border-none text-sm"
>
<Plus className="w-4 h-4" />
Import from Spotify
</button>
<div className="flex items-center gap-2">
<button
onClick={() => setShowYouTubeImport(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white font-medium rounded-xl hover:bg-red-700 transition-colors cursor-pointer border-none text-sm"
>
<Play className="w-4 h-4" />
Import from YouTube Music
</button>
<button
onClick={() => setShowLastfmImport(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-[#d51007] text-white font-medium rounded-xl hover:bg-[#b30d06] transition-colors cursor-pointer border-none text-sm"
>
<Music className="w-4 h-4" />
Import from Last.fm
</button>
<button
onClick={() => setShowPasteImport(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-charcoal text-white font-medium rounded-xl hover:bg-charcoal/80 transition-colors cursor-pointer border-none text-sm"
>
<ClipboardPaste className="w-4 h-4" />
Paste Your Songs
</button>
</div>
</div>
{error && (
@@ -91,15 +159,31 @@ export default function Playlists() {
</div>
<h2 className="text-xl font-semibold text-charcoal mb-2">No playlists yet</h2>
<p className="text-charcoal-muted mb-6 max-w-md mx-auto">
Import your Spotify playlists to start getting personalized music recommendations
Import your playlists from YouTube Music, Last.fm, or paste your songs to start getting personalized music recommendations
</p>
<button
onClick={openImportModal}
className="inline-flex items-center gap-2 px-6 py-3 bg-purple text-white font-medium rounded-xl hover:bg-purple-dark transition-colors cursor-pointer border-none text-sm"
>
<Download className="w-4 h-4" />
Import your first playlist
</button>
<div className="flex items-center justify-center gap-3">
<button
onClick={() => setShowYouTubeImport(true)}
className="inline-flex items-center gap-2 px-6 py-3 bg-red-600 text-white font-medium rounded-xl hover:bg-red-700 transition-colors cursor-pointer border-none text-sm"
>
<Play className="w-4 h-4" />
Import from YouTube Music
</button>
<button
onClick={() => setShowLastfmImport(true)}
className="inline-flex items-center gap-2 px-6 py-3 bg-[#d51007] text-white font-medium rounded-xl hover:bg-[#b30d06] transition-colors cursor-pointer border-none text-sm"
>
<Music className="w-4 h-4" />
Import from Last.fm
</button>
<button
onClick={() => setShowPasteImport(true)}
className="inline-flex items-center gap-2 px-6 py-3 bg-charcoal text-white font-medium rounded-xl hover:bg-charcoal/80 transition-colors cursor-pointer border-none text-sm"
>
<ClipboardPaste className="w-4 h-4" />
Paste Your Songs
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -139,73 +223,227 @@ export default function Playlists() {
</div>
)}
{/* Import Modal */}
{showImport && (
{/* YouTube Music Import Modal */}
{showYouTubeImport && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden">
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-purple-100">
<h2 className="text-lg font-semibold text-charcoal">Import from Spotify</h2>
<h2 className="text-lg font-semibold text-charcoal">Import from YouTube Music</h2>
<button
onClick={() => setShowImport(false)}
onClick={() => setShowYouTubeImport(false)}
className="p-1.5 rounded-lg hover:bg-purple-50 transition-colors cursor-pointer bg-transparent border-none"
>
<X className="w-5 h-5 text-charcoal-muted" />
</button>
</div>
<div className="overflow-y-auto max-h-[60vh] p-4">
{loadingSpotify ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-purple animate-spin" />
<div className="p-6 space-y-4">
<p className="text-sm text-charcoal-muted">
Paste a public YouTube Music playlist URL to import its tracks.
</p>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Link2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-charcoal-muted/50" />
<input
type="url"
value={youtubeUrl}
onChange={(e) => setPlayUrl(e.target.value)}
placeholder="https://music.youtube.com/playlist?list=..."
className="w-full pl-10 pr-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm"
/>
</div>
) : spotifyPlaylists.length === 0 ? (
<div className="text-center py-12">
<p className="text-charcoal-muted">No playlists found on Spotify</p>
</div>
) : (
<div className="space-y-2">
{spotifyPlaylists.map((sp) => (
<div
key={sp.id}
className="flex items-center gap-3 p-3 rounded-xl hover:bg-purple-50 transition-colors"
>
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-purple-200 to-purple-400 flex-shrink-0 flex items-center justify-center overflow-hidden">
{sp.image_url ? (
<img
src={sp.image_url}
alt={sp.name}
className="w-full h-full object-cover"
/>
) : (
<Music className="w-5 h-5 text-white/80" />
)}
</div>
<button
onClick={handleYouTubeImport}
disabled={importingYouTube || !youtubeUrl.trim()}
className="w-full py-3 bg-red-600 text-white font-medium rounded-xl hover:bg-red-700 transition-colors cursor-pointer border-none text-sm disabled:opacity-50 flex items-center justify-center gap-2"
>
{importingYouTube ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Importing...
</>
) : (
<>
<Download className="w-4 h-4" />
Import Playlist
</>
)}
</button>
</div>
</div>
</div>
)}
{/* Last.fm Import Modal */}
{showLastfmImport && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-purple-100">
<h2 className="text-lg font-semibold text-charcoal">Import from Last.fm</h2>
<button
onClick={() => { setShowLastfmImport(false); setLastfmPreview(null); setLastfmUsername(''); }}
className="p-1.5 rounded-lg hover:bg-purple-50 transition-colors cursor-pointer bg-transparent border-none"
>
<X className="w-5 h-5 text-charcoal-muted" />
</button>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-charcoal-muted">
Enter your Last.fm username to import your top tracks. No login required.
</p>
<div>
<label className="block text-sm font-medium text-charcoal mb-1">Username</label>
<input
type="text"
value={lastfmUsername}
onChange={(e) => { setLastfmUsername(e.target.value); setLastfmPreview(null); }}
placeholder="your-lastfm-username"
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-charcoal mb-1">Time Period</label>
<select
value={lastfmPeriod}
onChange={(e) => setLastfmPeriod(e.target.value)}
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm"
>
<option value="overall">All Time</option>
<option value="7day">Last 7 Days</option>
<option value="1month">Last Month</option>
<option value="3month">Last 3 Months</option>
<option value="6month">Last 6 Months</option>
<option value="12month">Last Year</option>
</select>
</div>
<div className="flex gap-2">
<button
onClick={handleLastfmPreview}
disabled={loadingLastfmPreview || !lastfmUsername.trim()}
className="flex-1 py-3 bg-charcoal/10 text-charcoal font-medium rounded-xl hover:bg-charcoal/20 transition-colors cursor-pointer border-none text-sm disabled:opacity-50 flex items-center justify-center gap-2"
>
{loadingLastfmPreview ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Checking...
</>
) : (
'Preview'
)}
</button>
<button
onClick={handleLastfmImport}
disabled={importingLastfm || !lastfmUsername.trim()}
className="flex-1 py-3 bg-[#d51007] text-white font-medium rounded-xl hover:bg-[#b30d06] transition-colors cursor-pointer border-none text-sm disabled:opacity-50 flex items-center justify-center gap-2"
>
{importingLastfm ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Importing...
</>
) : (
<>
<Download className="w-4 h-4" />
Import
</>
)}
</button>
</div>
{lastfmPreview && (
<div className="bg-cream/50 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-charcoal">{lastfmPreview.display_name}</span>
<span className="text-xs text-charcoal-muted">{lastfmPreview.track_count} top tracks</span>
</div>
<div className="space-y-2">
{lastfmPreview.sample_tracks.map((t, i) => (
<div key={i} className="flex items-center gap-3 text-sm">
<span className="text-charcoal-muted w-5 text-right">{i + 1}.</span>
<div className="flex-1 min-w-0">
<span className="text-charcoal font-medium truncate block">{t.title}</span>
<span className="text-charcoal-muted text-xs">{t.artist} &middot; {t.playcount} plays</span>
</div>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-charcoal text-sm truncate">{sp.name}</p>
<p className="text-xs text-charcoal-muted">
{sp.track_count} tracks &middot; {sp.owner}
</p>
</div>
<button
onClick={() => handleImport(sp.id)}
disabled={importing === sp.id}
className="px-4 py-2 bg-purple text-white text-xs font-medium rounded-lg hover:bg-purple-dark transition-colors cursor-pointer border-none disabled:opacity-50 flex items-center gap-1.5"
>
{importing === sp.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Import
</button>
</div>
))}
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Paste Songs Import Modal */}
{showPasteImport && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-purple-100">
<h2 className="text-lg font-semibold text-charcoal">Paste Your Songs</h2>
<button
onClick={() => setShowPasteImport(false)}
className="p-1.5 rounded-lg hover:bg-purple-50 transition-colors cursor-pointer bg-transparent border-none"
>
<X className="w-5 h-5 text-charcoal-muted" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-charcoal mb-1.5">Playlist name</label>
<input
type="text"
value={pasteName}
onChange={(e) => setPasteName(e.target.value)}
placeholder="My favorite songs"
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-sm font-medium text-charcoal">Songs</label>
{parsedLineCount > 0 && (
<span className="text-xs text-purple font-medium">
{parsedLineCount} {parsedLineCount === 1 ? 'song' : 'songs'} detected
</span>
)}
</div>
<textarea
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
placeholder={`Paste your songs, one per line:\n\nRadiohead - Everything In Its Right Place\nTame Impala - Let It Happen\nBeach House - Space Song\nLevitation by Beach House\nPink Floyd: Comfortably Numb`}
rows={8}
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm resize-none font-mono"
/>
<p className="text-xs text-charcoal-muted mt-1.5">
Supports formats: Artist - Title, Title by Artist, Artist: Title
</p>
</div>
<button
onClick={handlePasteImport}
disabled={importingPaste || !pasteName.trim() || !pasteText.trim()}
className="w-full py-3 bg-charcoal text-white font-medium rounded-xl hover:bg-charcoal/80 transition-colors cursor-pointer border-none text-sm disabled:opacity-50 flex items-center justify-center gap-2"
>
{importingPaste ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Importing...
</>
) : (
<>
<Download className="w-4 h-4" />
Import {parsedLineCount > 0 ? `${parsedLineCount} Songs` : 'Songs'}
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,232 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loader2, Fingerprint, Zap, Music2, CloudSun, Heart, Layers, Globe, Drama, User } from 'lucide-react'
import { getPublicProfile, type TasteProfileResponse } from '../lib/api'
const personalityIcons: Record<string, typeof Zap> = {
zap: Zap,
cloud: CloudSun,
heart: Heart,
layers: Layers,
globe: Globe,
drama: Drama,
music: Music2,
}
const genreBarColors = [
'bg-[#7C3AED]',
'bg-[#8B5CF6]',
'bg-[#9F6FFB]',
'bg-[#A78BFA]',
'bg-[#B49BFC]',
'bg-[#C4ABFD]',
'bg-[#D3BCFE]',
'bg-[#DDD6FE]',
'bg-[#E8DFFE]',
'bg-[#F0EAFF]',
]
type PublicProfileData = TasteProfileResponse & { name: string }
function possessive(name: string): string {
return name.endsWith('s') ? `${name}'` : `${name}'s`
}
export default function PublicProfile() {
const { userId, token } = useParams<{ userId: string; token: string }>()
const [profile, setProfile] = useState<PublicProfileData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!userId || !token) return
setLoading(true)
getPublicProfile(userId, token)
.then(setProfile)
.catch(() => setError('This profile link is invalid or has expired.'))
.finally(() => setLoading(false))
}, [userId, token])
if (loading) {
return (
<div className="min-h-screen bg-[#FFF7ED] flex items-center justify-center">
<div className="w-12 h-12 border-4 border-[#7C3AED] border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error || !profile) {
return (
<div className="min-h-screen bg-[#FFF7ED] flex items-center justify-center p-6">
<div className="text-center max-w-md">
<h1 className="text-2xl font-bold text-[#1C1917] mb-2">Profile Not Found</h1>
<p className="text-[#1C1917]/60 mb-6">{error || 'This taste profile could not be found.'}</p>
<Link
to="/register"
className="inline-block px-6 py-3 bg-[#7C3AED] text-white rounded-xl font-semibold hover:bg-[#6D28D9] transition-colors no-underline"
>
Discover Your Taste on Vynl
</Link>
</div>
</div>
)
}
const PersonalityIcon = personalityIcons[profile.personality.icon] || Music2
const maxGenrePercent = profile.genre_breakdown.length > 0
? Math.max(...profile.genre_breakdown.map((g) => g.percentage))
: 100
return (
<div className="min-h-screen bg-[#FFF7ED] flex flex-col">
{/* Header */}
<header className="py-6 px-6 text-center">
<Link to="/" className="inline-block no-underline">
<h1 className="text-2xl font-bold text-[#7C3AED] tracking-tight">vynl</h1>
</Link>
</header>
{/* Content */}
<main className="flex-1 px-4 pb-12 max-w-3xl mx-auto w-full space-y-6">
{/* Title */}
<div className="text-center">
<h2 className="text-3xl font-bold text-[#1C1917] flex items-center justify-center gap-3">
<Fingerprint className="w-8 h-8 text-[#7C3AED]" />
{possessive(profile.name)} Music DNA
</h2>
<p className="text-[#1C1917]/50 mt-1 text-sm">
Built from {profile.track_count} tracks across {profile.playlist_count} playlists
</p>
</div>
{/* Listening Personality */}
<div className="bg-gradient-to-br from-[#7C3AED] to-[#5B21B6] rounded-2xl p-8 text-white">
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-2xl bg-white/15 flex items-center justify-center flex-shrink-0">
<PersonalityIcon className="w-7 h-7 text-white" />
</div>
<div>
<p className="text-purple-200 text-xs font-semibold uppercase tracking-wider mb-1">
Listening Personality
</p>
<h3 className="text-2xl font-bold mb-2">{profile.personality.label}</h3>
<p className="text-purple-100 text-sm leading-relaxed">
{profile.personality.description}
</p>
</div>
</div>
</div>
{/* Genre DNA */}
{profile.genre_breakdown.length > 0 && (
<div className="bg-white rounded-2xl border border-[#7C3AED]/10 p-6">
<h3 className="text-lg font-semibold text-[#1C1917] mb-5">Genre DNA</h3>
<div className="space-y-3">
{profile.genre_breakdown.map((genre, i) => (
<div key={genre.name} className="flex items-center gap-3">
<span className="text-sm text-[#1C1917] w-28 truncate text-right flex-shrink-0 font-medium">
{genre.name}
</span>
<div className="flex-1 bg-[#F5F3FF] rounded-full h-6 overflow-hidden">
<div
className={`h-full rounded-full ${genreBarColors[i] || genreBarColors[genreBarColors.length - 1]} transition-all duration-700 ease-out flex items-center justify-end pr-2`}
style={{ width: `${(genre.percentage / maxGenrePercent) * 100}%`, minWidth: '2rem' }}
>
<span className="text-xs font-semibold text-white drop-shadow-sm">
{genre.percentage}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Audio Features */}
<div className="bg-white rounded-2xl border border-[#7C3AED]/10 p-6">
<h3 className="text-lg font-semibold text-[#1C1917] mb-5">Audio Features</h3>
<div className="space-y-4">
<PublicAudioMeter label="Energy" value={profile.audio_features.energy} />
<PublicAudioMeter label="Danceability" value={profile.audio_features.danceability} />
<PublicAudioMeter label="Mood / Valence" value={profile.audio_features.valence} />
<PublicAudioMeter label="Acousticness" value={profile.audio_features.acousticness} />
<div className="flex items-center gap-3 pt-2 border-t border-[#7C3AED]/10">
<span className="text-sm text-[#1C1917] w-28 text-right flex-shrink-0 font-medium">
Avg Tempo
</span>
<div className="flex-1 flex items-center gap-2">
<span className="text-2xl font-bold text-[#7C3AED]">{profile.audio_features.avg_tempo}</span>
<span className="text-sm text-[#1C1917]/50">BPM</span>
</div>
</div>
</div>
</div>
{/* Top Artists */}
{profile.top_artists.length > 0 && (
<div className="bg-white rounded-2xl border border-[#7C3AED]/10 p-6">
<h3 className="text-lg font-semibold text-[#1C1917] mb-5">Artists That Define Them</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{profile.top_artists.map((artist) => (
<div
key={artist.name}
className="flex items-center gap-3 p-3 rounded-xl bg-[#FFF7ED]/60 border border-[#7C3AED]/5"
>
<div className="w-10 h-10 rounded-full bg-[#F5F3FF] flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-[#7C3AED]" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-[#1C1917] truncate">{artist.name}</p>
<p className="text-xs text-[#1C1917]/50">
{artist.track_count} track{artist.track_count !== 1 ? 's' : ''}
{artist.genre ? ` · ${artist.genre}` : ''}
</p>
</div>
</div>
))}
</div>
</div>
)}
{/* CTA */}
<div className="bg-white rounded-2xl border border-[#7C3AED]/10 p-8 text-center">
<p className="text-[#1C1917]/60 text-sm mb-4">
Want to discover your own music DNA?
</p>
<Link
to="/register"
className="inline-block px-8 py-3.5 bg-[#7C3AED] text-white rounded-xl font-semibold hover:bg-[#6D28D9] transition-colors no-underline text-base"
>
Discover Your Taste on Vynl
</Link>
</div>
</main>
</div>
)
}
function PublicAudioMeter({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-[#1C1917] w-28 text-right flex-shrink-0 font-medium">
{label}
</span>
<div className="flex-1 bg-[#F5F3FF] rounded-full h-5 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-[#7C3AED] to-[#A78BFA] transition-all duration-700 ease-out flex items-center justify-end pr-2"
style={{ width: `${value}%`, minWidth: value > 0 ? '1.5rem' : '0' }}
>
{value > 10 && (
<span className="text-xs font-semibold text-white drop-shadow-sm">
{value}%
</span>
)}
</div>
</div>
{value <= 10 && (
<span className="text-xs font-medium text-[#1C1917]/50 w-10">{value}%</span>
)}
</div>
)
}

View File

@@ -0,0 +1,228 @@
import { useState, useEffect } 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(() => {
try {
const saved = sessionStorage.getItem('vynl_rabbithole_results')
return saved ? JSON.parse(saved).theme : ''
} catch { return '' }
})
const [steps, setSteps] = useState<RabbitHoleStep[]>(() => {
try {
const saved = sessionStorage.getItem('vynl_rabbithole_results')
return saved ? JSON.parse(saved).steps : []
} catch { return [] }
})
const [error, setError] = useState('')
useEffect(() => {
if (theme && steps.length > 0) {
sessionStorage.setItem('vynl_rabbithole_results', JSON.stringify({ theme, steps }))
}
}, [theme, steps])
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"> &middot; {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>
)
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { Loader2, Clock, Heart, Sparkles } from 'lucide-react'
import { Loader2, Clock, Heart, Sparkles, Download } from 'lucide-react'
import { useAuth } from '../lib/auth'
import { getRecommendationHistory, getSavedRecommendations, toggleSaveRecommendation, type RecommendationItem } from '../lib/api'
import { getRecommendationHistory, getSavedRecommendations, toggleSaveRecommendation, exportSaved, type RecommendationItem } from '../lib/api'
import RecommendationCard from '../components/RecommendationCard'
type Tab = 'saved' | 'history'
@@ -92,7 +92,18 @@ export default function Recommendations() {
</div>
)}
{/* Tabs */}
{/* Export + Tabs */}
<div className="flex items-center gap-4">
{tab === 'saved' && saved.length > 0 && (
<button
onClick={() => exportSaved()}
className="flex items-center gap-2 px-4 py-2.5 bg-purple-50 text-purple text-sm font-medium rounded-xl hover:bg-purple-100 transition-colors cursor-pointer border-none"
>
<Download className="w-4 h-4" />
Export Saved
</button>
)}
</div>
<div className="flex gap-1 bg-purple-50 p-1 rounded-xl w-fit">
<button
onClick={() => setTab('saved')}

View File

@@ -2,7 +2,7 @@ import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Disc3, Mail, Lock, User, Loader2 } from 'lucide-react'
import { useAuth } from '../lib/auth'
import { register as apiRegister, getSpotifyAuthUrl } from '../lib/api'
import { register as apiRegister } from '../lib/api'
export default function Register() {
const [name, setName] = useState('')
@@ -29,15 +29,6 @@ export default function Register() {
}
}
const handleSpotifyLogin = async () => {
try {
const { url } = await getSpotifyAuthUrl()
window.location.href = url
} catch {
setError('Could not connect to Spotify')
}
}
return (
<div className="min-h-screen bg-cream flex flex-col">
{/* Header */}
@@ -133,22 +124,6 @@ export default function Register() {
</button>
</form>
<div className="my-6 flex items-center gap-3">
<div className="flex-1 h-px bg-purple-100" />
<span className="text-xs text-charcoal-muted uppercase tracking-wider">or</span>
<div className="flex-1 h-px bg-purple-100" />
</div>
<button
onClick={handleSpotifyLogin}
className="w-full py-3 bg-[#1DB954] text-white font-semibold rounded-xl hover:bg-[#1aa34a] transition-colors cursor-pointer border-none text-sm flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
</svg>
Continue with Spotify
</button>
<p className="text-center mt-6 text-sm text-charcoal-muted">
Already have an account?{' '}
<Link to="/login" className="text-purple font-medium hover:underline">

View File

@@ -0,0 +1,216 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Settings as SettingsIcon, Save, Loader2, AlertTriangle, Check } from 'lucide-react'
import { useAuth } from '../lib/auth'
import { updateProfile, changePassword, deleteAccount } from '../lib/api'
export default function Settings() {
const { user, logout, refreshUser } = useAuth()
const navigate = useNavigate()
// Profile
const [name, setName] = useState(user?.name || '')
const [email, setEmail] = useState(user?.email || '')
const [profileLoading, setProfileLoading] = useState(false)
const [profileMsg, setProfileMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// Password
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordLoading, setPasswordLoading] = useState(false)
const [passwordMsg, setPasswordMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// Delete
const [deleteConfirm, setDeleteConfirm] = useState('')
const [deleteLoading, setDeleteLoading] = useState(false)
const [deleteMsg, setDeleteMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const handleUpdateProfile = async () => {
setProfileLoading(true)
setProfileMsg(null)
try {
await updateProfile({ name: name || undefined, email: email || undefined })
await refreshUser()
setProfileMsg({ type: 'success', text: 'Profile updated successfully' })
} catch (err: any) {
setProfileMsg({ type: 'error', text: err.response?.data?.detail || 'Failed to update profile' })
} finally {
setProfileLoading(false)
}
}
const handleChangePassword = async () => {
if (newPassword !== confirmPassword) {
setPasswordMsg({ type: 'error', text: 'New passwords do not match' })
return
}
if (newPassword.length < 6) {
setPasswordMsg({ type: 'error', text: 'New password must be at least 6 characters' })
return
}
setPasswordLoading(true)
setPasswordMsg(null)
try {
await changePassword(currentPassword, newPassword)
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
setPasswordMsg({ type: 'success', text: 'Password changed successfully' })
} catch (err: any) {
setPasswordMsg({ type: 'error', text: err.response?.data?.detail || 'Failed to change password' })
} finally {
setPasswordLoading(false)
}
}
const handleDeleteAccount = async () => {
if (deleteConfirm !== 'DELETE') return
setDeleteLoading(true)
setDeleteMsg(null)
try {
await deleteAccount()
logout()
navigate('/')
} catch (err: any) {
setDeleteMsg({ type: 'error', text: err.response?.data?.detail || 'Failed to delete account' })
setDeleteLoading(false)
}
}
return (
<div className="max-w-2xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-purple-100 flex items-center justify-center">
<SettingsIcon className="w-6 h-6 text-purple" />
</div>
<div>
<h1 className="text-2xl font-bold text-charcoal">Settings</h1>
<p className="text-charcoal-muted text-sm">Manage your account</p>
</div>
</div>
{/* Profile Section */}
<div className="bg-white rounded-2xl border border-purple-100 p-6 space-y-4">
<h2 className="text-lg font-semibold text-charcoal">Profile</h2>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-charcoal mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-purple-200 bg-cream focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent text-charcoal"
/>
</div>
<div>
<label className="block text-sm font-medium text-charcoal mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-purple-200 bg-cream focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent text-charcoal"
/>
</div>
</div>
{profileMsg && (
<div className={`flex items-center gap-2 text-sm ${profileMsg.type === 'success' ? 'text-green-600' : 'text-red-600'}`}>
{profileMsg.type === 'success' ? <Check className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
{profileMsg.text}
</div>
)}
<button
onClick={handleUpdateProfile}
disabled={profileLoading}
className="flex items-center gap-2 px-5 py-2.5 bg-purple text-white rounded-xl font-medium hover:bg-purple-600 transition-colors disabled:opacity-50 cursor-pointer border-none"
>
{profileLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save
</button>
</div>
{/* Change Password Section */}
<div className="bg-white rounded-2xl border border-purple-100 p-6 space-y-4">
<h2 className="text-lg font-semibold text-charcoal">Change Password</h2>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-charcoal mb-1">Current Password</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-purple-200 bg-cream focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent text-charcoal"
/>
</div>
<div>
<label className="block text-sm font-medium text-charcoal mb-1">New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-purple-200 bg-cream focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent text-charcoal"
/>
</div>
<div>
<label className="block text-sm font-medium text-charcoal mb-1">Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-purple-200 bg-cream focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent text-charcoal"
/>
</div>
</div>
{passwordMsg && (
<div className={`flex items-center gap-2 text-sm ${passwordMsg.type === 'success' ? 'text-green-600' : 'text-red-600'}`}>
{passwordMsg.type === 'success' ? <Check className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
{passwordMsg.text}
</div>
)}
<button
onClick={handleChangePassword}
disabled={passwordLoading || !currentPassword || !newPassword || !confirmPassword}
className="flex items-center gap-2 px-5 py-2.5 bg-purple text-white rounded-xl font-medium hover:bg-purple-600 transition-colors disabled:opacity-50 cursor-pointer border-none"
>
{passwordLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
Change Password
</button>
</div>
{/* Danger Zone */}
<div className="bg-white rounded-2xl border-2 border-red-200 p-6 space-y-4">
<h2 className="text-lg font-semibold text-red-600">Danger Zone</h2>
<p className="text-sm text-charcoal-muted">
Permanently delete your account and all associated data. This action cannot be undone.
</p>
<div>
<label className="block text-sm font-medium text-charcoal mb-1">
Type <span className="font-mono font-bold">DELETE</span> to confirm
</label>
<input
type="text"
value={deleteConfirm}
onChange={(e) => setDeleteConfirm(e.target.value)}
placeholder="DELETE"
className="w-full px-4 py-2.5 rounded-xl border border-red-200 bg-cream focus:outline-none focus:ring-2 focus:ring-red-400 focus:border-transparent text-charcoal"
/>
</div>
{deleteMsg && (
<div className="flex items-center gap-2 text-sm text-red-600">
<AlertTriangle className="w-4 h-4" />
{deleteMsg.text}
</div>
)}
<button
onClick={handleDeleteAccount}
disabled={deleteLoading || deleteConfirm !== 'DELETE'}
className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 cursor-pointer border-none"
>
{deleteLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <AlertTriangle className="w-4 h-4" />}
Delete Account
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Music, ExternalLink } from 'lucide-react'
import { getSharedRecommendation } from '../lib/api'
interface SharedRec {
title: string
artist: string
album: string | null
reason: string
youtube_url: string | null
image_url: string | null
}
export default function SharedView() {
const { recId, token } = useParams<{ recId: string; token: string }>()
const [rec, setRec] = useState<SharedRec | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!recId || !token) return
setLoading(true)
getSharedRecommendation(recId, token)
.then(setRec)
.catch(() => setError('This share link is invalid or has expired.'))
.finally(() => setLoading(false))
}, [recId, token])
if (loading) {
return (
<div className="min-h-screen bg-[#FFF7ED] flex items-center justify-center">
<div className="w-12 h-12 border-4 border-[#7C3AED] border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error || !rec) {
return (
<div className="min-h-screen bg-[#FFF7ED] flex items-center justify-center p-6">
<div className="text-center max-w-md">
<h1 className="text-2xl font-bold text-[#1C1917] mb-2">Link Not Found</h1>
<p className="text-[#1C1917]/60 mb-6">{error || 'This recommendation could not be found.'}</p>
<Link
to="/"
className="inline-block px-6 py-3 bg-[#7C3AED] text-white rounded-xl font-semibold hover:bg-[#6D28D9] transition-colors no-underline"
>
Discover Music on Vynl
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-[#FFF7ED] flex flex-col">
{/* Header */}
<header className="py-6 px-6 text-center">
<Link to="/" className="inline-block no-underline">
<h1 className="text-2xl font-bold text-[#7C3AED] tracking-tight">vynl</h1>
</Link>
<p className="text-sm text-[#1C1917]/50 mt-1">A friend shared a discovery with you</p>
</header>
{/* Card */}
<main className="flex-1 flex items-start justify-center px-6 pb-12">
<div className="w-full max-w-lg bg-white rounded-2xl shadow-lg border border-[#7C3AED]/10 overflow-hidden">
{/* Album Art / Header */}
<div className="bg-gradient-to-br from-[#7C3AED] to-[#6D28D9] p-8 flex items-center gap-5">
<div className="w-24 h-24 rounded-xl bg-white/20 flex-shrink-0 flex items-center justify-center overflow-hidden">
{rec.image_url ? (
<img
src={rec.image_url}
alt={`${rec.title} cover`}
className="w-full h-full object-cover"
/>
) : (
<Music className="w-10 h-10 text-white/70" />
)}
</div>
<div className="min-w-0">
<h2 className="text-xl font-bold text-white truncate">{rec.title}</h2>
<p className="text-white/80 text-base truncate">{rec.artist}</p>
{rec.album && (
<p className="text-white/60 text-sm truncate mt-1">{rec.album}</p>
)}
</div>
</div>
{/* Reason */}
<div className="p-6">
<h3 className="text-sm font-semibold text-[#7C3AED] uppercase tracking-wide mb-2">
Why you might love this
</h3>
<p className="text-[#1C1917] leading-relaxed">{rec.reason}</p>
{/* YouTube link */}
{rec.youtube_url && (
<a
href={rec.youtube_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 mt-5 px-5 py-2.5 bg-red-50 text-red-600 rounded-xl font-medium hover:bg-red-100 transition-colors no-underline text-sm"
>
<ExternalLink className="w-4 h-4" />
Listen on YouTube Music
</a>
)}
</div>
{/* CTA */}
<div className="border-t border-[#7C3AED]/10 p-6 text-center bg-[#FFF7ED]/50">
<p className="text-[#1C1917]/60 text-sm mb-3">
Want personalized music discoveries powered by AI?
</p>
<Link
to="/register"
className="inline-block px-6 py-3 bg-[#7C3AED] text-white rounded-xl font-semibold hover:bg-[#6D28D9] transition-colors no-underline"
>
Discover More on Vynl
</Link>
</div>
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,230 @@
import { useState, useEffect } from 'react'
import { Loader2, Fingerprint, Zap, Music2, CloudSun, Heart, Layers, Globe, Drama, User, Share2, Check, Copy } from 'lucide-react'
import { getTasteProfile, getProfileShareLink, type TasteProfileResponse } from '../lib/api'
const personalityIcons: Record<string, typeof Zap> = {
zap: Zap,
cloud: CloudSun,
heart: Heart,
layers: Layers,
globe: Globe,
drama: Drama,
music: Music2,
}
const genreBarColors = [
'bg-[#7C3AED]',
'bg-[#8B5CF6]',
'bg-[#9F6FFB]',
'bg-[#A78BFA]',
'bg-[#B49BFC]',
'bg-[#C4ABFD]',
'bg-[#D3BCFE]',
'bg-[#DDD6FE]',
'bg-[#E8DFFE]',
'bg-[#F0EAFF]',
]
export default function TasteProfilePage() {
const [profile, setProfile] = useState<TasteProfileResponse | null>(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
const [sharing, setSharing] = useState(false)
useEffect(() => {
getTasteProfile()
.then(setProfile)
.catch(() => setProfile(null))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-purple animate-spin" />
</div>
)
}
if (!profile) {
return (
<div className="text-center py-20">
<p className="text-charcoal-muted">Could not load your taste profile.</p>
</div>
)
}
const PersonalityIcon = personalityIcons[profile.personality.icon] || Music2
const maxGenrePercent = profile.genre_breakdown.length > 0
? Math.max(...profile.genre_breakdown.map((g) => g.percentage))
: 100
return (
<div className="space-y-8 max-w-3xl mx-auto">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-charcoal flex items-center gap-3">
<Fingerprint className="w-8 h-8 text-purple" />
My Taste DNA
</h1>
<p className="text-charcoal-muted mt-1">
Your musical identity, decoded from {profile.track_count} tracks across {profile.playlist_count} playlists
</p>
</div>
<button
onClick={async () => {
setSharing(true)
try {
const { share_url } = await getProfileShareLink()
await navigator.clipboard.writeText(share_url)
setCopied(true)
setTimeout(() => setCopied(false), 2500)
} catch {
// fallback: ignore
} finally {
setSharing(false)
}
}}
disabled={sharing}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-purple text-white font-semibold text-sm hover:bg-purple-dark transition-colors flex-shrink-0 disabled:opacity-60"
>
{copied ? (
<>
<Check className="w-4 h-4" />
Copied!
</>
) : (
<>
<Share2 className="w-4 h-4" />
Share My Taste
</>
)}
</button>
</div>
{/* Listening Personality */}
<div className="bg-gradient-to-br from-purple to-purple-dark rounded-2xl p-8 text-white">
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-2xl bg-white/15 flex items-center justify-center flex-shrink-0">
<PersonalityIcon className="w-7 h-7 text-white" />
</div>
<div>
<p className="text-purple-200 text-xs font-semibold uppercase tracking-wider mb-1">
Your Listening Personality
</p>
<h2 className="text-2xl font-bold mb-2">{profile.personality.label}</h2>
<p className="text-purple-100 text-sm leading-relaxed">
{profile.personality.description}
</p>
</div>
</div>
</div>
{/* Genre DNA */}
<div className="bg-white rounded-2xl border border-purple-100 p-6">
<h2 className="text-lg font-semibold text-charcoal mb-5">Genre DNA</h2>
{profile.genre_breakdown.length === 0 ? (
<p className="text-charcoal-muted text-sm">
No genre data yet. Import playlists to see your genre breakdown.
</p>
) : (
<div className="space-y-3">
{profile.genre_breakdown.map((genre, i) => (
<div key={genre.name} className="flex items-center gap-3">
<span className="text-sm text-charcoal w-28 truncate text-right flex-shrink-0 font-medium">
{genre.name}
</span>
<div className="flex-1 bg-purple-50 rounded-full h-6 overflow-hidden">
<div
className={`h-full rounded-full ${genreBarColors[i] || genreBarColors[genreBarColors.length - 1]} transition-all duration-700 ease-out flex items-center justify-end pr-2`}
style={{ width: `${(genre.percentage / maxGenrePercent) * 100}%`, minWidth: '2rem' }}
>
<span className="text-xs font-semibold text-white drop-shadow-sm">
{genre.percentage}%
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Audio Feature Radar */}
<div className="bg-white rounded-2xl border border-purple-100 p-6">
<h2 className="text-lg font-semibold text-charcoal mb-5">Audio Features</h2>
<div className="space-y-4">
<AudioMeter label="Energy" value={profile.audio_features.energy} />
<AudioMeter label="Danceability" value={profile.audio_features.danceability} />
<AudioMeter label="Mood / Valence" value={profile.audio_features.valence} />
<AudioMeter label="Acousticness" value={profile.audio_features.acousticness} />
<div className="flex items-center gap-3 pt-2 border-t border-purple-50">
<span className="text-sm text-charcoal w-28 text-right flex-shrink-0 font-medium">
Avg Tempo
</span>
<div className="flex-1 flex items-center gap-2">
<span className="text-2xl font-bold text-purple">{profile.audio_features.avg_tempo}</span>
<span className="text-sm text-charcoal-muted">BPM</span>
</div>
</div>
</div>
</div>
{/* Top Artists */}
<div className="bg-white rounded-2xl border border-purple-100 p-6">
<h2 className="text-lg font-semibold text-charcoal mb-5">Artists That Define You</h2>
{profile.top_artists.length === 0 ? (
<p className="text-charcoal-muted text-sm">
Import playlists to see your defining artists.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{profile.top_artists.map((artist) => (
<div
key={artist.name}
className="flex items-center gap-3 p-3 rounded-xl bg-cream/60 border border-purple-50"
>
<div className="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-purple" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-charcoal truncate">{artist.name}</p>
<p className="text-xs text-charcoal-muted">
{artist.track_count} track{artist.track_count !== 1 ? 's' : ''}
{artist.genre ? ` · ${artist.genre}` : ''}
</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
function AudioMeter({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-charcoal w-28 text-right flex-shrink-0 font-medium">
{label}
</span>
<div className="flex-1 bg-purple-50 rounded-full h-5 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-[#7C3AED] to-[#A78BFA] transition-all duration-700 ease-out flex items-center justify-end pr-2"
style={{ width: `${value}%`, minWidth: value > 0 ? '1.5rem' : '0' }}
>
{value > 10 && (
<span className="text-xs font-semibold text-white drop-shadow-sm">
{value}%
</span>
)}
</div>
</div>
{value <= 10 && (
<span className="text-xs font-medium text-charcoal-muted w-10">{value}%</span>
)}
</div>
)
}

View File

@@ -0,0 +1,171 @@
import { useState, useEffect } from 'react'
import { Clock, Loader2, Sparkles, Users } from 'lucide-react'
import { getTimeline, type TimelineResponse } from '../lib/api'
const decadeColors: Record<string, { bar: string; bg: string }> = {
'1960s': { bar: 'bg-[#DDD6FE]', bg: 'bg-[#F5F3FF]' },
'1970s': { bar: 'bg-[#C4ABFD]', bg: 'bg-[#F0EAFF]' },
'1980s': { bar: 'bg-[#A78BFA]', bg: 'bg-[#EDE9FE]' },
'1990s': { bar: 'bg-[#8B5CF6]', bg: 'bg-[#E8DFFE]' },
'2000s': { bar: 'bg-[#7C3AED]', bg: 'bg-[#DDD6FE]' },
'2010s': { bar: 'bg-[#6D28D9]', bg: 'bg-[#DDD6FE]' },
'2020s': { bar: 'bg-[#5B21B6]', bg: 'bg-[#DDD6FE]' },
}
export default function Timeline() {
const [timeline, setTimeline] = useState<TimelineResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
getTimeline()
.then(setTimeline)
.catch((err) => {
const msg = err.response?.data?.detail || 'Failed to load timeline.'
setError(msg)
})
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<Loader2 className="w-8 h-8 text-purple animate-spin" />
<p className="text-charcoal-muted text-sm">Analyzing your music across the decades...</p>
</div>
)
}
if (error || !timeline) {
return (
<div className="text-center py-20">
<Clock className="w-12 h-12 text-purple-300 mx-auto mb-4" />
<p className="text-charcoal-muted">{error || 'Could not load your music timeline.'}</p>
</div>
)
}
const maxPercentage = Math.max(...timeline.decades.map((d) => d.percentage), 1)
return (
<div className="space-y-8 max-w-3xl mx-auto">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-charcoal flex items-center gap-3">
<Clock className="w-8 h-8 text-purple" />
Your Music Timeline
</h1>
<p className="text-charcoal-muted mt-1">
How your taste spans the decades, based on {timeline.total_artists} artist{timeline.total_artists !== 1 ? 's' : ''} in your library
</p>
</div>
{/* AI Insight */}
<div className="bg-gradient-to-br from-purple to-purple-dark rounded-2xl p-6 text-white">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-white/15 flex items-center justify-center flex-shrink-0">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-purple-200 text-xs font-semibold uppercase tracking-wider mb-1">
Timeline Insight
</p>
<p className="text-white text-base leading-relaxed">
{timeline.insight}
</p>
</div>
</div>
</div>
{/* Dominant Era Badge */}
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-purple text-white text-sm font-semibold">
<Clock className="w-4 h-4" />
Dominant Era: {timeline.dominant_era}
</span>
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white border border-purple-100 text-charcoal text-sm font-medium">
<Users className="w-4 h-4 text-purple" />
{timeline.total_artists} artists analyzed
</span>
</div>
{/* Timeline Bar Chart */}
<div className="bg-white rounded-2xl border border-purple-100 p-6">
<h2 className="text-lg font-semibold text-charcoal mb-6">Decades Breakdown</h2>
<div className="flex items-end gap-3 sm:gap-4 justify-center mb-6" style={{ height: '220px' }}>
{timeline.decades.map((decade) => {
const colors = decadeColors[decade.decade] || { bar: 'bg-purple', bg: 'bg-purple-50' }
const barHeight = decade.percentage > 0
? Math.max((decade.percentage / maxPercentage) * 100, 8)
: 4
const isDominant = decade.decade === timeline.dominant_era
return (
<div key={decade.decade} className="flex flex-col items-center gap-2 flex-1 h-full justify-end">
{/* Percentage label */}
<span className={`text-xs font-bold ${decade.count > 0 ? 'text-charcoal' : 'text-charcoal-muted/40'}`}>
{decade.percentage > 0 ? `${decade.percentage}%` : ''}
</span>
{/* Bar */}
<div
className={`w-full rounded-t-lg transition-all duration-700 ease-out ${colors.bar} ${isDominant ? 'ring-2 ring-purple ring-offset-2' : ''}`}
style={{ height: `${barHeight}%`, minHeight: decade.count > 0 ? '20px' : '4px' }}
/>
{/* Decade label */}
<span className={`text-xs font-semibold ${isDominant ? 'text-purple' : 'text-charcoal-muted'}`}>
{decade.decade}
</span>
</div>
)
})}
</div>
</div>
{/* Artists by Decade */}
<div className="space-y-3">
{timeline.decades
.filter((d) => d.count > 0)
.sort((a, b) => b.count - a.count)
.map((decade) => {
const colors = decadeColors[decade.decade] || { bar: 'bg-purple', bg: 'bg-purple-50' }
const isDominant = decade.decade === timeline.dominant_era
return (
<div
key={decade.decade}
className={`bg-white rounded-xl border p-5 ${isDominant ? 'border-purple-300 ring-1 ring-purple-100' : 'border-purple-100'}`}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span className={`inline-block w-3 h-3 rounded-full ${colors.bar}`} />
<h3 className="text-base font-semibold text-charcoal">{decade.decade}</h3>
{isDominant && (
<span className="text-xs font-semibold text-purple bg-purple-50 px-2 py-0.5 rounded-full">
Dominant
</span>
)}
</div>
<span className="text-sm text-charcoal-muted">
{decade.count} artist{decade.count !== 1 ? 's' : ''} ({decade.percentage}%)
</span>
</div>
<div className="flex flex-wrap gap-2">
{decade.artists.map((artist) => (
<span
key={artist}
className={`text-sm px-3 py-1 rounded-full ${colors.bg} text-charcoal font-medium`}
>
{artist}
</span>
))}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -5,9 +5,10 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
allowedHosts: ["deepcutsai.com"],
proxy: {
'/api': {
target: 'http://localhost:8000',
target: 'http://backend:8000',
changeOrigin: true,
},
},