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:
159
frontend/src/lib/api.ts
Normal file
159
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('vynl_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('vynl_token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Types
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
is_pro: boolean
|
||||
daily_recommendations_remaining: number
|
||||
spotify_connected: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PlaylistResponse {
|
||||
id: string
|
||||
name: string
|
||||
source: string
|
||||
track_count: number
|
||||
image_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TrackItem {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
duration_ms: number
|
||||
image_url: string | null
|
||||
spotify_url: string | null
|
||||
}
|
||||
|
||||
export interface TasteProfile {
|
||||
top_genres: { name: string; weight: number }[]
|
||||
energy: number
|
||||
mood: number
|
||||
danceability: number
|
||||
acousticness: number
|
||||
instrumentalness: number
|
||||
}
|
||||
|
||||
export interface PlaylistDetailResponse {
|
||||
id: string
|
||||
name: string
|
||||
source: string
|
||||
track_count: number
|
||||
image_url: string | null
|
||||
tracks: TrackItem[]
|
||||
taste_profile: TasteProfile | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface SpotifyPlaylistItem {
|
||||
id: string
|
||||
name: string
|
||||
track_count: number
|
||||
image_url: string | null
|
||||
owner: string
|
||||
}
|
||||
|
||||
export interface RecommendationItem {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
image_url: string | null
|
||||
spotify_url: string | null
|
||||
reason: string
|
||||
saved: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface RecommendationResponse {
|
||||
recommendations: RecommendationItem[]
|
||||
remaining_today: number
|
||||
}
|
||||
|
||||
// Auth
|
||||
export const register = (email: string, name: string, password: string) =>
|
||||
api.post<TokenResponse>('/auth/register', { email, name, password }).then((r) => r.data)
|
||||
|
||||
export const login = (email: string, password: string) =>
|
||||
api.post<TokenResponse>('/auth/login', { email, password }).then((r) => r.data)
|
||||
|
||||
export const getMe = () =>
|
||||
api.get<UserResponse>('/auth/me').then((r) => r.data)
|
||||
|
||||
// Spotify OAuth
|
||||
export const getSpotifyAuthUrl = () =>
|
||||
api.get<{ url: string; state: string }>('/auth/spotify/authorize').then((r) => r.data)
|
||||
|
||||
export const spotifyCallback = (code: string) =>
|
||||
api.post<TokenResponse>('/auth/spotify/callback', { code }).then((r) => r.data)
|
||||
|
||||
// Playlists
|
||||
export const getPlaylists = () =>
|
||||
api.get<PlaylistResponse[]>('/playlists').then((r) => r.data)
|
||||
|
||||
export const getPlaylist = (id: string) =>
|
||||
api.get<PlaylistDetailResponse>(`/playlists/${id}`).then((r) => r.data)
|
||||
|
||||
export const deletePlaylist = (id: string) =>
|
||||
api.delete(`/playlists/${id}`).then((r) => r.data)
|
||||
|
||||
// Spotify Import
|
||||
export const getSpotifyPlaylists = () =>
|
||||
api.get<SpotifyPlaylistItem[]>('/spotify/playlists').then((r) => r.data)
|
||||
|
||||
export const importSpotifyPlaylist = (playlistId: string) =>
|
||||
api.post<PlaylistDetailResponse>('/spotify/import', { playlist_id: playlistId }).then((r) => r.data)
|
||||
|
||||
// Recommendations
|
||||
export const generateRecommendations = (playlistId?: string, query?: string) =>
|
||||
api.post<RecommendationResponse>('/recommendations/generate', {
|
||||
playlist_id: playlistId,
|
||||
query,
|
||||
}).then((r) => r.data)
|
||||
|
||||
export const getRecommendationHistory = () =>
|
||||
api.get<RecommendationItem[]>('/recommendations/history').then((r) => r.data)
|
||||
|
||||
export const getSavedRecommendations = () =>
|
||||
api.get<RecommendationItem[]>('/recommendations/saved').then((r) => r.data)
|
||||
|
||||
export const toggleSaveRecommendation = (id: string) =>
|
||||
api.post<{ saved: boolean }>(`/recommendations/${id}/toggle-save`).then((r) => r.data)
|
||||
|
||||
export default api
|
||||
100
frontend/src/lib/auth.tsx
Normal file
100
frontend/src/lib/auth.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { getMe, type UserResponse } from './api'
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserResponse | null
|
||||
token: string | null
|
||||
loading: boolean
|
||||
login: (token: string) => Promise<void>
|
||||
logout: () => void
|
||||
setToken: (token: string) => void
|
||||
refreshUser: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<UserResponse | null>(null)
|
||||
const [token, setTokenState] = useState<string | null>(
|
||||
localStorage.getItem('vynl_token')
|
||||
)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const userData = await getMe()
|
||||
setUser(userData)
|
||||
} catch {
|
||||
setUser(null)
|
||||
setTokenState(null)
|
||||
localStorage.removeItem('vynl_token')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
loadUser().finally(() => setLoading(false))
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const loginFn = async (newToken: string) => {
|
||||
localStorage.setItem('vynl_token', newToken)
|
||||
setTokenState(newToken)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('vynl_token')
|
||||
setTokenState(null)
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
const setToken = (newToken: string) => {
|
||||
localStorage.setItem('vynl_token', newToken)
|
||||
setTokenState(newToken)
|
||||
}
|
||||
|
||||
const refreshUser = async () => {
|
||||
await loadUser()
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ user, token, loading, login: loginFn, logout, setToken, refreshUser }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-cream flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-purple border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-charcoal-muted">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
Reference in New Issue
Block a user