diff --git a/src/App.tsx b/src/App.tsx
index 2932da7..d40b79f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,6 +5,7 @@ import { LoginPage } from './pages/LoginPage'
import { RegisterPage } from './pages/RegisterPage'
import { AuthCallbackPage } from './pages/AuthCallbackPage'
import { DashboardPage } from './pages/DashboardPage'
+import { Toaster } from './components/ui/sonner'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
@@ -20,7 +21,6 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <>{children}>
}
-
function AppRoutes() {
const { user } = useAuth()
@@ -43,6 +43,7 @@ function App() {
+
)
diff --git a/src/components/auth/OAuthButtons.tsx b/src/components/auth/OAuthButtons.tsx
index cadab5e..ae33ec3 100644
--- a/src/components/auth/OAuthButtons.tsx
+++ b/src/components/auth/OAuthButtons.tsx
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
-import { apiService } from '@/lib/api'
+import { api } from '@/lib/api'
export function OAuthButtons() {
const [providers, setProviders] = useState([])
@@ -10,7 +10,7 @@ export function OAuthButtons() {
useEffect(() => {
const fetchProviders = async () => {
try {
- const response = await apiService.getOAuthProviders()
+ const response = await api.auth.getOAuthProviders()
setProviders(response.providers)
} catch (error) {
console.error('Failed to fetch OAuth providers:', error)
@@ -23,7 +23,7 @@ export function OAuthButtons() {
const handleOAuthLogin = async (provider: string) => {
setLoading(provider)
try {
- const response = await apiService.getOAuthUrl(provider)
+ const response = await api.auth.getOAuthUrl(provider)
// Store state in sessionStorage for verification
sessionStorage.setItem('oauth_state', response.state)
diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx
index a3e425e..9fad5d6 100644
--- a/src/contexts/AuthContext.tsx
+++ b/src/contexts/AuthContext.tsx
@@ -1,5 +1,5 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
-import { apiService } from '@/lib/api'
+import { api } from '@/lib/api'
import type { AuthContextType, User, LoginRequest, RegisterRequest } from '@/types/auth'
const AuthContext = createContext(null)
@@ -24,7 +24,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
const initAuth = async () => {
try {
// Try to get user info using cookies
- const user = await apiService.getMe()
+ const user = await api.auth.getMe()
setUser(user)
} catch {
// User is not authenticated - this is normal for logged out users
@@ -37,8 +37,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
const login = async (credentials: LoginRequest) => {
try {
- const response = await apiService.login(credentials)
- setUser(response.user)
+ const user = await api.auth.login(credentials)
+ setUser(user)
} catch (error) {
console.error('Login failed:', error)
throw error
@@ -47,8 +47,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
const register = async (data: RegisterRequest) => {
try {
- const response = await apiService.register(data)
- setUser(response.user)
+ const user = await api.auth.register(data)
+ setUser(user)
} catch (error) {
console.error('Registration failed:', error)
throw error
@@ -56,7 +56,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
}
const logout = async () => {
- await apiService.logout()
+ await api.auth.logout()
setUser(null)
}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 0520991..d177983 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -1,209 +1,5 @@
-import type { LoginRequest, RegisterRequest } from '@/types/auth'
+// Main API exports - using the new modular API structure
+export * from './api/index'
-const API_BASE_URL = 'http://localhost:8000'
-
-class ApiError extends Error {
- public status: number
- public response?: unknown
-
- constructor(message: string, status: number, response?: unknown) {
- super(message)
- this.name = 'ApiError'
- this.status = status
- this.response = response
- }
-}
-
-class ApiService {
- private refreshPromise: Promise | null = null
-
- private async request(
- endpoint: string,
- options: RequestInit = {}
- ): Promise {
- const url = `${API_BASE_URL}${endpoint}`
- const headers: Record = {
- 'Content-Type': 'application/json',
- ...(options.headers as Record),
- }
-
- const config: RequestInit = {
- ...options,
- headers,
- credentials: 'include', // Always include cookies
- }
-
- try {
- const response = await fetch(url, config)
-
- if (!response.ok) {
- if (response.status === 401) {
- try {
- await this.refreshToken()
- const retryResponse = await fetch(url, config)
-
- if (!retryResponse.ok) {
- throw new ApiError(
- 'Request failed after token refresh',
- retryResponse.status,
- await retryResponse.json().catch(() => null)
- )
- }
-
- return await retryResponse.json()
- } catch (refreshError) {
- // Only redirect if we're not already on the login page to prevent infinite loops
- const currentPath = window.location.pathname
- if (currentPath !== '/login' && currentPath !== '/register') {
- window.location.href = '/login'
- }
- throw refreshError
- }
- }
-
- const errorData = await response.json().catch(() => null)
- throw new ApiError(
- errorData?.detail || 'Request failed',
- response.status,
- errorData
- )
- }
-
- return await response.json()
- } catch (error) {
- if (error instanceof ApiError) {
- throw error
- }
- throw new ApiError('Network error', 0)
- }
- }
-
- private async refreshToken(): Promise {
- if (this.refreshPromise) {
- await this.refreshPromise
- return
- }
-
- this.refreshPromise = this.performTokenRefresh()
-
- try {
- await this.refreshPromise
- } finally {
- this.refreshPromise = null
- }
- }
-
- private async performTokenRefresh(): Promise {
- const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- credentials: 'include', // Send cookies with refresh token
- })
-
- if (!response.ok) {
- throw new ApiError('Token refresh failed', response.status)
- }
-
- // Token is automatically set in cookies by the backend
- // No need to handle it manually
- }
-
- async login(credentials: LoginRequest): Promise<{ user: User }> {
- const response = await fetch(`${API_BASE_URL}/api/v1/auth/login`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(credentials),
- credentials: 'include', // Important for cookies
- })
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => null)
- throw new ApiError(
- errorData?.detail || 'Login failed',
- response.status,
- errorData
- )
- }
-
- const user = await response.json()
-
- return { user }
- }
-
- async register(userData: RegisterRequest): Promise<{ user: User }> {
- const response = await fetch(`${API_BASE_URL}/api/v1/auth/register`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(userData),
- credentials: 'include', // Important for cookies
- })
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => null)
- throw new ApiError(
- errorData?.detail || 'Registration failed',
- response.status,
- errorData
- )
- }
-
- const user = await response.json()
-
- return { user }
- }
-
- async getMe(): Promise {
- return this.request('/api/v1/auth/me')
- }
-
- async logout() {
- try {
- await fetch(`${API_BASE_URL}/api/v1/auth/logout`, {
- method: 'POST',
- credentials: 'include',
- })
- } catch (error) {
- console.error('Logout request failed:', error)
- }
- }
-
- async getOAuthUrl(provider: string): Promise<{ authorization_url: string; state: string }> {
- const response = await fetch(`${API_BASE_URL}/api/v1/auth/${provider}/authorize`, {
- method: 'GET',
- credentials: 'include',
- })
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => null)
- throw new ApiError(
- errorData?.detail || 'Failed to get OAuth URL',
- response.status,
- errorData
- )
- }
-
- return await response.json()
- }
-
- async getOAuthProviders(): Promise<{ providers: string[] }> {
- const response = await fetch(`${API_BASE_URL}/api/v1/auth/providers`, {
- method: 'GET',
- })
-
- if (!response.ok) {
- throw new ApiError('Failed to get OAuth providers', response.status)
- }
-
- return await response.json()
- }
-
-}
-
-export const apiService = new ApiService()
-export { ApiError }
\ No newline at end of file
+// Export the main API object as default
+export { default as api } from './api/index'
\ No newline at end of file
diff --git a/src/lib/api/README.md b/src/lib/api/README.md
new file mode 100644
index 0000000..e6af9c7
--- /dev/null
+++ b/src/lib/api/README.md
@@ -0,0 +1,214 @@
+# API Library
+
+A generic, maintainable API client library for the Soundboard application.
+
+## Features
+
+- **Generic HTTP Client**: Supports all REST methods with proper TypeScript typing
+- **Automatic Token Refresh**: Handles JWT token refresh automatically
+- **Error Handling**: Comprehensive error classes for different scenarios
+- **Modular Services**: Separate services for different API domains
+- **Configuration Management**: Centralized API configuration
+- **Backward Compatibility**: Legacy API service for existing code
+
+## Usage
+
+### New API (Recommended)
+
+```typescript
+import { api } from '@/lib/api'
+
+// Authentication
+const user = await api.auth.login({ email: 'user@example.com', password: 'password' })
+const currentUser = await api.auth.getMe()
+await api.auth.logout()
+
+// Sounds
+const sounds = await api.sounds.list({ page: 1, size: 20 })
+const sound = await api.sounds.get(1)
+const newSound = await api.sounds.create({ title: 'My Sound', file: audioFile })
+
+// Playlists
+const playlists = await api.playlists.list()
+const playlist = await api.playlists.create({ name: 'My Playlist' })
+
+// Users (admin only)
+const users = await api.users.list()
+```
+
+### Alternative Import Style
+
+```typescript
+import { authService, soundsService } from '@/lib/api'
+
+// Direct service imports
+const user = await authService.login(credentials)
+const sounds = await soundsService.list()
+```
+
+### Direct Client Usage
+
+```typescript
+import { apiClient } from '@/lib/api'
+
+// Generic HTTP requests
+const data = await apiClient.get('/api/v1/custom-endpoint')
+const result = await apiClient.post('/api/v1/data', requestData)
+```
+
+## Services
+
+### AuthService (`api.auth`)
+
+- `login(credentials)` - Authenticate user
+- `register(userData)` - Register new user
+- `getMe()` - Get current user
+- `logout()` - Sign out user
+- `getOAuthUrl(provider)` - Get OAuth authorization URL
+- `getOAuthProviders()` - Get available OAuth providers
+- `exchangeOAuthToken(request)` - Exchange OAuth code for auth cookies
+
+### SoundsService (`api.sounds`)
+
+- `list(params?)` - Get paginated sounds
+- `get(id)` - Get specific sound
+- `create(data)` - Create new sound
+- `update(id, data)` - Update sound
+- `delete(id)` - Delete sound
+- `upload(file, metadata?)` - Upload sound file
+
+### PlaylistsService (`api.playlists`)
+
+- `list(params?)` - Get paginated playlists
+- `get(id)` - Get specific playlist
+- `create(data)` - Create new playlist
+- `update(id, data)` - Update playlist
+- `delete(id)` - Delete playlist
+- `addSound(playlistId, soundData)` - Add sound to playlist
+- `removeSound(playlistId, soundId)` - Remove sound from playlist
+
+### UsersService (`api.users`)
+
+- `list(params?)` - Get paginated users (admin only)
+- `get(id)` - Get specific user
+- `update(id, data)` - Update user
+- `delete(id)` - Delete user (admin only)
+- `changePassword(userId, data)` - Change user password
+- `uploadAvatar(userId, file)` - Upload user avatar
+
+## Error Handling
+
+The library provides specific error classes for different scenarios:
+
+```typescript
+import {
+ ApiError,
+ NetworkError,
+ TimeoutError,
+ ValidationError,
+ AuthenticationError,
+ AuthorizationError,
+ NotFoundError,
+ ServerError
+} from '@/lib/api'
+
+try {
+ await api.auth.login(credentials)
+} catch (error) {
+ if (error instanceof AuthenticationError) {
+ // Handle login failure
+ } else if (error instanceof ValidationError) {
+ // Handle validation errors
+ console.log(error.fields) // Field-specific errors
+ } else if (error instanceof NetworkError) {
+ // Handle network issues
+ }
+}
+```
+
+## Configuration
+
+API configuration is centralized in `config.ts`:
+
+```typescript
+export const API_CONFIG = {
+ BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
+ TIMEOUT: 30000,
+ RETRY_ATTEMPTS: 1,
+ ENDPOINTS: {
+ AUTH: { /* ... */ },
+ SOUNDS: { /* ... */ },
+ // ...
+ }
+}
+```
+
+## Request Configuration
+
+All API methods accept optional configuration:
+
+```typescript
+// Custom timeout
+await api.sounds.list({}, { timeout: 60000 })
+
+// Skip authentication
+await api.auth.getOAuthProviders({}, { skipAuth: true })
+
+// Custom headers
+await api.sounds.create(data, {
+ headers: { 'X-Custom-Header': 'value' }
+})
+
+// Query parameters
+await api.sounds.list({}, {
+ params: { search: 'query', page: 1 }
+})
+```
+
+## File Structure
+
+```
+src/lib/api/
+├── index.ts # Main exports
+├── client.ts # Core HTTP client
+├── config.ts # API configuration
+├── types.ts # TypeScript types
+├── errors.ts # Error classes
+├── services/
+│ ├── auth.ts # Authentication service
+│ ├── sounds.ts # Sounds service
+│ ├── playlists.ts # Playlists service
+│ └── users.ts # Users service
+└── README.md # This file
+```
+
+## Migration Guide
+
+If migrating from an older API structure:
+
+1. **Use the main API object:**
+ ```typescript
+ import { api } from '@/lib/api'
+
+ // Authentication
+ const user = await api.auth.login(credentials)
+
+ // Resources
+ const sounds = await api.sounds.list()
+ const playlists = await api.playlists.list()
+ ```
+
+2. **Or import services directly:**
+ ```typescript
+ import { authService, soundsService } from '@/lib/api'
+
+ const user = await authService.login(credentials)
+ const sounds = await soundsService.list()
+ ```
+
+3. **For custom requests, use the client:**
+ ```typescript
+ import { apiClient } from '@/lib/api'
+
+ const data = await apiClient.get('/api/v1/custom-endpoint')
+ ```
\ No newline at end of file
diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts
new file mode 100644
index 0000000..5910a76
--- /dev/null
+++ b/src/lib/api/client.ts
@@ -0,0 +1,194 @@
+import { API_CONFIG } from './config'
+import { createApiError, NetworkError, TimeoutError } from './errors'
+import type { ApiClient, ApiRequestConfig, HttpMethod } from './types'
+
+export class BaseApiClient implements ApiClient {
+ private refreshPromise: Promise | null = null
+ private baseURL: string
+
+ constructor(baseURL: string = API_CONFIG.BASE_URL) {
+ this.baseURL = baseURL
+ }
+
+ private buildURL(endpoint: string, params?: Record): string {
+ const url = new URL(endpoint, this.baseURL)
+
+ if (params) {
+ Object.entries(params).forEach(([key, value]) => {
+ if (value !== undefined) {
+ url.searchParams.append(key, String(value))
+ }
+ })
+ }
+
+ return url.toString()
+ }
+
+ private async request(
+ method: HttpMethod,
+ endpoint: string,
+ data?: unknown,
+ config: ApiRequestConfig = {}
+ ): Promise {
+ const {
+ params,
+ skipAuth = false,
+ timeout = API_CONFIG.TIMEOUT,
+ headers: customHeaders,
+ ...fetchConfig
+ } = config
+
+ const url = this.buildURL(endpoint, params)
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ ...(customHeaders as Record),
+ }
+
+ const requestConfig: RequestInit = {
+ method,
+ headers,
+ credentials: skipAuth ? 'omit' : 'include',
+ ...fetchConfig,
+ }
+
+ if (data && ['POST', 'PUT', 'PATCH'].includes(method)) {
+ if (data instanceof FormData) {
+ // Remove Content-Type header for FormData to let browser set it with boundary
+ delete headers['Content-Type']
+ requestConfig.body = data
+ } else {
+ requestConfig.body = JSON.stringify(data)
+ }
+ }
+
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), timeout)
+ requestConfig.signal = controller.signal
+
+ try {
+ const response = await fetch(url, requestConfig)
+ clearTimeout(timeoutId)
+
+ if (!response.ok) {
+ if (response.status === 401 && !skipAuth) {
+ try {
+ await this.handleTokenRefresh()
+ // Retry the original request
+ const retryResponse = await fetch(url, requestConfig)
+
+ if (!retryResponse.ok) {
+ const errorData = await this.safeParseJSON(retryResponse)
+ throw createApiError(retryResponse, errorData)
+ }
+
+ return await this.safeParseJSON(retryResponse)
+ } catch (refreshError) {
+ this.handleAuthenticationFailure()
+ throw refreshError
+ }
+ }
+
+ const errorData = await this.safeParseJSON(response)
+ throw createApiError(response, errorData)
+ }
+
+ // Handle empty responses (204 No Content, etc.)
+ if (response.status === 204 || response.headers.get('content-length') === '0') {
+ return {} as T
+ }
+
+ return await this.safeParseJSON(response)
+ } catch (error) {
+ clearTimeout(timeoutId)
+
+ if ((error as Error).name === 'AbortError') {
+ throw new TimeoutError()
+ }
+
+ if (error instanceof TypeError && error.message.includes('fetch')) {
+ throw new NetworkError()
+ }
+
+ throw error
+ }
+ }
+
+ private async safeParseJSON(response: Response): Promise {
+ try {
+ const text = await response.text()
+ return text ? JSON.parse(text) : {}
+ } catch {
+ return {}
+ }
+ }
+
+ private async handleTokenRefresh(): Promise {
+ if (this.refreshPromise) {
+ await this.refreshPromise
+ return
+ }
+
+ this.refreshPromise = this.performTokenRefresh()
+
+ try {
+ await this.refreshPromise
+ } finally {
+ this.refreshPromise = null
+ }
+ }
+
+ private async performTokenRefresh(): Promise {
+ const response = await fetch(`${this.baseURL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ throw createApiError(response, await this.safeParseJSON(response))
+ }
+ }
+
+ private handleAuthenticationFailure(): void {
+ // Only redirect if we're not already on auth pages to prevent infinite loops
+ const currentPath = window.location.pathname
+ const authPaths = ['/login', '/register', '/auth/callback']
+
+ if (!authPaths.includes(currentPath)) {
+ window.location.href = '/login'
+ }
+ }
+
+ // Public API methods
+ async get(endpoint: string, config?: ApiRequestConfig): Promise {
+ return this.request('GET', endpoint, undefined, config)
+ }
+
+ async post(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise {
+ return this.request('POST', endpoint, data, config)
+ }
+
+ async put(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise {
+ return this.request('PUT', endpoint, data, config)
+ }
+
+ async patch(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise {
+ return this.request('PATCH', endpoint, data, config)
+ }
+
+ async delete(endpoint: string, config?: ApiRequestConfig): Promise {
+ return this.request('DELETE', endpoint, undefined, config)
+ }
+
+ // Utility methods
+ setBaseURL(baseURL: string): void {
+ this.baseURL = baseURL
+ }
+
+ getBaseURL(): string {
+ return this.baseURL
+ }
+}
+
+// Default API client instance
+export const apiClient = new BaseApiClient()
\ No newline at end of file
diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts
new file mode 100644
index 0000000..1a45836
--- /dev/null
+++ b/src/lib/api/config.ts
@@ -0,0 +1,44 @@
+// API Configuration
+export const API_CONFIG = {
+ BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
+ TIMEOUT: 30000, // 30 seconds
+ RETRY_ATTEMPTS: 1,
+
+ // API Endpoints
+ ENDPOINTS: {
+ AUTH: {
+ LOGIN: '/api/v1/auth/login',
+ REGISTER: '/api/v1/auth/register',
+ LOGOUT: '/api/v1/auth/logout',
+ REFRESH: '/api/v1/auth/refresh',
+ ME: '/api/v1/auth/me',
+ PROVIDERS: '/api/v1/auth/providers',
+ OAUTH_AUTHORIZE: (provider: string) => `/api/v1/auth/${provider}/authorize`,
+ OAUTH_CALLBACK: (provider: string) => `/api/v1/auth/${provider}/callback`,
+ EXCHANGE_OAUTH_TOKEN: '/api/v1/auth/exchange-oauth-token',
+ },
+ SOUNDS: {
+ LIST: '/api/v1/sounds',
+ CREATE: '/api/v1/sounds',
+ GET: (id: string | number) => `/api/v1/sounds/${id}`,
+ UPDATE: (id: string | number) => `/api/v1/sounds/${id}`,
+ DELETE: (id: string | number) => `/api/v1/sounds/${id}`,
+ UPLOAD: '/api/v1/sounds/upload',
+ },
+ PLAYLISTS: {
+ LIST: '/api/v1/playlists',
+ CREATE: '/api/v1/playlists',
+ GET: (id: string | number) => `/api/v1/playlists/${id}`,
+ UPDATE: (id: string | number) => `/api/v1/playlists/${id}`,
+ DELETE: (id: string | number) => `/api/v1/playlists/${id}`,
+ },
+ USERS: {
+ LIST: '/api/v1/users',
+ GET: (id: string | number) => `/api/v1/users/${id}`,
+ UPDATE: (id: string | number) => `/api/v1/users/${id}`,
+ DELETE: (id: string | number) => `/api/v1/users/${id}`,
+ },
+ },
+} as const
+
+export type ApiEndpoints = typeof API_CONFIG.ENDPOINTS
\ No newline at end of file
diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts
new file mode 100644
index 0000000..edd0efa
--- /dev/null
+++ b/src/lib/api/errors.ts
@@ -0,0 +1,98 @@
+// API Error Classes
+export class ApiError extends Error {
+ public status: number
+ public response?: unknown
+ public detail?: string
+
+ constructor(message: string, status: number, response?: unknown, detail?: string) {
+ super(message)
+ this.name = 'ApiError'
+ this.status = status
+ this.response = response
+ this.detail = detail
+ }
+
+ static fromResponse(response: Response, data?: unknown): ApiError {
+ const errorData = data as Record
+ const message = errorData?.detail as string || errorData?.message as string || `HTTP ${response.status}: ${response.statusText}`
+ return new ApiError(message, response.status, data, errorData?.detail as string)
+ }
+}
+
+export class NetworkError extends ApiError {
+ constructor(message: string = 'Network request failed') {
+ super(message, 0)
+ this.name = 'NetworkError'
+ }
+}
+
+export class TimeoutError extends ApiError {
+ constructor(message: string = 'Request timeout') {
+ super(message, 408)
+ this.name = 'TimeoutError'
+ }
+}
+
+export class ValidationError extends ApiError {
+ public fields?: Record
+
+ constructor(message: string, fields?: Record) {
+ super(message, 422)
+ this.name = 'ValidationError'
+ this.fields = fields
+ }
+}
+
+export class AuthenticationError extends ApiError {
+ constructor(message: string = 'Authentication required') {
+ super(message, 401)
+ this.name = 'AuthenticationError'
+ }
+}
+
+export class AuthorizationError extends ApiError {
+ constructor(message: string = 'Access denied') {
+ super(message, 403)
+ this.name = 'AuthorizationError'
+ }
+}
+
+export class NotFoundError extends ApiError {
+ constructor(message: string = 'Resource not found') {
+ super(message, 404)
+ this.name = 'NotFoundError'
+ }
+}
+
+export class ServerError extends ApiError {
+ constructor(message: string = 'Internal server error') {
+ super(message, 500)
+ this.name = 'ServerError'
+ }
+}
+
+// Error factory function
+export function createApiError(response: Response, data?: unknown): ApiError {
+ const status = response.status
+ const errorData = data as Record
+ const message = errorData?.detail as string || errorData?.message as string || `HTTP ${status}: ${response.statusText}`
+
+ switch (status) {
+ case 401:
+ return new AuthenticationError(message)
+ case 403:
+ return new AuthorizationError(message)
+ case 404:
+ return new NotFoundError(message)
+ case 422:
+ return new ValidationError(message, errorData?.fields as Record)
+ case 500:
+ case 501:
+ case 502:
+ case 503:
+ case 504:
+ return new ServerError(message)
+ default:
+ return new ApiError(message, status, data, errorData?.detail as string)
+ }
+}
\ No newline at end of file
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
new file mode 100644
index 0000000..e83efcd
--- /dev/null
+++ b/src/lib/api/index.ts
@@ -0,0 +1,29 @@
+// Re-export all API services and utilities
+export * from './client'
+export * from './config'
+export * from './types'
+export * from './errors'
+
+// Services
+export * from './services/auth'
+export * from './services/sounds'
+export * from './services/playlists'
+export * from './services/users'
+
+// Main API object for convenient access
+import { authService } from './services/auth'
+import { soundsService } from './services/sounds'
+import { playlistsService } from './services/playlists'
+import { usersService } from './services/users'
+import { apiClient } from './client'
+
+export const api = {
+ auth: authService,
+ sounds: soundsService,
+ playlists: playlistsService,
+ users: usersService,
+ client: apiClient,
+} as const
+
+// Default export for convenience
+export default api
\ No newline at end of file
diff --git a/src/lib/api/services/auth.ts b/src/lib/api/services/auth.ts
new file mode 100644
index 0000000..1596da5
--- /dev/null
+++ b/src/lib/api/services/auth.ts
@@ -0,0 +1,155 @@
+import type { User } from '@/types/auth'
+import { apiClient } from '../client'
+import { API_CONFIG } from '../config'
+
+export interface LoginRequest {
+ email: string
+ password: string
+}
+
+export interface RegisterRequest {
+ email: string
+ password: string
+ name: string
+}
+
+export interface OAuthProvider {
+ name: string
+ display_name?: string
+}
+
+export interface OAuthAuthorizationResponse {
+ authorization_url: string
+ state: string
+}
+
+export interface OAuthProvidersResponse {
+ providers: string[]
+}
+
+export interface ExchangeOAuthTokenRequest {
+ code: string
+}
+
+export interface ExchangeOAuthTokenResponse {
+ message: string
+ user_id: string
+}
+
+export class AuthService {
+ /**
+ * Authenticate user with email and password
+ */
+ async login(credentials: LoginRequest): Promise {
+ // Using direct fetch for auth endpoints to avoid circular dependency with token refresh
+ const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.LOGIN}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(credentials),
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.detail || 'Login failed')
+ }
+
+ return await response.json()
+ }
+
+ /**
+ * Register a new user account
+ */
+ async register(userData: RegisterRequest): Promise {
+ const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REGISTER}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(userData),
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.detail || 'Registration failed')
+ }
+
+ return await response.json()
+ }
+
+ /**
+ * Get current authenticated user
+ */
+ async getMe(): Promise {
+ return apiClient.get(API_CONFIG.ENDPOINTS.AUTH.ME)
+ }
+
+ /**
+ * Logout the current user
+ */
+ async logout(): Promise {
+ try {
+ await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.LOGOUT}`, {
+ method: 'POST',
+ credentials: 'include',
+ })
+ } catch (error) {
+ console.error('Logout request failed:', error)
+ }
+ }
+
+ /**
+ * Get OAuth authorization URL for a provider
+ */
+ async getOAuthUrl(provider: string): Promise {
+ return apiClient.get(
+ API_CONFIG.ENDPOINTS.AUTH.OAUTH_AUTHORIZE(provider),
+ { skipAuth: true }
+ )
+ }
+
+ /**
+ * Get list of available OAuth providers
+ */
+ async getOAuthProviders(): Promise {
+ return apiClient.get(
+ API_CONFIG.ENDPOINTS.AUTH.PROVIDERS,
+ { skipAuth: true }
+ )
+ }
+
+ /**
+ * Exchange OAuth temporary code for auth cookies
+ */
+ async exchangeOAuthToken(request: ExchangeOAuthTokenRequest): Promise {
+ // Use direct fetch for OAuth token exchange to avoid auth loops and ensure cookies are set
+ const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.EXCHANGE_OAUTH_TOKEN}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(request),
+ credentials: 'include', // Essential for receiving auth cookies
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.detail || 'OAuth token exchange failed')
+ }
+
+ return await response.json()
+ }
+
+ /**
+ * Refresh authentication token
+ */
+ async refreshToken(): Promise {
+ const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, {
+ method: 'POST',
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ throw new Error('Token refresh failed')
+ }
+ }
+}
+
+export const authService = new AuthService()
\ No newline at end of file
diff --git a/src/lib/api/services/playlists.ts b/src/lib/api/services/playlists.ts
new file mode 100644
index 0000000..0df8383
--- /dev/null
+++ b/src/lib/api/services/playlists.ts
@@ -0,0 +1,94 @@
+import { apiClient } from '../client'
+import { API_CONFIG } from '../config'
+import type { PaginatedResponse } from '../types'
+import type { Sound } from './sounds'
+
+export interface Playlist {
+ id: number
+ name: string
+ description?: string
+ user_id: number
+ is_public: boolean
+ sounds: Sound[]
+ created_at: string
+ updated_at: string
+}
+
+export interface CreatePlaylistRequest {
+ name: string
+ description?: string
+ is_public?: boolean
+}
+
+export interface UpdatePlaylistRequest {
+ name?: string
+ description?: string
+ is_public?: boolean
+}
+
+export interface PlaylistsListParams {
+ page?: number
+ size?: number
+ search?: string
+ user_id?: number
+ is_public?: boolean
+}
+
+export interface AddSoundToPlaylistRequest {
+ sound_id: number
+}
+
+export class PlaylistsService {
+ /**
+ * Get list of playlists with pagination
+ */
+ async list(params?: PlaylistsListParams): Promise> {
+ return apiClient.get>(API_CONFIG.ENDPOINTS.PLAYLISTS.LIST, {
+ params: params as Record
+ })
+ }
+
+ /**
+ * Get a specific playlist by ID
+ */
+ async get(id: string | number): Promise {
+ return apiClient.get(API_CONFIG.ENDPOINTS.PLAYLISTS.GET(id))
+ }
+
+ /**
+ * Create a new playlist
+ */
+ async create(data: CreatePlaylistRequest): Promise {
+ return apiClient.post(API_CONFIG.ENDPOINTS.PLAYLISTS.CREATE, data)
+ }
+
+ /**
+ * Update an existing playlist
+ */
+ async update(id: string | number, data: UpdatePlaylistRequest): Promise {
+ return apiClient.patch(API_CONFIG.ENDPOINTS.PLAYLISTS.UPDATE(id), data)
+ }
+
+ /**
+ * Delete a playlist
+ */
+ async delete(id: string | number): Promise {
+ return apiClient.delete(API_CONFIG.ENDPOINTS.PLAYLISTS.DELETE(id))
+ }
+
+ /**
+ * Add a sound to a playlist
+ */
+ async addSound(playlistId: string | number, data: AddSoundToPlaylistRequest): Promise {
+ return apiClient.post(`${API_CONFIG.ENDPOINTS.PLAYLISTS.GET(playlistId)}/sounds`, data)
+ }
+
+ /**
+ * Remove a sound from a playlist
+ */
+ async removeSound(playlistId: string | number, soundId: string | number): Promise {
+ return apiClient.delete(`${API_CONFIG.ENDPOINTS.PLAYLISTS.GET(playlistId)}/sounds/${soundId}`)
+ }
+}
+
+export const playlistsService = new PlaylistsService()
\ No newline at end of file
diff --git a/src/lib/api/services/sounds.ts b/src/lib/api/services/sounds.ts
new file mode 100644
index 0000000..604bec6
--- /dev/null
+++ b/src/lib/api/services/sounds.ts
@@ -0,0 +1,100 @@
+import { apiClient } from '../client'
+import { API_CONFIG } from '../config'
+import type { PaginatedResponse } from '../types'
+
+export interface Sound {
+ id: number
+ title: string
+ description?: string
+ file_url: string
+ duration: number
+ file_size: number
+ mime_type: string
+ play_count: number
+ user_id: number
+ created_at: string
+ updated_at: string
+}
+
+export interface CreateSoundRequest {
+ title: string
+ description?: string
+ file: File
+}
+
+export interface UpdateSoundRequest {
+ title?: string
+ description?: string
+}
+
+export interface SoundsListParams {
+ page?: number
+ size?: number
+ search?: string
+ user_id?: number
+}
+
+export class SoundsService {
+ /**
+ * Get list of sounds with pagination
+ */
+ async list(params?: SoundsListParams): Promise> {
+ return apiClient.get>(API_CONFIG.ENDPOINTS.SOUNDS.LIST, {
+ params: params as Record
+ })
+ }
+
+ /**
+ * Get a specific sound by ID
+ */
+ async get(id: string | number): Promise {
+ return apiClient.get(API_CONFIG.ENDPOINTS.SOUNDS.GET(id))
+ }
+
+ /**
+ * Create a new sound
+ */
+ async create(data: CreateSoundRequest): Promise {
+ const formData = new FormData()
+ formData.append('title', data.title)
+ if (data.description) {
+ formData.append('description', data.description)
+ }
+ formData.append('file', data.file)
+
+ return apiClient.post(API_CONFIG.ENDPOINTS.SOUNDS.CREATE, formData)
+ }
+
+ /**
+ * Update an existing sound
+ */
+ async update(id: string | number, data: UpdateSoundRequest): Promise {
+ return apiClient.patch(API_CONFIG.ENDPOINTS.SOUNDS.UPDATE(id), data)
+ }
+
+ /**
+ * Delete a sound
+ */
+ async delete(id: string | number): Promise {
+ return apiClient.delete(API_CONFIG.ENDPOINTS.SOUNDS.DELETE(id))
+ }
+
+ /**
+ * Upload a sound file
+ */
+ async upload(file: File, metadata?: { title?: string; description?: string }): Promise {
+ const formData = new FormData()
+ formData.append('file', file)
+
+ if (metadata?.title) {
+ formData.append('title', metadata.title)
+ }
+ if (metadata?.description) {
+ formData.append('description', metadata.description)
+ }
+
+ return apiClient.post(API_CONFIG.ENDPOINTS.SOUNDS.UPLOAD, formData)
+ }
+}
+
+export const soundsService = new SoundsService()
\ No newline at end of file
diff --git a/src/lib/api/services/users.ts b/src/lib/api/services/users.ts
new file mode 100644
index 0000000..9339115
--- /dev/null
+++ b/src/lib/api/services/users.ts
@@ -0,0 +1,74 @@
+import type { User } from '@/types/auth'
+import { apiClient } from '../client'
+import { API_CONFIG } from '../config'
+import type { PaginatedResponse } from '../types'
+
+export interface UpdateUserRequest {
+ name?: string
+ email?: string
+ picture?: string
+}
+
+export interface UsersListParams {
+ page?: number
+ size?: number
+ search?: string
+ role?: string
+ is_active?: boolean
+}
+
+export interface ChangePasswordRequest {
+ current_password: string
+ new_password: string
+}
+
+export class UsersService {
+ /**
+ * Get list of users with pagination (admin only)
+ */
+ async list(params?: UsersListParams): Promise> {
+ return apiClient.get>(API_CONFIG.ENDPOINTS.USERS.LIST, {
+ params: params as Record
+ })
+ }
+
+ /**
+ * Get a specific user by ID
+ */
+ async get(id: string | number): Promise {
+ return apiClient.get(API_CONFIG.ENDPOINTS.USERS.GET(id))
+ }
+
+ /**
+ * Update user profile
+ */
+ async update(id: string | number, data: UpdateUserRequest): Promise {
+ return apiClient.patch(API_CONFIG.ENDPOINTS.USERS.UPDATE(id), data)
+ }
+
+ /**
+ * Delete a user (admin only)
+ */
+ async delete(id: string | number): Promise {
+ return apiClient.delete(API_CONFIG.ENDPOINTS.USERS.DELETE(id))
+ }
+
+ /**
+ * Change user password
+ */
+ async changePassword(userId: string | number, data: ChangePasswordRequest): Promise {
+ return apiClient.post(`${API_CONFIG.ENDPOINTS.USERS.GET(userId)}/change-password`, data)
+ }
+
+ /**
+ * Upload user avatar
+ */
+ async uploadAvatar(userId: string | number, file: File): Promise {
+ const formData = new FormData()
+ formData.append('avatar', file)
+
+ return apiClient.post(`${API_CONFIG.ENDPOINTS.USERS.GET(userId)}/avatar`, formData)
+ }
+}
+
+export const usersService = new UsersService()
\ No newline at end of file
diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts
new file mode 100644
index 0000000..28b9adc
--- /dev/null
+++ b/src/lib/api/types.ts
@@ -0,0 +1,34 @@
+// Generic API types
+export interface ApiResponse {
+ data?: T
+ message?: string
+ error?: string
+ detail?: string
+}
+
+export interface PaginatedResponse {
+ items: T[]
+ total: number
+ page: number
+ size: number
+ pages: number
+}
+
+export interface ApiRequestConfig extends RequestInit {
+ params?: Record
+ skipAuth?: boolean
+ timeout?: number
+}
+
+
+// HTTP Methods
+export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
+
+// Generic API client interface
+export interface ApiClient {
+ get(endpoint: string, config?: ApiRequestConfig): Promise
+ post(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise
+ put(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise
+ patch(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise
+ delete(endpoint: string, config?: ApiRequestConfig): Promise
+}
\ No newline at end of file
diff --git a/src/pages/AuthCallbackPage.tsx b/src/pages/AuthCallbackPage.tsx
index 06bae9b..202e12d 100644
--- a/src/pages/AuthCallbackPage.tsx
+++ b/src/pages/AuthCallbackPage.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { useAuth } from '@/contexts/AuthContext'
-import { apiService } from '@/lib/api'
+import { api } from '@/lib/api'
export function AuthCallbackPage() {
const navigate = useNavigate()
@@ -23,25 +23,11 @@ export function AuthCallbackPage() {
console.log('Exchanging OAuth code for tokens...')
// Exchange the temporary code for proper auth cookies
- const response = await fetch('http://localhost:8000/api/v1/auth/exchange-oauth-token', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ code }),
- credentials: 'include', // Important for setting cookies
- })
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => null)
- throw new Error(errorData?.detail || 'Token exchange failed')
- }
-
- const result = await response.json()
+ const result = await api.auth.exchangeOAuthToken({ code })
console.log('Token exchange successful:', result)
// Now get the user info
- const user = await apiService.getMe()
+ const user = await api.auth.getMe()
console.log('User info retrieved:', user)
// Update auth context