diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 7636c389..92e274c0 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -26,178 +26,273 @@ import type { Friend, FriendRequest, InstanceStreamingLimits, + InstanceInfoResponse, + VerifyPasswordResponse, } from '@backspace/shared'; -const BASE_URL = '/api'; +export class BackspaceApiClient { + readonly auth: { + register: (data: RegisterRequest) => Promise; + login: (data: LoginRequest) => Promise; + }; -function getToken(): string | null { - return localStorage.getItem('backspace_token'); -} + readonly users: { + me: () => Promise; + update: (data: UpdateUserRequest) => Promise; + get: (id: string) => Promise; + verifyPassword: (password: string) => Promise; + }; -async function request( - method: string, - path: string, - body?: unknown, - requireAuth = true, -): Promise { - const headers: Record = {}; + readonly servers: { + list: () => Promise; + get: (id: string) => Promise; + create: (data: CreateServerRequest) => Promise; + update: (id: string, data: UpdateServerRequest) => Promise; + delete: (id: string) => Promise<{ success: boolean }>; + invite: (id: string) => Promise<{ inviteCode: string }>; + join: (id: string, data: JoinServerRequest) => Promise; + joinByCode: (inviteCode: string) => Promise; + members: (id: string) => Promise; + updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) => Promise; + removeMember: (serverId: string, userId: string) => Promise<{ success: boolean }>; + }; - if (body) { - headers['Content-Type'] = 'application/json'; - } + readonly channels: { + list: (serverId: string) => Promise; + create: (serverId: string, data: CreateChannelRequest) => Promise; + update: (id: string, data: UpdateChannelRequest) => Promise; + delete: (id: string) => Promise<{ success: boolean }>; + messages: (id: string, before?: string, limit?: number) => Promise; + sendMessage: (channelId: string, data: CreateMessageRequest) => Promise; + getOverrides: (channelId: string) => Promise<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>; + putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => Promise<{ success: boolean }>; + deleteOverride: (channelId: string, targetType: string, targetId: string) => Promise<{ success: boolean }>; + }; - if (requireAuth) { - const token = getToken(); - if (token) { - headers['Authorization'] = `Bearer ${token}`; + readonly messages: { + update: (id: string, data: UpdateMessageRequest) => Promise; + delete: (id: string) => Promise<{ success: boolean }>; + }; + + readonly uploads: { + upload: (file: File) => Promise; + url: (filename: string) => string; + }; + + readonly dm: { + list: () => Promise; + create: (data: CreateDmRequest) => Promise; + close: (id: string) => Promise<{ success: boolean }>; + messages: (id: string, before?: string, limit?: number) => Promise; + sendMessage: (id: string, data: CreateDmMessageRequest) => Promise; + updateMessage: (id: string, data: UpdateMessageRequest) => Promise; + deleteMessage: (id: string) => Promise<{ success: boolean }>; + addMember: (dmChannelId: string, data: { userId: string }) => Promise; + leave: (dmChannelId: string) => Promise<{ success: boolean }>; + }; + + readonly social: { + friends: () => Promise; + requests: () => Promise; + sendRequest: (username: string) => Promise<{ success: boolean }>; + updateRequest: (id: string, status: 'accepted' | 'declined') => Promise<{ success: boolean }>; + removeFriend: (id: string) => Promise<{ success: boolean }>; + cancelRequest: (id: string) => Promise<{ success: boolean }>; + search: (q: string) => Promise; + }; + + readonly livekit: { + token: (channelId: string) => Promise; + dmToken: (dmChannelId: string) => Promise; + }; + + readonly settings: { + getStreaming: () => Promise; + updateStreaming: (data: Partial) => Promise; + }; + + readonly instance: { + info: () => Promise; + }; + + constructor(baseUrl: string, getToken: () => string | null) { + async function request( + method: string, + path: string, + body?: unknown, + requireAuth = true, + ): Promise { + const headers: Record = {}; + + if (body) { + headers['Content-Type'] = 'application/json'; + } + + if (requireAuth) { + const token = getToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + + const response = await fetch(`${baseUrl}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Request failed' })); + throw new Error((error as { error: string }).error || `HTTP ${response.status}`); + } + + return response.json() as Promise; } + + async function uploadFile(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + const token = getToken(); + const headers: Record = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${baseUrl}/uploads`, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Upload failed' })); + throw new Error((error as { error: string }).error || `HTTP ${response.status}`); + } + + return response.json() as Promise; + } + + this.auth = { + register: (data: RegisterRequest) => + request('POST', '/auth/register', data, false), + login: (data: LoginRequest) => + request('POST', '/auth/login', data, false), + }; + + this.users = { + me: () => request('GET', '/users/@me'), + update: (data: UpdateUserRequest) => request('PATCH', '/users/@me', data), + get: (id: string) => request('GET', `/users/${id}`), + verifyPassword: (password: string) => + request('POST', '/users/@me/verify-password', { password }), + }; + + this.servers = { + list: () => request('GET', '/servers'), + get: (id: string) => request('GET', `/servers/${id}`), + create: (data: CreateServerRequest) => request('POST', '/servers', data), + update: (id: string, data: UpdateServerRequest) => request('PATCH', `/servers/${id}`, data), + delete: (id: string) => request<{ success: boolean }>('DELETE', `/servers/${id}`), + invite: (id: string) => request<{ inviteCode: string }>('POST', `/servers/${id}/invite`), + join: (id: string, data: JoinServerRequest) => request('POST', `/servers/${id}/join`, data), + joinByCode: (inviteCode: string) => request('POST', '/servers/join', { inviteCode }), + members: (id: string) => request('GET', `/servers/${id}/members`), + updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) => + request('PATCH', `/servers/${serverId}/members/${userId}`, data), + removeMember: (serverId: string, userId: string) => + request<{ success: boolean }>('DELETE', `/servers/${serverId}/members/${userId}`), + }; + + this.channels = { + list: (serverId: string) => request('GET', `/servers/${serverId}/channels`), + create: (serverId: string, data: CreateChannelRequest) => + request('POST', `/servers/${serverId}/channels`, data), + update: (id: string, data: UpdateChannelRequest) => request('PATCH', `/channels/${id}`, data), + delete: (id: string) => request<{ success: boolean }>('DELETE', `/channels/${id}`), + messages: (id: string, before?: string, limit = 50) => { + const params = new URLSearchParams(); + if (before) params.set('before', before); + params.set('limit', String(limit)); + return request('GET', `/channels/${id}/messages?${params}`); + }, + sendMessage: (channelId: string, data: CreateMessageRequest) => + request('POST', `/channels/${channelId}/messages`, data), + getOverrides: (channelId: string) => + request<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>( + 'GET', `/channels/${channelId}/overrides` + ), + putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => + request<{ success: boolean }>('PUT', `/channels/${channelId}/overrides`, data), + deleteOverride: (channelId: string, targetType: string, targetId: string) => + request<{ success: boolean }>('DELETE', `/channels/${channelId}/overrides/${targetType}/${targetId}`), + }; + + this.messages = { + update: (id: string, data: UpdateMessageRequest) => request('PATCH', `/messages/${id}`, data), + delete: (id: string) => request<{ success: boolean }>('DELETE', `/messages/${id}`), + }; + + this.uploads = { + upload: uploadFile, + url: (filename: string) => `${baseUrl}/uploads/${filename}`, + }; + + this.dm = { + list: () => request('GET', '/dm'), + create: (data: CreateDmRequest) => request('POST', '/dm', data), + close: (id: string) => request<{ success: boolean }>('DELETE', `/dm/${id}`), + messages: (id: string, before?: string, limit = 50) => { + const params = new URLSearchParams(); + if (before) params.set('before', before); + params.set('limit', String(limit)); + return request('GET', `/dm/${id}/messages?${params}`); + }, + sendMessage: (id: string, data: CreateDmMessageRequest) => + request('POST', `/dm/${id}/messages`, data), + updateMessage: (id: string, data: UpdateMessageRequest) => + request('PATCH', `/dm/messages/${id}`, data), + deleteMessage: (id: string) => + request<{ success: boolean }>('DELETE', `/dm/messages/${id}`), + addMember: (dmChannelId: string, data: { userId: string }) => + request('POST', `/dm/${dmChannelId}/members`, data), + leave: (dmChannelId: string) => + request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`), + }; + + this.social = { + friends: () => request('GET', '/social/friends'), + requests: () => request('GET', '/social/requests'), + sendRequest: (username: string) => request<{ success: boolean }>('POST', '/social/requests', { username }), + updateRequest: (id: string, status: 'accepted' | 'declined') => + request<{ success: boolean }>('PATCH', `/social/requests/${id}`, { status }), + removeFriend: (id: string) => request<{ success: boolean }>('DELETE', `/social/friends/${id}`), + cancelRequest: (id: string) => request<{ success: boolean }>('DELETE', `/social/requests/${id}`), + search: (q: string) => request('GET', `/social/search?q=${encodeURIComponent(q)}`), + }; + + this.livekit = { + token: (channelId: string) => + request('POST', '/livekit/token', { channelId }), + dmToken: (dmChannelId: string) => + request('POST', '/livekit/token', { dmChannelId }), + }; + + this.settings = { + getStreaming: () => request('GET', '/settings/streaming'), + updateStreaming: (data: Partial) => + request('PATCH', '/settings/streaming', data), + }; + + this.instance = { + info: () => request('GET', '/instance/info', undefined, false), + }; } - - const response = await fetch(`${BASE_URL}${path}`, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Request failed' })); - throw new Error((error as { error: string }).error || `HTTP ${response.status}`); - } - - return response.json() as Promise; } -async function uploadFile(file: File): Promise { - const formData = new FormData(); - formData.append('file', file); +export const api = new BackspaceApiClient('/api', () => localStorage.getItem('backspace_token')); - const token = getToken(); - const headers: Record = {}; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(`${BASE_URL}/uploads`, { - method: 'POST', - headers, - body: formData, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Upload failed' })); - throw new Error((error as { error: string }).error || `HTTP ${response.status}`); - } - - return response.json() as Promise; +export function createApiClient(origin: string, getToken: () => string | null): BackspaceApiClient { + const baseUrl = origin ? `${origin}/api` : '/api'; + return new BackspaceApiClient(baseUrl, getToken); } - -export const api = { - auth: { - register: (data: RegisterRequest) => - request('POST', '/auth/register', data, false), - login: (data: LoginRequest) => - request('POST', '/auth/login', data, false), - }, - - users: { - me: () => request('GET', '/users/@me'), - update: (data: UpdateUserRequest) => request('PATCH', '/users/@me', data), - get: (id: string) => request('GET', `/users/${id}`), - }, - - servers: { - list: () => request('GET', '/servers'), - get: (id: string) => request('GET', `/servers/${id}`), - create: (data: CreateServerRequest) => request('POST', '/servers', data), - update: (id: string, data: UpdateServerRequest) => request('PATCH', `/servers/${id}`, data), - delete: (id: string) => request<{ success: boolean }>('DELETE', `/servers/${id}`), - invite: (id: string) => request<{ inviteCode: string }>('POST', `/servers/${id}/invite`), - join: (id: string, data: JoinServerRequest) => request('POST', `/servers/${id}/join`, data), - joinByCode: (inviteCode: string) => request('POST', '/servers/join', { inviteCode }), - members: (id: string) => request('GET', `/servers/${id}/members`), - updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) => - request('PATCH', `/servers/${serverId}/members/${userId}`, data), - removeMember: (serverId: string, userId: string) => - request<{ success: boolean }>('DELETE', `/servers/${serverId}/members/${userId}`), - }, - - channels: { - list: (serverId: string) => request('GET', `/servers/${serverId}/channels`), - create: (serverId: string, data: CreateChannelRequest) => - request('POST', `/servers/${serverId}/channels`, data), - update: (id: string, data: UpdateChannelRequest) => request('PATCH', `/channels/${id}`, data), - delete: (id: string) => request<{ success: boolean }>('DELETE', `/channels/${id}`), - messages: (id: string, before?: string, limit = 50) => { - const params = new URLSearchParams(); - if (before) params.set('before', before); - params.set('limit', String(limit)); - return request('GET', `/channels/${id}/messages?${params}`); - }, - sendMessage: (channelId: string, data: CreateMessageRequest) => - request('POST', `/channels/${channelId}/messages`, data), - getOverrides: (channelId: string) => - request<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>( - 'GET', `/channels/${channelId}/overrides` - ), - putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => - request<{ success: boolean }>('PUT', `/channels/${channelId}/overrides`, data), - deleteOverride: (channelId: string, targetType: string, targetId: string) => - request<{ success: boolean }>('DELETE', `/channels/${channelId}/overrides/${targetType}/${targetId}`), - }, - - messages: { - update: (id: string, data: UpdateMessageRequest) => request('PATCH', `/messages/${id}`, data), - delete: (id: string) => request<{ success: boolean }>('DELETE', `/messages/${id}`), - }, - - uploads: { - upload: uploadFile, - url: (filename: string) => `${BASE_URL}/uploads/${filename}`, - }, - - dm: { - list: () => request('GET', '/dm'), - create: (data: CreateDmRequest) => request('POST', '/dm', data), - close: (id: string) => request<{ success: boolean }>('DELETE', `/dm/${id}`), - messages: (id: string, before?: string, limit = 50) => { - const params = new URLSearchParams(); - if (before) params.set('before', before); - params.set('limit', String(limit)); - return request('GET', `/dm/${id}/messages?${params}`); - }, - sendMessage: (id: string, data: CreateDmMessageRequest) => - request('POST', `/dm/${id}/messages`, data), - updateMessage: (id: string, data: UpdateMessageRequest) => - request('PATCH', `/dm/messages/${id}`, data), - deleteMessage: (id: string) => - request<{ success: boolean }>('DELETE', `/dm/messages/${id}`), - addMember: (dmChannelId: string, data: { userId: string }) => - request('POST', `/dm/${dmChannelId}/members`, data), - leave: (dmChannelId: string) => - request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`), - }, - - social: { - friends: () => request('GET', '/social/friends'), - requests: () => request('GET', '/social/requests'), - sendRequest: (username: string) => request<{ success: boolean }>('POST', '/social/requests', { username }), - updateRequest: (id: string, status: 'accepted' | 'declined') => - request<{ success: boolean }>('PATCH', `/social/requests/${id}`, { status }), - removeFriend: (id: string) => request<{ success: boolean }>('DELETE', `/social/friends/${id}`), - cancelRequest: (id: string) => request<{ success: boolean }>('DELETE', `/social/requests/${id}`), - search: (q: string) => request('GET', `/social/search?q=${encodeURIComponent(q)}`), - }, - - livekit: { - token: (channelId: string) => - request('POST', '/livekit/token', { channelId }), - dmToken: (dmChannelId: string) => - request('POST', '/livekit/token', { dmChannelId }), - }, - - settings: { - getStreaming: () => request('GET', '/settings/streaming'), - updateStreaming: (data: Partial) => - request('PATCH', '/settings/streaming', data), - }, -};