Initial MVP: full-stack music discovery app

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

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

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

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
dist/
__pycache__/
*.pyc
.env
*.egg-info/
.venv/
venv/
.mypy_cache/
.pytest_cache/

38
CLAUDE.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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()

View 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
View File

View File

View File

View 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))

View 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

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

View File

View 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()

View 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

View 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
View 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"}

View File

View 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

View 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

View 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

View 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

View File

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

View 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

View 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

View File

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View 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"
}
}

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
View 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>
)
}

View 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>
)
}

View File

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

View 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
View 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
View 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
View 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
View 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>,
)

View 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>
)
}

View 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>
)
}

View 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">
&copy; {new Date().getFullYear()} Vynl. All rights reserved.
</p>
</div>
</footer>
</div>
)
}

View 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>
)
}

View 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>&middot;</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> &middot; {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>
)
}

View 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 &middot; {sp.owner}
</p>
</div>
<button
onClick={() => handleImport(sp.id)}
disabled={importing === sp.id}
className="px-4 py-2 bg-purple text-white text-xs font-medium rounded-lg hover:bg-purple-dark transition-colors cursor-pointer border-none disabled:opacity-50 flex items-center gap-1.5"
>
{importing === sp.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Import
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

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