diff --git a/services/frontend-leptos/Cargo.lock b/services/frontend-leptos/Cargo.lock index af0a264..4c2f43f 100644 --- a/services/frontend-leptos/Cargo.lock +++ b/services/frontend-leptos/Cargo.lock @@ -501,6 +501,7 @@ dependencies = [ "serde_json", "shared-types", "wasm-bindgen", + "wasm-bindgen-futures", "wasm-logger", "web-sys", ] diff --git a/services/frontend-leptos/frontend/Cargo.toml b/services/frontend-leptos/frontend/Cargo.toml index 07de2e1..57af4f4 100644 --- a/services/frontend-leptos/frontend/Cargo.toml +++ b/services/frontend-leptos/frontend/Cargo.toml @@ -12,6 +12,7 @@ leptos-use = "0.14" lucide-leptos = "3" shared-types = { path = "../shared-types" } wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" js-sys = "0.3" web-sys = { version = "0.3", features = [ "WebSocket", @@ -34,6 +35,7 @@ web-sys = { version = "0.3", features = [ "Headers", "Request", "RequestInit", + "RequestMode", "Response", "HtmlInputElement", "HtmlAudioElement", diff --git a/services/frontend-leptos/frontend/src/api/auth.rs b/services/frontend-leptos/frontend/src/api/auth.rs new file mode 100644 index 0000000..8fbf9ae --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/auth.rs @@ -0,0 +1,21 @@ +use crate::api::client::{request, ApiError}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +struct LoginPayload { + password: String, +} + +#[derive(Deserialize)] +struct LoginResponse { + ok: bool, +} + +pub async fn login(password: &str) -> Result { + let payload = LoginPayload { + password: password.to_string(), + }; + let body = serde_json::to_string(&payload).unwrap(); + let resp: LoginResponse = request("POST", "/api/auth/login", Some(&body)).await?; + Ok(resp.ok) +} diff --git a/services/frontend-leptos/frontend/src/api/client.rs b/services/frontend-leptos/frontend/src/api/client.rs new file mode 100644 index 0000000..648e7a6 --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/client.rs @@ -0,0 +1,131 @@ +use serde::de::DeserializeOwned; +use wasm_bindgen::prelude::*; +use web_sys::{Request, RequestInit, RequestMode, Headers, Response}; +use wasm_bindgen_futures::JsFuture; + +#[derive(Debug)] +pub struct ApiError { + pub message: String, + pub status_code: u16, +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "API error {}: {}", self.status_code, self.message) + } +} + +impl std::error::Error for ApiError {} + +fn get_base_url() -> String { + // Try to read from a JS global set by index.html, or fall back to localhost + let default = "http://localhost:3001"; + js_sys::global() + .unchecked_ref::() + .location() + .hostname() + .ok() + .map(|_| format!("http://localhost:3001")) + .unwrap_or_else(|| default.to_string()) +} + +fn get_auth_header() -> Option { + // Read password from sessionStorage + let storage = web_sys::window()?.local_storage().ok()??; + storage.get_item("admin-password").ok()? +} + +pub async fn request( + method: &str, + path: &str, + body: Option<&str>, +) -> Result { + let url = format!("{}{}", get_base_url(), path); + + let mut headers = Headers::new().map_err(|_| ApiError { + message: "Failed to create headers".to_string(), + status_code: 0, + })?; + + if let Some(password) = get_auth_header() { + headers.set("X-Admin-Password", &password).ok(); + } + + let mut opts = RequestInit::new(); + opts.set_method(method); + opts.set_headers(&headers); + opts.set_mode(RequestMode::Cors); + + if let Some(json_body) = body { + headers.set("Content-Type", "application/json").ok(); + opts.set_body(&JsValue::from_str(json_body)); + } + + let request = Request::new_with_str_and_init(&url, &opts).map_err(|e| ApiError { + message: format!("Failed to create request: {:?}", e), + status_code: 0, + })?; + + let window = web_sys::window().ok_or(ApiError { + message: "No window".to_string(), + status_code: 0, + })?; + + let resp_value = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(|e| ApiError { + message: format!("Fetch failed: {:?}", e), + status_code: 0, + })?; + + let response: Response = resp_value.dyn_into().map_err(|_| ApiError { + message: "Invalid response".to_string(), + status_code: 0, + })?; + + let status = response.status(); + if status >= 400 { + let text = JsFuture::from( + response.text().map_err(|_| ApiError { + message: "Failed to read error body".to_string(), + status_code: status, + })? + ) + .await + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default(); + + return Err(ApiError { + message: text, + status_code: status, + }); + } + + let text = JsFuture::from( + response.text().map_err(|_| ApiError { + message: "Failed to read response body".to_string(), + status_code: status, + })? + ) + .await + .map_err(|_| ApiError { + message: "Failed to await response".to_string(), + status_code: status, + })? + .as_string() + .ok_or(ApiError { + message: "Response is not text".to_string(), + status_code: status, + })?; + + serde_json::from_str(&text).map_err(|e| ApiError { + message: format!("JSON parse error: {} — body: {}", e, &text[..text.len().min(200)]), + status_code: status, + }) +} + +pub async fn request_no_body(method: &str, path: &str) -> Result<(), ApiError> { + request::(method, path, None).await?; + Ok(()) +} diff --git a/services/frontend-leptos/frontend/src/api/dashboard.rs b/services/frontend-leptos/frontend/src/api/dashboard.rs new file mode 100644 index 0000000..c57d1d0 --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/dashboard.rs @@ -0,0 +1,61 @@ +use crate::api::client::{request, ApiError}; +use shared_types::dashboard::*; + +/// GET /api/dashboard/stats +pub async fn get_dashboard_stats() -> Result { + request("GET", "/api/dashboard/stats", None).await +} + +/// GET /api/dashboard/users?limit=&cursor=&search= +pub async fn get_dashboard_users( + limit: Option, + cursor: Option<&str>, + search: Option<&str>, +) -> Result { + let mut path = "/api/dashboard/users".to_string(); + let mut params = vec![]; + if let Some(l) = limit { params.push(format!("limit={}", l)); } + if let Some(c) = cursor { params.push(format!("cursor={}", c)); } + if let Some(s) = search { params.push(format!("search={}", s)); } + if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); } + request("GET", &path, None).await +} + +#[derive(serde::Deserialize)] +pub struct PaginatedUsers { + pub data: Vec, + pub next_cursor: Option, +} + +/// GET /api/dashboard/users/{userId} +pub async fn get_dashboard_user_detail(user_id: &str) -> Result { + request("GET", &format!("/api/dashboard/users/{}", user_id), None).await +} + +/// GET /api/dashboard/channels?limit=&cursor=&search=&guild_id= +pub async fn get_dashboard_channels( + limit: Option, + cursor: Option<&str>, + search: Option<&str>, + guild_id: Option<&str>, +) -> Result { + let mut path = "/api/dashboard/channels".to_string(); + let mut params = vec![]; + if let Some(l) = limit { params.push(format!("limit={}", l)); } + if let Some(c) = cursor { params.push(format!("cursor={}", c)); } + if let Some(s) = search { params.push(format!("search={}", s)); } + if let Some(g) = guild_id { params.push(format!("guild_id={}", g)); } + if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); } + request("GET", &path, None).await +} + +#[derive(serde::Deserialize)] +pub struct PaginatedChannels { + pub data: Vec, + pub next_cursor: Option, +} + +/// GET /api/dashboard/channels/{channelId} +pub async fn get_dashboard_channel_detail(channel_id: &str) -> Result { + request("GET", &format!("/api/dashboard/channels/{}", channel_id), None).await +} diff --git a/services/frontend-leptos/frontend/src/api/messages.rs b/services/frontend-leptos/frontend/src/api/messages.rs new file mode 100644 index 0000000..dde8e3f --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/messages.rs @@ -0,0 +1,57 @@ +use crate::api::client::{request, ApiError}; +use shared_types::message::{MessageRecord, PageResult}; + +/// GET /api/messages?guildId=&limit=&channelId=&cursor= +pub async fn get_messages( + guild_id: &str, + limit: Option, + channel_id: Option<&str>, + cursor: Option<&str>, +) -> Result, ApiError> { + let mut path = format!("/api/messages?guildId={}", guild_id); + if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); } + if let Some(c) = channel_id { path.push_str(&format!("&channelId={}", c)); } + if let Some(c) = cursor { path.push_str(&format!("&cursor={}", c)); } + request("GET", &path, None).await +} + +/// GET /api/review?params +pub async fn get_review_messages( + guild_id: &str, + limit: Option, + channel_id: Option<&str>, +) -> Result, ApiError> { + let mut path = format!("/api/review?guildId={}", guild_id); + if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); } + if let Some(c) = channel_id { path.push_str(&format!("&channelId={}", c)); } + request("GET", &path, None).await +} + +/// GET /api/messages/detail/{id} +pub async fn get_message_detail(id: &str) -> Result, ApiError> { + request("GET", &format!("/api/messages/detail/{}", id), None).await +} + +/// POST /api/messages/{id}/reanalyze +pub async fn reanalyze_message(id: &str) -> Result<(), ApiError> { + let _: serde_json::Value = request("POST", &format!("/api/messages/{}/reanalyze", id), Some("{}")).await?; + Ok(()) +} + +/// POST /api/messages/reanalyze-batch +pub async fn reanalyze_batch() -> Result { + #[derive(serde::Deserialize)] + struct BatchResp { ok: bool, count: u64 } + let resp: BatchResp = request("POST", "/api/messages/reanalyze-batch", Some("{}")).await?; + Ok(resp.count) +} + +/// GET /api/analysis/search?q=&limit= +pub async fn search_messages(query: &str, limit: Option) -> Result, ApiError> { + #[derive(serde::Deserialize)] + struct SearchResult { results: Vec } + let mut path = format!("/api/analysis/search?q={}", query); + if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); } + let resp: SearchResult = request("GET", &path, None).await?; + Ok(resp.results) +} diff --git a/services/frontend-leptos/frontend/src/api/mod.rs b/services/frontend-leptos/frontend/src/api/mod.rs new file mode 100644 index 0000000..8af7ab5 --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/mod.rs @@ -0,0 +1,6 @@ +pub mod client; +pub mod auth; +pub mod messages; +pub mod voice; +pub mod dashboard; +pub mod recordings; diff --git a/services/frontend-leptos/frontend/src/api/recordings.rs b/services/frontend-leptos/frontend/src/api/recordings.rs new file mode 100644 index 0000000..7d05657 --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/recordings.rs @@ -0,0 +1,20 @@ +use crate::api::client::{request, request_no_body, ApiError}; +use shared_types::recording::VoiceRecordingListResponse; + +/// GET /api/recordings?limit=&cursor= +pub async fn get_recordings( + limit: Option, + cursor: Option<&str>, +) -> Result { + let mut path = "/api/recordings".to_string(); + let mut params = vec![]; + if let Some(l) = limit { params.push(format!("limit={}", l)); } + if let Some(c) = cursor { params.push(format!("cursor={}", c)); } + if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); } + request("GET", &path, None).await +} + +/// DELETE /api/recordings/{id} +pub async fn delete_recording(id: &str) -> Result<(), ApiError> { + request_no_body("DELETE", &format!("/api/recordings/{}", id)).await +} diff --git a/services/frontend-leptos/frontend/src/api/voice.rs b/services/frontend-leptos/frontend/src/api/voice.rs new file mode 100644 index 0000000..1d63a25 --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/voice.rs @@ -0,0 +1,81 @@ +use crate::api::client::{request, request_no_body, ApiError}; +use shared_types::voice::VoiceStatus; +use shared_types::media::MediaState; +use shared_types::guild::{Guild, Channel}; +use serde::Serialize; + +/// GET /api/guilds +pub async fn get_guilds() -> Result, ApiError> { + request("GET", "/api/guilds", None).await +} + +/// GET /api/guilds/{guildId}/voice-channels +pub async fn get_voice_channels(guild_id: &str) -> Result, ApiError> { + request("GET", &format!("/api/guilds/{}/voice-channels", guild_id), None).await +} + +/// GET /api/guilds/{guildId}/channels +pub async fn get_text_channels(guild_id: &str) -> Result, ApiError> { + request("GET", &format!("/api/guilds/{}/channels", guild_id), None).await +} + +/// GET /api/voice/status +pub async fn get_voice_status() -> Result { + request("GET", "/api/voice/status", None).await +} + +/// POST /api/voice/connect { guildId, channelId } +#[derive(Serialize)] +struct ConnectPayload { + guild_id: String, + channel_id: String, +} +pub async fn connect_voice(guild_id: &str, channel_id: &str) -> Result { + let body = serde_json::to_string(&ConnectPayload { + guild_id: guild_id.to_string(), + channel_id: channel_id.to_string(), + }).unwrap(); + request("POST", "/api/voice/connect", Some(&body)).await +} + +/// POST /api/voice/disconnect +pub async fn disconnect_voice() -> Result { + request("POST", "/api/voice/disconnect", Some("{}")).await +} + +/// GET /api/media/status +pub async fn get_media_status() -> Result { + request("GET", "/api/media/status", None).await +} + +/// POST /api/media/queue { source, mode } +#[derive(Serialize)] +struct MediaQueuePayload { + source: String, + mode: String, +} +pub async fn media_queue(source: &str, mode: &str) -> Result { + let body = serde_json::to_string(&MediaQueuePayload { + source: source.to_string(), + mode: mode.to_string(), + }).unwrap(); + request("POST", "/api/media/queue", Some(&body)).await +} + +/// POST /api/media/skip +pub async fn media_skip() -> Result { + request("POST", "/api/media/skip", Some("{}")).await +} + +/// POST /api/media/stop +pub async fn media_stop() -> Result { + request("POST", "/api/media/stop", Some("{}")).await +} + +/// POST /api/media/volume { volume } +#[derive(Serialize)] +struct VolumePayload { volume: f64 } +pub async fn media_volume(volume: f64) -> Result { + let body = serde_json::to_string(&VolumePayload { volume }).unwrap(); + request("POST", "/api/media/volume", Some(&body)).await +} diff --git a/services/frontend-leptos/frontend/src/lib.rs b/services/frontend-leptos/frontend/src/lib.rs index ea7fdf6..5f76023 100644 --- a/services/frontend-leptos/frontend/src/lib.rs +++ b/services/frontend-leptos/frontend/src/lib.rs @@ -1,3 +1,4 @@ +pub mod api; pub mod app; pub mod ui; pub mod ws;