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

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