From f67bc5125bdb8d5adb50ee2945ce282451fd8b80 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 07:03:37 +0700 Subject: [PATCH 01/25] fix(leptos): correct shared-types timestamps to match JSON wire format --- services/frontend-leptos/.env.example | 3 + services/frontend-leptos/Cargo.toml | 3 + services/frontend-leptos/rust-toolchain.toml | 4 + .../frontend-leptos/shared-types/Cargo.toml | 7 + .../shared-types/src/dashboard.rs | 83 +++++++++ .../frontend-leptos/shared-types/src/guild.rs | 35 ++++ .../frontend-leptos/shared-types/src/lib.rs | 7 + .../frontend-leptos/shared-types/src/media.rs | 30 ++++ .../shared-types/src/message.rs | 170 ++++++++++++++++++ .../shared-types/src/recording.rs | 36 ++++ .../shared-types/src/ui_state.rs | 29 +++ .../frontend-leptos/shared-types/src/voice.rs | 24 +++ 12 files changed, 431 insertions(+) create mode 100644 services/frontend-leptos/.env.example create mode 100644 services/frontend-leptos/Cargo.toml create mode 100644 services/frontend-leptos/rust-toolchain.toml create mode 100644 services/frontend-leptos/shared-types/Cargo.toml create mode 100644 services/frontend-leptos/shared-types/src/dashboard.rs create mode 100644 services/frontend-leptos/shared-types/src/guild.rs create mode 100644 services/frontend-leptos/shared-types/src/lib.rs create mode 100644 services/frontend-leptos/shared-types/src/media.rs create mode 100644 services/frontend-leptos/shared-types/src/message.rs create mode 100644 services/frontend-leptos/shared-types/src/recording.rs create mode 100644 services/frontend-leptos/shared-types/src/ui_state.rs create mode 100644 services/frontend-leptos/shared-types/src/voice.rs diff --git a/services/frontend-leptos/.env.example b/services/frontend-leptos/.env.example new file mode 100644 index 0000000..3634e80 --- /dev/null +++ b/services/frontend-leptos/.env.example @@ -0,0 +1,3 @@ +# API/WS endpoints — set these before building +VITE_BE_API_URL=http://localhost:3001 +VITE_BE_WS_URL=ws://localhost:3001/ws diff --git a/services/frontend-leptos/Cargo.toml b/services/frontend-leptos/Cargo.toml new file mode 100644 index 0000000..3ff0eb7 --- /dev/null +++ b/services/frontend-leptos/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["shared-types", "frontend"] diff --git a/services/frontend-leptos/rust-toolchain.toml b/services/frontend-leptos/rust-toolchain.toml new file mode 100644 index 0000000..3aa3203 --- /dev/null +++ b/services/frontend-leptos/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly-2026-06-01" +components = ["rust-src", "rustc-dev"] +targets = ["wasm32-unknown-unknown"] diff --git a/services/frontend-leptos/shared-types/Cargo.toml b/services/frontend-leptos/shared-types/Cargo.toml new file mode 100644 index 0000000..3f27442 --- /dev/null +++ b/services/frontend-leptos/shared-types/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "shared-types" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/services/frontend-leptos/shared-types/src/dashboard.rs b/services/frontend-leptos/shared-types/src/dashboard.rs new file mode 100644 index 0000000..33eafbb --- /dev/null +++ b/services/frontend-leptos/shared-types/src/dashboard.rs @@ -0,0 +1,83 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardStats { + pub total_messages: u64, + pub total_users: u64, + pub total_flagged: u64, + pub total_clean: u64, + pub total_warned: u64, + pub total_error: u64, + pub total_voice_recordings: u64, + pub total_profiles: u64, + pub today_messages: u64, + pub today_flagged: u64, + pub active_users_24h: u64, + pub top_channels: Vec, + pub moderation_overview: ModerationOverview, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TopChannel { + pub channel_id: String, + pub channel_name: String, + pub message_count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModerationOverview { + pub pending: u64, + pub processing: u64, + pub error: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardUser { + pub user_id: String, + pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_summary: Option, + pub total_messages: u64, + pub flagged_count: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_message_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub trust_score: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardUserDetail { + #[serde(flatten)] + pub user: DashboardUser, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_analyzed_at: Option, + pub clean_message_streak: u64, + pub total_infractions: u64, + pub clean_count: u64, + pub recent_messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardChannel { + pub channel_id: String, + pub channel_name: String, + pub guild_id: String, + pub total_messages: u64, + pub flagged_count: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_message_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub culture_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_analyzed_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardChannelDetail { + #[serde(flatten)] + pub channel: DashboardChannel, + pub clean_count: u64, + pub recent_messages: Vec, +} diff --git a/services/frontend-leptos/shared-types/src/guild.rs b/services/frontend-leptos/shared-types/src/guild.rs new file mode 100644 index 0000000..85ecc4c --- /dev/null +++ b/services/frontend-leptos/shared-types/src/guild.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Guild { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Channel { + pub id: String, + pub name: String, + #[serde(rename = "type")] + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GuildVoiceEntry { + pub guild_id: String, + pub channel_id: String, + pub channel_name: String, + pub connected_at: i64, +} + +// ── Config ──────────────────────────────────────────────── +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub monitor_guild_id: Option, +} diff --git a/services/frontend-leptos/shared-types/src/lib.rs b/services/frontend-leptos/shared-types/src/lib.rs new file mode 100644 index 0000000..1ca4889 --- /dev/null +++ b/services/frontend-leptos/shared-types/src/lib.rs @@ -0,0 +1,7 @@ +pub mod message; +pub mod guild; +pub mod voice; +pub mod media; +pub mod dashboard; +pub mod recording; +pub mod ui_state; diff --git a/services/frontend-leptos/shared-types/src/media.rs b/services/frontend-leptos/shared-types/src/media.rs new file mode 100644 index 0000000..c175dd1 --- /dev/null +++ b/services/frontend-leptos/shared-types/src/media.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +pub type MediaMode = String; // "music" | "screen" + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaItem { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(rename = "durationMs")] + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(rename = "thumbnailUrl")] + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaState { + pub playing: bool, + #[serde(rename = "musicVolume")] + pub music_volume: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub current: Option, + pub queue: Vec, +} diff --git a/services/frontend-leptos/shared-types/src/message.rs b/services/frontend-leptos/shared-types/src/message.rs new file mode 100644 index 0000000..5f156d4 --- /dev/null +++ b/services/frontend-leptos/shared-types/src/message.rs @@ -0,0 +1,170 @@ +use serde::{Deserialize, Serialize}; + +// ── AI Status ───────────────────────────────────────────── +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AiStatus { + Pending, + Processing, + Clean, + Warn, + Flagged, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AiSeverity { + None, + Low, + Medium, + High, + Critical, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AiRecommendedAction { + None, + Monitor, + Warn, + Review, + Delete, + Escalate, +} + +// ── Message Metadata ────────────────────────────────────── +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MessageMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub stickers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub embeds: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StickerInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttachmentRef { + pub name: String, + pub url: String, + #[serde(rename = "contentType")] + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmbedInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmbedMedia { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelRef { + pub channel_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_name: Option, +} + +// ── Message Record ──────────────────────────────────────── +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageRecord { + pub id: String, + pub guild_id: String, + pub channel_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + pub user_id: String, + pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub edited_content: Option, + #[serde(rename = "type")] + pub msg_type: String, // "text" | "edited" | "deleted" + pub created_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub edited_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_severity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_confidence: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_moderation_flags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_moderation_score: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_analysis: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_categories: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_recommended_action: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ai_analyzed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +// ── Pagination ──────────────────────────────────────────── +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageResult { + pub data: Vec, + #[serde(rename = "nextCursor")] + pub next_cursor: Option, +} + +// ── Attachment ──────────────────────────────────────────── +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttachmentRecord { + pub id: String, + pub message_id: String, + pub guild_id: String, + pub channel_id: String, + pub filename: String, + pub size: u64, + #[serde(rename = "type")] + pub mime_type: String, + pub discord_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub uploaded_url: Option, + pub upload_status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub upload_error: Option, + pub created_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub uploaded_at: Option, +} diff --git a/services/frontend-leptos/shared-types/src/recording.rs b/services/frontend-leptos/shared-types/src/recording.rs new file mode 100644 index 0000000..5cb8fba --- /dev/null +++ b/services/frontend-leptos/shared-types/src/recording.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VoiceRecording { + pub id: String, + pub user_id: String, + pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub guild_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_name: Option, + pub filename: String, + pub size_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub download_url: Option, + pub upload_status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub upload_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transcription: Option, + pub created_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub uploaded_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VoiceRecordingListResponse { + pub items: Vec, + #[serde(rename = "nextCursor")] + pub next_cursor: Option, + pub has_more: bool, +} diff --git a/services/frontend-leptos/shared-types/src/ui_state.rs b/services/frontend-leptos/shared-types/src/ui_state.rs new file mode 100644 index 0000000..f0eb826 --- /dev/null +++ b/services/frontend-leptos/shared-types/src/ui_state.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Tab { + Messages, + Live, + Dashboard, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UiState { + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_guild: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_voice_guild: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_voice_channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_text_guild: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_text_channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_tab: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_listening: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_streaming: Option, +} diff --git a/services/frontend-leptos/shared-types/src/voice.rs b/services/frontend-leptos/shared-types/src/voice.rs new file mode 100644 index 0000000..888277c --- /dev/null +++ b/services/frontend-leptos/shared-types/src/voice.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; +use crate::guild::GuildVoiceEntry; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VoiceStatus { + pub connected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_guild_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_channel_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_channel_name: Option, + pub connections: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActiveSpeaker { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub user_id: String, + pub username: String, + pub avatar: String, + pub speaking: bool, +} From c4c43ce9129fa1c45128c51a4a7e0e97066b73a8 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 17:09:44 +0700 Subject: [PATCH 02/25] feat(leptos): frontend crate scaffold with empty app - frontend/ crate compiles to WASM via trunk - empty Leptos app mounted to body via wasm_bindgen(start) - dependencies: leptos 0.7 CSR, leptos-use, lucide-leptos, gloo-net, web-sys - workspace cargo check passes (first time the workspace compiles end-to-end) - trunk build produces 65KB WASM + 25KB JS glue Deviations from brief: - lucide-leptos: 0.5 (brief) -> 3 (only version published) - web-sys: removed invalid features ArrayBuffer/DataView (in js-sys/wasm-bindgen) - src/main.rs -> src/lib.rs with #[wasm_bindgen(start)] to avoid bin/lib duplicate artifact --- .gitignore | 2 + services/frontend-leptos/Cargo.lock | 2544 +++++++++++++++++ services/frontend-leptos/frontend/Cargo.toml | 49 + services/frontend-leptos/frontend/Trunk.toml | 7 + services/frontend-leptos/frontend/index.html | 20 + .../frontend-leptos/frontend/public/.gitkeep | 1 + services/frontend-leptos/frontend/src/app.rs | 10 + services/frontend-leptos/frontend/src/lib.rs | 14 + 8 files changed, 2647 insertions(+) create mode 100644 services/frontend-leptos/Cargo.lock create mode 100644 services/frontend-leptos/frontend/Cargo.toml create mode 100644 services/frontend-leptos/frontend/Trunk.toml create mode 100644 services/frontend-leptos/frontend/index.html create mode 100644 services/frontend-leptos/frontend/public/.gitkeep create mode 100644 services/frontend-leptos/frontend/src/app.rs create mode 100644 services/frontend-leptos/frontend/src/lib.rs diff --git a/.gitignore b/.gitignore index 2ad00ae..064f75c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ logs/ .moon/docker worktrees/ .worktrees/ +frontend-leptos/frontend/dist/ +target/ diff --git a/services/frontend-leptos/Cargo.lock b/services/frontend-leptos/Cargo.lock new file mode 100644 index 0000000..6449282 --- /dev/null +++ b/services/frontend-leptos/Cargo.lock @@ -0,0 +1,2544 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "any_spawner" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41058deaa38c9d9dd933d6d238d825227cffa668e2839b52879f6619c63eee3b" +dependencies = [ + "futures", + "thiserror 2.0.18", + "wasm-bindgen-futures", +] + +[[package]] +name = "any_spawner" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" +dependencies = [ + "futures", + "thiserror 2.0.18", + "wasm-bindgen-futures", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn", +] + +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "codee" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3ad3122b0001c7f140cf4d605ef9a9e2c24d96ab0b4fb4347b76de2425f445" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "codee" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" +dependencies = [ + "convert_case 0.6.0", + "pathdiff", + "serde_core", + "toml", + "winnow", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case_extras" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb" +dependencies = [ + "convert_case 0.11.0", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "default-struct-builder" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0df63c21a4383f94bd5388564829423f35c316aed85dc4f8427aded372c7c0d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "either_of" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" +dependencies = [ + "paste", + "pin-project-lite", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "frontend" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "gloo-net", + "js-sys", + "leptos 0.7.8", + "leptos-use", + "lucide-leptos", + "serde", + "serde-wasm-bindgen", + "shared-types", + "wasm-bindgen", + "wasm-logger", + "web-sys", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "hydration_context" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35485b3dcbf7e044b8f28c73f04f13e7b509c2466fd10cb2a8a447e38f8a93a" +dependencies = [ + "futures", + "once_cell", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error 0.2.0", +] + +[[package]] +name = "hydration_context" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbbeb23ee808258cef2c5585ff0dc8e41da21a8dde943f6b290da153a042a96" +dependencies = [ + "futures", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error 0.3.1", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leptos" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b8731cb00f3f0894058155410b95c8955b17273181d2bc72600ab84edd24f1" +dependencies = [ + "any_spawner 0.2.0", + "cfg-if", + "either_of", + "futures", + "hydration_context 0.2.1", + "leptos_config 0.7.8", + "leptos_dom 0.7.8", + "leptos_hot_reload 0.7.8", + "leptos_macro 0.7.9", + "leptos_server 0.7.8", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph 0.1.8", + "rustc-hash", + "send_wrapper", + "serde", + "serde_qs 0.13.0", + "server_fn 0.7.8", + "slotmap", + "tachys 0.1.9", + "thiserror 2.0.18", + "throw_error 0.2.0", + "typed-builder 0.20.1", + "typed-builder-macro 0.20.1", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705e2951f3688e0c4f66bbb7a2702282782dcee716971dbd6209c2619d272479" +dependencies = [ + "any_spawner 0.3.0", + "cfg-if", + "either_of", + "futures", + "hydration_context 0.3.1", + "leptos_config 0.8.10", + "leptos_dom 0.8.8", + "leptos_hot_reload 0.8.6", + "leptos_macro 0.8.17", + "leptos_server 0.8.7", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph 0.2.14", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "serde_json", + "serde_qs 0.15.0", + "server_fn 0.8.13", + "slotmap", + "tachys 0.2.18", + "thiserror 2.0.18", + "throw_error 0.3.1", + "typed-builder 0.23.2", + "typed-builder-macro 0.23.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_split_helpers", + "web-sys", +] + +[[package]] +name = "leptos-use" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b50a99041c6685fdbca516bf6f90e75013e00a093d231c5f6a73b7b9dfff633" +dependencies = [ + "cfg-if", + "codee 0.2.0", + "cookie", + "default-struct-builder", + "futures-util", + "gloo-timers", + "js-sys", + "lazy_static", + "leptos 0.7.8", + "paste", + "send_wrapper", + "thiserror 2.0.18", + "unic-langid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "leptos_config" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bae3e0ead5a7a814c8340eef7cb8b6cba364125bd8174b15dc9fe1b3cab7e03" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.18", + "typed-builder 0.20.1", +] + +[[package]] +name = "leptos_config" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c06f751315bccc0d193fab302ac01d25bcfcd97474d4676440e7e3250dc3fc3" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.18", + "typed-builder 0.23.2", +] + +[[package]] +name = "leptos_dom" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89d4eb263bd5a9e7c49f780f17063f15aca56fd638c90b9dfd5f4739152e87d" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph 0.1.8", + "send_wrapper", + "tachys 0.1.9", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_dom" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph 0.2.14", + "send_wrapper", + "tachys 0.2.18", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e80219388501d99b246f43b6e7d08a28f327cdd34ba630a35654d917f3e1788e" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "parking_lot", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn", + "walkdir", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2a0f220c8a5ef3c51199dfb9cdd702bc0eb80d52fbe70c7890adfaaae8a4b1" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "or_poisoned", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn", + "walkdir", +] + +[[package]] +name = "leptos_macro" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e621f8f5342b9bdc93bb263b839cee7405027a74560425a2dabea9de7952b1fd" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.7.1", + "html-escape", + "itertools", + "leptos_hot_reload 0.7.8", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "server_fn_macro 0.7.8", + "syn", + "uuid", +] + +[[package]] +name = "leptos_macro" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de6e8da9d4f1a7b74b447b317d590ebabb38709f588f7ee20564b773ccbcce" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.11.0", + "convert_case_extras", + "html-escape", + "itertools", + "leptos_hot_reload 0.8.6", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "rustc_version", + "server_fn_macro 0.8.10", + "syn", + "uuid", +] + +[[package]] +name = "leptos_server" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66985242812ec95e224fb48effe651ba02728beca92c461a9464c811a71aab11" +dependencies = [ + "any_spawner 0.2.0", + "base64", + "codee 0.3.5", + "futures", + "hydration_context 0.2.1", + "or_poisoned", + "reactive_graph 0.1.8", + "send_wrapper", + "serde", + "serde_json", + "server_fn 0.7.8", + "tachys 0.1.9", +] + +[[package]] +name = "leptos_server" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" +dependencies = [ + "any_spawner 0.3.0", + "base64", + "codee 0.3.5", + "futures", + "hydration_context 0.3.1", + "or_poisoned", + "reactive_graph 0.2.14", + "send_wrapper", + "serde", + "serde_json", + "server_fn 0.8.13", + "tachys 0.2.18", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linear-map" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lucide-leptos" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cd884208fcbdffa621eb2b867e51a1ef9d016c599ccb195195aedf42fb29c1" +dependencies = [ + "leptos 0.8.20", +] + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "oco_ref" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "reactive_graph" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a0ccddbc11a648bd09761801dac9e3f246ef7641130987d6120fced22515e6" +dependencies = [ + "any_spawner 0.2.0", + "async-lock", + "futures", + "guardian", + "hydration_context 0.2.1", + "or_poisoned", + "pin-project-lite", + "rustc-hash", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "reactive_graph" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c5a025366836190c7030e883cc2bcd9e384ff555336e3c7954741ca411b177" +dependencies = [ + "any_spawner 0.3.0", + "async-lock", + "futures", + "guardian", + "hydration_context 0.3.1", + "indexmap", + "or_poisoned", + "paste", + "pin-project-lite", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aadc7c19e3a360bf19cd595d2dc8b58ce67b9240b95a103fbc1317a8ff194237" +dependencies = [ + "guardian", + "itertools", + "or_poisoned", + "paste", + "reactive_graph 0.1.8", + "reactive_stores_macro 0.1.8", + "rustc-hash", +] + +[[package]] +name = "reactive_stores" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30fd35b7d299c591293bb69fed47a703eb2703b1cff0493e78b16ed007e5382" +dependencies = [ + "guardian", + "indexmap", + "itertools", + "or_poisoned", + "paste", + "reactive_graph 0.2.14", + "reactive_stores_macro 0.4.3", + "rustc-hash", + "send_wrapper", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221095cb028dc51fbc2833743ea8b1a585da1a2af19b440b3528027495bf1f2d" +dependencies = [ + "convert_case 0.7.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68072edd607edd30b9ebf57d984ba45d8ab8809e598d0f6046278373fb76a5a0" +dependencies = [ + "convert_case 0.11.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rstml" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" +dependencies = [ + "derive-where", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", + "syn_derive", + "thiserror 2.0.18", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_qs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "server_fn" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d05a9e3fd8d7404985418db38c6617cc793a1a27f398d4fbc9dfe8e41b804e6" +dependencies = [ + "bytes", + "const_format", + "dashmap", + "futures", + "gloo-net", + "http", + "js-sys", + "once_cell", + "pin-project-lite", + "send_wrapper", + "serde", + "serde_json", + "serde_qs 0.13.0", + "server_fn_macro_default 0.7.8", + "thiserror 2.0.18", + "throw_error 0.2.0", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn" +version = "0.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812" +dependencies = [ + "base64", + "bytes", + "const-str", + "const_format", + "futures", + "gloo-net", + "http", + "js-sys", + "or_poisoned", + "pin-project-lite", + "rustc_version", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs 0.15.0", + "server_fn_macro_default 0.8.5", + "thiserror 2.0.18", + "throw_error 0.3.1", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504b35e883267b3206317b46d02952ed7b8bf0e11b2e209e2eb453b609a5e052" +dependencies = [ + "const_format", + "convert_case 0.6.0", + "proc-macro2", + "quote", + "syn", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1295b54815397d30d986b63f93cfd515fa86d5e528e0bb589ce9d530502f9e0f" +dependencies = [ + "const_format", + "convert_case 0.11.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8b274f568c94226a8045668554aace8142a59b8bca5414ac5a79627c825568" +dependencies = [ + "server_fn_macro 0.7.8", + "syn", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" +dependencies = [ + "server_fn_macro 0.8.10", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared-types" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tachys" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66c3b70c32844a6f1e2943c72a33ebb777ad6acbeb20d1329d62e3a7806d6ec" +dependencies = [ + "any_spawner 0.2.0", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "dyn-clone", + "either_of", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "linear-map", + "next_tuple", + "oco_ref", + "once_cell", + "or_poisoned", + "parking_lot", + "paste", + "reactive_graph 0.1.8", + "reactive_stores 0.1.8", + "rustc-hash", + "send_wrapper", + "slotmap", + "throw_error 0.2.0", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "tachys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92ba81187437cc5df4281f2326a2e13cc81e8f96448292d1112388e2025ca66" +dependencies = [ + "any_spawner 0.3.0", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "either_of", + "erased", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "next_tuple", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph 0.2.14", + "reactive_stores 0.4.3", + "rustc-hash", + "rustc_version", + "send_wrapper", + "slotmap", + "throw_error 0.3.1", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "throw_error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ef8bf264c6ae02a065a4a16553283f0656bd6266fc1fcb09fd2e6b5e91427b" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "throw_error" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "typed-builder" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +dependencies = [ + "typed-builder-macro 0.20.1", +] + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro 0.23.2", +] + +[[package]] +name = "typed-builder-macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-logger" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "074649a66bb306c8f2068c9016395fa65d8e08d2affcbf95acf3c24c3ab19718" +dependencies = [ + "log", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm_split_helpers" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab578aae2fe2916edaea06843187d50f87b0965622da0ceef648edca27b385ba" +dependencies = [ + "async-once-cell", + "wasm_split_macros", +] + +[[package]] +name = "wasm_split_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e653af7ee4a9ef0fce481a9ec6f43cb78de20d0cdb4f4f5862e1dc6e407e6c8" +dependencies = [ + "base16", + "quote", + "sha2", + "syn", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xxhash-rust" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/services/frontend-leptos/frontend/Cargo.toml b/services/frontend-leptos/frontend/Cargo.toml new file mode 100644 index 0000000..c8cabf3 --- /dev/null +++ b/services/frontend-leptos/frontend/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "frontend" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +leptos = { version = "0.7", features = ["csr"] } +leptos-use = "0.14" +lucide-leptos = "3" +shared-types = { path = "../shared-types" } +wasm-bindgen = "0.2" +js-sys = "0.3" +web-sys = { version = "0.3", features = [ + "WebSocket", + "MessageEvent", + "CloseEvent", + "CanvasRenderingContext2d", + "AudioContext", + "AudioBuffer", + "AudioBufferSourceNode", + "Window", + "Document", + "Element", + "HtmlElement", + "KeyboardEvent", + "Storage", + "IntersectionObserver", + "ResizeObserver", + "Url", + "Headers", + "Request", + "RequestInit", + "Response", + "HtmlInputElement", + "HtmlAudioElement", + "HtmlCanvasElement", + "MediaDevices", + "MediaStream", + "Navigator", + "console", +] } +gloo-net = "0.6" +serde = { version = "1", features = ["derive"] } +serde-wasm-bindgen = "0.6" +wasm-logger = "0.2" +console_error_panic_hook = "0.1" diff --git a/services/frontend-leptos/frontend/Trunk.toml b/services/frontend-leptos/frontend/Trunk.toml new file mode 100644 index 0000000..1ee94b8 --- /dev/null +++ b/services/frontend-leptos/frontend/Trunk.toml @@ -0,0 +1,7 @@ +[build] +target = "index.html" +dist = "dist" + +[serve] +port = 8080 +open = false diff --git a/services/frontend-leptos/frontend/index.html b/services/frontend-leptos/frontend/index.html new file mode 100644 index 0000000..2a16284 --- /dev/null +++ b/services/frontend-leptos/frontend/index.html @@ -0,0 +1,20 @@ + + + + + + + IMPHNEN -- Discord Moderation + + + + + + + + + + + + + diff --git a/services/frontend-leptos/frontend/public/.gitkeep b/services/frontend-leptos/frontend/public/.gitkeep new file mode 100644 index 0000000..2f48bcb --- /dev/null +++ b/services/frontend-leptos/frontend/public/.gitkeep @@ -0,0 +1 @@ +# Trunk copies this directory to dist/ diff --git a/services/frontend-leptos/frontend/src/app.rs b/services/frontend-leptos/frontend/src/app.rs new file mode 100644 index 0000000..0d3ae3e --- /dev/null +++ b/services/frontend-leptos/frontend/src/app.rs @@ -0,0 +1,10 @@ +use leptos::prelude::*; + +#[component] +pub fn App() -> impl IntoView { + view! { +
+ "Hello from Leptos" +
+ } +} diff --git a/services/frontend-leptos/frontend/src/lib.rs b/services/frontend-leptos/frontend/src/lib.rs new file mode 100644 index 0000000..7314da4 --- /dev/null +++ b/services/frontend-leptos/frontend/src/lib.rs @@ -0,0 +1,14 @@ +pub mod app; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(start)] +pub fn start() { + // Set up panic hook for better error messages in the browser console + console_error_panic_hook::set_once(); + // Initialize logger + wasm_logger::init(wasm_logger::Config::default()); + + // Mount the Leptos app to the body + leptos::mount::mount_to_body(app::App); +} From 65804acacafeef86c427cca771eb29f6ef744c52 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 17:36:28 +0700 Subject: [PATCH 03/25] feat(leptos): css design system with component classes and animations --- services/frontend-leptos/frontend/index.html | 1 + services/frontend-leptos/frontend/src/app.css | 758 ++++++++++++++++++ services/frontend-leptos/frontend/src/app.rs | 7 +- 3 files changed, 764 insertions(+), 2 deletions(-) create mode 100644 services/frontend-leptos/frontend/src/app.css diff --git a/services/frontend-leptos/frontend/index.html b/services/frontend-leptos/frontend/index.html index 2a16284..bcc7ac8 100644 --- a/services/frontend-leptos/frontend/index.html +++ b/services/frontend-leptos/frontend/index.html @@ -6,6 +6,7 @@ IMPHNEN -- Discord Moderation + diff --git a/services/frontend-leptos/frontend/src/app.css b/services/frontend-leptos/frontend/src/app.css new file mode 100644 index 0000000..ea0b704 --- /dev/null +++ b/services/frontend-leptos/frontend/src/app.css @@ -0,0 +1,758 @@ +/* ── Design Tokens ────────────────────────────────────── */ +:root { + /* Brand colors */ + --color-primary: #23a1eb; + --color-primary-hover: #1d8fd1; + --color-secondary: #1877f2; + --color-tertiary: #5865f2; + + /* Surface colors (light) */ + --surface-base: #ffffff; + --surface-raised: #f8fafc; + --surface-overlay: #f1f5f9; + --surface-border: #e2e8f0; + --surface-hover: #f1f5f9; + + /* Text colors (light) */ + --text-primary: #0f172a; + --text-secondary: #475569; + --text-tertiary: #94a3b8; + --text-inverse: #ffffff; + + /* Semantic colors */ + --color-success: #22c55e; + --color-warning: #f59e0b; + --color-error: #ef4444; + --color-info: #3b82f6; + + /* AI semantic colors */ + --color-ai-flagged: #ef4444; + --color-ai-clean: #22c55e; + --color-ai-warn: #f59e0b; + --color-ai-pending: #94a3b8; + --color-ai-processing: #3b82f6; + --color-ai-error: #dc2626; + --color-ai-deleted: #6b7280; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1); + + /* Glows */ + --glow-primary: 0 0 20px rgba(35, 161, 235, 0.3); + --glow-error: 0 0 20px rgba(239, 68, 68, 0.3); + + /* Border radius */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-full: 9999px; + + /* Spacing */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + + /* Sidebar */ + --sidebar-width: 256px; + --sidebar-collapsed-width: 64px; + + /* Header */ + --header-height: 56px; + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-normal: 250ms ease; + --transition-slow: 400ms ease; + + /* Z-index layers */ + --z-sidebar: 30; + --z-header: 20; + --z-overlay: 40; + --z-modal: 50; + --z-toast: 60; +} + +[data-theme="dark"] { + --surface-base: #0f172a; + --surface-raised: #1e293b; + --surface-overlay: #334155; + --surface-border: #334155; + --surface-hover: #1e293b; + + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-tertiary: #64748b; + --text-inverse: #0f172a; + + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.4); + + --glow-primary: 0 0 20px rgba(35, 161, 235, 0.15); + --glow-error: 0 0 20px rgba(239, 68, 68, 0.15); +} + +/* ── Base ─────────────────────────────────────────────── */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--surface-base); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; + overflow-x: hidden; +} + +a { + color: var(--color-primary); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +img { + max-width: 100%; + height: auto; +} + +/* ── Scrollbar ────────────────────────────────────────── */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--color-primary); + border-radius: var(--radius-full); +} + +/* ── Layout Utilities ─────────────────────────────────── */ +.flex { display: flex; } +.flex-col { flex-direction: column; } +.flex-wrap { flex-wrap: wrap; } +.items-center { align-items: center; } +.items-start { align-items: flex-start; } +.items-end { align-items: flex-end; } +.justify-center { justify-content: center; } +.justify-between { justify-content: space-between; } +.justify-end { justify-content: flex-end; } +.gap-1 { gap: var(--space-1); } +.gap-2 { gap: var(--space-2); } +.gap-3 { gap: var(--space-3); } +.gap-4 { gap: var(--space-4); } +.gap-6 { gap: var(--space-6); } +.gap-8 { gap: var(--space-8); } + +.grid { display: grid; } +.grid-cols-2 { grid-template-columns: repeat(2, 1fr); } +.grid-cols-3 { grid-template-columns: repeat(3, 1fr); } + +.w-full { width: 100%; } +.h-full { height: 100%; } + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.text-center { text-align: center; } +.text-sm { font-size: 0.875rem; } +.text-xs { font-size: 0.75rem; } +.text-lg { font-size: 1.125rem; } +.text-xl { font-size: 1.25rem; } +.text-2xl { font-size: 1.5rem; } +.text-3xl { font-size: 1.875rem; } + +.font-medium { font-weight: 500; } +.font-semibold { font-weight: 600; } +.font-bold { font-weight: 700; } + +.text-secondary { color: var(--text-secondary); } +.text-tertiary { color: var(--text-tertiary); } +.text-primary-color { color: var(--color-primary); } +.text-error { color: var(--color-error); } +.text-success { color: var(--color-success); } +.text-warning { color: var(--color-warning); } + +.ml-auto { margin-left: auto; } +.mr-2 { margin-right: var(--space-2); } +.mb-2 { margin-bottom: var(--space-2); } +.mb-4 { margin-bottom: var(--space-4); } +.mb-6 { margin-bottom: var(--space-6); } +.mt-2 { margin-top: var(--space-2); } +.mt-4 { margin-top: var(--space-4); } + +.p-2 { padding: var(--space-2); } +.p-3 { padding: var(--space-3); } +.p-4 { padding: var(--space-4); } +.p-6 { padding: var(--space-6); } +.px-3 { padding-left: var(--space-3); padding-right: var(--space-3); } +.px-4 { padding-left: var(--space-4); padding-right: var(--space-4); } +.py-2 { padding-top: var(--space-2); padding-bottom: var(--space-2); } + +.relative { position: relative; } +.absolute { position: absolute; } +.fixed { position: fixed; } +.overflow-auto { overflow: auto; } +.overflow-hidden { overflow: hidden; } +.overflow-y-auto { overflow-y: auto; } + +.hidden { display: none; } +.invisible { visibility: hidden; } +.cursor-pointer { cursor: pointer; } +.select-none { user-select: none; } + +/* ── Button ───────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: var(--radius-md); + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; + user-select: none; +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.btn-primary { + background: var(--color-primary); + color: white; +} +.btn-primary:hover:not(:disabled) { + background: var(--color-primary-hover); +} +.btn-secondary { + background: var(--surface-raised); + color: var(--text-primary); + border-color: var(--surface-border); +} +.btn-secondary:hover:not(:disabled) { + background: var(--surface-hover); + border-color: var(--color-primary); +} +.btn-tertiary { + background: transparent; + color: var(--text-primary); +} +.btn-tertiary:hover:not(:disabled) { + background: var(--surface-hover); +} +.btn-destructive { + background: var(--color-error); + color: white; +} +.btn-destructive:hover:not(:disabled) { + background: #dc2626; +} +.btn-outline { + background: transparent; + border-color: var(--surface-border); + color: var(--text-primary); +} +.btn-outline:hover:not(:disabled) { + background: var(--surface-hover); + border-color: var(--color-primary); +} +.btn-ghost { + background: transparent; + color: var(--text-secondary); + border: none; +} +.btn-ghost:hover:not(:disabled) { + background: var(--surface-hover); + color: var(--text-primary); +} +.btn-link { + background: none; + border: none; + color: var(--color-primary); + padding: 0; + text-decoration: none; +} +.btn-link:hover:not(:disabled) { + text-decoration: underline; +} +.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; } +.btn-lg { padding: 0.75rem 1.5rem; font-size: 1rem; } +.btn-icon { padding: 0.5rem; } +.btn-icon-sm { padding: 0.25rem; } + +/* ── Badge ────────────────────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.625rem; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 500; + line-height: 1.25rem; + background: var(--surface-raised); + color: var(--text-secondary); + border: 1px solid var(--surface-border); +} +.badge-primary { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); border-color: transparent; } +.badge-success { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); border-color: transparent; } +.badge-warning { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); border-color: transparent; } +.badge-destructive { background: color-mix(in srgb, var(--color-error) 15%, transparent); color: var(--color-error); border-color: transparent; } +.badge-outline { background: transparent; border-color: var(--surface-border); } +.badge-info { background: color-mix(in srgb, var(--color-info) 15%, transparent); color: var(--color-info); border-color: transparent; } + +/* ── Card ─────────────────────────────────────────────── */ +.card { + background: var(--surface-base); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + overflow: hidden; +} +.card-elevated { + box-shadow: var(--shadow-md); +} +.card-bordered { + border-width: 1px; +} +.card-header { + padding: var(--space-4) var(--space-6); + border-bottom: 1px solid var(--surface-border); +} +.card-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} +.card-description { + font-size: 0.875rem; + color: var(--text-secondary); + margin-top: var(--space-1); +} +.card-content { + padding: var(--space-6); +} +.card-footer { + padding: var(--space-4) var(--space-6); + border-top: 1px solid var(--surface-border); +} + +/* ── Input ────────────────────────────────────────────── */ +.input { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + background: var(--surface-raised); + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: inherit; + font-size: 0.875rem; + line-height: 1.25rem; + transition: all var(--transition-fast); + outline: none; +} +.input::placeholder { + color: var(--text-tertiary); +} +.input:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.15); +} +.input-soft { + background: var(--surface-overlay); + border-color: transparent; +} +.input-soft:focus { + background: var(--surface-base); + border-color: var(--color-primary); +} +.input[aria-invalid="true"], +.input-error { + border-color: var(--color-error); + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + +.select { + display: block; + width: 100%; + padding: 0.5rem 2rem 0.5rem 0.75rem; + background: var(--surface-raised); + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: inherit; + font-size: 0.875rem; + line-height: 1.25rem; + cursor: pointer; + transition: all var(--transition-fast); + outline: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.5rem center; +} +.select:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.15); +} + +/* ── Skeleton ─────────────────────────────────────────── */ +.skeleton { + background: linear-gradient(90deg, var(--surface-overlay) 25%, var(--surface-hover) 50%, var(--surface-overlay) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: var(--radius-md); +} +.skeleton-circular { + border-radius: 50%; +} +.skeleton-rectangular { + border-radius: 0; +} +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ── Tabs ─────────────────────────────────────────────── */ +.tabs { display: flex; flex-direction: column; } +.tab-list { + display: flex; + border-bottom: 1px solid var(--surface-border); + gap: 0; +} +.tab-trigger { + padding: 0.5rem 1rem; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); +} +.tab-trigger:hover { + color: var(--text-primary); +} +.tab-trigger[aria-selected="true"] { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} +.tab-content { + padding-top: var(--space-4); +} + +/* ── ScrollArea ───────────────────────────────────────── */ +.scroll-area { + overflow: auto; + scrollbar-width: thin; + scrollbar-color: var(--color-primary) transparent; +} + +/* ── Status Badge ─────────────────────────────────────── */ +.status-badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 0.125rem 0.5rem; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 500; +} +.status-badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; +} +.status-badge-flagged { + background: color-mix(in srgb, var(--color-ai-flagged) 15%, transparent); + color: var(--color-ai-flagged); +} +.status-badge-flagged::before { background: var(--color-ai-flagged); } +.status-badge-clean { + background: color-mix(in srgb, var(--color-ai-clean) 15%, transparent); + color: var(--color-ai-clean); +} +.status-badge-clean::before { background: var(--color-ai-clean); } +.status-badge-warn { + background: color-mix(in srgb, var(--color-ai-warn) 15%, transparent); + color: var(--color-ai-warn); +} +.status-badge-warn::before { background: var(--color-ai-warn); } +.status-badge-pending { + background: color-mix(in srgb, var(--color-ai-pending) 15%, transparent); + color: var(--color-ai-pending); +} +.status-badge-pending::before { background: var(--color-ai-pending); } +.status-badge-processing { + background: color-mix(in srgb, var(--color-ai-processing) 15%, transparent); + color: var(--color-ai-processing); +} +.status-badge-processing::before { + background: var(--color-ai-processing); + animation: pulse-dot 1.5s ease-in-out infinite; +} +.status-badge-error { + background: color-mix(in srgb, var(--color-ai-error) 15%, transparent); + color: var(--color-ai-error); +} +.status-badge-error::before { background: var(--color-ai-error); } +.status-badge-deleted { + background: color-mix(in srgb, var(--color-ai-deleted) 15%, transparent); + color: var(--color-ai-deleted); +} +.status-badge-deleted::before { background: var(--color-ai-deleted); } +.status-badge-none { + background: var(--surface-raised); + color: var(--text-tertiary); +} +.status-badge-none::before { background: var(--text-tertiary); } + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* ── Toast ────────────────────────────────────────────── */ +.toast-container { + position: fixed; + top: var(--space-4); + right: var(--space-4); + z-index: var(--z-toast); + display: flex; + flex-direction: column; + gap: var(--space-2); + pointer-events: none; +} +.toast { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0.75rem 1rem; + background: var(--surface-base); + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + min-width: 300px; + max-width: 420px; + pointer-events: auto; + animation: slide-in-right var(--transition-normal) ease-out; +} +.toast-success { border-left: 3px solid var(--color-success); } +.toast-error { border-left: 3px solid var(--color-error); } +.toast-warning { border-left: 3px solid var(--color-warning); } +.toast-info { border-left: 3px solid var(--color-info); } +.toast-close { + margin-left: auto; + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 0.25rem; +} + +/* ── Modal ────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-modal); + animation: fade-in var(--transition-fast) ease-out; +} +.modal-content { + background: var(--surface-base); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + max-width: 90vw; + max-height: 85vh; + overflow: auto; + animation: scale-in var(--transition-normal) ease-out; +} +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-6); + border-bottom: 1px solid var(--surface-border); +} +.modal-body { + padding: var(--space-6); +} +.modal-footer { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-4) var(--space-6); + border-top: 1px solid var(--surface-border); +} + +/* ── Empty State ──────────────────────────────────────── */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-12); + text-align: center; + color: var(--text-tertiary); +} +.empty-state-icon { + font-size: 2.5rem; + margin-bottom: var(--space-4); + opacity: 0.5; +} +.empty-state-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: var(--space-2); +} +.empty-state-description { + font-size: 0.875rem; + max-width: 300px; +} + +/* ── Animations ───────────────────────────────────────── */ +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes fade-in-up { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes scale-in { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} +@keyframes slide-in-right { + from { opacity: 0; transform: translateX(100%); } + to { opacity: 1; transform: translateX(0); } +} +@keyframes bar-pulse { + 0%, 100% { transform: scaleY(1); } + 50% { transform: scaleY(0.6); } +} +@keyframes glow-pulse { + 0%, 100% { box-shadow: var(--glow-primary); } + 50% { box-shadow: 0 0 30px rgba(35, 161, 235, 0.5); } +} +@keyframes notification-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} +@keyframes mascot-wiggle { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-5deg); } + 75% { transform: rotate(5deg); } +} + +.animate-fade-in { animation: fade-in var(--transition-normal) ease-out; } +.animate-fade-in-up { animation: fade-in-up var(--transition-normal) ease-out; } +.animate-scale-in { animation: scale-in var(--transition-normal) ease-out; } +.animate-slide-in-right { animation: slide-in-right var(--transition-normal) ease-out; } + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ── Grid Pattern Background ──────────────────────────── */ +.grid-pattern { + background-image: + linear-gradient(var(--surface-border) 1px, transparent 1px), + linear-gradient(90deg, var(--surface-border) 1px, transparent 1px); + background-size: 40px 40px; +} + +/* ── Particle Background ──────────────────────────────── */ +.particle-bg { + position: fixed; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 0; +} +.particle-orb { + position: absolute; + border-radius: 50%; + filter: blur(80px); + opacity: 0.15; +} +.particle-orb:nth-child(1) { + width: 500px; + height: 500px; + top: -100px; + right: -100px; + background: var(--color-primary); +} +.particle-orb:nth-child(2) { + width: 400px; + height: 400px; + bottom: -100px; + left: -100px; + background: var(--color-tertiary); +} +.particle-orb:nth-child(3) { + width: 300px; + height: 300px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--color-secondary); +} +@media (max-width: 768px) { + .particle-bg { display: none; } +} + +/* ── Responsive ───────────────────────────────────────── */ +@media (max-width: 768px) { + .grid-cols-2 { grid-template-columns: 1fr; } + .grid-cols-3 { grid-template-columns: 1fr; } + .hide-mobile { display: none !important; } +} +@media (min-width: 769px) { + .hide-desktop { display: none !important; } +} diff --git a/services/frontend-leptos/frontend/src/app.rs b/services/frontend-leptos/frontend/src/app.rs index 0d3ae3e..050f0a2 100644 --- a/services/frontend-leptos/frontend/src/app.rs +++ b/services/frontend-leptos/frontend/src/app.rs @@ -3,8 +3,11 @@ use leptos::prelude::*; #[component] pub fn App() -> impl IntoView { view! { -
- "Hello from Leptos" +
+ +
+ "Hello from Leptos" +
} } From 2b514142e6c226bb40dd2ad904b9be93135b1378 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 17:41:07 +0700 Subject: [PATCH 04/25] =?UTF-8?q?feat(leptos):=20UI=20primitives=20?= =?UTF-8?q?=E2=80=94=20Button,=20Badge,=20Card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/frontend-leptos/frontend/src/lib.rs | 1 + .../frontend-leptos/frontend/src/ui/badge.rs | 42 ++++++++++++ .../frontend-leptos/frontend/src/ui/button.rs | 67 +++++++++++++++++++ .../frontend-leptos/frontend/src/ui/card.rs | 46 +++++++++++++ .../frontend-leptos/frontend/src/ui/mod.rs | 4 ++ 5 files changed, 160 insertions(+) create mode 100644 services/frontend-leptos/frontend/src/ui/badge.rs create mode 100644 services/frontend-leptos/frontend/src/ui/button.rs create mode 100644 services/frontend-leptos/frontend/src/ui/card.rs create mode 100644 services/frontend-leptos/frontend/src/ui/mod.rs diff --git a/services/frontend-leptos/frontend/src/lib.rs b/services/frontend-leptos/frontend/src/lib.rs index 7314da4..096f807 100644 --- a/services/frontend-leptos/frontend/src/lib.rs +++ b/services/frontend-leptos/frontend/src/lib.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod ui; use wasm_bindgen::prelude::*; diff --git a/services/frontend-leptos/frontend/src/ui/badge.rs b/services/frontend-leptos/frontend/src/ui/badge.rs new file mode 100644 index 0000000..ed54523 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/badge.rs @@ -0,0 +1,42 @@ +use leptos::prelude::*; + +#[derive(Clone)] +pub enum BadgeVariant { + Default, + Primary, + Success, + Warning, + Destructive, + Outline, + Info, +} + +impl Default for BadgeVariant { + fn default() -> Self { + Self::Default + } +} + +#[component] +pub fn Badge( + #[prop(optional)] variant: BadgeVariant, + children: Children, +) -> impl IntoView { + let variant_class = match variant { + BadgeVariant::Default => "", + BadgeVariant::Primary => "badge-primary", + BadgeVariant::Success => "badge-success", + BadgeVariant::Warning => "badge-warning", + BadgeVariant::Destructive => "badge-destructive", + BadgeVariant::Outline => "badge-outline", + BadgeVariant::Info => "badge-info", + }; + + let combined = format!("badge {}", variant_class); + + view! { + + {children()} + + } +} diff --git a/services/frontend-leptos/frontend/src/ui/button.rs b/services/frontend-leptos/frontend/src/ui/button.rs new file mode 100644 index 0000000..4063181 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/button.rs @@ -0,0 +1,67 @@ +use leptos::prelude::*; + +#[derive(Clone, Default)] +pub enum ButtonVariant { + #[default] + Primary, + Secondary, + Tertiary, + Destructive, + Outline, + Ghost, + Link, +} + +#[derive(Clone)] +pub enum ButtonSize { + Default, + Sm, + Lg, + Icon, + IconSm, +} + +impl Default for ButtonSize { + fn default() -> Self { + Self::Default + } +} + +#[component] +pub fn Button( + #[prop(optional)] variant: ButtonVariant, + #[prop(optional)] size: ButtonSize, + #[prop(optional)] disabled: bool, + #[prop(optional)] class: &'static str, + #[prop(optional)] on_click: Option>, + children: Children, +) -> impl IntoView { + let variant_class = match variant { + ButtonVariant::Primary => "btn-primary", + ButtonVariant::Secondary => "btn-secondary", + ButtonVariant::Tertiary => "btn-tertiary", + ButtonVariant::Destructive => "btn-destructive", + ButtonVariant::Outline => "btn-outline", + ButtonVariant::Ghost => "btn-ghost", + ButtonVariant::Link => "btn-link", + }; + let size_class = match size { + ButtonSize::Default => "", + ButtonSize::Sm => "btn-sm", + ButtonSize::Lg => "btn-lg", + ButtonSize::Icon => "btn-icon", + ButtonSize::IconSm => "btn-icon-sm", + }; + + let combined = format!("btn {} {} {}", variant_class, size_class, class); + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/ui/card.rs b/services/frontend-leptos/frontend/src/ui/card.rs new file mode 100644 index 0000000..e0341e0 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/card.rs @@ -0,0 +1,46 @@ +use leptos::prelude::*; + +#[component] +pub fn Card( + #[prop(optional)] elevated: bool, + #[prop(optional)] bordered: bool, + #[prop(optional)] class: &'static str, + children: Children, +) -> impl IntoView { + let combined = format!("card {}", class); + + view! { +
+ {children()} +
+ } +} + +#[component] +pub fn CardHeader(children: Children) -> impl IntoView { + view! {
{children()}
} +} + +#[component] +pub fn CardTitle(children: Children) -> impl IntoView { + view! {

