Refactor API structure and integrate new modular API client

- Replaced legacy apiService with a new modular api client structure.
- Updated AuthContext, OAuthButtons, and AuthCallbackPage to use the new api client.
- Created separate services for auth, sounds, playlists, and users.
- Implemented centralized API configuration and error handling.
- Added support for OAuth providers and token exchange.
- Introduced a Toaster component for notifications in App.
- Updated API endpoints and request handling for better maintainability.
This commit is contained in:
JSC
2025-07-26 19:21:36 +02:00
parent 57429f9414
commit 6ce83c8317
15 changed files with 1055 additions and 236 deletions

View File

@@ -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<void> | null = null
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
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<void> {
if (this.refreshPromise) {
await this.refreshPromise
return
}
this.refreshPromise = this.performTokenRefresh()
try {
await this.refreshPromise
} finally {
this.refreshPromise = null
}
}
private async performTokenRefresh(): Promise<void> {
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<User> {
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 }
// Export the main API object as default
export { default as api } from './api/index'

214
src/lib/api/README.md Normal file
View File

@@ -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<MyType>('/api/v1/custom-endpoint')
const result = await apiClient.post<ResponseType>('/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<MyType>('/api/v1/custom-endpoint')
```

194
src/lib/api/client.ts Normal file
View File

@@ -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<void> | null = null
private baseURL: string
constructor(baseURL: string = API_CONFIG.BASE_URL) {
this.baseURL = baseURL
}
private buildURL(endpoint: string, params?: Record<string, string | number | boolean | undefined>): 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<T>(
method: HttpMethod,
endpoint: string,
data?: unknown,
config: ApiRequestConfig = {}
): Promise<T> {
const {
params,
skipAuth = false,
timeout = API_CONFIG.TIMEOUT,
headers: customHeaders,
...fetchConfig
} = config
const url = this.buildURL(endpoint, params)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(customHeaders as Record<string, string>),
}
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<unknown> {
try {
const text = await response.text()
return text ? JSON.parse(text) : {}
} catch {
return {}
}
}
private async handleTokenRefresh(): Promise<void> {
if (this.refreshPromise) {
await this.refreshPromise
return
}
this.refreshPromise = this.performTokenRefresh()
try {
await this.refreshPromise
} finally {
this.refreshPromise = null
}
}
private async performTokenRefresh(): Promise<void> {
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<T>(endpoint: string, config?: ApiRequestConfig): Promise<T> {
return this.request<T>('GET', endpoint, undefined, config)
}
async post<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
return this.request<T>('POST', endpoint, data, config)
}
async put<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
return this.request<T>('PUT', endpoint, data, config)
}
async patch<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
return this.request<T>('PATCH', endpoint, data, config)
}
async delete<T>(endpoint: string, config?: ApiRequestConfig): Promise<T> {
return this.request<T>('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()

44
src/lib/api/config.ts Normal file
View File

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

98
src/lib/api/errors.ts Normal file
View File

@@ -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<string, unknown>
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<string, string[]>
constructor(message: string, fields?: Record<string, string[]>) {
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<string, unknown>
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<string, string[]>)
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)
}
}

29
src/lib/api/index.ts Normal file
View File

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

View File

@@ -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<User> {
// 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<User> {
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<User> {
return apiClient.get<User>(API_CONFIG.ENDPOINTS.AUTH.ME)
}
/**
* Logout the current user
*/
async logout(): Promise<void> {
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<OAuthAuthorizationResponse> {
return apiClient.get<OAuthAuthorizationResponse>(
API_CONFIG.ENDPOINTS.AUTH.OAUTH_AUTHORIZE(provider),
{ skipAuth: true }
)
}
/**
* Get list of available OAuth providers
*/
async getOAuthProviders(): Promise<OAuthProvidersResponse> {
return apiClient.get<OAuthProvidersResponse>(
API_CONFIG.ENDPOINTS.AUTH.PROVIDERS,
{ skipAuth: true }
)
}
/**
* Exchange OAuth temporary code for auth cookies
*/
async exchangeOAuthToken(request: ExchangeOAuthTokenRequest): Promise<ExchangeOAuthTokenResponse> {
// 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<void> {
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()

View File

@@ -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<PaginatedResponse<Playlist>> {
return apiClient.get<PaginatedResponse<Playlist>>(API_CONFIG.ENDPOINTS.PLAYLISTS.LIST, {
params: params as Record<string, string | number | boolean | undefined>
})
}
/**
* Get a specific playlist by ID
*/
async get(id: string | number): Promise<Playlist> {
return apiClient.get<Playlist>(API_CONFIG.ENDPOINTS.PLAYLISTS.GET(id))
}
/**
* Create a new playlist
*/
async create(data: CreatePlaylistRequest): Promise<Playlist> {
return apiClient.post<Playlist>(API_CONFIG.ENDPOINTS.PLAYLISTS.CREATE, data)
}
/**
* Update an existing playlist
*/
async update(id: string | number, data: UpdatePlaylistRequest): Promise<Playlist> {
return apiClient.patch<Playlist>(API_CONFIG.ENDPOINTS.PLAYLISTS.UPDATE(id), data)
}
/**
* Delete a playlist
*/
async delete(id: string | number): Promise<void> {
return apiClient.delete<void>(API_CONFIG.ENDPOINTS.PLAYLISTS.DELETE(id))
}
/**
* Add a sound to a playlist
*/
async addSound(playlistId: string | number, data: AddSoundToPlaylistRequest): Promise<Playlist> {
return apiClient.post<Playlist>(`${API_CONFIG.ENDPOINTS.PLAYLISTS.GET(playlistId)}/sounds`, data)
}
/**
* Remove a sound from a playlist
*/
async removeSound(playlistId: string | number, soundId: string | number): Promise<Playlist> {
return apiClient.delete<Playlist>(`${API_CONFIG.ENDPOINTS.PLAYLISTS.GET(playlistId)}/sounds/${soundId}`)
}
}
export const playlistsService = new PlaylistsService()

View File

@@ -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<PaginatedResponse<Sound>> {
return apiClient.get<PaginatedResponse<Sound>>(API_CONFIG.ENDPOINTS.SOUNDS.LIST, {
params: params as Record<string, string | number | boolean | undefined>
})
}
/**
* Get a specific sound by ID
*/
async get(id: string | number): Promise<Sound> {
return apiClient.get<Sound>(API_CONFIG.ENDPOINTS.SOUNDS.GET(id))
}
/**
* Create a new sound
*/
async create(data: CreateSoundRequest): Promise<Sound> {
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<Sound>(API_CONFIG.ENDPOINTS.SOUNDS.CREATE, formData)
}
/**
* Update an existing sound
*/
async update(id: string | number, data: UpdateSoundRequest): Promise<Sound> {
return apiClient.patch<Sound>(API_CONFIG.ENDPOINTS.SOUNDS.UPDATE(id), data)
}
/**
* Delete a sound
*/
async delete(id: string | number): Promise<void> {
return apiClient.delete<void>(API_CONFIG.ENDPOINTS.SOUNDS.DELETE(id))
}
/**
* Upload a sound file
*/
async upload(file: File, metadata?: { title?: string; description?: string }): Promise<Sound> {
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<Sound>(API_CONFIG.ENDPOINTS.SOUNDS.UPLOAD, formData)
}
}
export const soundsService = new SoundsService()

View File

@@ -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<PaginatedResponse<User>> {
return apiClient.get<PaginatedResponse<User>>(API_CONFIG.ENDPOINTS.USERS.LIST, {
params: params as Record<string, string | number | boolean | undefined>
})
}
/**
* Get a specific user by ID
*/
async get(id: string | number): Promise<User> {
return apiClient.get<User>(API_CONFIG.ENDPOINTS.USERS.GET(id))
}
/**
* Update user profile
*/
async update(id: string | number, data: UpdateUserRequest): Promise<User> {
return apiClient.patch<User>(API_CONFIG.ENDPOINTS.USERS.UPDATE(id), data)
}
/**
* Delete a user (admin only)
*/
async delete(id: string | number): Promise<void> {
return apiClient.delete<void>(API_CONFIG.ENDPOINTS.USERS.DELETE(id))
}
/**
* Change user password
*/
async changePassword(userId: string | number, data: ChangePasswordRequest): Promise<void> {
return apiClient.post<void>(`${API_CONFIG.ENDPOINTS.USERS.GET(userId)}/change-password`, data)
}
/**
* Upload user avatar
*/
async uploadAvatar(userId: string | number, file: File): Promise<User> {
const formData = new FormData()
formData.append('avatar', file)
return apiClient.post<User>(`${API_CONFIG.ENDPOINTS.USERS.GET(userId)}/avatar`, formData)
}
}
export const usersService = new UsersService()

34
src/lib/api/types.ts Normal file
View File

@@ -0,0 +1,34 @@
// Generic API types
export interface ApiResponse<T = any> {
data?: T
message?: string
error?: string
detail?: string
}
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
size: number
pages: number
}
export interface ApiRequestConfig extends RequestInit {
params?: Record<string, string | number | boolean | undefined>
skipAuth?: boolean
timeout?: number
}
// HTTP Methods
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
// Generic API client interface
export interface ApiClient {
get<T>(endpoint: string, config?: ApiRequestConfig): Promise<T>
post<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T>
put<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T>
patch<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T>
delete<T>(endpoint: string, config?: ApiRequestConfig): Promise<T>
}