diff --git a/src-tauri/src/audio/commands.rs b/src-tauri/src/audio/commands.rs new file mode 100644 index 0000000..934d5be --- /dev/null +++ b/src-tauri/src/audio/commands.rs @@ -0,0 +1 @@ +use tauri::command; diff --git a/src/api/api-client.ts b/src/api/api-client.ts new file mode 100644 index 0000000..5b9817d --- /dev/null +++ b/src/api/api-client.ts @@ -0,0 +1,159 @@ +import type {IApiClient} from './client'; +import {getPlatform} from '../platform'; +import {useAuthStore} from '../stores/auth'; +import type { + CategoryResponse, + ChannelResponse, + ClaimAdminRequest, + CreateCategoryRequest, + CreateChannelRequest, + CreateMessageRequest, + CreateServerRequest, + LoginRequest, + LoginResponse, + MessageResponse, + ServerResponse, + SshChallengeResponse, + UserResponse, +} from './types'; + +/** + * Unified API Client for OxSpeak. + * Uses fetch() directly in all environments. + * Uses the Auth store for token persistence and state. + */ +export class ApiClient implements IApiClient { + private token: string | null = null; + private tokenLoaded = false; + + constructor(public readonly baseUrl: string) { + } + + private async ensureTokenLoaded() { + if (!this.tokenLoaded) { + try { + const authStore = useAuthStore(); + this.token = await authStore.loadToken(this.baseUrl); + } catch (e) { + // Fallback to platform directly if store is not available + this.token = await getPlatform().loadToken(this.baseUrl); + } + this.tokenLoaded = true; + } + } + + private async request(method: string, path: string, body?: any): Promise { + await this.ensureTokenLoaded(); + + const cleanBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl; + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + const url = `${cleanBaseUrl}/${cleanPath}`; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch (e) { + errorData = {message: await response.text()}; + } + throw new Error(errorData.message || `Erreur HTTP ${response.status}`); + } + + if (response.status === 204) { + return {} as T; + } + + return await response.json(); + } + + private async setToken(token: string) { + this.token = token; + this.tokenLoaded = true; + try { + const authStore = useAuthStore(); + await authStore.saveToken(this.baseUrl, token); + } catch (e) { + // Fallback to platform directly + await getPlatform().saveToken(this.baseUrl, token); + } + } + + // AUTH + async login(req: LoginRequest): Promise { + const res = await this.request('POST', 'api/auth/login', req); + await this.setToken(res.token); + return res; + } + + async verifyToken(): Promise { + return await this.request('GET', 'api/auth/me'); + } + + async claimAdmin(req: ClaimAdminRequest): Promise { + return await this.request('POST', 'api/auth/claim-admin', req); + } + + async sshChallenge(): Promise { + return await this.request('POST', 'api/auth/ssh-challenge'); + } + + // SERVER + async getServerList(): Promise { + return await this.request('GET', 'api/server'); + } + + async createServer(req: CreateServerRequest): Promise { + return await this.request('POST', 'api/server', req); + } + + async getServerTree(serverId: string): Promise { + return await this.request('GET', `api/server/servers/${serverId}/tree/`); + } + + // CATEGORY + async getCategoryList(serverId?: string): Promise { + const path = serverId ? `api/category?server_id=${serverId}` : 'api/category'; + return await this.request('GET', path); + } + + async createCategory(req: CreateCategoryRequest): Promise { + return await this.request('POST', 'api/category', req); + } + + // CHANNEL + async getChannelList(): Promise { + return await this.request('GET', 'api/channel'); + } + + async createChannel(req: CreateChannelRequest): Promise { + return await this.request('POST', 'api/channel', req); + } + + // MESSAGE + async getMessageList(): Promise { + return await this.request('GET', 'api/message'); + } + + async createMessage(req: CreateMessageRequest): Promise { + return await this.request('POST', 'api/message', req); + } + + // USER + async getUserList(): Promise { + return await this.request('GET', 'api/user'); + } +} diff --git a/src/components/JoinServerModal.vue b/src/components/JoinServerModal.vue new file mode 100644 index 0000000..460df6f --- /dev/null +++ b/src/components/JoinServerModal.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/pages/config/identities.vue b/src/pages/config/identities.vue new file mode 100644 index 0000000..e60d3e6 --- /dev/null +++ b/src/pages/config/identities.vue @@ -0,0 +1,85 @@ + + + diff --git a/src/pages/config/servers.vue b/src/pages/config/servers.vue new file mode 100644 index 0000000..2a2bb4a --- /dev/null +++ b/src/pages/config/servers.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/platform/implementations.ts b/src/platform/implementations.ts new file mode 100644 index 0000000..ca8f6c5 --- /dev/null +++ b/src/platform/implementations.ts @@ -0,0 +1,58 @@ +import {invoke} from '@tauri-apps/api/core'; +import type {IPlatform} from './types'; + +export class WebPlatform implements IPlatform { + readonly isTauri = false; + + async saveToken(baseUrl: string, token: string): Promise { + localStorage.setItem(`token_${baseUrl}`, token); + } + + async loadToken(baseUrl: string): Promise { + return localStorage.getItem(`token_${baseUrl}`); + } + + async clearToken(baseUrl: string): Promise { + localStorage.removeItem(`token_${baseUrl}`); + } + + async startAudio(): Promise { + console.warn('Audio is not supported in Web mode yet'); + } + + async stopAudio(): Promise { + console.warn('Audio is not supported in Web mode yet'); + } + + async sendAudioPacket(data: ArrayBuffer | Uint8Array): Promise { + console.warn('Audio is not supported in Web mode yet'); + } +} + +export class TauriPlatform implements IPlatform { + readonly isTauri = true; + + async saveToken(baseUrl: string, token: string): Promise { + await invoke('save_token', {baseUrl, token}); + } + + async loadToken(baseUrl: string): Promise { + return await invoke('load_token', {baseUrl}); + } + + async clearToken(baseUrl: string): Promise { + await invoke('clear_token', {baseUrl}); + } + + async startAudio(): Promise { + await invoke('start_audio'); + } + + async stopAudio(): Promise { + await invoke('stop_audio'); + } + + async sendAudioPacket(data: ArrayBuffer | Uint8Array): Promise { + await invoke('send_audio_packet', {data: Array.from(new Uint8Array(data))}); + } +} diff --git a/src/platform/index.ts b/src/platform/index.ts new file mode 100644 index 0000000..2d70e6d --- /dev/null +++ b/src/platform/index.ts @@ -0,0 +1,14 @@ +import {TauriPlatform, WebPlatform} from './implementations'; +import type {IPlatform} from './types'; + +let platformInstance: IPlatform | null = null; + +export function getPlatform(): IPlatform { + if (platformInstance) return platformInstance; + + const isTauri = !!(window as any).__TAURI_INTERNALS__; + platformInstance = isTauri ? new TauriPlatform() : new WebPlatform(); + return platformInstance; +} + +export * from './types'; diff --git a/src/platform/types.ts b/src/platform/types.ts new file mode 100644 index 0000000..3a50f9b --- /dev/null +++ b/src/platform/types.ts @@ -0,0 +1,18 @@ +export interface IPlatform { + // Token handling + saveToken(baseUrl: string, token: string): Promise; + + loadToken(baseUrl: string): Promise; + + clearToken(baseUrl: string): Promise; + + // Audio control + startAudio(): Promise; + + stopAudio(): Promise; + + sendAudioPacket(data: ArrayBuffer | Uint8Array): Promise; + + // Environment + readonly isTauri: boolean; +} diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..145d4c5 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,37 @@ +import {createRouter, createWebHistory} from 'vue-router' +import IndexPage from './pages/index.vue' +import ConfigPage from './pages/config.vue' +import ServersPage from './pages/config/servers.vue' +import IdentitiesPage from './pages/config/identities.vue' +import ServerDetailPage from './pages/server_detail.vue' +import ChannelDetailPage from './pages/channel_detail.vue' + +const router = createRouter({ + routes: [ + {path: '/', component: IndexPage}, + { + path: '/config', + component: ConfigPage, + children: [ + {path: '', redirect: '/config/servers'}, + {path: 'servers', component: ServersPage}, + {path: 'identities', component: IdentitiesPage}, + ] + }, + { + path: '/server/:server_id', + component: ServerDetailPage, + props: true, + children: [ + { + path: 'channel/:channel_id', + component: ChannelDetailPage, + props: true + }, + ] + } + ], + history: createWebHistory() +}) + +export default router diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..98e74da --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1,35 @@ +import {defineStore} from 'pinia'; +import {ref} from 'vue'; +import {getPlatform} from '../platform'; + +export const useAuthStore = defineStore('auth', () => { + const tokens = ref>({}); + + async function loadToken(baseUrl: string): Promise { + if (tokens.value[baseUrl]) { + return tokens.value[baseUrl]; + } + const token = await getPlatform().loadToken(baseUrl); + if (token) { + tokens.value[baseUrl] = token; + } + return token; + } + + async function saveToken(baseUrl: string, token: string): Promise { + tokens.value[baseUrl] = token; + await getPlatform().saveToken(baseUrl, token); + } + + async function clearToken(baseUrl: string): Promise { + delete tokens.value[baseUrl]; + await getPlatform().clearToken(baseUrl); + } + + return { + tokens, + loadToken, + saveToken, + clearToken, + }; +});