feat(leptos): API client with all endpoint functions
This commit is contained in:
Generated
+1
@@ -501,6 +501,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"shared-types",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-logger",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<bool, ApiError> {
|
||||
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)
|
||||
}
|
||||
@@ -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::<web_sys::Window>()
|
||||
.location()
|
||||
.hostname()
|
||||
.ok()
|
||||
.map(|_| format!("http://localhost:3001"))
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
fn get_auth_header() -> Option<String> {
|
||||
// Read password from sessionStorage
|
||||
let storage = web_sys::window()?.local_storage().ok()??;
|
||||
storage.get_item("admin-password").ok()?
|
||||
}
|
||||
|
||||
pub async fn request<T: DeserializeOwned>(
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<&str>,
|
||||
) -> Result<T, ApiError> {
|
||||
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::<serde_json::Value>(method, path, None).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -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<DashboardStats, ApiError> {
|
||||
request("GET", "/api/dashboard/stats", None).await
|
||||
}
|
||||
|
||||
/// GET /api/dashboard/users?limit=&cursor=&search=
|
||||
pub async fn get_dashboard_users(
|
||||
limit: Option<u32>,
|
||||
cursor: Option<&str>,
|
||||
search: Option<&str>,
|
||||
) -> Result<PaginatedUsers, ApiError> {
|
||||
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<DashboardUser>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/dashboard/users/{userId}
|
||||
pub async fn get_dashboard_user_detail(user_id: &str) -> Result<DashboardUserDetail, ApiError> {
|
||||
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<u32>,
|
||||
cursor: Option<&str>,
|
||||
search: Option<&str>,
|
||||
guild_id: Option<&str>,
|
||||
) -> Result<PaginatedChannels, ApiError> {
|
||||
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<DashboardChannel>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/dashboard/channels/{channelId}
|
||||
pub async fn get_dashboard_channel_detail(channel_id: &str) -> Result<DashboardChannelDetail, ApiError> {
|
||||
request("GET", &format!("/api/dashboard/channels/{}", channel_id), None).await
|
||||
}
|
||||
@@ -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<u32>,
|
||||
channel_id: Option<&str>,
|
||||
cursor: Option<&str>,
|
||||
) -> Result<PageResult<MessageRecord>, 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<u32>,
|
||||
channel_id: Option<&str>,
|
||||
) -> Result<PageResult<MessageRecord>, 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<Option<MessageRecord>, 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<u64, ApiError> {
|
||||
#[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<u32>) -> Result<Vec<MessageRecord>, ApiError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SearchResult { results: Vec<MessageRecord> }
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod client;
|
||||
pub mod auth;
|
||||
pub mod messages;
|
||||
pub mod voice;
|
||||
pub mod dashboard;
|
||||
pub mod recordings;
|
||||
@@ -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<u32>,
|
||||
cursor: Option<&str>,
|
||||
) -> Result<VoiceRecordingListResponse, ApiError> {
|
||||
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
|
||||
}
|
||||
@@ -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<Vec<Guild>, ApiError> {
|
||||
request("GET", "/api/guilds", None).await
|
||||
}
|
||||
|
||||
/// GET /api/guilds/{guildId}/voice-channels
|
||||
pub async fn get_voice_channels(guild_id: &str) -> Result<Vec<Channel>, 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<Vec<Channel>, ApiError> {
|
||||
request("GET", &format!("/api/guilds/{}/channels", guild_id), None).await
|
||||
}
|
||||
|
||||
/// GET /api/voice/status
|
||||
pub async fn get_voice_status() -> Result<VoiceStatus, ApiError> {
|
||||
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<VoiceStatus, ApiError> {
|
||||
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<VoiceStatus, ApiError> {
|
||||
request("POST", "/api/voice/disconnect", Some("{}")).await
|
||||
}
|
||||
|
||||
/// GET /api/media/status
|
||||
pub async fn get_media_status() -> Result<MediaState, ApiError> {
|
||||
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<MediaState, ApiError> {
|
||||
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<MediaState, ApiError> {
|
||||
request("POST", "/api/media/skip", Some("{}")).await
|
||||
}
|
||||
|
||||
/// POST /api/media/stop
|
||||
pub async fn media_stop() -> Result<MediaState, ApiError> {
|
||||
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<MediaState, ApiError> {
|
||||
let body = serde_json::to_string(&VolumePayload { volume }).unwrap();
|
||||
request("POST", "/api/media/volume", Some(&body)).await
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod api;
|
||||
pub mod app;
|
||||
pub mod ui;
|
||||
pub mod ws;
|
||||
|
||||
Reference in New Issue
Block a user