Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcff90289c | ||
|
|
f2b8dadbf8 | ||
|
|
0ee8f9a144 | ||
|
|
db2767bfda | ||
|
|
5215e8c792 | ||
|
|
957a66bbd0 | ||
|
|
d1ee78fc27 | ||
|
|
cb6de2f43e | ||
|
|
4b4f383f48 | ||
|
|
2cb6f4d6b2 | ||
|
|
5773870c91 | ||
|
|
34eabf0fae | ||
|
|
2b56d0c06b | ||
|
|
88e7bc9c30 | ||
|
|
086b9e4e71 | ||
|
|
aeadf722cb | ||
|
|
5b603f4acc | ||
|
|
53ab59f0fc | ||
|
|
7abec6de7c | ||
|
|
0b82149b97 | ||
|
|
da94df01da | ||
|
|
2e26aa03c4 | ||
|
|
3bab0b5911 | ||
|
|
99ca2ff7cc | ||
|
|
a0d9f1f9d9 | ||
|
|
40322e8861 | ||
|
|
cc8bb0dd09 | ||
|
|
fcca23e3ca | ||
|
|
51040e3723 | ||
|
|
75ca5fff64 | ||
|
|
85d4210a21 | ||
|
|
9f9f9581d6 | ||
|
|
152f217675 | ||
|
|
be30a47bbb | ||
|
|
ccb49aa693 | ||
|
|
240032d972 | ||
|
|
47ab3dd847 | ||
|
|
cf2d8019bb | ||
|
|
018f5f23cb | ||
|
|
50e9b492d5 | ||
|
|
1eea237c08 | ||
|
|
789de25c1a | ||
|
|
1efa5cd628 | ||
|
|
c6a82cf9d9 | ||
|
|
44eab20614 | ||
|
|
37fccc6eef | ||
|
|
dd4df6a070 | ||
|
|
3303cd1507 | ||
|
|
90945932ad | ||
|
|
d0ab1755bb | ||
|
|
f799a12ed5 | ||
|
|
234a914480 | ||
|
|
93c0ba81d3 | ||
|
|
cef7d576d4 | ||
|
|
b97955d004 | ||
|
|
58c17498be | ||
|
|
cd88ed2983 | ||
|
|
32f7dca1c9 |
93
ARCHITECTURE.md
Normal file
93
ARCHITECTURE.md
Normal 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.0–1.0, intensity/activity)
|
||||
- **Danceability** (0.0–1.0)
|
||||
- **Valence** (0.0–1.0, musical positivity)
|
||||
- **Acousticness** (0.0–1.0)
|
||||
- **Instrumentalness** (0.0–1.0)
|
||||
- **Key** and **Mode** (major/minor)
|
||||
- **Loudness** (dB)
|
||||
- **Speechiness** (0.0–1.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)
|
||||
@@ -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
19
Caddyfile
Normal 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
BIN
FEATURES.pdf
Normal file
Binary file not shown.
51
LAUNCH_CHECKLIST.md
Normal file
51
LAUNCH_CHECKLIST.md
Normal 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
105
LOCAL_SETUP.md
Normal 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
54
PRE_RELEASE_TODO.md
Normal 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
92
ROADMAP.md
Normal 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
|
||||
@@ -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
16
backend/Dockerfile.prod
Normal 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"]
|
||||
90
backend/alembic/versions/001_initial.py
Normal file
90
backend/alembic/versions/001_initial.py
Normal 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")
|
||||
24
backend/alembic/versions/002_add_bandcamp_url.py
Normal file
24
backend/alembic/versions/002_add_bandcamp_url.py
Normal 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")
|
||||
28
backend/alembic/versions/003_add_dislike_and_preferences.py
Normal file
28
backend/alembic/versions/003_add_dislike_and_preferences.py
Normal 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")
|
||||
24
backend/alembic/versions/004_add_youtube_url.py
Normal file
24
backend/alembic/versions/004_add_youtube_url.py
Normal 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")
|
||||
180
backend/app/api/endpoints/admin.py
Normal file
180
backend/app/api/endpoints/admin.py
Normal 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)}
|
||||
@@ -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))
|
||||
|
||||
25
backend/app/api/endpoints/bandcamp.py
Normal file
25
backend/app/api/endpoints/bandcamp.py
Normal 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()
|
||||
152
backend/app/api/endpoints/billing.py
Normal file
152
backend/app/api/endpoints/billing.py
Normal 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,
|
||||
}
|
||||
225
backend/app/api/endpoints/compatibility.py
Normal file
225
backend/app/api/endpoints/compatibility.py
Normal 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,
|
||||
)
|
||||
42
backend/app/api/endpoints/concerts.py
Normal file
42
backend/app/api/endpoints/concerts.py
Normal 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]
|
||||
],
|
||||
}
|
||||
147
backend/app/api/endpoints/lastfm.py
Normal file
147
backend/app/api/endpoints/lastfm.py
Normal 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
|
||||
112
backend/app/api/endpoints/manual_import.py
Normal file
112
backend/app/api/endpoints/manual_import.py
Normal 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
|
||||
141
backend/app/api/endpoints/playlist_fix.py
Normal file
141
backend/app/api/endpoints/playlist_fix.py
Normal 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", [])],
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
364
backend/app/api/endpoints/profile.py
Normal file
364
backend/app/api/endpoints/profile.py
Normal 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
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
192
backend/app/api/endpoints/timeline.py
Normal file
192
backend/app/api/endpoints/timeline.py
Normal 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,
|
||||
)
|
||||
153
backend/app/api/endpoints/youtube_music.py
Normal file
153
backend/app/api/endpoints/youtube_music.py
Normal 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
|
||||
@@ -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"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
73
backend/app/services/bandcamp.py
Normal file
73
backend/app/services/bandcamp.py
Normal 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",
|
||||
]
|
||||
100
backend/app/services/lastfm.py
Normal file
100
backend/app/services/lastfm.py
Normal 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
|
||||
104
backend/app/services/musicbrainz.py
Normal file
104
backend/app/services/musicbrainz.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
87
backend/app/services/youtube_music.py
Normal file
87
backend/app/services/youtube_music.py
Normal 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
|
||||
@@ -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
20
backup.sh
Executable 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
33
deploy.sh
Executable 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
51
docker-compose.prod.yml
Normal 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:
|
||||
@@ -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
|
||||
"
|
||||
|
||||
@@ -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
18
frontend/Dockerfile.prod
Normal 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;"]
|
||||
@@ -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
14
frontend/nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
12
frontend/public/manifest.json
Normal file
12
frontend/public/manifest.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
225
frontend/src/components/Onboarding.tsx
Normal file
225
frontend/src/components/Onboarding.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"> · {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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
267
frontend/src/pages/Admin.tsx
Normal file
267
frontend/src/pages/Admin.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
216
frontend/src/pages/Analyze.tsx
Normal file
216
frontend/src/pages/Analyze.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
frontend/src/pages/ArtistDive.tsx
Normal file
228
frontend/src/pages/ArtistDive.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
frontend/src/pages/BandcampDiscover.tsx
Normal file
228
frontend/src/pages/BandcampDiscover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
281
frontend/src/pages/Billing.tsx
Normal file
281
frontend/src/pages/Billing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
239
frontend/src/pages/Compatibility.tsx
Normal file
239
frontend/src/pages/Compatibility.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
336
frontend/src/pages/CrateDigger.tsx
Normal file
336
frontend/src/pages/CrateDigger.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} — {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} — {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}
|
||||
|
||||
235
frontend/src/pages/PlaylistGenerator.tsx
Normal file
235
frontend/src/pages/PlaylistGenerator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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} · {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 · {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>
|
||||
)
|
||||
}
|
||||
|
||||
232
frontend/src/pages/PublicProfile.tsx
Normal file
232
frontend/src/pages/PublicProfile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
frontend/src/pages/RabbitHole.tsx
Normal file
228
frontend/src/pages/RabbitHole.tsx
Normal 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"> · {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>
|
||||
)
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
216
frontend/src/pages/Settings.tsx
Normal file
216
frontend/src/pages/Settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
frontend/src/pages/SharedView.tsx
Normal file
127
frontend/src/pages/SharedView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
230
frontend/src/pages/TasteProfilePage.tsx
Normal file
230
frontend/src/pages/TasteProfilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
frontend/src/pages/Timeline.tsx
Normal file
171
frontend/src/pages/Timeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user