feat(frontend): kontrakt danych i typowany klient API
- types/status.ts spisuje kształt odpowiedzi /api/status oraz pakietów rozgłaszanych po WebSockecie. Dotąd struktura tych danych istniała wyłącznie w głowie autora i w rozsianych po kodzie odwołaniach. - api/client.ts zbiera wszystkie adresy endpointów w jednym miejscu i ujednolica obsługę błędów: pole "detail" z odpowiedzi FastAPI trafia do wyjątku ApiError, więc warstwa widoku dostaje gotowy komunikat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab3c980597
commit
6bfa9b2a77
2 files changed
+137
No files matched your search
@@ -0,0 +1,59 @@
|
||||
/** Cienka warstwa nad REST API bota - jedno miejsce, w którym znane są adresy. */
|
||||
|
||||
import type { BotStatus, UploadedFile } from '@/types/status'
|
||||
|
||||
export class ApiError extends Error {}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, init)
|
||||
const payload = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload.detail === 'string' ? payload.detail : response.statusText
|
||||
throw new ApiError(detail || 'Żądanie nie powiodło się')
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
|
||||
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'POST',
|
||||
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export const api = {
|
||||
status: () => request<BotStatus>('/api/status'),
|
||||
|
||||
connect: (channelId: string) => post('/api/connect', { channel_id: channelId }),
|
||||
disconnect: () => post('/api/disconnect'),
|
||||
|
||||
play: (query: string) => post('/api/play', { query }),
|
||||
playFile: (filename: string) => post('/api/play_file', { filename }),
|
||||
pause: () => post('/api/pause'),
|
||||
resume: () => post('/api/resume'),
|
||||
skip: () => post('/api/skip'),
|
||||
stop: () => post('/api/stop'),
|
||||
setVolume: (volume: number) => post('/api/volume', { volume }),
|
||||
cycleLoop: () => post<{ mode: 'off' | 'playlist' | 'single' }>('/api/loop_cycle'),
|
||||
|
||||
jump: (index: number) => post('/api/jump', { index }),
|
||||
remove: (id: string) => post('/api/remove', { id }),
|
||||
reorder: (queueIds: string[]) => post('/api/reorder', { queue_ids: queueIds }),
|
||||
setTrackPlays: (id: string, plays: number) => post('/api/queue/plays', { id, plays }),
|
||||
|
||||
uploads: () => request<{ files: UploadedFile[] }>('/api/uploads'),
|
||||
deleteUpload: (filename: string) =>
|
||||
request(`/api/uploads/${encodeURIComponent(filename)}`, { method: 'DELETE' }),
|
||||
upload: (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request<{ filename: string }>('/api/upload', { method: 'POST', body: form })
|
||||
},
|
||||
|
||||
setDefaultPlays: (defaultPlays: number) =>
|
||||
post('/api/settings/default_plays', { default_plays: defaultPlays }),
|
||||
setLogChannel: (guildId: string, channelId: string) =>
|
||||
post('/api/settings/text_channel', { guild_id: guildId, channel_id: channelId }),
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Kształt danych zwracanych przez /api/status oraz rozgłaszanych po WebSockecie. */
|
||||
|
||||
export interface VoiceChannel {
|
||||
guild_id: string
|
||||
guild_name: string
|
||||
channel_id: string
|
||||
channel_name: string
|
||||
}
|
||||
|
||||
export interface TextChannel {
|
||||
channel_id: string
|
||||
channel_name: string
|
||||
}
|
||||
|
||||
export interface GuildSettings {
|
||||
guild_id: string
|
||||
guild_name: string
|
||||
text_channels: TextChannel[]
|
||||
active_notification_channel_id: string | null
|
||||
}
|
||||
|
||||
export interface CurrentTrack {
|
||||
id: string
|
||||
title: string
|
||||
url: string | null
|
||||
duration: number | null
|
||||
elapsed: number
|
||||
plays_left: number
|
||||
}
|
||||
|
||||
export interface QueueTrack {
|
||||
id: string
|
||||
title: string
|
||||
url: string | null
|
||||
plays_left: number
|
||||
}
|
||||
|
||||
export interface HistoryTrack {
|
||||
id: string
|
||||
title: string
|
||||
url: string | null
|
||||
duration: number | null
|
||||
is_local: boolean
|
||||
filepath: string | null
|
||||
}
|
||||
|
||||
export interface UploadedFile {
|
||||
filename: string
|
||||
size_mb: number
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export interface BotStatus {
|
||||
playing: boolean
|
||||
paused: boolean
|
||||
loop_single: boolean
|
||||
loop_playlist: boolean
|
||||
volume: number
|
||||
current: CurrentTrack | null
|
||||
queue: QueueTrack[]
|
||||
queue_index: number
|
||||
history: HistoryTrack[]
|
||||
voice_channels: VoiceChannel[]
|
||||
active_voice_channel: VoiceChannel | null
|
||||
text_channels: VoiceChannel[]
|
||||
active_text_channel_id: string | null
|
||||
guilds: GuildSettings[]
|
||||
default_plays: number
|
||||
}
|
||||
|
||||
export type LoopMode = 'off' | 'playlist' | 'single'
|
||||
|
||||
export function loopModeOf(status: BotStatus | null): LoopMode {
|
||||
if (!status) return 'off'
|
||||
if (status.loop_single) return 'single'
|
||||
if (status.loop_playlist) return 'playlist'
|
||||
return 'off'
|
||||
}
|
||||
Reference in new issue
Block a user