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:
lgooseandClaude Bot committed 2026-08-21 01:08:04 +02:00
1 parent ab3c980597
commit 6bfa9b2a77
2 files changed
+137

No files matched your search

+59
View File
@@ -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 }),
}
+78
View File
@@ -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'
}