{children()}

} +} + +#[component] +pub fn CardDescription(children: Children) -> impl IntoView { + view! {

{children()}

} +} + +#[component] +pub fn CardContent(children: Children) -> impl IntoView { + view! {
{children()}
} +} + +#[component] +pub fn CardFooter(children: Children) -> impl IntoView { + view! { } +} diff --git a/services/frontend-leptos/frontend/src/ui/mod.rs b/services/frontend-leptos/frontend/src/ui/mod.rs new file mode 100644 index 0000000..cf0c674 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/mod.rs @@ -0,0 +1,4 @@ +// services/frontend-leptos/frontend/src/ui/mod.rs +pub mod badge; +pub mod button; +pub mod card; From b9e3a255bb9e94d0339279828dbb7df34cfa64b9 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 17:47:21 +0700 Subject: [PATCH 05/25] =?UTF-8?q?feat(leptos):=20UI=20primitives=20?= =?UTF-8?q?=E2=80=94=20Input,=20Select,=20Tabs,=20ScrollArea,=20Toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frontend-leptos/frontend/src/ui/input.rs | 47 ++++++++++ .../frontend-leptos/frontend/src/ui/mod.rs | 5 ++ .../frontend/src/ui/scroll_area.rs | 15 ++++ .../frontend-leptos/frontend/src/ui/select.rs | 30 +++++++ .../frontend-leptos/frontend/src/ui/tabs.rs | 66 ++++++++++++++ .../frontend-leptos/frontend/src/ui/toast.rs | 85 +++++++++++++++++++ 6 files changed, 248 insertions(+) create mode 100644 services/frontend-leptos/frontend/src/ui/input.rs create mode 100644 services/frontend-leptos/frontend/src/ui/scroll_area.rs create mode 100644 services/frontend-leptos/frontend/src/ui/select.rs create mode 100644 services/frontend-leptos/frontend/src/ui/tabs.rs create mode 100644 services/frontend-leptos/frontend/src/ui/toast.rs diff --git a/services/frontend-leptos/frontend/src/ui/input.rs b/services/frontend-leptos/frontend/src/ui/input.rs new file mode 100644 index 0000000..809aa61 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/input.rs @@ -0,0 +1,47 @@ +// services/frontend-leptos/frontend/src/ui/input.rs +use leptos::prelude::*; + +#[component] +pub fn Input( + #[prop(optional)] input_type: &'static str, + #[prop(optional)] placeholder: &'static str, + #[prop(optional)] value: RwSignal, + #[prop(optional)] soft: bool, + #[prop(optional)] error: bool, + #[prop(optional)] class: &'static str, + #[prop(optional)] on_input: Option>, +) -> impl IntoView { + view! { + + } +} + +#[component] +pub fn TextArea( + #[prop(optional)] placeholder: &'static str, + #[prop(optional)] value: RwSignal, + #[prop(optional)] rows: u32, + #[prop(optional)] class: &'static str, +) -> impl IntoView { + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/ui/mod.rs b/services/frontend-leptos/frontend/src/ui/mod.rs index cf0c674..d67bd50 100644 --- a/services/frontend-leptos/frontend/src/ui/mod.rs +++ b/services/frontend-leptos/frontend/src/ui/mod.rs @@ -2,3 +2,8 @@ pub mod badge; pub mod button; pub mod card; +pub mod input; +pub mod scroll_area; +pub mod select; +pub mod tabs; +pub mod toast; diff --git a/services/frontend-leptos/frontend/src/ui/scroll_area.rs b/services/frontend-leptos/frontend/src/ui/scroll_area.rs new file mode 100644 index 0000000..22f8f30 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/scroll_area.rs @@ -0,0 +1,15 @@ +// services/frontend-leptos/frontend/src/ui/scroll_area.rs +use leptos::prelude::*; + +#[component] +pub fn ScrollArea( + #[prop(optional)] class: &'static str, + #[prop(optional)] style: &'static str, + children: Children, +) -> impl IntoView { + view! { +
+ {children()} +
+ } +} diff --git a/services/frontend-leptos/frontend/src/ui/select.rs b/services/frontend-leptos/frontend/src/ui/select.rs new file mode 100644 index 0000000..24119b8 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/select.rs @@ -0,0 +1,30 @@ +// services/frontend-leptos/frontend/src/ui/select.rs +use leptos::prelude::*; + +/// Simple select — values and labels are the same +/// For options with different value/label, use `SelectOptions` +#[component] +pub fn Select( + #[prop(optional)] value: RwSignal, + options: Vec<(&'static str, &'static str)>, // (value, label) + #[prop(optional)] placeholder: &'static str, + #[prop(optional)] class: &'static str, + #[prop(optional)] on_change: Option>, +) -> impl IntoView { + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/ui/tabs.rs b/services/frontend-leptos/frontend/src/ui/tabs.rs new file mode 100644 index 0000000..f1916a1 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/tabs.rs @@ -0,0 +1,66 @@ +// services/frontend-leptos/frontend/src/ui/tabs.rs +use leptos::prelude::*; + +#[component] +pub fn Tabs( + active: RwSignal, + #[prop(optional)] class: &'static str, + children: Children, +) -> impl IntoView { + view! { +
+ {children()} +
+ } +} + +#[component] +pub fn TabList( + #[prop(optional)] class: &'static str, + children: Children, +) -> impl IntoView { + view! { +
+ {children()} +
+ } +} + +#[component] +pub fn TabTrigger( + value: String, + active: RwSignal, + children: Children, +) -> impl IntoView { + let v1 = value.clone(); + let v2 = value.clone(); + view! { + + } +} + +#[component] +pub fn TabContent( + value: String, + active: RwSignal, + children: Children, +) -> impl IntoView { + let is_selected = move || active.get() == value; + view! { +
+ {children()} +
+ } +} diff --git a/services/frontend-leptos/frontend/src/ui/toast.rs b/services/frontend-leptos/frontend/src/ui/toast.rs new file mode 100644 index 0000000..ac47d49 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/toast.rs @@ -0,0 +1,85 @@ +// services/frontend-leptos/frontend/src/ui/toast.rs +use leptos::prelude::*; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +pub enum ToastType { + Info, + Success, + Error, + Warning, +} + +#[derive(Clone)] +pub struct ToastMessage { + pub id: u64, + pub message: String, + pub toast_type: ToastType, +} + +#[derive(Clone)] +pub struct ToastContext { + pub toasts: RwSignal>, + next_id: Arc>, +} + +impl ToastContext { + pub fn new() -> Self { + Self { + toasts: create_rw_signal(vec![]), + next_id: Arc::new(Mutex::new(0)), + } + } + + pub fn show(&self, message: &str, toast_type: ToastType) { + let id = { + let mut n = self.next_id.lock().unwrap(); + *n += 1; + *n + }; + let msg = ToastMessage { + id, + message: message.to_string(), + toast_type, + }; + self.toasts.update(|t| t.push(msg)); + + // Auto-dismiss after 4 seconds + let toasts = self.toasts; + leptos::prelude::set_timeout( + move || { + toasts.update(|t| t.retain(|m| m.id != id)); + }, + std::time::Duration::from_secs(4), + ); + } +} + +#[component] +pub fn ToastProvider(children: Children) -> impl IntoView { + let ctx = ToastContext::new(); + provide_context(ctx.clone()); + + view! { + {children()} +
+ {move || ctx.toasts.get().into_iter().map(|msg| { + let type_class = match msg.toast_type { + ToastType::Info => "toast-info", + ToastType::Success => "toast-success", + ToastType::Error => "toast-error", + ToastType::Warning => "toast-warning", + }; + let toasts = ctx.toasts; + view! { +
+ {msg.message} + +
+ } + }).collect::>()} +
+ } +} From 5832ca7cd45af609b9377ed74b965289938cbdfe Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 17:59:38 +0700 Subject: [PATCH 06/25] =?UTF-8?q?feat(leptos):=20UI=20primitives=20?= =?UTF-8?q?=E2=80=94=20Skeleton,=20StatusBadge,=20EmptyState,=20Modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frontend/src/ui/empty_state.rs | 19 ++++++++ .../frontend-leptos/frontend/src/ui/mod.rs | 4 ++ .../frontend-leptos/frontend/src/ui/modal.rs | 46 +++++++++++++++++++ .../frontend/src/ui/skeleton.rs | 30 ++++++++++++ .../frontend/src/ui/status_badge.rs | 21 +++++++++ 5 files changed, 120 insertions(+) create mode 100644 services/frontend-leptos/frontend/src/ui/empty_state.rs create mode 100644 services/frontend-leptos/frontend/src/ui/modal.rs create mode 100644 services/frontend-leptos/frontend/src/ui/skeleton.rs create mode 100644 services/frontend-leptos/frontend/src/ui/status_badge.rs diff --git a/services/frontend-leptos/frontend/src/ui/empty_state.rs b/services/frontend-leptos/frontend/src/ui/empty_state.rs new file mode 100644 index 0000000..662f5d5 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/empty_state.rs @@ -0,0 +1,19 @@ +// services/frontend-leptos/frontend/src/ui/empty_state.rs +use leptos::prelude::*; + +#[component] +pub fn EmptyState( + #[prop(optional)] icon: Option, + title: &'static str, + #[prop(optional)] description: Option<&'static str>, + #[prop(optional)] children: Option, +) -> impl IntoView { + view! { +
+ {icon.map(|i| view! {
{i}
})} +
{title}
+ {description.map(|d| view! {

{d}

})} + {children.map(|c| c())} +
+ } +} diff --git a/services/frontend-leptos/frontend/src/ui/mod.rs b/services/frontend-leptos/frontend/src/ui/mod.rs index d67bd50..7ee2c6f 100644 --- a/services/frontend-leptos/frontend/src/ui/mod.rs +++ b/services/frontend-leptos/frontend/src/ui/mod.rs @@ -7,3 +7,7 @@ pub mod scroll_area; pub mod select; pub mod tabs; pub mod toast; +pub mod skeleton; +pub mod status_badge; +pub mod empty_state; +pub mod modal; diff --git a/services/frontend-leptos/frontend/src/ui/modal.rs b/services/frontend-leptos/frontend/src/ui/modal.rs new file mode 100644 index 0000000..866f9a9 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/modal.rs @@ -0,0 +1,46 @@ +// services/frontend-leptos/frontend/src/ui/modal.rs +use std::sync::Arc; +use leptos::prelude::*; + +#[component] +pub fn Modal( + is_open: RwSignal, + #[prop(optional)] title: Option<&'static str>, + #[prop(optional)] on_close: Option>, + children: Children, +) -> impl IntoView { + let oc1 = on_close.clone(); + let oc2 = on_close; + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/ui/skeleton.rs b/services/frontend-leptos/frontend/src/ui/skeleton.rs new file mode 100644 index 0000000..5cb9f49 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/skeleton.rs @@ -0,0 +1,30 @@ +// services/frontend-leptos/frontend/src/ui/skeleton.rs +use leptos::prelude::*; + +#[derive(Clone, Default)] +pub enum SkeletonShape { + #[default] + Rounded, + Circular, + Rectangular, +} + +#[component] +pub fn Skeleton( + #[prop(optional)] width: &'static str, + #[prop(optional)] height: &'static str, + #[prop(optional)] shape: SkeletonShape, +) -> impl IntoView { + let shape_class = match shape { + SkeletonShape::Rounded => "", + SkeletonShape::Circular => "skeleton-circular", + SkeletonShape::Rectangular => "skeleton-rectangular", + }; + let combined = format!("skeleton {}", shape_class); + view! { +
+ } +} diff --git a/services/frontend-leptos/frontend/src/ui/status_badge.rs b/services/frontend-leptos/frontend/src/ui/status_badge.rs new file mode 100644 index 0000000..5dfb59d --- /dev/null +++ b/services/frontend-leptos/frontend/src/ui/status_badge.rs @@ -0,0 +1,21 @@ +// services/frontend-leptos/frontend/src/ui/status_badge.rs +use leptos::prelude::*; +use shared_types::message::AiStatus; + +#[component] +pub fn StatusBadge(status: AiStatus) -> impl IntoView { + let (class, label) = match status { + AiStatus::Flagged => ("status-badge-flagged", "Flagged"), + AiStatus::Clean => ("status-badge-clean", "Clean"), + AiStatus::Warn => ("status-badge-warn", "Warned"), + AiStatus::Pending => ("status-badge-pending", "Pending"), + AiStatus::Processing => ("status-badge-processing", "Processing"), + AiStatus::Error => ("status-badge-error", "Error"), + }; + let combined = format!("status-badge {}", class); + view! { + + {label} + + } +} From 20ef8446827f57dbe5cc5aa4edc6446dcf096fd3 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 18:07:01 +0700 Subject: [PATCH 07/25] feat(leptos): WebSocket singleton with event dispatch --- services/frontend-leptos/Cargo.lock | 1 + services/frontend-leptos/frontend/Cargo.toml | 2 + services/frontend-leptos/frontend/src/lib.rs | 1 + .../frontend/src/ws/context.rs | 140 ++++++++++++++++ .../frontend-leptos/frontend/src/ws/mod.rs | 3 + .../frontend-leptos/frontend/src/ws/socket.rs | 152 ++++++++++++++++++ 6 files changed, 299 insertions(+) create mode 100644 services/frontend-leptos/frontend/src/ws/context.rs create mode 100644 services/frontend-leptos/frontend/src/ws/mod.rs create mode 100644 services/frontend-leptos/frontend/src/ws/socket.rs diff --git a/services/frontend-leptos/Cargo.lock b/services/frontend-leptos/Cargo.lock index 6449282..af0a264 100644 --- a/services/frontend-leptos/Cargo.lock +++ b/services/frontend-leptos/Cargo.lock @@ -498,6 +498,7 @@ dependencies = [ "lucide-leptos", "serde", "serde-wasm-bindgen", + "serde_json", "shared-types", "wasm-bindgen", "wasm-logger", diff --git a/services/frontend-leptos/frontend/Cargo.toml b/services/frontend-leptos/frontend/Cargo.toml index c8cabf3..07de2e1 100644 --- a/services/frontend-leptos/frontend/Cargo.toml +++ b/services/frontend-leptos/frontend/Cargo.toml @@ -17,6 +17,7 @@ web-sys = { version = "0.3", features = [ "WebSocket", "MessageEvent", "CloseEvent", + "ErrorEvent", "CanvasRenderingContext2d", "AudioContext", "AudioBuffer", @@ -44,6 +45,7 @@ web-sys = { version = "0.3", features = [ ] } gloo-net = "0.6" serde = { version = "1", features = ["derive"] } +serde_json = "1" serde-wasm-bindgen = "0.6" wasm-logger = "0.2" console_error_panic_hook = "0.1" diff --git a/services/frontend-leptos/frontend/src/lib.rs b/services/frontend-leptos/frontend/src/lib.rs index 096f807..ea7fdf6 100644 --- a/services/frontend-leptos/frontend/src/lib.rs +++ b/services/frontend-leptos/frontend/src/lib.rs @@ -1,5 +1,6 @@ pub mod app; pub mod ui; +pub mod ws; use wasm_bindgen::prelude::*; diff --git a/services/frontend-leptos/frontend/src/ws/context.rs b/services/frontend-leptos/frontend/src/ws/context.rs new file mode 100644 index 0000000..e93789f --- /dev/null +++ b/services/frontend-leptos/frontend/src/ws/context.rs @@ -0,0 +1,140 @@ +// services/frontend-leptos/frontend/src/ws/context.rs +use leptos::prelude::*; +use crate::ws::socket::{WsHandle, WsStatus, WsEvent}; +use shared_types::message::MessageRecord; +use shared_types::voice::ActiveSpeaker; +use shared_types::media::MediaState; +use shared_types::recording::VoiceRecording; + +#[derive(Clone)] +pub struct WsContext { + pub handle: std::rc::Rc, + pub status: ReadSignal, + // Per-event callbacks (set externally by feature components) + // Wrapped in Rc so cloning shares the same callback slots + pub on_message_created: std::rc::Rc>>>, + pub on_message_updated: std::rc::Rc>>>, + pub on_message_deleted: std::rc::Rc>>>, + pub on_message_analyzed: std::rc::Rc>>>, + pub on_voice_active_user: std::rc::Rc>>>, + pub on_voice_recording_uploaded: std::rc::Rc>>>, + pub on_media_state: std::rc::Rc>>>, + pub on_binary: std::rc::Rc)>>>>, +} + +impl WsContext { + pub fn new(url: &str) -> Self { + let ws_handle = std::rc::Rc::new(WsHandle::new(url)); + let status = ws_handle.status; + + let ctx = Self { + status, + handle: ws_handle, + on_message_created: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_message_updated: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_message_deleted: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_message_analyzed: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_voice_active_user: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_voice_recording_uploaded: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_media_state: std::rc::Rc::new(std::cell::RefCell::new(None)), + on_binary: std::rc::Rc::new(std::cell::RefCell::new(None)), + }; + + // Wire up the main event dispatcher + let ctx_clone = ctx.clone(); + ctx.handle.on_event(move |event| { + ctx_clone.dispatch_event(event); + }); + + ctx + } + + fn dispatch_event(&self, event: WsEvent) { + match event { + WsEvent::Text(text) => { + // Parse JSON envelope: { type: string, data?: any } + if let Ok(parsed) = serde_json::from_str::(&text) { + let event_type = parsed["type"].as_str().unwrap_or("").to_string(); + let data = parsed.get("data"); + + match event_type.as_str() { + "message_created" => { + if let Some(d) = data.and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(cb) = self.on_message_created.borrow().as_ref() { + cb(d); + } + } + } + "message_updated" => { + if let Some(d) = data.and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(cb) = self.on_message_updated.borrow().as_ref() { + cb(d); + } + } + } + "message_deleted" => { + if let Some(d) = data.and_then(|v| v.as_str().map(String::from)) { + if let Some(cb) = self.on_message_deleted.borrow().as_ref() { + cb(d); + } + } + } + "message_analyzed" => { + if let Some(d) = data.and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(cb) = self.on_message_analyzed.borrow().as_ref() { + cb(d); + } + } + } + "voice_active_user" => { + if let Some(d) = data.and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(cb) = self.on_voice_active_user.borrow().as_ref() { + cb(d); + } + } + } + "voice_recording_uploaded" => { + if let Some(d) = data.and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(cb) = self.on_voice_recording_uploaded.borrow().as_ref() { + cb(d); + } + } + } + "media_state" => { + if let Some(d) = data.and_then(|v| serde_json::from_value::(v.clone()).ok()) { + if let Some(cb) = self.on_media_state.borrow().as_ref() { + cb(d); + } + } + } + _ => { + // Unknown event type — log and ignore + web_sys::console::log_1(&format!("[WS] unhandled event: {}", event_type).into()); + } + } + } + } + WsEvent::Binary(data) => { + if let Some(cb) = self.on_binary.borrow().as_ref() { + cb(data); + } + } + } + } + + pub fn connect(&self) { + self.handle.connect(); + } + + pub fn disconnect(&self) { + self.handle.disconnect(); + } + + pub fn send_text(&self, text: &str) { + let _ = self.handle.send_text(text); + } + + pub fn send_binary(&self, data: &[u8]) { + let _ = self.handle.send_binary(data); + } +} diff --git a/services/frontend-leptos/frontend/src/ws/mod.rs b/services/frontend-leptos/frontend/src/ws/mod.rs new file mode 100644 index 0000000..71e685b --- /dev/null +++ b/services/frontend-leptos/frontend/src/ws/mod.rs @@ -0,0 +1,3 @@ +// services/frontend-leptos/frontend/src/ws/mod.rs +pub mod socket; +pub mod context; diff --git a/services/frontend-leptos/frontend/src/ws/socket.rs b/services/frontend-leptos/frontend/src/ws/socket.rs new file mode 100644 index 0000000..1e612e4 --- /dev/null +++ b/services/frontend-leptos/frontend/src/ws/socket.rs @@ -0,0 +1,152 @@ +// services/frontend-leptos/frontend/src/ws/socket.rs +use leptos::prelude::*; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use web_sys::{WebSocket, MessageEvent, CloseEvent, ErrorEvent}; + +#[derive(Debug, Clone, PartialEq)] +pub enum WsStatus { + Disconnected, + Connecting, + Connected, + Error(String), +} + +#[derive(Debug, Clone)] +pub enum WsEvent { + Text(String), + Binary(Vec), +} + +pub struct WsHandle { + pub status: ReadSignal, + set_status: WriteSignal, + ws: std::cell::RefCell>, + on_event: std::rc::Rc>>>, + url: String, + reconnect_attempt: std::cell::Cell, +} + +impl WsHandle { + pub fn new(url: &str) -> Self { + let (status, set_status) = create_signal(WsStatus::Disconnected); + Self { + status, + set_status, + ws: std::cell::RefCell::new(None), + on_event: std::rc::Rc::new(std::cell::RefCell::new(None)), + url: url.to_string(), + reconnect_attempt: std::cell::Cell::new(0), + } + } + + pub fn on_event(&self, callback: F) + where + F: Fn(WsEvent) + 'static, + { + *self.on_event.borrow_mut() = Some(Box::new(callback)); + } + + pub fn connect(&self) { + if self.status.get() == WsStatus::Connected || self.status.get() == WsStatus::Connecting { + return; + } + self.set_status.set(WsStatus::Connecting); + + let url = self.url.clone(); + let status_clone = self.set_status.clone(); + // Clone the Rc wrapper (cheap pointer copy, inner RefCell is shared) + let event_clone: std::rc::Rc>>> = self.on_event.clone(); + let ws_holder = &self.ws as *const std::cell::RefCell>; + let reconnect_attempt = &self.reconnect_attempt as *const std::cell::Cell; + + // Clone again for closures + let status2 = status_clone.clone(); + let status3 = status_clone.clone(); + + match WebSocket::new(&url) { + Ok(ws) => { + // Store reference + unsafe { *(*ws_holder).borrow_mut() = Some(ws.clone()) }; + + // onopen + let onopen_cb = Closure::::new(move |_| { + status_clone.set(WsStatus::Connected); + unsafe { (*reconnect_attempt).set(0) }; + }); + ws.set_onopen(Some(onopen_cb.as_ref().unchecked_ref())); + onopen_cb.forget(); + + // onclose + let onclose_cb = Closure::::new(move |_| { + status2.set(WsStatus::Disconnected); + unsafe { *(*ws_holder).borrow_mut() = None }; + }); + ws.set_onclose(Some(onclose_cb.as_ref().unchecked_ref())); + onclose_cb.forget(); + + // onerror + let onerror_cb = Closure::::new(move |e: ErrorEvent| { + let msg = e.message(); + status3.set(WsStatus::Error(msg)); + }); + ws.set_onerror(Some(onerror_cb.as_ref().unchecked_ref())); + onerror_cb.forget(); + + // onmessage + let onmsg_cb = Closure::::new(move |e: MessageEvent| { + if let Some(cb) = &*event_clone.borrow() { + if let Some(text) = e.data().as_string() { + cb(WsEvent::Text(text)); + } else if let Some(abuf) = e.data().dyn_ref::() { + let len = abuf.byte_length() as usize; + let u8view = js_sys::Uint8Array::new(abuf); + let mut bytes = vec![0u8; len]; + u8view.copy_to(&mut bytes); + cb(WsEvent::Binary(bytes)); + } else { + // Try Blob + let data = e.data(); + let blob = data.dyn_ref::(); + if blob.is_some() { + // Blob handling would need async FileReader — skip for now + } + } + } + }); + ws.set_onmessage(Some(onmsg_cb.as_ref().unchecked_ref())); + onmsg_cb.forget(); + } + Err(e) => { + status_clone.set(WsStatus::Error( + js_sys::Error::from(e).to_string().as_string().unwrap_or_default(), + )); + } + } + } + + pub fn disconnect(&self) { + if let Some(ws) = self.ws.borrow_mut().take() { + ws.close().ok(); + } + self.set_status.set(WsStatus::Disconnected); + } + + pub fn send_text(&self, text: &str) -> Result<(), JsValue> { + if let Some(ws) = self.ws.borrow().as_ref() { + ws.send_with_str(text) + } else { + Err(JsValue::from_str("WebSocket not connected")) + } + } + + pub fn send_binary(&self, data: &[u8]) -> Result<(), JsValue> { + if let Some(ws) = self.ws.borrow().as_ref() { + let array = js_sys::Uint8Array::from(data); + let buffer = array.buffer(); + ws.send_with_array_buffer(&buffer) + } else { + Err(JsValue::from_str("WebSocket not connected")) + } + } +} From aa20d1a633139ad243284c6900f3810b4d90aed0 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 18:09:30 +0700 Subject: [PATCH 08/25] feat(leptos): API client with all endpoint functions --- services/frontend-leptos/Cargo.lock | 1 + services/frontend-leptos/frontend/Cargo.toml | 2 + .../frontend-leptos/frontend/src/api/auth.rs | 21 +++ .../frontend/src/api/client.rs | 131 ++++++++++++++++++ .../frontend/src/api/dashboard.rs | 61 ++++++++ .../frontend/src/api/messages.rs | 57 ++++++++ .../frontend-leptos/frontend/src/api/mod.rs | 6 + .../frontend/src/api/recordings.rs | 20 +++ .../frontend-leptos/frontend/src/api/voice.rs | 81 +++++++++++ services/frontend-leptos/frontend/src/lib.rs | 1 + 10 files changed, 381 insertions(+) create mode 100644 services/frontend-leptos/frontend/src/api/auth.rs create mode 100644 services/frontend-leptos/frontend/src/api/client.rs create mode 100644 services/frontend-leptos/frontend/src/api/dashboard.rs create mode 100644 services/frontend-leptos/frontend/src/api/messages.rs create mode 100644 services/frontend-leptos/frontend/src/api/mod.rs create mode 100644 services/frontend-leptos/frontend/src/api/recordings.rs create mode 100644 services/frontend-leptos/frontend/src/api/voice.rs 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; From 5389c82f31a5d9782f7ef0f9c218bbf53487c0a0 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 18:16:08 +0700 Subject: [PATCH 09/25] feat(leptos): layout components -- Header, Sidebar, TabStrip, MobileTabBar --- services/frontend-leptos/frontend/src/app.rs | 6 ++ .../frontend/src/layout/dashboard_layout.rs | 28 ++++++++ .../frontend/src/layout/header.rs | 56 +++++++++++++++ .../frontend/src/layout/mobile_tab_bar.rs | 63 ++++++++++++++++ .../frontend/src/layout/mod.rs | 6 ++ .../frontend/src/layout/sidebar.rs | 71 +++++++++++++++++++ .../frontend/src/layout/tab_strip.rs | 54 ++++++++++++++ services/frontend-leptos/frontend/src/lib.rs | 1 + 8 files changed, 285 insertions(+) create mode 100644 services/frontend-leptos/frontend/src/layout/dashboard_layout.rs create mode 100644 services/frontend-leptos/frontend/src/layout/header.rs create mode 100644 services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs create mode 100644 services/frontend-leptos/frontend/src/layout/mod.rs create mode 100644 services/frontend-leptos/frontend/src/layout/sidebar.rs create mode 100644 services/frontend-leptos/frontend/src/layout/tab_strip.rs diff --git a/services/frontend-leptos/frontend/src/app.rs b/services/frontend-leptos/frontend/src/app.rs index 050f0a2..cbfa661 100644 --- a/services/frontend-leptos/frontend/src/app.rs +++ b/services/frontend-leptos/frontend/src/app.rs @@ -1,5 +1,11 @@ use leptos::prelude::*; +// Temporary stub until Task 4 creates the real UiContext +#[derive(Clone)] +pub struct UiContext { + pub active_tab: RwSignal, +} + #[component] pub fn App() -> impl IntoView { view! { diff --git a/services/frontend-leptos/frontend/src/layout/dashboard_layout.rs b/services/frontend-leptos/frontend/src/layout/dashboard_layout.rs new file mode 100644 index 0000000..64a7005 --- /dev/null +++ b/services/frontend-leptos/frontend/src/layout/dashboard_layout.rs @@ -0,0 +1,28 @@ +// services/frontend-leptos/frontend/src/layout/dashboard_layout.rs +use leptos::children::Children; +use leptos::prelude::*; +use super::header::Header; +use super::mobile_tab_bar::MobileTabBar; +use super::sidebar::Sidebar; +use super::tab_strip::TabStrip; + +#[component] +pub fn DashboardLayout( + children: Children, +) -> impl IntoView { + view! { +
+
+
+ +
+ +
+ {children()} +
+
+
+ +
+ } +} diff --git a/services/frontend-leptos/frontend/src/layout/header.rs b/services/frontend-leptos/frontend/src/layout/header.rs new file mode 100644 index 0000000..4b8a95e --- /dev/null +++ b/services/frontend-leptos/frontend/src/layout/header.rs @@ -0,0 +1,56 @@ +// services/frontend-leptos/frontend/src/layout/header.rs +use leptos::prelude::*; +use crate::ws::context::WsContext; +use crate::ws::socket::WsStatus; + +#[component] +pub fn Header() -> impl IntoView { + let ws = use_context::().expect("WsContext not provided"); + let ws_status = ws.status; + + let indicator_text_memo = create_memo(move |_| match ws_status.get() { + WsStatus::Connected => "Online", + WsStatus::Connecting => "Menghubungkan...", + WsStatus::Disconnected => "Offline", + WsStatus::Error(_) => "Error", + }); + let indicator_color_memo = create_memo(move |_| match ws_status.get() { + WsStatus::Connected => "var(--color-success)", + WsStatus::Connecting => "var(--color-warning)", + WsStatus::Disconnected => "var(--text-tertiary)", + WsStatus::Error(_) => "var(--color-error)", + }); + + view! { +
+
+ + "IMPHNEN" + + + "Guild Watcher" + +
+ +
+
+ + {move || indicator_text_memo.get()} +
+
+
+ } +} diff --git a/services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs b/services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs new file mode 100644 index 0000000..9de8dcd --- /dev/null +++ b/services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs @@ -0,0 +1,63 @@ +// services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs +use leptos::prelude::*; +use shared_types::ui_state::Tab; +use crate::app::UiContext; + +#[component] +pub fn MobileTabBar() -> impl IntoView { + let ui = use_context::().expect("UiContext not provided"); + + view! { +
+ + + +
+ } +} + +#[component] +fn MobileTabItem( + icon: &'static str, + label: &'static str, + tab: Tab, + ui: UiContext, +) -> impl IntoView { + let tab_active = tab.clone(); + let tab_click = tab; + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/layout/mod.rs b/services/frontend-leptos/frontend/src/layout/mod.rs new file mode 100644 index 0000000..63c4a67 --- /dev/null +++ b/services/frontend-leptos/frontend/src/layout/mod.rs @@ -0,0 +1,6 @@ +// services/frontend-leptos/frontend/src/layout/mod.rs +pub mod dashboard_layout; +pub mod header; +pub mod mobile_tab_bar; +pub mod sidebar; +pub mod tab_strip; diff --git a/services/frontend-leptos/frontend/src/layout/sidebar.rs b/services/frontend-leptos/frontend/src/layout/sidebar.rs new file mode 100644 index 0000000..764e569 --- /dev/null +++ b/services/frontend-leptos/frontend/src/layout/sidebar.rs @@ -0,0 +1,71 @@ +// services/frontend-leptos/frontend/src/layout/sidebar.rs +use leptos::prelude::*; +use shared_types::ui_state::Tab; +use crate::app::UiContext; + +#[component] +pub fn Sidebar() -> impl IntoView { + let ui = use_context::().expect("UiContext not provided"); + let (collapsed, _set_collapsed) = create_signal(false); + + view! { + + } +} + +#[component] +fn NavItem( + icon: &'static str, + label: &'static str, + tab: Tab, + ui: UiContext, +) -> impl IntoView { + let tab_bg = tab.clone(); + let tab_clr = tab.clone(); + let tab_click = tab; + let handle_click = move |_| ui.active_tab.set(tab_click.clone()); + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/layout/tab_strip.rs b/services/frontend-leptos/frontend/src/layout/tab_strip.rs new file mode 100644 index 0000000..7052f3f --- /dev/null +++ b/services/frontend-leptos/frontend/src/layout/tab_strip.rs @@ -0,0 +1,54 @@ +// services/frontend-leptos/frontend/src/layout/tab_strip.rs +use leptos::prelude::*; +use shared_types::ui_state::Tab; +use crate::app::UiContext; + +#[component] +pub fn TabStrip() -> impl IntoView { + let ui = use_context::().expect("UiContext not provided"); + + view! { +
+ + + +
+ } +} + +#[component] +fn TabItem( + label: &'static str, + tab: Tab, + ui: UiContext, +) -> impl IntoView { + let tab_color = tab.clone(); + let tab_border = tab.clone(); + let tab_click = tab; + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/lib.rs b/services/frontend-leptos/frontend/src/lib.rs index 5f76023..e2ac5be 100644 --- a/services/frontend-leptos/frontend/src/lib.rs +++ b/services/frontend-leptos/frontend/src/lib.rs @@ -1,5 +1,6 @@ pub mod api; pub mod app; +pub mod layout; pub mod ui; pub mod ws; From 2520faad47cb580f4b54f1cfd7f6c18ef2a275cb Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 18:22:28 +0700 Subject: [PATCH 10/25] feat(leptos): app shell with auth gate and tab routing --- services/frontend-leptos/frontend/src/app.rs | 102 ++++++++++++++++-- services/frontend-leptos/frontend/src/auth.rs | 56 ++++++++++ services/frontend-leptos/frontend/src/lib.rs | 1 + 3 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 services/frontend-leptos/frontend/src/auth.rs diff --git a/services/frontend-leptos/frontend/src/app.rs b/services/frontend-leptos/frontend/src/app.rs index cbfa661..69472ec 100644 --- a/services/frontend-leptos/frontend/src/app.rs +++ b/services/frontend-leptos/frontend/src/app.rs @@ -1,19 +1,109 @@ use leptos::prelude::*; +use shared_types::ui_state::Tab; +use crate::auth::AuthOverlay; + +// ── Contexts ──────────────────────────────────────────── + +#[derive(Clone)] +pub struct AuthContext { + pub authenticated: RwSignal, + pub password: RwSignal, +} -// Temporary stub until Task 4 creates the real UiContext #[derive(Clone)] pub struct UiContext { - pub active_tab: RwSignal, + pub active_tab: RwSignal, + pub selected_guild: RwSignal>, } +// ── App ───────────────────────────────────────────────── + #[component] pub fn App() -> impl IntoView { + // Initialize contexts + let auth = AuthContext { + authenticated: create_rw_signal(false), + password: create_rw_signal(String::new()), + }; + let ui = UiContext { + active_tab: create_rw_signal(Tab::Messages), + selected_guild: create_rw_signal(None), + }; + + provide_context(auth.clone()); + provide_context(ui.clone()); + + // Auth check: redirect "live" tab to "messages" if not authenticated + create_effect(move |_| { + if !auth.authenticated.get() && ui.active_tab.get() == Tab::Live { + ui.active_tab.set(Tab::Messages); + } + }); + view! { -
- -
- "Hello from Leptos" +
+ // Auth overlay + {move || (!auth.authenticated.get()).then(|| { + view! { } + })} + + // Main content (minimal for now — filled in later tasks) +
+
+ "IMPHNEN" + "Discord Moderation" +
+ +
+ // Sidebar placeholder + + + // Content area +
+ {move || match ui.active_tab.get() { + Tab::Messages => view! {
"Messages Panel"
}.into_view(), + Tab::Live => view! {
"Live Panel"
}.into_view(), + Tab::Dashboard => view! {
"Dashboard Panel"
}.into_view(), + }} +
+
} } + +// ── Tab Button Helper ─────────────────────────────────── + +#[component] +fn TabButton( + tab: Tab, + ui: UiContext, + label: &'static str, +) -> impl IntoView { + let active_tab = ui.active_tab.clone(); + let tab1 = tab.clone(); + let tab2 = tab.clone(); + let tab3 = tab.clone(); + let tab4 = tab; + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/auth.rs b/services/frontend-leptos/frontend/src/auth.rs new file mode 100644 index 0000000..a3c97b0 --- /dev/null +++ b/services/frontend-leptos/frontend/src/auth.rs @@ -0,0 +1,56 @@ +use leptos::prelude::*; +use leptos::ev::SubmitEvent; + +#[component] +pub fn AuthOverlay( + /// Called when password is submitted — parent handles the actual API call + #[prop(default = ())] + on_submit: (), +) -> impl IntoView { + let (password, set_password) = create_signal(String::new()); + let (error, set_error) = create_signal(Option::::None); + let (loading, set_loading) = create_signal(false); + + let handle_submit = move |ev: SubmitEvent| { + ev.prevent_default(); + if password.get().is_empty() { + set_error.set(Some("Password diperlukan".to_string())); + return; + } + // TODO: actual login call (wired in Task 7) + set_loading.set(true); + }; + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/lib.rs b/services/frontend-leptos/frontend/src/lib.rs index e2ac5be..74a9c4d 100644 --- a/services/frontend-leptos/frontend/src/lib.rs +++ b/services/frontend-leptos/frontend/src/lib.rs @@ -1,5 +1,6 @@ pub mod api; pub mod app; +pub mod auth; pub mod layout; pub mod ui; pub mod ws; From bf36bf55fec5f5575dff0e9a27ebc36ce3fa1773 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 18:30:41 +0700 Subject: [PATCH 11/25] feat(leptos): integrate auth with API + WS auth gating --- services/frontend-leptos/frontend/src/app.rs | 14 +++++ services/frontend-leptos/frontend/src/auth.rs | 60 +++++++++++++++---- .../frontend/src/ws/context.rs | 7 +++ .../frontend-leptos/frontend/src/ws/socket.rs | 7 +++ 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/services/frontend-leptos/frontend/src/app.rs b/services/frontend-leptos/frontend/src/app.rs index 69472ec..d60f57f 100644 --- a/services/frontend-leptos/frontend/src/app.rs +++ b/services/frontend-leptos/frontend/src/app.rs @@ -1,6 +1,7 @@ use leptos::prelude::*; use shared_types::ui_state::Tab; use crate::auth::AuthOverlay; +use crate::ws::context::WsContext; // ── Contexts ──────────────────────────────────────────── @@ -33,6 +34,9 @@ pub fn App() -> impl IntoView { provide_context(auth.clone()); provide_context(ui.clone()); + let ws = WsContext::new("ws://localhost:3001"); + provide_context(ws.clone()); + // Auth check: redirect "live" tab to "messages" if not authenticated create_effect(move |_| { if !auth.authenticated.get() && ui.active_tab.get() == Tab::Live { @@ -40,6 +44,16 @@ pub fn App() -> impl IntoView { } }); + { + let ws = ws.clone(); + let auth = auth.clone(); + create_effect(move |_| { + if auth.authenticated.get() { + ws.connect(); + } + }); + } + view! {
// Auth overlay diff --git a/services/frontend-leptos/frontend/src/auth.rs b/services/frontend-leptos/frontend/src/auth.rs index a3c97b0..c718809 100644 --- a/services/frontend-leptos/frontend/src/auth.rs +++ b/services/frontend-leptos/frontend/src/auth.rs @@ -1,24 +1,53 @@ +// services/frontend-leptos/frontend/src/auth.rs use leptos::prelude::*; -use leptos::ev::SubmitEvent; +use wasm_bindgen_futures::spawn_local; +use crate::app::AuthContext; +use crate::api::auth as auth_api; #[component] -pub fn AuthOverlay( - /// Called when password is submitted — parent handles the actual API call - #[prop(default = ())] - on_submit: (), -) -> impl IntoView { +pub fn AuthOverlay() -> impl IntoView { + let auth = use_context::().expect("AuthContext not provided"); let (password, set_password) = create_signal(String::new()); let (error, set_error) = create_signal(Option::::None); let (loading, set_loading) = create_signal(false); - let handle_submit = move |ev: SubmitEvent| { + let handle_submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); - if password.get().is_empty() { + let pwd = password.get(); + if pwd.is_empty() { set_error.set(Some("Password diperlukan".to_string())); return; } - // TODO: actual login call (wired in Task 7) set_loading.set(true); + set_error.set(None); + + let auth_clone = auth.clone(); + let pwd_clone = pwd.clone(); + let set_loading_clone = set_loading.clone(); + let set_error_clone = set_error.clone(); + + spawn_local(async move { + match auth_api::login(&pwd_clone).await { + Ok(true) => { + // Store password in sessionStorage + if let Some(storage) = web_sys::window() + .and_then(|w| w.local_storage().ok()) + .flatten() + { + let _ = storage.set_item("admin-password", &pwd_clone); + } + auth_clone.authenticated.set(true); + auth_clone.password.set(pwd_clone); + } + Ok(false) => { + set_error_clone.set(Some("Login gagal — password salah".to_string())); + } + Err(e) => { + set_error_clone.set(Some(format!("Error: {}", e.message))); + } + } + set_loading_clone.set(false); + }); }; view! { @@ -26,9 +55,14 @@ pub fn AuthOverlay( + } +} + +// ─── MessageCard ────────────────────────────────────────── +#[component] +pub fn MessageCard( + messages: Vec, + on_reanalyze: Arc, +) -> impl IntoView { + let first = &messages[0]; + let has_multi = messages.len() > 1; + let deleted = first.deleted_at.is_some(); + let avatar = first.avatar_url.clone() + .unwrap_or_else(|| "https://cdn.discordapp.com/embed/avatars/0.png".into()); + let loc_label = first.metadata.as_ref().and_then(|m| m.channel.as_ref()).map(|c| { + if let Some(ref tn) = c.thread_name { + format!("# {} › {}", c.channel_name.as_deref().unwrap_or("?"), tn) + } else { + format!("# {}", c.channel_name.as_deref().unwrap_or("?")) + } + }); + let card_cls = if deleted { "border-destructive/20 opacity-60" } else { "" }; + + view! { +
+
+ +
+
+ {&first.username} + {loc_label.as_ref().map(|l| view! { + + "#" " " {l} + + })} + + {time_ago(first.created_at)} + {has_multi.then(|| format!(" · {} msgs", messages.len()))} + +
+
+ {messages.into_iter().enumerate().map(|(i, msg)| { + let sep = has_multi && i > 0; + view! { +
+ +
+ } + }).collect::>()} +
+
+
+
+ } +} + +// ─── Skeleton ───────────────────────────────────────────── +#[component] +pub fn MessageCardSkeleton() -> impl IntoView { + view! { +
+
+
+
+
+
+
+
+
+
+
+
+
+
} } diff --git a/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs b/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs index 057e1d4..cced974 100644 --- a/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs +++ b/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs @@ -1,8 +1,127 @@ use leptos::prelude::*; +use shared_types::message::MessageRecord; +use std::sync::Arc; +use wasm_bindgen::prelude::*; +use web_sys::IntersectionObserver; +use leptos::html; + +const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000; + +fn group_messages(messages: Vec) -> Vec> { + let mut groups: Vec> = Vec::new(); + for msg in messages { + if let Some(last_group) = groups.last_mut() { + let same_user = last_group.first() + .map(|m| m.user_id == msg.user_id) + .unwrap_or(false); + let same_window = last_group.last() + .map(|m| (m.created_at - msg.created_at).abs() < GROUP_WINDOW_MS) + .unwrap_or(false); + if same_user && same_window { + last_group.push(msg); + continue; + } + } + groups.push(vec![msg]); + } + groups +} #[component] -pub fn MessageFeed() -> impl IntoView { +pub fn MessageFeed( + messages: Vec, + #[prop(optional)] empty_text: &'static str, + #[prop(optional)] loading: bool, + #[prop(optional)] has_more: bool, + #[prop(optional)] loading_more: bool, + #[prop(optional)] on_load_more: Option>, + on_reanalyze: Arc, +) -> impl IntoView { + let sentinel_ref = create_node_ref::(); + let (intersecting, set_intersecting) = create_signal(false); + + create_effect(move |_| { + let _ = intersecting.get(); // track signal + if let Some(node) = sentinel_ref.get() { + let cb = Closure::)>::new(move |entries: Vec| { + for entry in entries { + if let Some(entry) = entry.dyn_ref::() { + if entry.is_intersecting() { + if let Some(ref cb) = on_load_more { + cb(); + } + } + } + } + }); + let observer = IntersectionObserver::new(cb.as_ref().unchecked_ref()) + .expect("IntersectionObserver failed"); + observer.observe(&node); + on_cleanup(move || { + observer.disconnect(); + }); + // Keep closure alive + cb.forget(); + } + }); + + // Loading state + if loading { + return view! { +
+ {std::iter::repeat_with(|| { + use super::message_card::MessageCardSkeleton; + view! { } + }).take(3).collect::>()} +
+ }.into_any(); + } + + if messages.is_empty() { + return view! { +
+
+ {if empty_text.is_empty() { "No messages" } else { empty_text }} +
+
+ }.into_any(); + } + + let groups = group_messages(messages); + let has_more_val = has_more; + let loading_more_val = loading_more; + view! { -
"Message Feed (placeholder)"
+
+ {groups.into_iter().map(|group| { + let cb = on_reanalyze.clone(); + view! { + + } + }).collect::>()} + + {/* Infinite scroll sentinel */} + {has_more_val.then(|| { + view! { +
+ {loading_more_val.then(|| { + use super::message_card::MessageCardSkeleton; + view! { } + })} +
+ } + })} +
+ }.into_any() +} + +#[component] +fn MessageCardGroup( + messages: Vec, + on_reanalyze: Arc, +) -> impl IntoView { + use super::message_card::MessageCard; + view! { + } } diff --git a/services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs b/services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs index 4bbac14..ce907b7 100644 --- a/services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs +++ b/services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs @@ -53,7 +53,6 @@ pub struct MessagesState { } /// Hook to manage message data fetching and state -#[component] pub fn use_messages() -> MessagesState { // Core signals let messages_signal = RwSignal::new(Vec::::new()); diff --git a/services/frontend-leptos/frontend/src/features/messages/mod.rs b/services/frontend-leptos/frontend/src/features/messages/mod.rs index 5bb1d17..07a724a 100644 --- a/services/frontend-leptos/frontend/src/features/messages/mod.rs +++ b/services/frontend-leptos/frontend/src/features/messages/mod.rs @@ -1,11 +1,293 @@ use leptos::prelude::*; +use shared_types::message::{AiStatus, MessageRecord}; +use std::sync::Arc; +use wasm_bindgen_futures::spawn_local; pub mod components; pub mod hooks; +use components::message_feed::MessageFeed; +use components::image_grid::ImageGrid; +use hooks::use_messages::{merge_messages, use_messages}; + +type AiFilter = &'static str; +const FILTERS: &[AiFilter] = &["all", "analyzed", "clean", "flagged", "error", "pending"]; + +#[derive(Clone, PartialEq)] +enum ViewTab { All, Images } + #[component] pub fn MessagesPanel() -> impl IntoView { + let state = use_messages(); + let (search_query, set_search_query) = create_signal(String::new()); + let (search_results, set_search_results) = create_signal::>(Vec::new()); + let (show_search, set_show_search) = create_signal(false); + let (is_searching, set_is_searching) = create_signal(false); + let ai_filter = RwSignal::new("analyzed".to_string()); + let view_tab = RwSignal::new(ViewTab::All); + let (retrying_all, set_retrying_all) = create_signal(false); + + // Stats derived from filtered messages + let stats = create_memo(move |_| { + let base = if show_search.get() { search_results.get() } else { state.messages.get() }; + let total = base.len(); + let clean = base.iter().filter(|m| m.ai_status == Some(AiStatus::Clean)).count(); + let flagged = base.iter().filter(|m| m.ai_status == Some(AiStatus::Flagged)).count(); + let error = base.iter().filter(|m| m.ai_status == Some(AiStatus::Error)).count(); + let pending = base.iter().filter(|m| m.ai_status.is_none() || m.ai_status == Some(AiStatus::Pending)).count(); + let deleted = base.iter().filter(|m| m.deleted_at.is_some()).count(); + let edited = base.iter().filter(|m| m.edited_at.is_some()).count(); + (total, clean, flagged, error, pending, deleted, edited) + }); + + // Filter messages based on active filter + let filtered_messages = create_memo(move |_| { + let base = if show_search.get() { search_results.get() } else { state.messages.get() }; + let filter = ai_filter.get(); + if filter == "all" { return base; } + base.into_iter().filter(|m| { + let status = m.ai_status.clone().unwrap_or(AiStatus::Pending); + if filter == "analyzed" { return status != AiStatus::Pending; } + if filter == "pending" { return status == AiStatus::Pending; } + format!("{:?}", status).to_lowercase() == filter + }).collect() + }); + + // Search handler + let handle_search = { + let q = search_query; + move |_| { + let query = q.get(); + if query.trim().is_empty() { + set_show_search.set(false); + set_search_results.set(Vec::new()); + return; + } + set_is_searching.set(true); + let q_clone = query.trim().to_string(); + spawn_local(async move { + match crate::api::messages::search_messages(&q_clone, Some(50)).await { + Ok(results) => { + set_search_results.set(results); + set_show_search.set(true); + } + Err(_) => { + set_search_results.set(Vec::new()); + } + } + set_is_searching.set(false); + }); + } + }; + + // Clear search + let clear_search = move |_| { + set_show_search.set(false); + set_search_results.set(Vec::new()); + set_search_query.set(String::new()); + }; + + // Reanalyze all errors + let handle_retry_all = move |_| { + set_retrying_all.set(true); + let cb = state.reanalyze_all_errors.clone(); + spawn_local(async move { + cb(); + set_retrying_all.set(false); + }); + }; + + // Filter chip click + let set_filter = { + let af = ai_filter; + move |f: &'static str| af.set(f.to_string()) + }; + + // WS event handlers (wire once on mount) + let ws = use_context::(); + if let Some(ref ws) = ws { + // Subscribe to real-time message events + { + let msgs = state.messages; + *ws.on_message_created.borrow_mut() = Some(Box::new(move |msg| { + let current = msgs.get(); + msgs.set(merge_messages(¤t, &[msg])); + })); + } + { + let msgs = state.messages; + *ws.on_message_updated.borrow_mut() = Some(Box::new(move |msg| { + let current = msgs.get(); + msgs.set(merge_messages(¤t, &[msg])); + })); + } + { + let msgs = state.messages; + *ws.on_message_deleted.borrow_mut() = Some(Box::new(move |id| { + let current = msgs.get(); + msgs.set(current.into_iter().filter(|m| m.id != id).collect()); + })); + } + { + let msgs = state.messages; + *ws.on_message_analyzed.borrow_mut() = Some(Box::new(move |msg| { + let current = msgs.get(); + msgs.set(merge_messages(¤t, &[msg])); + })); + } + } + + // Fetch messages on mount if guild is configured + create_effect(move |_| { + if let Some(config) = use_context::() { + if let Some(ref guild_id) = config.monitor_guild_id { + state.fetch_messages(guild_id.clone()); + } + } + }); + + // ─── View ──────────────────────────────────────────────── + let (total, clean, flagged, error, pending, deleted, edited) = move || stats.get(); + view! { -
"Messages Panel (loading...)"
+
+ {/* Header card */} +
+
+
"Messages"
+

