feat: update API client and remove unused services; enhance error handling and configuration

This commit is contained in:
JSC
2025-07-26 19:49:00 +02:00
parent 6ce83c8317
commit 6018a5c8c5
14 changed files with 14 additions and 606 deletions

5
.gitignore vendored
View File

@@ -12,6 +12,11 @@ dist
dist-ssr dist-ssr
*.local *.local
# Environment variables
.env.local
.env.development.local
.env.production.local
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json

View File

@@ -1,69 +0,0 @@
# 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 [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## 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 tseslint.config([
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 tseslint.config([
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...
},
},
])
```

View File

@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react" import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile"

View File

@@ -1,5 +1,5 @@
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner" import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme()

View File

@@ -36,23 +36,13 @@ export function AuthProvider({ children }: AuthProviderProps) {
}, []) }, [])
const login = async (credentials: LoginRequest) => { const login = async (credentials: LoginRequest) => {
try {
const user = await api.auth.login(credentials) const user = await api.auth.login(credentials)
setUser(user) setUser(user)
} catch (error) {
console.error('Login failed:', error)
throw error
}
} }
const register = async (data: RegisterRequest) => { const register = async (data: RegisterRequest) => {
try {
const user = await api.auth.register(data) const user = await api.auth.register(data)
setUser(user) setUser(user)
} catch (error) {
console.error('Registration failed:', error)
throw error
}
} }
const logout = async () => { const logout = async () => {
@@ -62,7 +52,6 @@ export function AuthProvider({ children }: AuthProviderProps) {
const value: AuthContextType = { const value: AuthContextType = {
user, user,
token: user ? 'cookie-based' : null,
login, login,
register, register,
logout, logout,

View File

@@ -1,214 +0,0 @@
# 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')
```

View File

@@ -81,7 +81,7 @@ export class BaseApiClient implements ApiClient {
throw createApiError(retryResponse, errorData) throw createApiError(retryResponse, errorData)
} }
return await this.safeParseJSON(retryResponse) return await this.safeParseJSON(retryResponse) as T
} catch (refreshError) { } catch (refreshError) {
this.handleAuthenticationFailure() this.handleAuthenticationFailure()
throw refreshError throw refreshError
@@ -97,7 +97,7 @@ export class BaseApiClient implements ApiClient {
return {} as T return {} as T
} }
return await this.safeParseJSON(response) return await this.safeParseJSON(response) as T
} catch (error) { } catch (error) {
clearTimeout(timeoutId) clearTimeout(timeoutId)

View File

@@ -1,6 +1,6 @@
// API Configuration // API Configuration
export const API_CONFIG = { export const API_CONFIG = {
BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000', BASE_URL: 'http://localhost:8000',
TIMEOUT: 30000, // 30 seconds TIMEOUT: 30000, // 30 seconds
RETRY_ATTEMPTS: 1, RETRY_ATTEMPTS: 1,
@@ -17,27 +17,6 @@ export const API_CONFIG = {
OAUTH_CALLBACK: (provider: string) => `/api/v1/auth/${provider}/callback`, OAUTH_CALLBACK: (provider: string) => `/api/v1/auth/${provider}/callback`,
EXCHANGE_OAUTH_TOKEN: '/api/v1/auth/exchange-oauth-token', 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 } as const

View File

@@ -6,22 +6,13 @@ export * from './errors'
// Services // Services
export * from './services/auth' export * from './services/auth'
export * from './services/sounds'
export * from './services/playlists'
export * from './services/users'
// Main API object for convenient access // Main API object for convenient access
import { authService } from './services/auth' import { authService } from './services/auth'
import { soundsService } from './services/sounds'
import { playlistsService } from './services/playlists'
import { usersService } from './services/users'
import { apiClient } from './client' import { apiClient } from './client'
export const api = { export const api = {
auth: authService, auth: authService,
sounds: soundsService,
playlists: playlistsService,
users: usersService,
client: apiClient, client: apiClient,
} as const } as const

View File

@@ -1,94 +0,0 @@
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

@@ -1,100 +0,0 @@
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

@@ -1,74 +0,0 @@
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()

View File

@@ -20,15 +20,11 @@ export function AuthCallbackPage() {
throw new Error('No authorization code received') throw new Error('No authorization code received')
} }
console.log('Exchanging OAuth code for tokens...')
// Exchange the temporary code for proper auth cookies // Exchange the temporary code for proper auth cookies
const result = await api.auth.exchangeOAuthToken({ code }) const result = await api.auth.exchangeOAuthToken({ code })
console.log('Token exchange successful:', result)
// Now get the user info // Now get the user info
const user = await api.auth.getMe() const user = await api.auth.getMe()
console.log('User info retrieved:', user)
// Update auth context // Update auth context
if (setUser) setUser(user) if (setUser) setUser(user)

View File

@@ -40,7 +40,6 @@ export interface RegisterRequest {
export interface AuthContextType { export interface AuthContextType {
user: User | null user: User | null
token: string | null
login: (credentials: LoginRequest) => Promise<void> login: (credentials: LoginRequest) => Promise<void>
register: (data: RegisterRequest) => Promise<void> register: (data: RegisterRequest) => Promise<void>
logout: () => Promise<void> logout: () => Promise<void>