Initial MVP: full-stack music discovery app
Backend (FastAPI): - User auth with email/password and Spotify OAuth - Spotify playlist import with audio feature extraction - AI recommendation engine using Claude API with taste profiling - Save/bookmark recommendations - Rate limiting for free tier (10 recs/day, 1 playlist) - PostgreSQL models with Alembic migrations - Redis-ready configuration Frontend (React 19 + TypeScript + Vite + Tailwind): - Landing page, auth flows (email + Spotify OAuth) - Dashboard with stats and quick discover - Playlist management and import from Spotify - Discover page with custom query support - Recommendation cards with explanations and save toggle - Taste profile visualization - Responsive layout with mobile navigation - PWA-ready configuration Infrastructure: - Docker Compose with PostgreSQL, Redis, backend, frontend - Environment-based configuration
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
38
CLAUDE.md
Normal file
38
CLAUDE.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Vynl - AI Music Discovery App
|
||||
|
||||
## Project Status: Ready to build MVP
|
||||
|
||||
## Design Doc
|
||||
See `DESIGN.md` for full product spec.
|
||||
|
||||
## Quick Summary
|
||||
- Import playlists from Spotify/Apple Music/YouTube Music/Tidal
|
||||
- AI finds new music you'll love and explains why
|
||||
- Free tier (10 recs/day, 1 playlist) + Pro ($4.99/month, unlimited)
|
||||
- Cross-platform import/export
|
||||
|
||||
## Tech Stack
|
||||
- **Backend**: Python 3.12+ / FastAPI / PostgreSQL / Redis / Celery
|
||||
- **Frontend**: React 19 + TypeScript + Vite + Tailwind CSS
|
||||
- **AI**: Claude API for recommendations + Spotify audio features + MusicBrainz + Last.fm
|
||||
- **Auth**: Email + OAuth (Spotify, Apple, Google)
|
||||
- **Payments**: Stripe
|
||||
- **Mobile**: PWA (progressive web app)
|
||||
|
||||
## MVP Scope (v1.0)
|
||||
1. User auth (email + Spotify OAuth)
|
||||
2. Spotify playlist import
|
||||
3. Manual song/artist search
|
||||
4. AI recommendations via Claude API
|
||||
5. "Why you'll like this" explanations
|
||||
6. Taste profile display
|
||||
7. Save/bookmark recommendations
|
||||
8. Responsive web app (PWA)
|
||||
|
||||
## Brand
|
||||
- **Colors**: Deep purple (#7C3AED) + warm cream (#FFF7ED) + charcoal (#1C1917)
|
||||
- **Vibe**: Warm analog nostalgia meets modern AI
|
||||
|
||||
## Git
|
||||
- Repo: To be created on Gitea (chrisryn/vynl)
|
||||
- Branches: master + dev (standard workflow)
|
||||
209
DESIGN.md
Normal file
209
DESIGN.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Vynl - AI Music Discovery
|
||||
|
||||
## Tagline
|
||||
"Dig deeper. Discover more."
|
||||
|
||||
## What It Does
|
||||
Import your playlists from any streaming platform, drop in a song or artist, and Vynl's AI finds music you'll love - from underground gems to hidden tracks from artists you already know. It tells you **why** you'll like it, not just **what** to listen to.
|
||||
|
||||
## Target Audience
|
||||
- Music lovers who feel stuck in algorithm bubbles
|
||||
- People who use multiple streaming platforms
|
||||
- Playlist curators
|
||||
- Anyone who misses the feeling of discovering new music
|
||||
|
||||
## Core Features
|
||||
|
||||
### Free Tier
|
||||
- Import 1 playlist (up to 50 songs)
|
||||
- Search by artist or song
|
||||
- 10 AI recommendations per day
|
||||
- Basic "similar to" recommendations
|
||||
- Export recommendations as text list
|
||||
|
||||
### Pro Tier ($4.99/month)
|
||||
- Unlimited playlist imports from all platforms
|
||||
- Unlimited AI recommendations
|
||||
- Deep analysis: mood, tempo, era, production style, lyrical themes
|
||||
- "Go deeper" mode - find underground/indie artists in the same space
|
||||
- "Time machine" - find music from a specific era that matches your taste
|
||||
- "Mood shift" - "I like this but want something darker/faster/mellower"
|
||||
- Export playlists directly back to Spotify/Apple Music/YouTube Music/Tidal
|
||||
- Recommendation history and saved discoveries
|
||||
- "Why you'll like this" AI explanations
|
||||
- Cross-platform playlist sync
|
||||
|
||||
## Platform Support
|
||||
|
||||
### Import From
|
||||
- Spotify (API - OAuth)
|
||||
- Apple Music (MusicKit API)
|
||||
- YouTube Music (ytmusicapi)
|
||||
- Tidal (API)
|
||||
- Last.fm (scrobble history)
|
||||
- Manual entry (paste song/artist name)
|
||||
- CSV/text file upload
|
||||
|
||||
### Export To
|
||||
- Spotify (create playlist via API)
|
||||
- Apple Music (create playlist via MusicKit)
|
||||
- YouTube Music (create playlist via API)
|
||||
- Tidal (create playlist via API)
|
||||
- CSV/shareable link
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- Python 3.12+ / FastAPI
|
||||
- PostgreSQL (users, playlists, recommendations, history)
|
||||
- Redis (caching, rate limiting, session)
|
||||
- Celery (async playlist analysis, background jobs)
|
||||
|
||||
### Frontend
|
||||
- React 19 + TypeScript + Vite
|
||||
- Tailwind CSS
|
||||
- Mobile: React Native or PWA (progressive web app)
|
||||
|
||||
### AI/ML
|
||||
- Claude API for intelligent recommendations with explanations
|
||||
- Spotify Audio Features API (tempo, energy, danceability, valence, acousticness)
|
||||
- MusicBrainz API (artist relationships, genres, tags)
|
||||
- Last.fm API (similar artists, tags, listener stats)
|
||||
- Audio embeddings for sonic similarity (optional - Essentia/Librosa)
|
||||
|
||||
### Infrastructure
|
||||
- Azure App Service or self-hosted (like BillWise)
|
||||
- Stripe for payments
|
||||
- OAuth 2.0 for all streaming platform connections
|
||||
|
||||
## AI Recommendation Engine
|
||||
|
||||
### How It Works
|
||||
```
|
||||
User imports playlist or enters song/artist
|
||||
│
|
||||
▼
|
||||
Gather metadata from all sources:
|
||||
- Spotify: audio features (tempo, key, energy, mood)
|
||||
- MusicBrainz: genres, relationships, tags
|
||||
- Last.fm: similar artists, listener overlap
|
||||
- Lyrics analysis (optional)
|
||||
│
|
||||
▼
|
||||
Build "taste profile":
|
||||
- Genre distribution
|
||||
- Mood/energy preferences
|
||||
- Era preferences
|
||||
- Production style (acoustic vs electronic, raw vs polished)
|
||||
- Lyrical themes
|
||||
│
|
||||
▼
|
||||
Claude AI analyzes profile + user request:
|
||||
"Based on your love of [specific patterns], here are artists
|
||||
you likely haven't heard that share [specific qualities]"
|
||||
│
|
||||
▼
|
||||
Filter and rank recommendations:
|
||||
- Exclude what user already has
|
||||
- Prioritize lesser-known artists (discovery factor)
|
||||
- Include preview links
|
||||
- Generate "why you'll like this" for each
|
||||
```
|
||||
|
||||
### Example Prompts to Claude
|
||||
- "My playlist is 60% indie rock, 20% shoegaze, 20% post-punk. Find me 10 artists I probably haven't heard."
|
||||
- "I love the production style of Tame Impala but want something with female vocals and darker lyrics."
|
||||
- "Find songs from the 80s that match the energy of my playlist but aren't the obvious hits."
|
||||
|
||||
## Data Model
|
||||
|
||||
### Users
|
||||
- id, email, name, plan (free/pro), created_at
|
||||
- connected_platforms (spotify_token, apple_token, etc.)
|
||||
|
||||
### Playlists
|
||||
- id, user_id, platform_source, name, track_count, imported_at
|
||||
- taste_profile (JSON - computed analysis)
|
||||
|
||||
### Tracks
|
||||
- id, title, artist, album, isrc
|
||||
- spotify_id, apple_id, youtube_id, tidal_id
|
||||
- audio_features (tempo, energy, key, etc.)
|
||||
- genres, tags, mood
|
||||
|
||||
### Recommendations
|
||||
- id, user_id, source_playlist_id, recommended_track_id
|
||||
- reason (AI explanation), score, created_at
|
||||
- user_feedback (liked/disliked/saved)
|
||||
|
||||
### Discovery Sessions
|
||||
- id, user_id, query, mode (similar/deeper/mood/era)
|
||||
- results, created_at
|
||||
|
||||
## Monetization
|
||||
|
||||
### Free
|
||||
- Limited to 10 recommendations/day
|
||||
- 1 playlist import
|
||||
- No export to streaming platforms
|
||||
- Ads (tasteful, music-related)
|
||||
|
||||
### Pro ($4.99/month or $39.99/year)
|
||||
- Unlimited everything
|
||||
- All export features
|
||||
- Priority AI processing
|
||||
- No ads
|
||||
|
||||
### Potential Revenue Streams
|
||||
- Affiliate links to streaming platforms
|
||||
- Artist promotion (paid placement in "sponsored discovery")
|
||||
- API access for other apps
|
||||
- Data insights for labels (anonymized trends)
|
||||
|
||||
## Competitive Landscape
|
||||
|
||||
| Product | What It Does | Vynl Advantage |
|
||||
|---------|-------------|----------------|
|
||||
| Spotify Discover Weekly | Algorithm-based weekly playlist | Only works within Spotify, no cross-platform |
|
||||
| Last.fm | Scrobble tracking + similar artists | No AI explanations, stale recommendations |
|
||||
| EveryNoise | Genre exploration map | Academic, not personalized |
|
||||
| Maroofy | Song similarity search | Single song only, no playlist analysis |
|
||||
| Chosic | Spotify playlist analyzer | Spotify only, basic recommendations |
|
||||
| Discoverify | Spotify discovery tool | Spotify only |
|
||||
|
||||
**Vynl's edge**: Cross-platform + AI that explains WHY + deep/underground discovery + mood/era controls
|
||||
|
||||
## MVP (v1.0) Scope
|
||||
|
||||
### Must Have
|
||||
- [ ] User auth (email + OAuth)
|
||||
- [ ] Spotify playlist import
|
||||
- [ ] Manual song/artist search
|
||||
- [ ] AI recommendations (Claude API)
|
||||
- [ ] "Why you'll like this" explanations
|
||||
- [ ] Basic taste profile display
|
||||
- [ ] Save/bookmark recommendations
|
||||
- [ ] Responsive web app
|
||||
|
||||
### Nice to Have (v1.1)
|
||||
- [ ] Apple Music import/export
|
||||
- [ ] YouTube Music import/export
|
||||
- [ ] Export playlist to Spotify
|
||||
- [ ] "Go deeper" underground mode
|
||||
- [ ] Mood shift controls
|
||||
|
||||
### Future (v2.0)
|
||||
- [ ] Tidal, Last.fm, Deezer support
|
||||
- [ ] Mobile app (React Native)
|
||||
- [ ] Social features (share discoveries, follow curators)
|
||||
- [ ] "Listening rooms" - real-time shared discovery sessions
|
||||
- [ ] Artist dashboard (see who's discovering your music)
|
||||
|
||||
## Brand Identity
|
||||
|
||||
- **Name**: Vynl
|
||||
- **Vibe**: Warm, analog nostalgia meets modern AI
|
||||
- **Colors**: Deep purple/violet (#7C3AED) + warm cream (#FFF7ED) + charcoal (#1C1917)
|
||||
- **Font**: Something with character - not sterile tech (Inter for body, custom display font)
|
||||
- **Logo**: Stylized vinyl record with AI circuit pattern in the grooves
|
||||
- **Voice**: Music-nerd friendly, never pretentious, excited about discovery
|
||||
9
backend/.env.example
Normal file
9
backend/.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
SECRET_KEY=change-me-to-a-random-string
|
||||
DATABASE_URL=postgresql+asyncpg://vynl:vynl@db:5432/vynl
|
||||
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
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
12
backend/Dockerfile
Normal file
12
backend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
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
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
36
backend/alembic.ini
Normal file
36
backend/alembic.ini
Normal file
@@ -0,0 +1,36 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = postgresql://vynl:vynl@localhost:5432/vynl
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
46
backend/alembic/env.py
Normal file
46
backend/alembic/env.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
|
||||
# Import all models so they register with Base.metadata
|
||||
from app.models.user import User # noqa: F401
|
||||
from app.models.playlist import Playlist # noqa: F401
|
||||
from app.models.track import Track # noqa: F401
|
||||
from app.models.recommendation import Recommendation # noqa: F401
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/endpoints/__init__.py
Normal file
0
backend/app/api/endpoints/__init__.py
Normal file
93
backend/app/api/endpoints/auth.py
Normal file
93
backend/app/api/endpoints/auth.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
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 app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse
|
||||
from app.services.spotify import get_spotify_auth_url, exchange_spotify_code, get_spotify_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
user = User(
|
||||
email=data.email,
|
||||
name=data.name,
|
||||
hashed_password=hash_password(data.password),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
return TokenResponse(access_token=create_access_token(user.id))
|
||||
|
||||
|
||||
@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))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.hashed_password or not verify_password(data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
return TokenResponse(access_token=create_access_token(user.id))
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(user: User = Depends(get_current_user)):
|
||||
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.get("/spotify/url")
|
||||
async def spotify_auth_url():
|
||||
state = secrets.token_urlsafe(32)
|
||||
url = get_spotify_auth_url(state)
|
||||
return {"url": url, "state": state}
|
||||
|
||||
|
||||
@router.post("/spotify/callback", response_model=TokenResponse)
|
||||
async def spotify_callback(code: str, db: AsyncSession = Depends(get_db)):
|
||||
token_data = await exchange_spotify_code(code)
|
||||
access_token = token_data["access_token"]
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
|
||||
spotify_user = await get_spotify_user(access_token)
|
||||
spotify_id = spotify_user["id"]
|
||||
email = spotify_user.get("email", f"{spotify_id}@spotify.user")
|
||||
name = spotify_user.get("display_name") or spotify_id
|
||||
|
||||
# Check if user exists by spotify_id or email
|
||||
result = await db.execute(select(User).where(User.spotify_id == spotify_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
user.spotify_id = spotify_id
|
||||
user.spotify_access_token = access_token
|
||||
user.spotify_refresh_token = refresh_token or user.spotify_refresh_token
|
||||
else:
|
||||
user = User(
|
||||
email=email,
|
||||
name=name,
|
||||
spotify_id=spotify_id,
|
||||
spotify_access_token=access_token,
|
||||
spotify_refresh_token=refresh_token,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
await db.flush()
|
||||
return TokenResponse(access_token=create_access_token(user.id))
|
||||
153
backend/app/api/endpoints/playlists.py
Normal file
153
backend/app/api/endpoints/playlists.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
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.schemas.playlist import (
|
||||
PlaylistResponse,
|
||||
PlaylistDetailResponse,
|
||||
SpotifyPlaylistItem,
|
||||
ImportSpotifyRequest,
|
||||
)
|
||||
from app.services.spotify import get_user_playlists, get_playlist_tracks, get_audio_features
|
||||
from app.services.recommender import build_taste_profile
|
||||
|
||||
router = APIRouter(prefix="/playlists", tags=["playlists"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[PlaylistResponse])
|
||||
async def list_playlists(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.user_id == user.id).order_by(Playlist.imported_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{playlist_id}", response_model=PlaylistDetailResponse)
|
||||
async def get_playlist(
|
||||
playlist_id: int,
|
||||
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")
|
||||
return playlist
|
||||
|
||||
|
||||
@router.delete("/{playlist_id}")
|
||||
async def delete_playlist(
|
||||
playlist_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
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")
|
||||
await db.delete(playlist)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/spotify/available", response_model=list[SpotifyPlaylistItem])
|
||||
async def list_spotify_playlists(user: User = Depends(get_current_user)):
|
||||
if not user.spotify_access_token:
|
||||
raise HTTPException(status_code=400, detail="Spotify not connected")
|
||||
playlists = await get_user_playlists(user.spotify_access_token)
|
||||
return playlists
|
||||
|
||||
|
||||
@router.post("/spotify/import", response_model=PlaylistDetailResponse)
|
||||
async def import_spotify_playlist(
|
||||
data: ImportSpotifyRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not user.spotify_access_token:
|
||||
raise HTTPException(status_code=400, detail="Spotify not connected")
|
||||
|
||||
# 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.")
|
||||
|
||||
# Fetch tracks from Spotify
|
||||
raw_tracks = await get_playlist_tracks(user.spotify_access_token, data.playlist_id)
|
||||
|
||||
# Get playlist name from Spotify playlists
|
||||
spotify_playlists = await get_user_playlists(user.spotify_access_token)
|
||||
playlist_name = data.playlist_id
|
||||
for sp in spotify_playlists:
|
||||
if sp["id"] == data.playlist_id:
|
||||
playlist_name = sp["name"]
|
||||
break
|
||||
|
||||
# Create playlist
|
||||
playlist = Playlist(
|
||||
user_id=user.id,
|
||||
name=playlist_name,
|
||||
platform_source="spotify",
|
||||
external_id=data.playlist_id,
|
||||
track_count=len(raw_tracks),
|
||||
)
|
||||
db.add(playlist)
|
||||
await db.flush()
|
||||
|
||||
# Create tracks
|
||||
tracks = []
|
||||
for rt in raw_tracks:
|
||||
track = Track(
|
||||
playlist_id=playlist.id,
|
||||
title=rt["title"],
|
||||
artist=rt["artist"],
|
||||
album=rt.get("album"),
|
||||
spotify_id=rt.get("spotify_id"),
|
||||
isrc=rt.get("isrc"),
|
||||
preview_url=rt.get("preview_url"),
|
||||
image_url=rt.get("image_url"),
|
||||
)
|
||||
db.add(track)
|
||||
tracks.append(track)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Fetch audio features
|
||||
spotify_ids = [t.spotify_id for t in tracks if t.spotify_id]
|
||||
if spotify_ids:
|
||||
features = await get_audio_features(user.spotify_access_token, spotify_ids)
|
||||
features_map = {f["id"]: f for f in features if f}
|
||||
for track in tracks:
|
||||
if track.spotify_id and track.spotify_id in features_map:
|
||||
f = features_map[track.spotify_id]
|
||||
track.tempo = f.get("tempo")
|
||||
track.energy = f.get("energy")
|
||||
track.danceability = f.get("danceability")
|
||||
track.valence = f.get("valence")
|
||||
track.acousticness = f.get("acousticness")
|
||||
track.instrumentalness = f.get("instrumentalness")
|
||||
|
||||
# Build taste profile
|
||||
playlist.taste_profile = build_taste_profile(tracks)
|
||||
playlist.tracks = tracks
|
||||
|
||||
return playlist
|
||||
77
backend/app/api/endpoints/recommendations.py
Normal file
77
backend/app/api/endpoints/recommendations.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
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.recommendation import Recommendation
|
||||
from app.schemas.recommendation import RecommendationRequest, RecommendationResponse, RecommendationItem
|
||||
from app.services.recommender import generate_recommendations
|
||||
|
||||
router = APIRouter(prefix="/recommendations", tags=["recommendations"])
|
||||
|
||||
|
||||
@router.post("/generate", response_model=RecommendationResponse)
|
||||
async def generate(
|
||||
data: RecommendationRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not data.playlist_id and not data.query:
|
||||
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
|
||||
)
|
||||
|
||||
if not recs and remaining == 0:
|
||||
raise HTTPException(status_code=429, detail="Daily recommendation limit reached. Upgrade to Pro for unlimited.")
|
||||
|
||||
return RecommendationResponse(
|
||||
recommendations=[RecommendationItem.model_validate(r) for r in recs],
|
||||
remaining_today=remaining,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history", response_model=list[RecommendationItem])
|
||||
async def history(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Recommendation)
|
||||
.where(Recommendation.user_id == user.id)
|
||||
.order_by(Recommendation.created_at.desc())
|
||||
.limit(50)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/saved", response_model=list[RecommendationItem])
|
||||
async def 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())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/{rec_id}/save")
|
||||
async def save_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.saved = not rec.saved
|
||||
return {"saved": rec.saved}
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
35
backend/app/core/config.py
Normal file
35
backend/app/core/config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "Vynl"
|
||||
DEBUG: bool = False
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://vynl:vynl@localhost:5432/vynl"
|
||||
DATABASE_URL_SYNC: str = "postgresql://vynl:vynl@localhost:5432/vynl"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# Spotify
|
||||
SPOTIFY_CLIENT_ID: str = ""
|
||||
SPOTIFY_CLIENT_SECRET: str = ""
|
||||
SPOTIFY_REDIRECT_URI: str = "http://localhost:5173/auth/spotify/callback"
|
||||
|
||||
# Claude API
|
||||
ANTHROPIC_API_KEY: str = ""
|
||||
|
||||
# Frontend
|
||||
FRONTEND_URL: str = "http://localhost:5173"
|
||||
|
||||
# Rate limits (free tier)
|
||||
FREE_DAILY_RECOMMENDATIONS: int = 10
|
||||
FREE_MAX_PLAYLISTS: int = 1
|
||||
|
||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
21
backend/app/core/database.py
Normal file
21
backend/app/core/database.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
55
backend/app/core/security.py
Normal file
55
backend/app/core/security.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(user_id: int) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return jwt.encode({"sub": str(user_id), "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.models.user import User
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
24
backend/app/main.py
Normal file
24
backend/app/main.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.api.endpoints import auth, playlists, recommendations
|
||||
|
||||
app = FastAPI(title="Vynl API", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.FRONTEND_URL],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(playlists.router, prefix="/api")
|
||||
app.include_router(recommendations.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "app": "vynl"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
28
backend/app/models/playlist.py
Normal file
28
backend/app/models/playlist.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, JSON, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Playlist(Base):
|
||||
__tablename__ = "playlists"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(500))
|
||||
platform_source: Mapped[str] = mapped_column(String(50)) # spotify, manual, etc.
|
||||
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
track_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
taste_profile: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
imported_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="playlists")
|
||||
tracks: Mapped[list["Track"]] = relationship(back_populates="playlist", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
from app.models.user import User # noqa: E402, F811
|
||||
from app.models.track import Track # noqa: E402
|
||||
38
backend/app/models/recommendation.py
Normal file
38
backend/app/models/recommendation.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, Integer, Float, DateTime, ForeignKey, Text, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Recommendation(Base):
|
||||
__tablename__ = "recommendations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
playlist_id: Mapped[int | None] = mapped_column(ForeignKey("playlists.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# Recommended track info
|
||||
title: Mapped[str] = mapped_column(String(500))
|
||||
artist: Mapped[str] = mapped_column(String(500))
|
||||
album: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
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)
|
||||
|
||||
# AI explanation
|
||||
reason: Mapped[str] = mapped_column(Text)
|
||||
score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
query: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# User interaction
|
||||
saved: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="recommendations")
|
||||
|
||||
|
||||
from app.models.user import User # noqa: E402, F811
|
||||
33
backend/app/models/track.py
Normal file
33
backend/app/models/track.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from sqlalchemy import String, Integer, Float, ForeignKey, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Track(Base):
|
||||
__tablename__ = "tracks"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(500))
|
||||
artist: Mapped[str] = mapped_column(String(500))
|
||||
album: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
spotify_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
isrc: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
preview_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
image_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Audio features from Spotify
|
||||
tempo: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
energy: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
danceability: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
valence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
acousticness: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
instrumentalness: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
genres: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
playlist: Mapped["Playlist"] = relationship(back_populates="tracks")
|
||||
|
||||
|
||||
from app.models.playlist import Playlist # noqa: E402, F811
|
||||
32
backend/app/models/user.py
Normal file
32
backend/app/models/user.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
is_pro: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
# 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)
|
||||
spotify_refresh_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
playlists: Mapped[list["Playlist"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
recommendations: Mapped[list["Recommendation"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
from app.models.playlist import Playlist # noqa: E402
|
||||
from app.models.recommendation import Recommendation # noqa: E402
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
27
backend/app/schemas/auth.py
Normal file
27
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
name: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
is_pro: bool
|
||||
spotify_connected: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
46
backend/app/schemas/playlist.py
Normal file
46
backend/app/schemas/playlist.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TrackResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
artist: str
|
||||
album: str | None = None
|
||||
spotify_id: str | None = None
|
||||
preview_url: str | None = None
|
||||
image_url: str | None = None
|
||||
tempo: float | None = None
|
||||
energy: float | None = None
|
||||
danceability: float | None = None
|
||||
valence: float | None = None
|
||||
genres: list | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PlaylistResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
platform_source: str
|
||||
track_count: int
|
||||
taste_profile: dict | None = None
|
||||
imported_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PlaylistDetailResponse(PlaylistResponse):
|
||||
tracks: list[TrackResponse] = []
|
||||
|
||||
|
||||
class SpotifyPlaylistItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
track_count: int
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
class ImportSpotifyRequest(BaseModel):
|
||||
playlist_id: str
|
||||
39
backend/app/schemas/recommendation.py
Normal file
39
backend/app/schemas/recommendation.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RecommendationRequest(BaseModel):
|
||||
playlist_id: int | None = None
|
||||
query: str | None = None # Manual search/request
|
||||
|
||||
|
||||
class RecommendationItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
artist: str
|
||||
album: str | None = None
|
||||
spotify_id: str | None = None
|
||||
preview_url: str | None = None
|
||||
image_url: str | None = None
|
||||
reason: str
|
||||
score: float | None = None
|
||||
saved: bool = False
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RecommendationResponse(BaseModel):
|
||||
recommendations: list[RecommendationItem]
|
||||
remaining_today: int | None = None # None = unlimited (pro)
|
||||
|
||||
|
||||
class TasteProfile(BaseModel):
|
||||
top_genres: list[dict]
|
||||
avg_energy: float
|
||||
avg_danceability: float
|
||||
avg_valence: float
|
||||
avg_tempo: float
|
||||
era_preferences: list[str]
|
||||
mood_summary: str
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
176
backend/app/services/recommender.py
Normal file
176
backend/app/services/recommender.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import anthropic
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.track import Track
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.recommendation import Recommendation
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def build_taste_profile(tracks: list[Track]) -> dict:
|
||||
"""Analyze tracks to build a taste profile summary."""
|
||||
if not tracks:
|
||||
return {}
|
||||
|
||||
genres_count: dict[str, int] = {}
|
||||
total_energy = 0.0
|
||||
total_dance = 0.0
|
||||
total_valence = 0.0
|
||||
total_tempo = 0.0
|
||||
count_features = 0
|
||||
|
||||
for t in tracks:
|
||||
if t.genres:
|
||||
for g in t.genres:
|
||||
genres_count[g] = genres_count.get(g, 0) + 1
|
||||
if t.energy is not None:
|
||||
total_energy += t.energy
|
||||
total_dance += t.danceability or 0
|
||||
total_valence += t.valence or 0
|
||||
total_tempo += t.tempo or 0
|
||||
count_features += 1
|
||||
|
||||
top_genres = sorted(genres_count.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||
n = max(count_features, 1)
|
||||
|
||||
return {
|
||||
"top_genres": [{"name": g, "count": c} for g, c in top_genres],
|
||||
"avg_energy": round(total_energy / n, 3),
|
||||
"avg_danceability": round(total_dance / n, 3),
|
||||
"avg_valence": round(total_valence / n, 3),
|
||||
"avg_tempo": round(total_tempo / n, 1),
|
||||
"track_count": len(tracks),
|
||||
"sample_artists": list({t.artist for t in tracks[:20]}),
|
||||
"sample_tracks": [f"{t.artist} - {t.title}" for t in tracks[:15]],
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
result = await db.execute(
|
||||
select(func.count(Recommendation.id)).where(
|
||||
Recommendation.user_id == user_id,
|
||||
Recommendation.created_at >= today_start,
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def generate_recommendations(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
playlist_id: int | None = None,
|
||||
query: str | 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)
|
||||
if remaining <= 0:
|
||||
return [], 0
|
||||
|
||||
# Gather context
|
||||
taste_context = ""
|
||||
existing_tracks = set()
|
||||
|
||||
if playlist_id:
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.id == playlist_id, Playlist.user_id == user.id)
|
||||
)
|
||||
playlist = result.scalar_one_or_none()
|
||||
if playlist:
|
||||
result = await db.execute(
|
||||
select(Track).where(Track.playlist_id == playlist.id)
|
||||
)
|
||||
tracks = list(result.scalars().all())
|
||||
existing_tracks = {f"{t.artist} - {t.title}".lower() for t in tracks}
|
||||
profile = build_taste_profile(tracks)
|
||||
taste_context = f"Taste profile from playlist '{playlist.name}':\n{json.dumps(profile, indent=2)}"
|
||||
else:
|
||||
# Gather from all user playlists
|
||||
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())
|
||||
existing_tracks = {f"{t.artist} - {t.title}".lower() for t in all_tracks}
|
||||
if all_tracks:
|
||||
profile = build_taste_profile(all_tracks)
|
||||
taste_context = f"Taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}"
|
||||
|
||||
# Build prompt
|
||||
user_request = query or "Find me music I'll love based on my taste profile. Prioritize lesser-known artists and hidden gems."
|
||||
|
||||
prompt = f"""You are Vynl, an AI music discovery assistant. You help people discover new music they'll love.
|
||||
|
||||
{taste_context}
|
||||
|
||||
User request: {user_request}
|
||||
|
||||
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)
|
||||
- "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.
|
||||
Return ONLY the JSON array, no other text."""
|
||||
|
||||
# Call Claude API
|
||||
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
message = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=2000,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
|
||||
# Parse response
|
||||
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:
|
||||
recs_data = json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
return [], remaining
|
||||
|
||||
# Save to DB
|
||||
recommendations = []
|
||||
for rec in recs_data[:5]:
|
||||
r = Recommendation(
|
||||
user_id=user.id,
|
||||
playlist_id=playlist_id,
|
||||
title=rec.get("title", "Unknown"),
|
||||
artist=rec.get("artist", "Unknown"),
|
||||
album=rec.get("album"),
|
||||
reason=rec.get("reason", ""),
|
||||
score=rec.get("score"),
|
||||
query=query,
|
||||
)
|
||||
db.add(r)
|
||||
recommendations.append(r)
|
||||
|
||||
await db.flush()
|
||||
|
||||
if remaining is not None:
|
||||
remaining = max(0, remaining - len(recommendations))
|
||||
|
||||
return recommendations, remaining
|
||||
129
backend/app/services/spotify.py
Normal file
129
backend/app/services/spotify.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import httpx
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize"
|
||||
SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||
SPOTIFY_API_URL = "https://api.spotify.com/v1"
|
||||
|
||||
SCOPES = "user-read-private user-read-email playlist-read-private playlist-read-collaborative"
|
||||
|
||||
|
||||
def get_spotify_auth_url(state: str) -> str:
|
||||
params = {
|
||||
"client_id": settings.SPOTIFY_CLIENT_ID,
|
||||
"response_type": "code",
|
||||
"redirect_uri": settings.SPOTIFY_REDIRECT_URI,
|
||||
"scope": SCOPES,
|
||||
"state": state,
|
||||
}
|
||||
return f"{SPOTIFY_AUTH_URL}?{urlencode(params)}"
|
||||
|
||||
|
||||
async def exchange_spotify_code(code: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
SPOTIFY_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": settings.SPOTIFY_REDIRECT_URI,
|
||||
"client_id": settings.SPOTIFY_CLIENT_ID,
|
||||
"client_secret": settings.SPOTIFY_CLIENT_SECRET,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def refresh_spotify_token(refresh_token: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
SPOTIFY_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": settings.SPOTIFY_CLIENT_ID,
|
||||
"client_secret": settings.SPOTIFY_CLIENT_SECRET,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def get_spotify_user(access_token: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{SPOTIFY_API_URL}/me",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def get_user_playlists(access_token: str) -> list[dict]:
|
||||
playlists = []
|
||||
url = f"{SPOTIFY_API_URL}/me/playlists?limit=50"
|
||||
async with httpx.AsyncClient() as client:
|
||||
while url:
|
||||
response = await client.get(
|
||||
url, headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
playlists.append({
|
||||
"id": item["id"],
|
||||
"name": item["name"],
|
||||
"track_count": item["tracks"]["total"],
|
||||
"image_url": item["images"][0]["url"] if item.get("images") else None,
|
||||
})
|
||||
url = data.get("next")
|
||||
return playlists
|
||||
|
||||
|
||||
async def get_playlist_tracks(access_token: str, playlist_id: str) -> list[dict]:
|
||||
tracks = []
|
||||
url = f"{SPOTIFY_API_URL}/playlists/{playlist_id}/tracks?limit=100"
|
||||
async with httpx.AsyncClient() as client:
|
||||
while url:
|
||||
response = await client.get(
|
||||
url, headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
t = item.get("track")
|
||||
if not t or not t.get("id"):
|
||||
continue
|
||||
tracks.append({
|
||||
"spotify_id": t["id"],
|
||||
"title": t["name"],
|
||||
"artist": ", ".join(a["name"] for a in t["artists"]),
|
||||
"album": t["album"]["name"] if t.get("album") else None,
|
||||
"isrc": t.get("external_ids", {}).get("isrc"),
|
||||
"preview_url": t.get("preview_url"),
|
||||
"image_url": t["album"]["images"][0]["url"]
|
||||
if t.get("album", {}).get("images")
|
||||
else None,
|
||||
})
|
||||
url = data.get("next")
|
||||
return tracks
|
||||
|
||||
|
||||
async def get_audio_features(access_token: str, track_ids: list[str]) -> list[dict]:
|
||||
features = []
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Spotify allows max 100 IDs per request
|
||||
for i in range(0, len(track_ids), 100):
|
||||
batch = track_ids[i : i + 100]
|
||||
response = await client.get(
|
||||
f"{SPOTIFY_API_URL}/audio-features",
|
||||
params={"ids": ",".join(batch)},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
features.extend(data.get("audio_features") or [])
|
||||
return features
|
||||
17
backend/requirements.txt
Normal file
17
backend/requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
alembic==1.14.1
|
||||
asyncpg==0.30.0
|
||||
psycopg2-binary==2.9.10
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.20
|
||||
pydantic[email]==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
redis==5.2.1
|
||||
celery==5.4.0
|
||||
httpx==0.28.1
|
||||
anthropic==0.42.0
|
||||
spotipy==2.24.0
|
||||
python-dotenv==1.0.1
|
||||
47
docker-compose.yml
Normal file
47
docker-compose.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: vynl
|
||||
POSTGRES_PASSWORD: vynl
|
||||
POSTGRES_DB: vynl
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file: ./backend/.env
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
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
|
||||
"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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" />
|
||||
<title>Vynl - AI Music Discovery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3652
frontend/package-lock.json
generated
Normal file
3652
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"enhanced-resolve": "^5.20.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
98
frontend/src/App.tsx
Normal file
98
frontend/src/App.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth, ProtectedRoute } from './lib/auth'
|
||||
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'
|
||||
|
||||
function RootRedirect() {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-cream flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-4 border-purple border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return user ? <Navigate to="/dashboard" replace /> : <Landing />
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<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={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/playlists"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Playlists />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/playlists/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<PlaylistDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/discover"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Discover />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/saved"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Recommendations />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
158
frontend/src/components/Layout.tsx
Normal file
158
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
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 { useAuth } from '../lib/auth'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||
{ path: '/discover', label: 'Discover', icon: Compass },
|
||||
{ path: '/saved', label: 'Saved', icon: Heart },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const { user, logout } = useAuth()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cream">
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white/80 backdrop-blur-md border-b border-purple-100 sticky top-0 z-50">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link to="/dashboard" className="flex items-center gap-2 no-underline">
|
||||
<Disc3 className="w-8 h-8 text-purple" strokeWidth={2.5} />
|
||||
<span className="text-2xl font-bold text-charcoal tracking-tight">
|
||||
Vynl
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = location.pathname === item.path
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium no-underline transition-colors ${
|
||||
isActive
|
||||
? 'bg-purple text-white'
|
||||
: 'text-charcoal-muted hover:bg-purple-50 hover:text-purple'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-purple-50 transition-colors cursor-pointer bg-transparent border-none text-sm"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-purple-100 flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-purple" />
|
||||
</div>
|
||||
<span className="text-charcoal font-medium">{user?.name}</span>
|
||||
</button>
|
||||
{userMenuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-purple-100 py-2 z-50">
|
||||
<div className="px-4 py-2 border-b border-purple-50">
|
||||
<p className="text-sm font-medium text-charcoal">{user?.name}</p>
|
||||
<p className="text-xs text-charcoal-muted">{user?.email}</p>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden p-2 rounded-lg hover:bg-purple-50 transition-colors cursor-pointer bg-transparent border-none"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="w-6 h-6 text-charcoal" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6 text-charcoal" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Nav */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-purple-100 bg-white">
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = location.pathname === item.path
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium no-underline transition-colors ${
|
||||
isActive
|
||||
? 'bg-purple text-white'
|
||||
: 'text-charcoal-muted hover:bg-purple-50 hover:text-purple'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
<div className="border-t border-purple-50 pt-2 mt-2">
|
||||
<div className="px-4 py-2">
|
||||
<p className="text-sm font-medium text-charcoal">{user?.name}</p>
|
||||
<p className="text-xs text-charcoal-muted">{user?.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors cursor-pointer bg-transparent border-none text-left"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
frontend/src/components/RecommendationCard.tsx
Normal file
78
frontend/src/components/RecommendationCard.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Heart, ExternalLink, Music } from 'lucide-react'
|
||||
import type { RecommendationItem } from '../lib/api'
|
||||
|
||||
interface Props {
|
||||
recommendation: RecommendationItem
|
||||
onToggleSave: (id: string) => void
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
export default function RecommendationCard({ recommendation, onToggleSave, saving }: Props) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-purple-100 shadow-sm hover:shadow-md transition-shadow overflow-hidden">
|
||||
<div className="flex gap-4 p-5">
|
||||
{/* Album Art */}
|
||||
<div className="w-20 h-20 rounded-xl bg-gradient-to-br from-purple-200 to-purple-400 flex-shrink-0 flex items-center justify-center overflow-hidden">
|
||||
{recommendation.image_url ? (
|
||||
<img
|
||||
src={recommendation.image_url}
|
||||
alt={`${recommendation.title} cover`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-8 h-8 text-white/80" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-charcoal text-base truncate">
|
||||
{recommendation.title}
|
||||
</h3>
|
||||
<p className="text-charcoal-muted text-sm truncate">
|
||||
{recommendation.artist}
|
||||
{recommendation.album && (
|
||||
<span className="text-charcoal-muted/60"> · {recommendation.album}</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Reason */}
|
||||
<p className="text-sm text-charcoal-muted mt-2 line-clamp-2 leading-relaxed">
|
||||
{recommendation.reason}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => onToggleSave(recommendation.id)}
|
||||
disabled={saving}
|
||||
className={`p-2 rounded-full transition-colors cursor-pointer border-none ${
|
||||
recommendation.saved
|
||||
? 'bg-red-50 text-red-500 hover:bg-red-100'
|
||||
: 'bg-purple-50 text-purple-400 hover:bg-purple-100 hover:text-purple'
|
||||
} ${saving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={recommendation.saved ? 'Remove from saved' : 'Save recommendation'}
|
||||
>
|
||||
<Heart
|
||||
className="w-5 h-5"
|
||||
fill={recommendation.saved ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{recommendation.spotify_url && (
|
||||
<a
|
||||
href={recommendation.spotify_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-full bg-green-50 text-green-600 hover:bg-green-100 transition-colors"
|
||||
title="Open in Spotify"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
frontend/src/components/TasteProfile.tsx
Normal file
76
frontend/src/components/TasteProfile.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { TasteProfile as TasteProfileType } from '../lib/api'
|
||||
import { Zap, Smile, Music2, Waves, Piano } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
profile: TasteProfileType
|
||||
}
|
||||
|
||||
const meterItems = [
|
||||
{ key: 'energy' as const, label: 'Energy', icon: Zap, color: 'from-orange-400 to-red-500' },
|
||||
{ key: 'mood' as const, label: 'Mood', icon: Smile, color: 'from-yellow-400 to-amber-500' },
|
||||
{ key: 'danceability' as const, label: 'Danceability', icon: Music2, color: 'from-pink-400 to-rose-500' },
|
||||
{ key: 'acousticness' as const, label: 'Acousticness', icon: Waves, color: 'from-cyan-400 to-blue-500' },
|
||||
{ key: 'instrumentalness' as const, label: 'Instrumental', icon: Piano, color: 'from-green-400 to-emerald-500' },
|
||||
]
|
||||
|
||||
export default function TasteProfile({ profile }: Props) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Genre Bars */}
|
||||
{profile.top_genres.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-charcoal uppercase tracking-wider mb-3">
|
||||
Top Genres
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{profile.top_genres.slice(0, 8).map((genre) => (
|
||||
<div key={genre.name} className="flex items-center gap-3">
|
||||
<span className="text-sm text-charcoal-muted w-28 truncate text-right">
|
||||
{genre.name}
|
||||
</span>
|
||||
<div className="flex-1 h-6 bg-purple-50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple to-purple-light rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.round(genre.weight * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-charcoal-muted w-10">
|
||||
{Math.round(genre.weight * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio Feature Meters */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-charcoal uppercase tracking-wider mb-3">
|
||||
Audio Features
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{meterItems.map(({ key, label, icon: Icon, color }) => {
|
||||
const value = profile[key]
|
||||
return (
|
||||
<div key={key} className="bg-white rounded-xl p-4 border border-purple-50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="w-4 h-4 text-charcoal-muted" />
|
||||
<span className="text-sm font-medium text-charcoal">{label}</span>
|
||||
<span className="text-xs text-charcoal-muted ml-auto">
|
||||
{Math.round(value * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r ${color} rounded-full transition-all duration-700`}
|
||||
style={{ width: `${Math.round(value * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
frontend/src/index.css
Normal file
30
frontend/src/index.css
Normal file
@@ -0,0 +1,30 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-purple: #7C3AED;
|
||||
--color-purple-light: #8B5CF6;
|
||||
--color-purple-dark: #6D28D9;
|
||||
--color-purple-50: #F5F3FF;
|
||||
--color-purple-100: #EDE9FE;
|
||||
--color-purple-200: #DDD6FE;
|
||||
--color-purple-300: #C4B5FD;
|
||||
--color-purple-400: #A78BFA;
|
||||
--color-purple-500: #8B5CF6;
|
||||
--color-purple-600: #7C3AED;
|
||||
--color-purple-700: #6D28D9;
|
||||
--color-purple-800: #5B21B6;
|
||||
--color-purple-900: #4C1D95;
|
||||
--color-cream: #FFF7ED;
|
||||
--color-cream-dark: #FFF1E0;
|
||||
--color-charcoal: #1C1917;
|
||||
--color-charcoal-light: #292524;
|
||||
--color-charcoal-muted: #78716C;
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
159
frontend/src/lib/api.ts
Normal file
159
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('vynl_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('vynl_token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Types
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
is_pro: boolean
|
||||
daily_recommendations_remaining: number
|
||||
spotify_connected: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PlaylistResponse {
|
||||
id: string
|
||||
name: string
|
||||
source: string
|
||||
track_count: number
|
||||
image_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TrackItem {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
duration_ms: number
|
||||
image_url: string | null
|
||||
spotify_url: string | null
|
||||
}
|
||||
|
||||
export interface TasteProfile {
|
||||
top_genres: { name: string; weight: number }[]
|
||||
energy: number
|
||||
mood: number
|
||||
danceability: number
|
||||
acousticness: number
|
||||
instrumentalness: number
|
||||
}
|
||||
|
||||
export interface PlaylistDetailResponse {
|
||||
id: string
|
||||
name: string
|
||||
source: string
|
||||
track_count: number
|
||||
image_url: string | null
|
||||
tracks: TrackItem[]
|
||||
taste_profile: TasteProfile | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface SpotifyPlaylistItem {
|
||||
id: string
|
||||
name: string
|
||||
track_count: number
|
||||
image_url: string | null
|
||||
owner: string
|
||||
}
|
||||
|
||||
export interface RecommendationItem {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
image_url: string | null
|
||||
spotify_url: string | null
|
||||
reason: string
|
||||
saved: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface RecommendationResponse {
|
||||
recommendations: RecommendationItem[]
|
||||
remaining_today: number
|
||||
}
|
||||
|
||||
// Auth
|
||||
export const register = (email: string, name: string, password: string) =>
|
||||
api.post<TokenResponse>('/auth/register', { email, name, password }).then((r) => r.data)
|
||||
|
||||
export const login = (email: string, password: string) =>
|
||||
api.post<TokenResponse>('/auth/login', { email, password }).then((r) => r.data)
|
||||
|
||||
export const getMe = () =>
|
||||
api.get<UserResponse>('/auth/me').then((r) => r.data)
|
||||
|
||||
// Spotify OAuth
|
||||
export const getSpotifyAuthUrl = () =>
|
||||
api.get<{ url: string; state: string }>('/auth/spotify/authorize').then((r) => r.data)
|
||||
|
||||
export const spotifyCallback = (code: string) =>
|
||||
api.post<TokenResponse>('/auth/spotify/callback', { code }).then((r) => r.data)
|
||||
|
||||
// Playlists
|
||||
export const getPlaylists = () =>
|
||||
api.get<PlaylistResponse[]>('/playlists').then((r) => r.data)
|
||||
|
||||
export const getPlaylist = (id: string) =>
|
||||
api.get<PlaylistDetailResponse>(`/playlists/${id}`).then((r) => r.data)
|
||||
|
||||
export const deletePlaylist = (id: string) =>
|
||||
api.delete(`/playlists/${id}`).then((r) => r.data)
|
||||
|
||||
// Spotify Import
|
||||
export const getSpotifyPlaylists = () =>
|
||||
api.get<SpotifyPlaylistItem[]>('/spotify/playlists').then((r) => r.data)
|
||||
|
||||
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) =>
|
||||
api.post<RecommendationResponse>('/recommendations/generate', {
|
||||
playlist_id: playlistId,
|
||||
query,
|
||||
}).then((r) => r.data)
|
||||
|
||||
export const getRecommendationHistory = () =>
|
||||
api.get<RecommendationItem[]>('/recommendations/history').then((r) => r.data)
|
||||
|
||||
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)
|
||||
|
||||
export default api
|
||||
100
frontend/src/lib/auth.tsx
Normal file
100
frontend/src/lib/auth.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { getMe, type UserResponse } from './api'
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserResponse | null
|
||||
token: string | null
|
||||
loading: boolean
|
||||
login: (token: string) => Promise<void>
|
||||
logout: () => void
|
||||
setToken: (token: string) => void
|
||||
refreshUser: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<UserResponse | null>(null)
|
||||
const [token, setTokenState] = useState<string | null>(
|
||||
localStorage.getItem('vynl_token')
|
||||
)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const userData = await getMe()
|
||||
setUser(userData)
|
||||
} catch {
|
||||
setUser(null)
|
||||
setTokenState(null)
|
||||
localStorage.removeItem('vynl_token')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
loadUser().finally(() => setLoading(false))
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const loginFn = async (newToken: string) => {
|
||||
localStorage.setItem('vynl_token', newToken)
|
||||
setTokenState(newToken)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('vynl_token')
|
||||
setTokenState(null)
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
const setToken = (newToken: string) => {
|
||||
localStorage.setItem('vynl_token', newToken)
|
||||
setTokenState(newToken)
|
||||
}
|
||||
|
||||
const refreshUser = async () => {
|
||||
await loadUser()
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ user, token, loading, login: loginFn, logout, setToken, refreshUser }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-cream flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-purple border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-charcoal-muted">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
212
frontend/src/pages/Dashboard.tsx
Normal file
212
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
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 { useAuth } from '../lib/auth'
|
||||
import { getPlaylists, getRecommendationHistory, getSavedRecommendations, generateRecommendations, type RecommendationItem, type PlaylistResponse } from '../lib/api'
|
||||
import RecommendationCard from '../components/RecommendationCard'
|
||||
import { toggleSaveRecommendation } from '../lib/api'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [playlists, setPlaylists] = useState<PlaylistResponse[]>([])
|
||||
const [recentRecs, setRecentRecs] = useState<RecommendationItem[]>([])
|
||||
const [savedCount, setSavedCount] = useState(0)
|
||||
const [query, setQuery] = useState('')
|
||||
const [discovering, setDiscovering] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [playlistData, historyData, savedData] = await Promise.all([
|
||||
getPlaylists().catch(() => []),
|
||||
getRecommendationHistory().catch(() => []),
|
||||
getSavedRecommendations().catch(() => []),
|
||||
])
|
||||
setPlaylists(playlistData)
|
||||
setRecentRecs(historyData.slice(0, 3))
|
||||
setSavedCount(savedData.length)
|
||||
} catch {
|
||||
// silent fail
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleQuickDiscover = async () => {
|
||||
if (!query.trim()) return
|
||||
setDiscovering(true)
|
||||
try {
|
||||
await generateRecommendations(undefined, query)
|
||||
navigate('/discover')
|
||||
} catch {
|
||||
// handle error
|
||||
} finally {
|
||||
setDiscovering(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleSave = async (id: string) => {
|
||||
try {
|
||||
const { saved } = await toggleSaveRecommendation(id)
|
||||
setRecentRecs((prev) =>
|
||||
prev.map((r) => (r.id === id ? { ...r, saved } : r))
|
||||
)
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-purple animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Welcome */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-charcoal">
|
||||
Welcome back, {user?.name?.split(' ')[0]}
|
||||
</h1>
|
||||
<p className="text-charcoal-muted mt-1">
|
||||
Here's what's happening with your music discovery
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Link
|
||||
to="/playlists"
|
||||
className="bg-white rounded-2xl border border-purple-100 p-6 hover:shadow-md transition-shadow no-underline group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center group-hover:bg-purple-100 transition-colors">
|
||||
<ListMusic className="w-5 h-5 text-purple" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-charcoal">{playlists.length}</p>
|
||||
<p className="text-sm text-charcoal-muted">Playlists imported</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/saved"
|
||||
className="bg-white rounded-2xl border border-purple-100 p-6 hover:shadow-md transition-shadow no-underline group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-red-50 flex items-center justify-center group-hover:bg-red-100 transition-colors">
|
||||
<Heart className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-charcoal">{savedCount}</p>
|
||||
<p className="text-sm text-charcoal-muted">Saved recommendations</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-charcoal">
|
||||
{user?.daily_recommendations_remaining ?? 10}
|
||||
</p>
|
||||
<p className="text-sm text-charcoal-muted">
|
||||
Recommendations left today
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Discover */}
|
||||
<div className="bg-gradient-to-br from-purple to-purple-dark rounded-2xl p-8 text-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Compass className="w-5 h-5" />
|
||||
<h2 className="text-lg font-semibold">Quick Discover</h2>
|
||||
</div>
|
||||
<p className="text-purple-200 text-sm mb-5">
|
||||
Describe what you're in the mood for and let AI find the perfect tracks
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleQuickDiscover()}
|
||||
placeholder='e.g., "chill lo-fi beats for studying" or "upbeat indie rock"'
|
||||
className="flex-1 px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-white/30 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleQuickDiscover}
|
||||
disabled={discovering || !query.trim()}
|
||||
className="px-6 py-3 bg-white text-purple font-semibold rounded-xl hover:bg-cream transition-colors disabled:opacity-50 cursor-pointer border-none text-sm flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
{discovering ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4" />
|
||||
)}
|
||||
Discover
|
||||
</button>
|
||||
</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>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-charcoal">Recent Discoveries</h2>
|
||||
<Link
|
||||
to="/saved"
|
||||
className="text-sm text-purple font-medium hover:underline no-underline flex items-center gap-1"
|
||||
>
|
||||
View all
|
||||
<Music className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{recentRecs.map((rec) => (
|
||||
<RecommendationCard
|
||||
key={rec.id}
|
||||
recommendation={rec}
|
||||
onToggleSave={handleToggleSave}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
189
frontend/src/pages/Discover.tsx
Normal file
189
frontend/src/pages/Discover.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Compass, Sparkles, Loader2, ListMusic, Search } from 'lucide-react'
|
||||
import { useAuth } from '../lib/auth'
|
||||
import { getPlaylists, generateRecommendations, toggleSaveRecommendation, type PlaylistResponse, type RecommendationItem } from '../lib/api'
|
||||
import RecommendationCard from '../components/RecommendationCard'
|
||||
|
||||
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 [remaining, setRemaining] = useState<number | null>(null)
|
||||
const [discovering, setDiscovering] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [savingIds, setSavingIds] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await getPlaylists()
|
||||
setPlaylists(data)
|
||||
const preselected = searchParams.get('playlist')
|
||||
if (preselected && data.some((p) => p.id === preselected)) {
|
||||
setSelectedPlaylist(preselected)
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [searchParams])
|
||||
|
||||
const handleDiscover = async () => {
|
||||
if (!selectedPlaylist && !query.trim()) return
|
||||
setDiscovering(true)
|
||||
setError('')
|
||||
setResults([])
|
||||
|
||||
try {
|
||||
const response = await generateRecommendations(
|
||||
selectedPlaylist || undefined,
|
||||
query.trim() || undefined
|
||||
)
|
||||
setResults(response.recommendations)
|
||||
setRemaining(response.remaining_today)
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err.response?.data?.detail || 'Failed to generate recommendations. Please try again.'
|
||||
)
|
||||
} finally {
|
||||
setDiscovering(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleSave = async (id: string) => {
|
||||
setSavingIds((prev) => new Set(prev).add(id))
|
||||
try {
|
||||
const { saved } = await toggleSaveRecommendation(id)
|
||||
setResults((prev) =>
|
||||
prev.map((r) => (r.id === id ? { ...r, saved } : r))
|
||||
)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setSavingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-purple animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-charcoal flex items-center gap-3">
|
||||
<Compass className="w-8 h-8 text-purple" />
|
||||
Discover
|
||||
</h1>
|
||||
<p className="text-charcoal-muted mt-1">
|
||||
Find new music based on your taste or a specific vibe
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Discovery Form */}
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6 space-y-5">
|
||||
{/* Playlist Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-2">
|
||||
<ListMusic className="w-4 h-4 inline mr-1.5" />
|
||||
Based on a playlist
|
||||
</label>
|
||||
<select
|
||||
value={selectedPlaylist}
|
||||
onChange={(e) => setSelectedPlaylist(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 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">Select a playlist (optional)</option>
|
||||
{playlists.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.track_count} tracks)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Custom Query */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-2">
|
||||
<Search className="w-4 h-4 inline mr-1.5" />
|
||||
Or describe what you want
|
||||
</label>
|
||||
<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"'
|
||||
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>
|
||||
|
||||
{/* 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
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering || (!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 ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Discovering new music...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
Discover Music
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-charcoal mb-4">
|
||||
Your Recommendations
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{results.map((rec) => (
|
||||
<RecommendationCard
|
||||
key={rec.id}
|
||||
recommendation={rec}
|
||||
onToggleSave={handleToggleSave}
|
||||
saving={savingIds.has(rec.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
158
frontend/src/pages/Landing.tsx
Normal file
158
frontend/src/pages/Landing.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Disc3, Sparkles, ListMusic, Heart, ArrowRight } from 'lucide-react'
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: ListMusic,
|
||||
title: 'Import Your Music',
|
||||
description: 'Connect Spotify and import your playlists to build your taste profile.',
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: 'AI-Powered Discovery',
|
||||
description: 'Our AI analyzes your taste and finds hidden gems you\'ll actually love.',
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: 'Understand Why',
|
||||
description: 'Every recommendation comes with a personal explanation of why it fits your taste.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function Landing() {
|
||||
return (
|
||||
<div className="min-h-screen bg-cream">
|
||||
{/* Header */}
|
||||
<header className="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} />
|
||||
<span className="text-2xl font-bold text-charcoal tracking-tight">Vynl</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/login"
|
||||
className="px-4 py-2 text-sm font-medium text-charcoal hover:text-purple transition-colors no-underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="px-5 py-2.5 bg-purple text-white text-sm font-medium rounded-full hover:bg-purple-dark transition-colors no-underline"
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="px-6 pt-16 pb-24 md:pt-24 md:pb-32">
|
||||
<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">
|
||||
Dig deeper.
|
||||
<br />
|
||||
<span className="text-purple">Discover more.</span>
|
||||
</h1>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/register"
|
||||
className="flex items-center gap-2 px-8 py-4 bg-purple text-white text-base font-semibold rounded-full hover:bg-purple-dark transition-all hover:shadow-lg hover:shadow-purple/25 no-underline"
|
||||
>
|
||||
Start discovering
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center gap-2 px-8 py-4 bg-white text-charcoal text-base font-semibold rounded-full border border-purple-200 hover:border-purple hover:text-purple transition-all no-underline"
|
||||
>
|
||||
I have an account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-charcoal-muted">
|
||||
Free tier includes 10 recommendations per day
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="px-6 py-20 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>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{features.map((feature, i) => {
|
||||
const Icon = feature.icon
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white rounded-2xl p-8 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>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</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">
|
||||
<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.
|
||||
</p>
|
||||
<Link
|
||||
to="/register"
|
||||
className="inline-flex items-center gap-2 px-8 py-4 bg-white text-purple text-base font-semibold rounded-full hover:bg-cream transition-colors no-underline"
|
||||
>
|
||||
Get started free
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</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">
|
||||
<div className="flex items-center gap-2 text-charcoal-muted">
|
||||
<Disc3 className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Vynl</span>
|
||||
</div>
|
||||
<p className="text-sm text-charcoal-muted">
|
||||
© {new Date().getFullYear()} Vynl. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
143
frontend/src/pages/Login.tsx
Normal file
143
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
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'
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await apiLogin(email, password)
|
||||
await login(response.access_token)
|
||||
navigate('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Invalid email or password')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 */}
|
||||
<header className="px-6 py-5">
|
||||
<Link to="/" className="flex items-center gap-2 no-underline w-fit">
|
||||
<Disc3 className="w-7 h-7 text-purple" strokeWidth={2.5} />
|
||||
<span className="text-xl font-bold text-charcoal tracking-tight">Vynl</span>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{/* Form */}
|
||||
<div className="flex-1 flex items-center justify-center px-6 pb-16">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-lg shadow-purple/5 border border-purple-100 p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-charcoal">Welcome back</h1>
|
||||
<p className="text-charcoal-muted mt-1">Sign in to your Vynl account</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-charcoal-muted/50" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
className="w-full pl-11 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 box-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-charcoal-muted/50" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
className="w-full pl-11 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 box-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-purple text-white font-semibold rounded-xl hover:bg-purple-dark transition-colors disabled:opacity-50 cursor-pointer border-none text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</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">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
frontend/src/pages/PlaylistDetail.tsx
Normal file
168
frontend/src/pages/PlaylistDetail.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
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 TasteProfile from '../components/TasteProfile'
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const minutes = Math.floor(ms / 60000)
|
||||
const seconds = Math.floor((ms % 60000) / 1000)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function PlaylistDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [playlist, setPlaylist] = useState<PlaylistDetailResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [showProfile, setShowProfile] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await getPlaylist(id)
|
||||
setPlaylist(data)
|
||||
} catch {
|
||||
navigate('/playlists')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [id, navigate])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id || !confirm('Are you sure you want to delete this playlist?')) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deletePlaylist(id)
|
||||
navigate('/playlists')
|
||||
} catch {
|
||||
setDeleting(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 (!playlist) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="/playlists"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-charcoal-muted hover:text-purple transition-colors no-underline"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to playlists
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6">
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="w-24 h-24 rounded-xl bg-gradient-to-br from-purple-200 to-purple-400 flex-shrink-0 flex items-center justify-center overflow-hidden">
|
||||
{playlist.image_url ? (
|
||||
<img
|
||||
src={playlist.image_url}
|
||||
alt={playlist.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-10 h-10 text-white/80" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl font-bold text-charcoal">{playlist.name}</h1>
|
||||
<div className="flex items-center gap-3 mt-2 text-sm text-charcoal-muted">
|
||||
<span>{playlist.track_count} tracks</span>
|
||||
<span>·</span>
|
||||
<span className="capitalize">{playlist.source}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<Link
|
||||
to={`/discover?playlist=${playlist.id}`}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-purple text-white text-sm font-medium rounded-xl hover:bg-purple-dark transition-colors no-underline"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Get Recommendations
|
||||
</Link>
|
||||
{playlist.taste_profile && (
|
||||
<button
|
||||
onClick={() => setShowProfile(!showProfile)}
|
||||
className="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"
|
||||
>
|
||||
{showProfile ? 'Hide' : 'Show'} Taste Profile
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="ml-auto p-2.5 text-charcoal-muted hover:text-red-500 hover:bg-red-50 rounded-xl transition-colors cursor-pointer bg-transparent border-none"
|
||||
title="Delete playlist"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Taste Profile */}
|
||||
{showProfile && playlist.taste_profile && (
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6">
|
||||
<h2 className="text-lg font-semibold text-charcoal mb-5">Taste Profile</h2>
|
||||
<TasteProfile profile={playlist.taste_profile} />
|
||||
</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">
|
||||
<h2 className="text-lg font-semibold text-charcoal">Tracks</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-purple-50">
|
||||
{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"
|
||||
>
|
||||
<span className="w-8 text-sm text-charcoal-muted/50 text-right flex-shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-purple-100 to-purple-200 flex-shrink-0 flex items-center justify-center overflow-hidden">
|
||||
{track.image_url ? (
|
||||
<img
|
||||
src={track.image_url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-4 h-4 text-purple/40" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-charcoal truncate">{track.title}</p>
|
||||
<p className="text-xs text-charcoal-muted truncate">
|
||||
{track.artist}
|
||||
{track.album && <span> · {track.album}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-charcoal-muted/50 flex items-center gap-1 flex-shrink-0">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDuration(track.duration_ms)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
211
frontend/src/pages/Playlists.tsx
Normal file
211
frontend/src/pages/Playlists.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
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'
|
||||
|
||||
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 [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadPlaylists()
|
||||
}, [])
|
||||
|
||||
const loadPlaylists = async () => {
|
||||
try {
|
||||
const data = await getPlaylists()
|
||||
setPlaylists(data)
|
||||
} catch {
|
||||
setError('Failed to load playlists')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openImportModal = async () => {
|
||||
setShowImport(true)
|
||||
setLoadingSpotify(true)
|
||||
try {
|
||||
const data = await getSpotifyPlaylists()
|
||||
setSpotifyPlaylists(data)
|
||||
} catch {
|
||||
setError('Failed to load Spotify playlists. Make sure your Spotify account is connected.')
|
||||
} finally {
|
||||
setLoadingSpotify(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async (playlistId: string) => {
|
||||
setImporting(playlistId)
|
||||
try {
|
||||
const imported = await importSpotifyPlaylist(playlistId)
|
||||
setPlaylists((prev) => [...prev, imported])
|
||||
setSpotifyPlaylists((prev) => prev.filter((p) => p.id !== playlistId))
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to import playlist')
|
||||
} finally {
|
||||
setImporting(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-purple animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playlist Grid */}
|
||||
{playlists.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-12 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-purple-50 flex items-center justify-center mx-auto mb-4">
|
||||
<ListMusic className="w-8 h-8 text-purple" />
|
||||
</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
|
||||
</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>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{playlists.map((playlist) => (
|
||||
<Link
|
||||
key={playlist.id}
|
||||
to={`/playlists/${playlist.id}`}
|
||||
className="bg-white rounded-2xl border border-purple-100 p-5 hover:shadow-md transition-shadow no-underline group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-purple-200 to-purple-400 flex-shrink-0 flex items-center justify-center overflow-hidden">
|
||||
{playlist.image_url ? (
|
||||
<img
|
||||
src={playlist.image_url}
|
||||
alt={playlist.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-7 h-7 text-white/80" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-charcoal truncate group-hover:text-purple transition-colors">
|
||||
{playlist.name}
|
||||
</h3>
|
||||
<p className="text-sm text-charcoal-muted mt-0.5">
|
||||
{playlist.track_count} tracks
|
||||
</p>
|
||||
<span className="inline-block mt-2 px-2 py-0.5 bg-purple-50 text-purple text-xs font-medium rounded-full">
|
||||
{playlist.source}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-charcoal-muted/30 group-hover:text-purple transition-colors flex-shrink-0 mt-1" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Modal */}
|
||||
{showImport && (
|
||||
<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="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>
|
||||
<button
|
||||
onClick={() => setShowImport(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>
|
||||
) : 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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
154
frontend/src/pages/Recommendations.tsx
Normal file
154
frontend/src/pages/Recommendations.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Loader2, Clock, Heart, Sparkles } from 'lucide-react'
|
||||
import { useAuth } from '../lib/auth'
|
||||
import { getRecommendationHistory, getSavedRecommendations, toggleSaveRecommendation, type RecommendationItem } from '../lib/api'
|
||||
import RecommendationCard from '../components/RecommendationCard'
|
||||
|
||||
type Tab = 'saved' | 'history'
|
||||
|
||||
export default function Recommendations() {
|
||||
const { user } = useAuth()
|
||||
const [tab, setTab] = useState<Tab>('saved')
|
||||
const [saved, setSaved] = useState<RecommendationItem[]>([])
|
||||
const [history, setHistory] = useState<RecommendationItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savingIds, setSavingIds] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [savedData, historyData] = await Promise.all([
|
||||
getSavedRecommendations(),
|
||||
getRecommendationHistory(),
|
||||
])
|
||||
setSaved(savedData)
|
||||
setHistory(historyData)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleToggleSave = async (id: string) => {
|
||||
setSavingIds((prev) => new Set(prev).add(id))
|
||||
try {
|
||||
const { saved: isSaved } = await toggleSaveRecommendation(id)
|
||||
const updater = (items: RecommendationItem[]) =>
|
||||
items.map((r) => (r.id === id ? { ...r, saved: isSaved } : r))
|
||||
setSaved(updater)
|
||||
setHistory(updater)
|
||||
|
||||
// If unsaved, remove from saved tab
|
||||
if (!isSaved) {
|
||||
setSaved((prev) => prev.filter((r) => r.id !== id))
|
||||
}
|
||||
// If saved, add to saved tab if not already there
|
||||
if (isSaved) {
|
||||
const item = history.find((r) => r.id === id)
|
||||
if (item) {
|
||||
setSaved((prev) =>
|
||||
prev.some((r) => r.id === id) ? prev : [...prev, { ...item, saved: true }]
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setSavingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const items = tab === 'saved' ? saved : history
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-purple animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-charcoal">Recommendations</h1>
|
||||
<p className="text-charcoal-muted mt-1">Your discovered music</p>
|
||||
</div>
|
||||
|
||||
{/* Daily remaining */}
|
||||
{!user?.is_pro && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-amber-50 border border-amber-200 rounded-xl text-sm">
|
||||
<Sparkles className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
||||
<span className="text-amber-700">
|
||||
<strong>{user?.daily_recommendations_remaining ?? 0}</strong> recommendations remaining today (free tier)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-purple-50 p-1 rounded-xl w-fit">
|
||||
<button
|
||||
onClick={() => setTab('saved')}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors cursor-pointer border-none ${
|
||||
tab === 'saved'
|
||||
? 'bg-white text-purple shadow-sm'
|
||||
: 'bg-transparent text-charcoal-muted hover:text-charcoal'
|
||||
}`}
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
Saved ({saved.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('history')}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors cursor-pointer border-none ${
|
||||
tab === 'history'
|
||||
? 'bg-white text-purple shadow-sm'
|
||||
: 'bg-transparent text-charcoal-muted hover:text-charcoal'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
History ({history.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{items.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-12 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-purple-50 flex items-center justify-center mx-auto mb-4">
|
||||
{tab === 'saved' ? (
|
||||
<Heart className="w-8 h-8 text-purple" />
|
||||
) : (
|
||||
<Clock className="w-8 h-8 text-purple" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-charcoal mb-2">
|
||||
{tab === 'saved' ? 'No saved recommendations' : 'No recommendations yet'}
|
||||
</h2>
|
||||
<p className="text-charcoal-muted max-w-md mx-auto">
|
||||
{tab === 'saved'
|
||||
? 'Tap the heart icon on recommendations to save them here'
|
||||
: 'Head to the Discover page to get your first recommendations'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{items.map((rec) => (
|
||||
<RecommendationCard
|
||||
key={rec.id}
|
||||
recommendation={rec}
|
||||
onToggleSave={handleToggleSave}
|
||||
saving={savingIds.has(rec.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
163
frontend/src/pages/Register.tsx
Normal file
163
frontend/src/pages/Register.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
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'
|
||||
|
||||
export default function Register() {
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await apiRegister(email, name, password)
|
||||
await login(response.access_token)
|
||||
navigate('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Registration failed. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 */}
|
||||
<header className="px-6 py-5">
|
||||
<Link to="/" className="flex items-center gap-2 no-underline w-fit">
|
||||
<Disc3 className="w-7 h-7 text-purple" strokeWidth={2.5} />
|
||||
<span className="text-xl font-bold text-charcoal tracking-tight">Vynl</span>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{/* Form */}
|
||||
<div className="flex-1 flex items-center justify-center px-6 pb-16">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-lg shadow-purple/5 border border-purple-100 p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-charcoal">Create your account</h1>
|
||||
<p className="text-charcoal-muted mt-1">Start discovering music you'll love</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-1.5">
|
||||
Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-charcoal-muted/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
required
|
||||
className="w-full pl-11 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 box-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-charcoal-muted/50" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
className="w-full pl-11 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 box-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-charcoal-muted/50" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create a password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full pl-11 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 box-border"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-charcoal-muted mt-1.5">Must be at least 8 characters</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-purple text-white font-semibold rounded-xl hover:bg-purple-dark transition-colors disabled:opacity-50 cursor-pointer border-none text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
'Create account'
|
||||
)}
|
||||
</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">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
frontend/src/pages/SpotifyCallback.tsx
Normal file
60
frontend/src/pages/SpotifyCallback.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { Disc3, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { useAuth } from '../lib/auth'
|
||||
import { spotifyCallback } from '../lib/api'
|
||||
|
||||
export default function SpotifyCallback() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [error, setError] = useState('')
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get('code')
|
||||
if (!code) {
|
||||
setError('No authorization code received from Spotify')
|
||||
return
|
||||
}
|
||||
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
const response = await spotifyCallback(code)
|
||||
await login(response.access_token)
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to connect Spotify account')
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback()
|
||||
}, [searchParams, login, navigate])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cream flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
{error ? (
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-red-200 p-8 max-w-md">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-charcoal mb-2">Connection failed</h2>
|
||||
<p className="text-charcoal-muted text-sm mb-6">{error}</p>
|
||||
<a
|
||||
href="/login"
|
||||
className="inline-block px-6 py-2.5 bg-purple text-white font-medium rounded-xl hover:bg-purple-dark transition-colors no-underline text-sm"
|
||||
>
|
||||
Back to login
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Disc3 className="w-16 h-16 text-purple mx-auto mb-4 animate-[spin_2s_linear_infinite]" />
|
||||
<div className="flex items-center gap-2 text-charcoal-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Connecting your Spotify account...</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user