feat(leptos): API client with all endpoint functions

This commit is contained in:
asepharyana
2026-07-03 18:09:30 +07:00
parent 20ef844682
commit aa20d1a633
10 changed files with 381 additions and 0 deletions
+1
View File
@@ -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;