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
|
||||
Reference in New Issue
Block a user