+ "Messages are automatically captured from all text channels. Real-time updates arrive via WebSocket." +

+
+
+ + {/* Stats badges */} + {(total() > 0).then(|| view! { +
+ {total()} " total" {state.has_more.get().then(|| "+")} + {clean()} " clean" + {flagged()} " flagged" + {error()} " error" + {pending()} " pending" + {(deleted() > 0).then(|| view! { + {deleted()} " deleted" + })} + {(edited() > 0).then(|| view! { + {edited()} " edited" + })} +
+ })} + + {/* Search + filters row */} +
+
+ {/* Search icon as SVG */} + + +
+ + {show_search.get().then(|| view! { + + })} + {(error() > 0 && !show_search.get()).then(|| view! { + + })} +
+ {/* Filter icon as SVG since lucide-leptos Filter unavailable */} + + {FILTERS.iter().map(|f| { + let active = ai_filter.get() == *f; + let cls = if active { + "filter-chip active" + } else { + "filter-chip" + }; + let f_ptr: &'static str = f; + view! { + + } + }).collect::>()} +
+
+ + {/* Search results count */} + {show_search.get().then(|| { + let n = search_results.get().len(); + view! { +
+ "Found " {n} " result" {if n != 1 { "s" } else { "" }} +
+ } + })} + + {/* View tabs + content */} +
+
+ + +
+ +
+ ) + } + on_reanalyze=state.reanalyze.clone() + /> +
+
+ +
+
+
} } From 3de7f8d823c139f9c4745e5a4e2b846489dc6169 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 20:45:35 +0700 Subject: [PATCH 15/25] fix(leptos): resolve Phase 3 compilation errors - Add PartialEq to MessageRecord and all nested types in shared-types - Fix String references in view! macro by cloning owned values - Fix match arm types with .into_any() in app.rs - Clone on_load_more before FnMut closure in message_feed.rs - Pre-compute class strings before passing to view! in message_card.rs - Fix lifetime issues with imgs/vids/cats by using owned Vec - Use separate closures for different event types in mod.rs - Fix disabled property to use signal pattern - Fix filter chip closure signatures --- services/frontend-leptos/frontend/src/app.rs | 6 +- .../messages/components/message_card.rs | 179 +++++++++++------- .../messages/components/message_feed.rs | 1 + .../frontend/src/features/messages/mod.rs | 58 ++++-- .../shared-types/src/message.rs | 18 +- 5 files changed, 165 insertions(+), 97 deletions(-) diff --git a/services/frontend-leptos/frontend/src/app.rs b/services/frontend-leptos/frontend/src/app.rs index 7aaf4f7..c5e9bec 100644 --- a/services/frontend-leptos/frontend/src/app.rs +++ b/services/frontend-leptos/frontend/src/app.rs @@ -92,9 +92,9 @@ pub fn App() -> impl IntoView { // Content area
{move || match ui.active_tab.get() { - Tab::Messages => view! { }.into_view(), - Tab::Live => view! {
"Live Panel"
}.into_view(), - Tab::Dashboard => view! {
"Dashboard Panel"
}.into_view(), + Tab::Messages => view! { }.into_any(), + Tab::Live => view! {
"Live Panel"
}.into_any(), + Tab::Dashboard => view! {
"Dashboard Panel"
}.into_any(), }}
diff --git a/services/frontend-leptos/frontend/src/features/messages/components/message_card.rs b/services/frontend-leptos/frontend/src/features/messages/components/message_card.rs index 4c24941..af88917 100644 --- a/services/frontend-leptos/frontend/src/features/messages/components/message_card.rs +++ b/services/frontend-leptos/frontend/src/features/messages/components/message_card.rs @@ -1,6 +1,6 @@ use leptos::prelude::*; use regex::Regex; -use shared_types::message::{AiSeverity, AiStatus, MessageRecord}; +use shared_types::message::{AiSeverity, AiStatus, AttachmentRef, MessageRecord}; use std::sync::{Arc, OnceLock}; use wasm_bindgen::prelude::*; @@ -84,13 +84,13 @@ fn get_cats(raw: &Option>) -> Vec { // ─── StatusBadgeInline ──────────────────────────────────── #[component] fn StatusBadgeInline(status: AiStatus) -> impl IntoView { - let (cl, icon_svg) = match &status { - AiStatus::Clean => ("status-badge-clean", Some(view! { }).into_any()), - AiStatus::Flagged => ("status-badge-flagged", Some(view! { }).into_any()), - AiStatus::Error => ("status-badge-error", Some(view! { }).into_any()), - AiStatus::Pending => ("status-badge-pending", None.into_any()), - AiStatus::Processing => ("status-badge-processing", None.into_any()), - AiStatus::Warn => ("status-badge-warn", None.into_any()), + let (cl, icon_svg): (&'static str, AnyView) = match &status { + AiStatus::Clean => ("status-badge-clean", view! { }.into_any()), + AiStatus::Flagged => ("status-badge-flagged", view! { }.into_any()), + AiStatus::Error => ("status-badge-error", view! { }.into_any()), + AiStatus::Pending => ("status-badge-pending", view! { }.into_any()), + AiStatus::Processing => ("status-badge-processing", view! { }.into_any()), + AiStatus::Warn => ("status-badge-warn", view! { }.into_any()), }; view! { @@ -125,20 +125,20 @@ pub fn MessageRow( // Attachments let all_atts = message.metadata.as_ref() .and_then(|m| m.attachments.as_ref()).cloned().unwrap_or_default(); - let imgs: Vec<_> = all_atts.iter().filter(|a| { + let imgs: Vec = all_atts.iter().filter(|a| { a.content_type.as_deref().map(|ct| ct.starts_with("image/")).unwrap_or(false) || a.name.to_lowercase().ends_with(".png") || a.name.to_lowercase().ends_with(".jpg") || a.name.to_lowercase().ends_with(".jpeg") || a.name.to_lowercase().ends_with(".gif") || a.name.to_lowercase().ends_with(".webp") - }).collect(); - let vids: Vec<_> = all_atts.iter().filter(|a| { + }).cloned().collect(); + let vids: Vec = all_atts.iter().filter(|a| { a.content_type.as_deref().map(|ct| ct.starts_with("video/")).unwrap_or(false) || a.name.to_lowercase().ends_with(".mp4") || a.name.to_lowercase().ends_with(".webm") || a.name.to_lowercase().ends_with(".mov") - }).collect(); + }).cloned().collect(); let stickers = message.metadata.as_ref() .and_then(|m| m.stickers.as_ref()).cloned().unwrap_or_default(); @@ -180,9 +180,10 @@ pub fn MessageRow( {/* Content */} {show.then(|| { let rendered = render_emojis(display); - let cls = if message.deleted_at.is_some() { "text-secondary/60" } else { "" }; + let cls_str = if message.deleted_at.is_some() { "text-secondary/60" } else { "" }; + let class_str = format!("whitespace-pre-wrap break-words text-sm leading-6 {}", cls_str); view! { -

+

{rendered.into_iter().collect::>()}

} @@ -192,17 +193,22 @@ pub fn MessageRow( {(!stickers.is_empty()).then(|| view! {
{stickers.iter().map(|s| { - let url = s.url.clone(); - let name = s.name.clone().unwrap_or_default(); + let url_owned = s.url.clone().unwrap_or_default(); + let name_owned = s.name.clone().unwrap_or_default(); + let has_url = !url_owned.is_empty(); view! {
- {url.as_ref().map(|u| view! { - &name - }).unwrap_or_else(|| view! { -
- "😊" -
- })} + {if has_url { + view! { + name_owned + }.into_any() + } else { + view! { +
+ "😊" +
+ }.into_any() + }}
} }).collect::>()} @@ -210,55 +216,94 @@ pub fn MessageRow( })} {/* Images */} - {(!imgs.is_empty()).then(|| view! { -
- {imgs.iter().take(4).map(|a| view! { - - &a.name + {if !imgs.is_empty() { + let imgs_local = imgs.clone(); + let images_view = imgs_local.iter().take(4).map(|a| { + let url1 = a.url.clone(); + let url2 = a.url.clone(); + let name1 = a.name.clone(); + view! { + + name1 - }).collect::>()} - {(imgs.len() > 4).then(|| view! { + } + }).collect::>(); + let overflow = if imgs.len() > 4 { + let extra = imgs.len() - 4; + view! {
- {"+"} {imgs.len() - 4} "🖼" + {"+"} {extra} "🖼"
- })} -
- })} + }.into_any() + } else { + view! {}.into_any() + }; + view! { +
+ {images_view} + {overflow} +
+ }.into_any() + } else { + view! {}.into_any() + }} {/* Videos */} - {(!vids.is_empty()).then(|| view! { -
- {vids.iter().take(4).map(|a| view! { - - }).collect::>()} - {(vids.len() > 4).then(|| view! { + {if !vids.is_empty() { + let vids_local = vids.clone(); + let videos_view = vids_local.iter().take(4).map(|a| { + let url = a.url.clone(); + view! { + + } + }).collect::>(); + let overflow = if vids.len() > 4 { + let extra = vids.len() - 4; + view! {
- {"+"} {vids.len() - 4} "▶" + {"+"} {extra} "▶"
- })} -
- })} + }.into_any() + } else { + view! {}.into_any() + }; + view! { +
+ {videos_view} + {overflow} +
+ }.into_any() + } else { + view! {}.into_any() + }} {/* Categories */} - {(!cats.is_empty()).then(|| view! { -
- {cats.iter().map(|c| view! { - {c.clone()} - }).collect::>()} -
- })} + {if !cats.is_empty() { + let cats_local = cats.clone(); + view! { +
+ {cats_local.iter().map(|c| view! { + {c.clone()} + }).collect::>()} +
+ }.into_any() + } else { + view! {}.into_any() + }} {/* AI Analysis */} {message.ai_analysis.as_ref().map(|analysis| { - let border = if ai_st == AiStatus::Flagged { "border-l-3 bg-warning/5" } else { "border-l-3 bg-success/5" }; + let border_str = if ai_st == AiStatus::Flagged { "border-l-3 bg-warning/5" } else { "border-l-3 bg-success/5" }; let icon = if ai_st == AiStatus::Flagged { "🚨" } else { "ℹ️" }; + let analysis_summary_str = analysis_summary.clone(); + let analysis_str = analysis.clone(); view! { -
+
{icon}
- {&analysis_summary} -
{analysis}
+ {analysis_summary_str} +
{analysis_str}
@@ -266,10 +311,13 @@ pub fn MessageRow( })} {/* Error */} - {message.ai_error.as_ref().map(|e| view! { -
- "AI error: "{e} -
+ {message.ai_error.as_ref().map(|e| { + let error_str = e.clone(); + view! { +
+ "AI error: "{error_str} +
+ } })} {/* Re-analyze */} @@ -316,11 +364,14 @@ pub fn MessageCard(
- {&first.username} - {loc_label.as_ref().map(|l| view! { - - "#" " " {l} - + {first.username.clone()} + {loc_label.as_ref().map(|l| { + let label_str = l.clone(); + view! { + + "#" " " {label_str} + + } })} {time_ago(first.created_at)} diff --git a/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs b/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs index cced974..6f1758b 100644 --- a/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs +++ b/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs @@ -43,6 +43,7 @@ pub fn MessageFeed( create_effect(move |_| { let _ = intersecting.get(); // track signal if let Some(node) = sentinel_ref.get() { + let on_load_more = on_load_more.clone(); let cb = Closure::)>::new(move |entries: Vec| { for entry in entries { if let Some(entry) = entry.dyn_ref::() { diff --git a/services/frontend-leptos/frontend/src/features/messages/mod.rs b/services/frontend-leptos/frontend/src/features/messages/mod.rs index 07a724a..5af8065 100644 --- a/services/frontend-leptos/frontend/src/features/messages/mod.rs +++ b/services/frontend-leptos/frontend/src/features/messages/mod.rs @@ -53,10 +53,10 @@ pub fn MessagesPanel() -> impl IntoView { }).collect() }); - // Search handler - let handle_search = { + // Search handler - takes any event type and triggers the search + let do_search = { let q = search_query; - move |_| { + move || { let query = q.get(); if query.trim().is_empty() { set_show_search.set(false); @@ -79,6 +79,9 @@ pub fn MessagesPanel() -> impl IntoView { }); } }; + // Separate closures for different event types so on:click/on:keydown type-check + let handle_search_click = move |_: web_sys::MouseEvent| do_search(); + let handle_search_keydown = move |_: web_sys::KeyboardEvent| do_search(); // Clear search let clear_search = move |_| { @@ -141,13 +144,22 @@ pub fn MessagesPanel() -> impl IntoView { create_effect(move |_| { if let Some(config) = use_context::() { if let Some(ref guild_id) = config.monitor_guild_id { - state.fetch_messages(guild_id.clone()); + (state.fetch_messages)(guild_id.clone()); } } }); // ─── View ──────────────────────────────────────────────── - let (total, clean, flagged, error, pending, deleted, edited) = move || stats.get(); + let get_stats = move || stats.get(); + let (total, clean, flagged, error, pending, deleted, edited) = ( + move || get_stats().0, + move || get_stats().1, + move || get_stats().2, + move || get_stats().3, + move || get_stats().4, + move || get_stats().5, + move || get_stats().6, + ); view! {
@@ -190,15 +202,15 @@ pub fn MessagesPanel() -> impl IntoView { prop:value=search_query on:input=move |ev| set_search_query.set(event_target_value(&ev)) on:keydown=move |ev| { - if ev.key() == "Enter" { handle_search(()); } + if ev.key() == "Enter" { handle_search_keydown(ev); } } - disabled=is_searching.get() + disabled=move || is_searching.get() />
@@ -211,7 +223,7 @@ pub fn MessagesPanel() -> impl IntoView {
- ) + { + let load_more_cb = state.load_more.clone(); + let empty_text: &'static str = if show_search.get() { "No messages found matching your search." } else { "No captures yet." }; + let has_more = if show_search.get() { false } else { state.has_more.get() }; + view! { + + on_reanalyze=state.reanalyze.clone() + /> } - on_reanalyze=state.reanalyze.clone() - /> + }
diff --git a/services/frontend-leptos/shared-types/src/message.rs b/services/frontend-leptos/shared-types/src/message.rs index 5f156d4..1d9e696 100644 --- a/services/frontend-leptos/shared-types/src/message.rs +++ b/services/frontend-leptos/shared-types/src/message.rs @@ -34,7 +34,7 @@ pub enum AiRecommendedAction { } // ── Message Metadata ────────────────────────────────────── -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct MessageMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub stickers: Option>, @@ -46,7 +46,7 @@ pub struct MessageMetadata { pub channel: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StickerInfo { #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, @@ -54,7 +54,7 @@ pub struct StickerInfo { pub url: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct AttachmentRef { pub name: String, pub url: String, @@ -63,7 +63,7 @@ pub struct AttachmentRef { pub content_type: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct EmbedInfo { #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, @@ -73,7 +73,7 @@ pub struct EmbedInfo { pub thumbnail: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct EmbedMedia { pub url: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -82,7 +82,7 @@ pub struct EmbedMedia { pub height: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ChannelRef { pub channel_id: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -94,7 +94,7 @@ pub struct ChannelRef { } // ── Message Record ──────────────────────────────────────── -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct MessageRecord { pub id: String, pub guild_id: String, @@ -140,7 +140,7 @@ pub struct MessageRecord { } // ── Pagination ──────────────────────────────────────────── -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PageResult { pub data: Vec, #[serde(rename = "nextCursor")] @@ -148,7 +148,7 @@ pub struct PageResult { } // ── Attachment ──────────────────────────────────────────── -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct AttachmentRecord { pub id: String, pub message_id: String, From c9bc5f5b507e18121ed9d0db2b425f238feeaf52 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 20:55:19 +0700 Subject: [PATCH 16/25] feat(leptos): Phase 4 Task 1 - voice/media control hooks --- .../src/features/live/components/mod.rs | 1 + .../frontend/src/features/live/hooks/mod.rs | 2 + .../features/live/hooks/use_media_control.rs | 157 ++++++++++++++++ .../features/live/hooks/use_voice_control.rs | 177 ++++++++++++++++++ .../frontend/src/features/live/mod.rs | 21 +++ .../frontend/src/features/mod.rs | 1 + 6 files changed, 359 insertions(+) create mode 100644 services/frontend-leptos/frontend/src/features/live/components/mod.rs create mode 100644 services/frontend-leptos/frontend/src/features/live/hooks/mod.rs create mode 100644 services/frontend-leptos/frontend/src/features/live/hooks/use_media_control.rs create mode 100644 services/frontend-leptos/frontend/src/features/live/hooks/use_voice_control.rs create mode 100644 services/frontend-leptos/frontend/src/features/live/mod.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/mod.rs b/services/frontend-leptos/frontend/src/features/live/components/mod.rs new file mode 100644 index 0000000..9f1039a --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/mod.rs @@ -0,0 +1 @@ +// Empty for now - components will be added in future phases diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/mod.rs b/services/frontend-leptos/frontend/src/features/live/hooks/mod.rs new file mode 100644 index 0000000..88d7ece --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/hooks/mod.rs @@ -0,0 +1,2 @@ +pub mod use_voice_control; +pub mod use_media_control; diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/use_media_control.rs b/services/frontend-leptos/frontend/src/features/live/hooks/use_media_control.rs new file mode 100644 index 0000000..bc9ca30 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/hooks/use_media_control.rs @@ -0,0 +1,157 @@ +use leptos::prelude::*; +use shared_types::media::MediaState; +use crate::api::voice::{ + get_media_status, media_queue, media_skip, media_stop, media_volume, +}; +use std::sync::Arc; +use wasm_bindgen_futures::spawn_local; + +/// Callback type for enqueue +pub type EnqueueCallback = Arc; +/// Callback type for skip_track +pub type SkipTrackCallback = Arc; +/// Callback type for stop_playback +pub type StopPlaybackCallback = Arc; +/// Callback type for set_volume +pub type SetVolumeCallback = Arc; +/// Callback type for refresh +pub type RefreshCallback = Arc; + +/// State returned by use_media_control hook +#[derive(Clone)] +pub struct MediaControlState { + /// Current media playback state + pub media_state: RwSignal>, + /// Whether we're currently loading data + pub loading: RwSignal, + /// Last error message if any + pub error: RwSignal>, + /// Enqueue media (source URL, mode: "music" or "screen") + pub enqueue: EnqueueCallback, + /// Skip to next track + pub skip_track: SkipTrackCallback, + /// Stop all playback + pub stop_playback: StopPlaybackCallback, + /// Set volume level (0.0 - 1.0) + pub set_volume: SetVolumeCallback, + /// Refresh media status from server + pub refresh: RefreshCallback, +} + +/// Hook to manage media playback state and controls +pub fn use_media_control() -> MediaControlState { + // Core signals + let media_state_signal = RwSignal::new(None::); + let loading_signal = RwSignal::new(false); + let error_signal = RwSignal::new(None::); + + // Enqueue media + let enqueue_impl = Arc::new(move |source: String, mode: String| { + spawn_local({ + let source = source.clone(); + let mode = mode.clone(); + async move { + error_signal.set(None); + loading_signal.set(true); + + match media_queue(&source, &mode).await { + Ok(state) => { + media_state_signal.set(Some(state)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to enqueue media: {}", e))); + loading_signal.set(false); + } + } + } + }); + }); + + // Skip to next track + let skip_track_impl = Arc::new(move || { + spawn_local(async move { + error_signal.set(None); + loading_signal.set(true); + + match media_skip().await { + Ok(state) => { + media_state_signal.set(Some(state)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to skip track: {}", e))); + loading_signal.set(false); + } + } + }); + }); + + // Stop all playback + let stop_playback_impl = Arc::new(move || { + spawn_local(async move { + error_signal.set(None); + loading_signal.set(true); + + match media_stop().await { + Ok(state) => { + media_state_signal.set(Some(state)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to stop playback: {}", e))); + loading_signal.set(false); + } + } + }); + }); + + // Set volume level + let set_volume_impl = Arc::new(move |volume: f64| { + spawn_local(async move { + error_signal.set(None); + loading_signal.set(true); + + match media_volume(volume).await { + Ok(state) => { + media_state_signal.set(Some(state)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to set volume: {}", e))); + loading_signal.set(false); + } + } + }); + }); + + // Refresh media status + let refresh_impl = Arc::new(move || { + spawn_local(async move { + error_signal.set(None); + loading_signal.set(true); + + match get_media_status().await { + Ok(state) => { + media_state_signal.set(Some(state)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to refresh media status: {}", e))); + loading_signal.set(false); + } + } + }); + }); + + MediaControlState { + media_state: media_state_signal, + loading: loading_signal, + error: error_signal, + enqueue: enqueue_impl, + skip_track: skip_track_impl, + stop_playback: stop_playback_impl, + set_volume: set_volume_impl, + refresh: refresh_impl, + } +} diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/use_voice_control.rs b/services/frontend-leptos/frontend/src/features/live/hooks/use_voice_control.rs new file mode 100644 index 0000000..8af2bca --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/hooks/use_voice_control.rs @@ -0,0 +1,177 @@ +use leptos::prelude::*; +use shared_types::guild::{Guild, Channel}; +use shared_types::voice::VoiceStatus; +use crate::api::voice::{ + get_guilds, get_voice_channels, get_text_channels, get_voice_status, + connect_voice, disconnect_voice, +}; +use std::sync::Arc; +use wasm_bindgen_futures::spawn_local; + +/// Callback type for join_voice +pub type JoinVoiceCallback = Arc; +/// Callback type for leave_voice +pub type LeaveVoiceCallback = Arc; +/// Callback type for load_guilds +pub type LoadGuildsCallback = Arc; +/// Callback type for load_voice_channels +pub type LoadVoiceChannelsCallback = Arc; +/// Callback type for load_text_channels +pub type LoadTextChannelsCallback = Arc; + +/// State returned by use_voice_control hook +#[derive(Clone)] +pub struct VoiceControlState { + /// List of available guilds + pub guilds: RwSignal>, + /// List of voice channels for current guild + pub voice_channels: RwSignal>, + /// List of text channels for current guild + pub text_channels: RwSignal>, + /// Current voice connection status + pub voice_status: RwSignal>, + /// Whether we're currently loading data + pub loading: RwSignal, + /// Last error message if any + pub error: RwSignal>, + /// Join a voice channel + pub join_voice: JoinVoiceCallback, + /// Leave the current voice channel + pub leave_voice: LeaveVoiceCallback, + /// Fetch list of guilds + pub load_guilds: LoadGuildsCallback, + /// Fetch voice channels for a guild + pub load_voice_channels: LoadVoiceChannelsCallback, + /// Fetch text channels for a guild + pub load_text_channels: LoadTextChannelsCallback, +} + +/// Hook to manage voice connection state and controls +pub fn use_voice_control() -> VoiceControlState { + // Core signals + let guilds_signal = RwSignal::new(Vec::::new()); + let voice_channels_signal = RwSignal::new(Vec::::new()); + let text_channels_signal = RwSignal::new(Vec::::new()); + let voice_status_signal = RwSignal::new(None::); + let loading_signal = RwSignal::new(false); + let error_signal = RwSignal::new(None::); + + // Join a voice channel + let join_voice_impl = Arc::new(move |guild_id: String, channel_id: String| { + spawn_local({ + let guild_id = guild_id.clone(); + let channel_id = channel_id.clone(); + async move { + error_signal.set(None); + loading_signal.set(true); + + match connect_voice(&guild_id, &channel_id).await { + Ok(status) => { + voice_status_signal.set(Some(status)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to join voice: {}", e))); + loading_signal.set(false); + } + } + } + }); + }); + + // Leave the current voice channel + let leave_voice_impl = Arc::new(move || { + spawn_local(async move { + error_signal.set(None); + loading_signal.set(true); + + match disconnect_voice().await { + Ok(status) => { + voice_status_signal.set(Some(status)); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to leave voice: {}", e))); + loading_signal.set(false); + } + } + }); + }); + + // Fetch list of guilds + let load_guilds_impl = Arc::new(move || { + spawn_local(async move { + error_signal.set(None); + loading_signal.set(true); + + match get_guilds().await { + Ok(guilds) => { + guilds_signal.set(guilds); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to load guilds: {}", e))); + loading_signal.set(false); + } + } + }); + }); + + // Fetch voice channels for a guild + let load_voice_channels_impl = Arc::new(move |guild_id: String| { + spawn_local({ + let guild_id = guild_id.clone(); + async move { + error_signal.set(None); + loading_signal.set(true); + + match get_voice_channels(&guild_id).await { + Ok(channels) => { + voice_channels_signal.set(channels); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to load voice channels: {}", e))); + loading_signal.set(false); + } + } + } + }); + }); + + // Fetch text channels for a guild + let load_text_channels_impl = Arc::new(move |guild_id: String| { + spawn_local({ + let guild_id = guild_id.clone(); + async move { + error_signal.set(None); + loading_signal.set(true); + + match get_text_channels(&guild_id).await { + Ok(channels) => { + text_channels_signal.set(channels); + loading_signal.set(false); + } + Err(e) => { + error_signal.set(Some(format!("Failed to load text channels: {}", e))); + loading_signal.set(false); + } + } + } + }); + }); + + VoiceControlState { + guilds: guilds_signal, + voice_channels: voice_channels_signal, + text_channels: text_channels_signal, + voice_status: voice_status_signal, + loading: loading_signal, + error: error_signal, + join_voice: join_voice_impl, + leave_voice: leave_voice_impl, + load_guilds: load_guilds_impl, + load_voice_channels: load_voice_channels_impl, + load_text_channels: load_text_channels_impl, + } +} diff --git a/services/frontend-leptos/frontend/src/features/live/mod.rs b/services/frontend-leptos/frontend/src/features/live/mod.rs new file mode 100644 index 0000000..7f745eb --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/mod.rs @@ -0,0 +1,21 @@ +pub mod components; +pub mod hooks; + +use leptos::prelude::*; +use crate::ui::card::Card; + +/// Placeholder LivePanel component for Phase 4 Task 1 +/// Will be expanded with voice connection, music player, and recordings components +#[component] +pub fn LivePanel() -> impl IntoView { + view! { + +
+

Live Monitoring

+

+ Live panel components coming in Phase 4 +

+
+
+ } +} diff --git a/services/frontend-leptos/frontend/src/features/mod.rs b/services/frontend-leptos/frontend/src/features/mod.rs index ba63992..e660cd8 100644 --- a/services/frontend-leptos/frontend/src/features/mod.rs +++ b/services/frontend-leptos/frontend/src/features/mod.rs @@ -1 +1,2 @@ +pub mod live; pub mod messages; From 1cc04bda664cf4f383e02c91a857056da7752bcb Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 20:56:06 +0700 Subject: [PATCH 17/25] feat(leptos): Phase 4 Task 2 - VoiceConnectionCard component --- .../live/components/voice_connection_card.rs | 211 ++++++++++++++++++ .../frontend/src/features/messages/mod.rs | 3 +- 2 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs b/services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs new file mode 100644 index 0000000..fc5e79f --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs @@ -0,0 +1,211 @@ +use leptos::prelude::*; +use crate::features::live::hooks::use_voice_control::{use_voice_control, VoiceControlState}; +use crate::ui::button::{Button, ButtonVariant}; +use crate::ui::card::{Card, CardContent, CardDescription, CardHeader, CardTitle}; +use shared_types::guild::{Guild, Channel}; + +/// VoiceConnectionCard component for Leptos +/// Renders guild and voice channel selectors with connect/disconnect controls +#[component] +pub fn VoiceConnectionCard( + #[prop(optional)] voice_state: Option, + #[prop(optional)] class: &'static str, +) -> impl IntoView { + let default_state = use_voice_control(); + let state = voice_state.unwrap_or(default_state); + + // Reactive signal for selected guild + let (selected_guild, set_selected_guild) = create_signal::(String::new()); + // Reactive signal for selected channel + let (selected_channel, set_selected_channel) = create_signal::(String::new()); + + // When guild is selected, load voice channels + create_effect(move |_| { + let guild_id = selected_guild.get(); + if !guild_id.is_empty() { + (state.load_voice_channels)(guild_id); + } + }); + + // Load guilds on mount + create_effect(move |_| { + (state.load_guilds)(); + }); + + let on_guild_change = move |ev: leptos::ev::Event| { + if let Some(target) = ev.target().and_then(|t| t.dyn_into::().ok()) { + set_selected_guild(target.value()); + } + }; + + let on_channel_change = move |ev: leptos::ev::Event| { + if let Some(target) = ev.target().and_then(|t| t.dyn_into::().ok()) { + set_selected_channel(target.value()); + } + }; + + let on_join_click = move |_| { + let guild_id = selected_guild.get(); + let channel_id = selected_channel.get(); + if !guild_id.is_empty() && !channel_id.is_empty() { + (state.join_voice)(guild_id, channel_id); + } + }; + + let on_disconnect_click = move |_| { + (state.leave_voice)(); + }; + + // Read signals for reactive rendering + let guilds = state.guilds; + let voice_channels = state.voice_channels; + let loading = state.loading; + let error = state.error; + let voice_status = state.voice_status; + + let is_connected = move || { + voice_status.get().map(|s| s.connected).unwrap_or(false) + }; + + let can_join = move || { + !selected_guild.get().is_empty() && !selected_channel.get().is_empty() && !loading.get() + }; + + let can_disconnect = move || { + is_connected() && !loading.get() + }; + + view! { + + +
+ + + + "Voice Bridge" +
+ + "Join a Discord voice channel, listen, and transmit audio." + +
+ + + {/* Guild and Channel Selectors */} +
+
+ + +
+ +
+ + +
+
+ + {/* Error Display */} + {move || { + error.get().map(|err| { + view! { +
+ {err} +
+ } + }) + }} + + {/* Status Display */} + {move || { + voice_status.get().map(|status| { + view! { +
+
+ + {move || if status.connected { "Connected" } else { "Disconnected" }} + + {move || { + status.active_channel_name.clone().map(|name| { + view! { + + " - "{name} + + } + }) + }} +
+ } + }) + }} + + {/* Control Buttons */} +
+ + + + + {move || { + if loading.get() { + view! { + + "Loading..." + + }.into_view() + } else { + view! { }.into_view() + } + }} +
+
+
+ } +} diff --git a/services/frontend-leptos/frontend/src/features/messages/mod.rs b/services/frontend-leptos/frontend/src/features/messages/mod.rs index 5af8065..6569235 100644 --- a/services/frontend-leptos/frontend/src/features/messages/mod.rs +++ b/services/frontend-leptos/frontend/src/features/messages/mod.rs @@ -287,6 +287,7 @@ pub fn MessagesPanel() -> impl IntoView { let load_more_cb = state.load_more.clone(); let empty_text: &'static str = if show_search.get() { "No messages found matching your search." } else { "No captures yet." }; let has_more = if show_search.get() { false } else { state.has_more.get() }; + let on_load_more_clone: Arc = Arc::new(move || load_more_cb()); view! { impl IntoView { loading=state.loading.get() has_more=has_more loading_more=state.loading_more.get() - on_load_more=Arc::new(move || load_more_cb()) as Arc + on_load_more=on_load_more_clone on_reanalyze=state.reanalyze.clone() /> } From d797b6b4c5baac47de77f2b104a7006aaf65fdc1 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 21:04:41 +0700 Subject: [PATCH 18/25] feat(leptos): Phase 4 Task 2 - ActiveSpeakers component --- .../live/components/active_speakers.rs | 80 +++++++++++++++++++ .../src/features/live/components/mod.rs | 6 +- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 services/frontend-leptos/frontend/src/features/live/components/active_speakers.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/active_speakers.rs b/services/frontend-leptos/frontend/src/features/live/components/active_speakers.rs new file mode 100644 index 0000000..5738c08 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/active_speakers.rs @@ -0,0 +1,80 @@ +use leptos::prelude::*; +use shared_types::voice::ActiveSpeaker; + +/// ActiveSpeakers component for Leptos +/// Displays a real-time list of speaking users with avatar and status indicator +#[component] +pub fn ActiveSpeakers( + #[prop(optional)] speakers: RwSignal>, + #[prop(optional)] class: &'static str, +) -> impl IntoView { + let empty_state = move || speakers.get().is_empty(); + + view! { +
+ + +
+
+ {speaker.avatar.as_ref().map(|avatar_url| { + let url = avatar_url.clone(); + view! { + + } + })} +
+
+
+ {speaker.username.clone()} +
+
+ + + {move || if speaker.speaking { "Speaking" } else { "Silent" }} + +
+
+
+
+
+ } + } + > +
+
+
+ "🎤" +
+

+ "No active speakers" +

+
+
+ +
+ } +} diff --git a/services/frontend-leptos/frontend/src/features/live/components/mod.rs b/services/frontend-leptos/frontend/src/features/live/components/mod.rs index 9f1039a..63159fc 100644 --- a/services/frontend-leptos/frontend/src/features/live/components/mod.rs +++ b/services/frontend-leptos/frontend/src/features/live/components/mod.rs @@ -1 +1,5 @@ -// Empty for now - components will be added in future phases +pub mod voice_connection_card; +pub mod active_speakers; + +pub use voice_connection_card::VoiceConnectionCard; +pub use active_speakers::ActiveSpeakers; From 8a3ac78bde9141340948a88b3782dbb7ad03f656 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 21:50:48 +0700 Subject: [PATCH 19/25] feat(leptos): Phase 4 Task 3 - AudioVisualizer + MicLevelMeter components --- .../live/components/audio_visualizer.rs | 69 ++++++++++++ .../live/components/mic_level_meter.rs | 84 ++++++++++++++ .../src/features/live/components/mod.rs | 4 + .../live/components/voice_connection_card.rs | 104 ++++++++++-------- 4 files changed, 215 insertions(+), 46 deletions(-) create mode 100644 services/frontend-leptos/frontend/src/features/live/components/audio_visualizer.rs create mode 100644 services/frontend-leptos/frontend/src/features/live/components/mic_level_meter.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/audio_visualizer.rs b/services/frontend-leptos/frontend/src/features/live/components/audio_visualizer.rs new file mode 100644 index 0000000..21e928c --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/audio_visualizer.rs @@ -0,0 +1,69 @@ +use leptos::prelude::*; +use std::sync::{Arc, Mutex}; + +/// AudioVisualizer — Real-time 32-bar frequency spectrum display +/// Simplified implementation using CSS bars updated via signals +#[component] +pub fn AudioVisualizer( + #[prop(default = true)] _active: bool, + #[prop(optional)] pcm_data: Option>>>, +) -> impl IntoView { + let bars = create_rw_signal::>(vec![0.0; 32]); + + // Periodically update bars from PCM data + create_effect(move |_| { + if let Some(ref pcm_arc) = pcm_data { + if let Ok(pcm_vec) = pcm_arc.lock() { + let computed = compute_frequency_bands(&pcm_vec); + bars.update(|b| { + for i in 0..32 { + let target = computed.get(i).copied().unwrap_or(0.0).max(0.0).min(1.0); + b[i] = b[i] * 0.7 + target * 0.3; // Smooth decay + } + }); + } + } + }); + + view! { +
+
+ {(0..32).map(|i| { + view! { +
+ } + }).collect::>()} +
+
+ } +} + +/// Compute 32-band frequency spectrum from PCM samples +fn compute_frequency_bands(pcm_samples: &[f32]) -> Vec { + let mut bands = vec![0.0; 32]; + + if pcm_samples.is_empty() { + return bands; + } + + let samples_per_band = (pcm_samples.len() / 32).max(1); + + for (band_idx, band) in bands.iter_mut().enumerate() { + let start = band_idx * samples_per_band; + let end = ((band_idx + 1) * samples_per_band).min(pcm_samples.len()); + + if start < pcm_samples.len() { + let slice = &pcm_samples[start..end]; + let rms = (slice.iter().map(|s| s * s).sum::() / slice.len() as f32).sqrt(); + *band = rms.min(1.0); + } + } + + bands +} diff --git a/services/frontend-leptos/frontend/src/features/live/components/mic_level_meter.rs b/services/frontend-leptos/frontend/src/features/live/components/mic_level_meter.rs new file mode 100644 index 0000000..d2d45e1 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/live/components/mic_level_meter.rs @@ -0,0 +1,84 @@ +use leptos::prelude::*; +use std::sync::{Arc, Mutex}; + +/// MicLevelMeter — Horizontal level indicator for microphone input +/// Displays 0-100% amplitude as a filling bar with smooth decay +#[component] +pub fn MicLevelMeter( + #[prop(default = true)] active: bool, + #[prop(optional)] pcm_data: Option>>>, + #[prop(optional)] label: Option<&'static str>, +) -> impl IntoView { + let level = create_rw_signal::(0.0); + let peak = create_rw_signal::(0.0); + + // Update level periodically + create_effect(move |_| { + if !active { + return; + } + + if let Some(ref pcm_arc) = pcm_data { + if let Ok(pcm_vec) = pcm_arc.lock() { + let current_level = compute_rms(&pcm_vec); + level.update(|l| { + *l = *l * 0.8 + current_level * 0.2; // Smooth decay + }); + peak.update(|p| { + *p = (*p * 0.95).max(current_level); // Peak hold with decay + }); + } + } + }); + + let level_percent = move || (level.get() * 100.0).min(100.0); + let peak_percent = move || (peak.get() * 100.0).min(100.0); + + // Determine color based on level + let level_color = move || { + let l = level.get(); + if l < 0.5 { + "bg-green-500" + } else if l < 0.75 { + "bg-yellow-500" + } else { + "bg-red-500" + } + }; + + view! { +
+ {label.map(|l| view! { + + })} +
+ {/* Main level bar */} +
+
+ {/* Peak indicator */} +
+
+ {/* Percentage display */} + + {move || format!("{}%", (level_percent() as u8))} + +
+
+ } +} + +/// Compute RMS (Root Mean Square) amplitude from PCM samples +/// Returns normalized value 0.0-1.0 +fn compute_rms(samples: &[f32]) -> f32 { + if samples.is_empty() { + return 0.0; + } + let mean_square = samples.iter().map(|s| s * s).sum::() / samples.len() as f32; + mean_square.sqrt() +} diff --git a/services/frontend-leptos/frontend/src/features/live/components/mod.rs b/services/frontend-leptos/frontend/src/features/live/components/mod.rs index 63159fc..4227868 100644 --- a/services/frontend-leptos/frontend/src/features/live/components/mod.rs +++ b/services/frontend-leptos/frontend/src/features/live/components/mod.rs @@ -1,5 +1,9 @@ pub mod voice_connection_card; pub mod active_speakers; +pub mod audio_visualizer; +pub mod mic_level_meter; pub use voice_connection_card::VoiceConnectionCard; pub use active_speakers::ActiveSpeakers; +pub use audio_visualizer::AudioVisualizer; +pub use mic_level_meter::MicLevelMeter; diff --git a/services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs b/services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs index fc5e79f..37e600e 100644 --- a/services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs +++ b/services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs @@ -1,8 +1,6 @@ use leptos::prelude::*; +use wasm_bindgen::prelude::*; use crate::features::live::hooks::use_voice_control::{use_voice_control, VoiceControlState}; -use crate::ui::button::{Button, ButtonVariant}; -use crate::ui::card::{Card, CardContent, CardDescription, CardHeader, CardTitle}; -use shared_types::guild::{Guild, Channel}; /// VoiceConnectionCard component for Leptos /// Renders guild and voice channel selectors with connect/disconnect controls @@ -33,14 +31,18 @@ pub fn VoiceConnectionCard( }); let on_guild_change = move |ev: leptos::ev::Event| { - if let Some(target) = ev.target().and_then(|t| t.dyn_into::().ok()) { - set_selected_guild(target.value()); + if let Some(target) = ev.target() { + if let Ok(select_el) = target.dyn_into::() { + set_selected_guild.set(select_el.value()); + } } }; let on_channel_change = move |ev: leptos::ev::Event| { - if let Some(target) = ev.target().and_then(|t| t.dyn_into::().ok()) { - set_selected_channel(target.value()); + if let Some(target) = ev.target() { + if let Ok(select_el) = target.dyn_into::() { + set_selected_channel.set(select_el.value()); + } } }; @@ -76,9 +78,9 @@ pub fn VoiceConnectionCard( }; view! { - - -
+
+
+
- "Voice Bridge" +

"Voice Bridge"

- +

"Join a Discord voice channel, listen, and transmit audio." - - +

- {/* Guild and Channel Selectors */} -
+
- + - +