# Leptos Frontend Rewrite — Phase 1 & 2 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Scaffold the Leptos CSR WASM project, port all shared types, build the CSS design system, app shell, auth, WebSocket infrastructure, API client, and all shared UI primitives. **Architecture:** Workspace with two crates (`shared-types` for Rust types, `frontend` for Leptos app). TypeScript types ported 1:1 to Rust structs with serde. WebSocket singleton with per-event channels. Leptos signals for reactive state. **Tech Stack:** Leptos 0.7 CSR, plain CSS, `lucide-leptos`, `gloo-net`, `web-sys`, `trunk` builder. ## Global Constraints - Leptos 0.7 CSR mode only (no SSR) - Plain CSS only — no Tailwind, no CSS framework - All icons from `lucide-leptos` crate - All browser APIs through `web-sys` crate - Rust types match TypeScript types exactly (same field names, same optional/nullable patterns) - All API endpoints and WebSocket protocol unchanged from current React app - Build verification: `cargo check` for types, `trunk build` for WASM output --- ## File Structure ``` services/frontend-leptos/ ├── Cargo.toml ├── rust-toolchain.toml ├── .env ├── shared-types/ │ ├── Cargo.toml │ └── src/ │ ├── lib.rs │ ├── message.rs │ ├── guild.rs │ ├── voice.rs │ ├── media.rs │ ├── dashboard.rs │ ├── recording.rs │ └── ui_state.rs └── frontend/ ├── Cargo.toml ├── Trunk.toml ├── index.html └── src/ ├── main.rs ├── lib.rs ├── app.rs ├── app.css ├── auth.rs ├── ws/ │ ├── mod.rs │ ├── socket.rs │ └── context.rs ├── api/ │ ├── mod.rs │ ├── client.rs │ ├── auth.rs │ ├── messages.rs │ ├── voice.rs │ ├── dashboard.rs │ └── recordings.rs ├── ui/ │ ├── mod.rs │ ├── button.rs │ ├── badge.rs │ ├── card.rs │ ├── input.rs │ ├── select.rs │ ├── tabs.rs │ ├── scroll_area.rs │ ├── toast.rs │ ├── skeleton.rs │ ├── status_badge.rs │ ├── empty_state.rs │ └── modal.rs ├── layout/ │ ├── mod.rs │ ├── dashboard_layout.rs │ ├── header.rs │ ├── sidebar.rs │ ├── tab_strip.rs │ └── mobile_tab_bar.rs └── features/ ├── mod.rs └── (Phases 3-6) ``` --- ## Phase 1 — Foundation ### Task 1: Workspace scaffold + shared-types crate **Files:** - Create: `services/frontend-leptos/Cargo.toml` - Create: `services/frontend-leptos/rust-toolchain.toml` - Create: `services/frontend-leptos/.env` - Create: `services/frontend-leptos/shared-types/Cargo.toml` - Create: `services/frontend-leptos/shared-types/src/lib.rs` - Create: `services/frontend-leptos/shared-types/src/message.rs` - Create: `services/frontend-leptos/shared-types/src/guild.rs` - Create: `services/frontend-leptos/shared-types/src/voice.rs` - Create: `services/frontend-leptos/shared-types/src/media.rs` - Create: `services/frontend-leptos/shared-types/src/dashboard.rs` - Create: `services/frontend-leptos/shared-types/src/recording.rs` - Create: `services/frontend-leptos/shared-types/src/ui_state.rs` **Interfaces:** - Consumes: nothing (first task) - Produces: `shared_types::*` — all Rust structs with serde Serialize/Deserialize - [ ] **Step 1: Create workspace Cargo.toml** ```toml # services/frontend-leptos/Cargo.toml [workspace] resolver = "2" members = ["shared-types", "frontend"] ``` - [ ] **Step 2: Create rust-toolchain.toml** ```toml # services/frontend-leptos/rust-toolchain.toml [toolchain] channel = "nightly-2026-06-01" components = ["rust-src", "rustc-dev"] targets = ["wasm32-unknown-unknown"] ``` - [ ] **Step 3: Create .env template** ``` # services/frontend-leptos/.env # API/WS endpoints — set these before building VITE_BE_API_URL=http://localhost:3001 VITE_BE_WS_URL=ws://localhost:3001/ws ``` - [ ] **Step 4: Create shared-types Cargo.toml** ```toml # services/frontend-leptos/shared-types/Cargo.toml [package] name = "shared-types" version = "0.1.0" edition = "2021" [dependencies] serde = { version = "1", features = ["derive"] } ``` - [ ] **Step 5: Create shared-types/src/lib.rs** ```rust // services/frontend-leptos/shared-types/src/lib.rs pub mod message; pub mod guild; pub mod voice; pub mod media; pub mod dashboard; pub mod recording; pub mod ui_state; ``` - [ ] **Step 6: Create shared-types/src/message.rs** Port of `@bete/shared` MessageRecord + related types and `services/frontend/src/entities/message/types.ts`. ```rust 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: String, #[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: String, #[serde(skip_serializing_if = "Option::is_none")] pub uploaded_at: Option, } ``` - [ ] **Step 7: Create shared-types/src/guild.rs** ```rust 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: String, } // ── Config ──────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { #[serde(skip_serializing_if = "Option::is_none")] pub monitor_guild_id: Option, } ``` - [ ] **Step 8: Create shared-types/src/voice.rs** ```rust 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, #[serde(skip_serializing_if = "Option::is_none")] pub avatar: Option, pub speaking: bool, } ``` - [ ] **Step 9: Create shared-types/src/media.rs** ```rust 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, } ``` - [ ] **Step 10: Create shared-types/src/dashboard.rs** ```rust 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, } ``` - [ ] **Step 11: Create shared-types/src/recording.rs** ```rust 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, pub guild_id: String, pub channel_id: String, pub channel_name: String, 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: String, #[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, } ``` - [ ] **Step 12: Create shared-types/src/ui_state.rs** ```rust 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, } ``` - [ ] **Step 13: Verify shared-types compiles** Run: `cd services/frontend-leptos && cargo check -p shared-types` Expected: `Checking shared-types v0.1.0` — no errors - [ ] **Step 14: Commit** ```bash cd /mnt/code/bete git add services/frontend-leptos/ git commit -m "feat(leptos): scaffold workspace + shared-types port" ``` --- ### Task 2: Frontend crate scaffold + empty Leptos app **Files:** - Create: `services/frontend-leptos/frontend/Cargo.toml` - Create: `services/frontend-leptos/frontend/Trunk.toml` - Create: `services/frontend-leptos/frontend/index.html` - Create: `services/frontend-leptos/frontend/src/main.rs` - Create: `services/frontend-leptos/frontend/src/lib.rs` - Create: `services/frontend-leptos/frontend/src/app.rs` **Interfaces:** - Consumes: `shared-types` crate - Produces: A working `trunk serve` that shows an HTML page with "Hello from Leptos" text - [ ] **Step 1: Create frontend Cargo.toml** ```toml # services/frontend-leptos/frontend/Cargo.toml [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 = "0.5" 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", "DataView", "ArrayBuffer", "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" ``` - [ ] **Step 2: Create Trunk.toml** ```toml # services/frontend-leptos/frontend/Trunk.toml [build] target = "index.html" dist = "dist" [serve] port = 8080 open = false ``` - [ ] **Step 3: Create index.html (entry point)** ```html IMPHNEN -- Discord Moderation ``` - [ ] **Step 4: Create main.rs (CSR entry point)** ```rust // services/frontend-leptos/frontend/src/main.rs use frontend::app::App; fn main() { _ = console_error_panic_hook::set_once(); wasm_logger::init(wasm_logger::Config::default()); leptos::mount_to_body(App); } ``` - [ ] **Step 5: Create lib.rs (module declarations)** ```rust // services/frontend-leptos/frontend/src/lib.rs pub mod app; ``` - [ ] **Step 6: Create app.rs (empty app placeholder)** ```rust // services/frontend-leptos/frontend/src/app.rs use leptos::*; #[component] pub fn App() -> impl IntoView { view! {
"Hello from Leptos"
} } ``` - [ ] **Step 7: Install trunk if needed** Run: `which trunk || cargo install trunk` Note: May need to install wasm32 target: `rustup target add wasm32-unknown-unknown` - [ ] **Step 8: Verify build** Run: `cd services/frontend-leptos/frontend && cargo check` Expected: Compilation succeeds with no errors Run: `trunk build --release` (takes a while first time) Expected: Produces `dist/` folder with `frontend.wasm` and `index.html` - [ ] **Step 9: Commit** ```bash cd /mnt/code/bete git add services/frontend-leptos/ git commit -m "feat(leptos): frontend scaffold with empty app" ``` --- ## Phase 2 — Skeleton ### Task 3: CSS Design System **Files:** - Create: `services/frontend-leptos/frontend/src/app.css` (~700 lines) **Interfaces:** - Consumes: nothing (standalone CSS file) - Produces: Complete CSS design system with custom properties, component classes, animations, dark theme - [ ] **Step 1: Define CSS custom properties (`:root` + `[data-theme="dark"]`)** ```css /* services/frontend-leptos/frontend/src/app.css */ /* ── 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); } ``` - [ ] **Step 2: Add base/reset styles and typography** ```css /* ── 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); } ``` - [ ] **Step 3: Add layout utility classes** ```css /* ── 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; } ``` - [ ] **Step 4: Add all component classes** ```css /* ── 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; } ``` - [ ] **Step 5: Add animation keyframes** ```css /* ── 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; } } ``` - [ ] **Step 6: Update app.rs to import CSS** Add `#[component]` import and CSS import in `app.rs`: ```rust use leptos::*; #[component] pub fn App() -> impl IntoView { view! {
"Hello from Leptos"
} } ``` Note: Leptos CSR builds the CSS file alongside the WASM. Trunk copies the CSS into `dist/` via `` — but since we reference it from the same directory, `` works when served by Trunk's built-in dev server. For production, update the path to use Trunk's `data-trunk` pipeline. - [ ] **Step 7: Verify build with CSS** Run: `cd services/frontend-leptos/frontend && cargo check && trunk build --release` Expected: Build succeeds, `dist/app.css` exists, page shows styled text - [ ] **Step 8: Commit** ```bash cd /mnt/code/bete git add services/frontend-leptos/ git commit -m "feat(leptos): CSS design system with component classes and animations" ``` --- ### Task 4: App shell + auth gate + tab routing **Files:** - Create: `services/frontend-leptos/frontend/src/auth.rs` - Modify: `services/frontend-leptos/frontend/src/app.rs` (full app shell) - Modify: `services/frontend-leptos/frontend/src/lib.rs` (add module declarations) **Interfaces:** - Consumes: `shared_types::ui_state::Tab` - Produces: `App` component with auth gate, tab-based routing, and context providers - [ ] **Step 1: Create auth.rs** ```rust // services/frontend-leptos/frontend/src/auth.rs use leptos::*; #[component] pub fn AuthOverlay( /// Called when password is submitted — parent handles the actual API call on_submit: leptos::html::InputEvent, ) -> 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: leptos::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! { } } ``` - [ ] **Step 2: Rewrite app.rs with full shell** ```rust // services/frontend-leptos/frontend/src/app.rs use leptos::*; use shared_types::ui_state::Tab; use crate::auth::AuthOverlay; // ── Contexts ──────────────────────────────────────────── #[derive(Clone)] pub struct AuthContext { pub authenticated: RwSignal, pub password: RwSignal, } #[derive(Clone)] pub struct UiContext { 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! {
// 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 is_active = move || ui.active_tab.get() == tab; let handle_click = move |_| ui.active_tab.set(tab); view! { } } ``` - [ ] **Step 3: Update lib.rs** ```rust // services/frontend-leptos/frontend/src/lib.rs pub mod app; pub mod auth; ``` - [ ] **Step 4: Verify build** Run: `cd services/frontend-leptos/frontend && cargo check` Expected: Compiles with no errors - [ ] **Step 5: Commit** ```bash cd /mnt/code/bete git add services/frontend-leptos/ git commit -m "feat(leptos): app shell with auth gate and tab routing" ``` --- ### Task 5: WebSocket infrastructure **Files:** - Create: `services/frontend-leptos/frontend/src/ws/mod.rs` - Create: `services/frontend-leptos/frontend/src/ws/socket.rs` - Create: `services/frontend-leptos/frontend/src/ws/context.rs` **Interfaces:** - Produces: `WsHandle` struct with `status: ReadSignal`, `send_text(text: &str)`, `send_binary(data: &[u8])`, and per-event channels for all WS event types - Consumes: `ws_url: &str` from env configuration - [ ] **Step 1: Create ws/mod.rs** ```rust // services/frontend-leptos/frontend/src/ws/mod.rs pub mod socket; pub mod context; ``` - [ ] **Step 2: Create ws/socket.rs** ```rust // services/frontend-leptos/frontend/src/ws/socket.rs use leptos::*; 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::cell::RefCell>>, 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::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(); let event_clone: std::cell::RefCell>> = 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(); let event2 = event_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| { let msg = e .as_ref() .unchecked_ref::() .inner_text(); 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| { 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 blob = e.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")) } } } ``` - [ ] **Step 3: Create ws/context.rs** ```rust // services/frontend-leptos/frontend/src/ws/context.rs use leptos::*; 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) pub on_message_created: std::cell::RefCell>>, pub on_message_updated: std::cell::RefCell>>, pub on_message_deleted: std::cell::RefCell>>, pub on_message_analyzed: std::cell::RefCell>>, pub on_voice_active_user: std::cell::RefCell>>, pub on_voice_recording_uploaded: std::cell::RefCell>>, pub on_media_state: std::cell::RefCell>>, pub on_binary: std::cell::RefCell)>>>, } 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::cell::RefCell::new(None), on_message_updated: std::cell::RefCell::new(None), on_message_deleted: std::cell::RefCell::new(None), on_message_analyzed: std::cell::RefCell::new(None), on_voice_active_user: std::cell::RefCell::new(None), on_voice_recording_uploaded: std::cell::RefCell::new(None), on_media_state: std::cell::RefCell::new(None), on_binary: 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); } } ``` - [ ] **Step 4: Update lib.rs** ```rust pub mod app; pub mod auth; pub mod ws; ``` - [ ] **Step 5: Update app.rs to provide WsContext** Add to `app.rs`: ```rust use crate::ws::context::WsContext; // Inside App component, after UiContext: const WS_URL: &str = "ws://localhost:3001/ws"; // TODO: make configurable at build time let ws = WsContext::new(WS_URL); provide_context(ws.clone()); // Connect WS on mount let ws_connect = ws.clone(); create_effect(move |_| { ws_connect.connect(); }); ``` - [ ] **Step 6: Verify build** Run: `cd services/frontend-leptos/frontend && cargo check` Expected: Compiles with no errors - [ ] **Step 7: Commit** ```bash cd /mnt/code/bete git add services/frontend-leptos/ git commit -m "feat(leptos): WebSocket singleton with event dispatch" ``` --- ### Task 6: API client **Files:** - Create: `services/frontend-leptos/frontend/src/api/mod.rs` - Create: `services/frontend-leptos/frontend/src/api/client.rs` - Create: `services/frontend-leptos/frontend/src/api/auth.rs` - Create: `services/frontend-leptos/frontend/src/api/messages.rs` - Create: `services/frontend-leptos/frontend/src/api/voice.rs` - Create: `services/frontend-leptos/frontend/src/api/dashboard.rs` - Create: `services/frontend-leptos/frontend/src/api/recordings.rs` - Modify: `services/frontend-leptos/frontend/src/lib.rs` **Interfaces:** - Produces: `ApiClient` with typed methods for all 20+ API endpoints - Produces: `ApiError` type for error handling - [ ] **Step 1: Create api/mod.rs** ```rust // services/frontend-leptos/frontend/src/api/mod.rs pub mod client; pub mod auth; pub mod messages; pub mod voice; pub mod dashboard; pub mod recordings; ``` - [ ] **Step 2: Create api/client.rs** ```rust // services/frontend-leptos/frontend/src/api/client.rs 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(()) } ``` - [ ] **Step 3: Create api/auth.rs** ```rust // services/frontend-leptos/frontend/src/api/auth.rs 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) } ``` - [ ] **Step 4: Create api/messages.rs** ```rust // services/frontend-leptos/frontend/src/api/messages.rs 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) } ``` - [ ] **Step 5: Create api/voice.rs** ```rust // services/frontend-leptos/frontend/src/api/voice.rs 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 } ``` - [ ] **Step 6: Create api/dashboard.rs** ```rust // services/frontend-leptos/frontend/src/api/dashboard.rs 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 } ``` - [ ] **Step 7: Create api/recordings.rs** ```rust // services/frontend-leptos/frontend/src/api/recordings.rs 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 } ``` - [ ] **Step 8: Update lib.rs** ```rust pub mod app; pub mod auth; pub mod ws; pub mod api; ``` - [ ] **Step 9: Verify build** Run: `cd services/frontend-leptos/frontend && cargo check` Expected: Compiles with no errors - [ ] **Step 10: Commit** ```bash cd /mnt/code/bete git add services/frontend-leptos/ git commit -m "feat(leptos): API client with all endpoint functions" ``` --- ### Task 7: UI Primitives — Button, Badge, Card **Files:** - Create: `services/frontend-leptos/frontend/src/ui/mod.rs` - Create: `services/frontend-leptos/frontend/src/ui/button.rs` - Create: `services/frontend-leptos/frontend/src/ui/badge.rs` - Create: `services/frontend-leptos/frontend/src/ui/card.rs` - [ ] **Step 1: Create ui/mod.rs** ```rust // services/frontend-leptos/frontend/src/ui/mod.rs pub mod button; pub mod badge; pub mod card; pub mod input; pub mod select; pub mod tabs; pub mod scroll_area; pub mod toast; pub mod skeleton; pub mod status_badge; pub mod empty_state; pub mod modal; ``` - [ ] **Step 2: Create ui/button.rs** ```rust // services/frontend-leptos/frontend/src/ui/button.rs use leptos::*; #[derive(Clone)] pub enum ButtonVariant { Primary, Secondary, Tertiary, Destructive, Outline, Ghost, Link, } #[derive(Clone)] pub enum ButtonSize { Default, Sm, Lg, Icon, IconSm, } #[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", }; view! { } } ``` - [ ] **Step 3: Create ui/badge.rs** ```rust // services/frontend-leptos/frontend/src/ui/badge.rs use leptos::*; #[derive(Clone)] pub enum BadgeVariant { Default, Primary, Success, Warning, Destructive, Outline, Info, } #[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", }; view! { {children()} } } ``` - [ ] **Step 4: Create ui/card.rs** ```rust // services/frontend-leptos/frontend/src/ui/card.rs use leptos::*; #[component] pub fn Card( #[prop(optional)] elevated: bool, #[prop(optional)] bordered: bool, #[prop(optional)] class: &'static str, children: Children, ) -> impl IntoView { 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! { } } ``` - [ ] **Step 5: Update lib.rs** ```rust pub mod ui; ``` - [ ] **Step 6: Verify build** Run: `cd services/frontend-leptos/frontend && cargo check` Expected: Compiles with no errors - [ ] **Step 7: Commit** ```bash cd /mnt/code/bete git add services/frontend-leptos/ git commit -m "feat(leptos): UI primitives — Button, Badge, Card" ``` --- ### Task 8: UI Primitives — Input, Select, Tabs, ScrollArea, Toast **Files:** - Create: `services/frontend-leptos/frontend/src/ui/input.rs` - Create: `services/frontend-leptos/frontend/src/ui/select.rs` - Create: `services/frontend-leptos/frontend/src/ui/tabs.rs` - Create: `services/frontend-leptos/frontend/src/ui/scroll_area.rs` - Create: `services/frontend-leptos/frontend/src/ui/toast.rs` - [ ] **Step 1: Create ui/input.rs** ```rust // services/frontend-leptos/frontend/src/ui/input.rs use leptos::*; #[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! { } } ``` - [ ] **Step 2: Create ui/select.rs** ```rust // services/frontend-leptos/frontend/src/ui/select.rs use leptos::*; /// 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! { } } ``` - [ ] **Step 3: Create ui/tabs.rs** ```rust // services/frontend-leptos/frontend/src/ui/tabs.rs use leptos::*; #[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 is_selected = move || active.get() == value; view! { } } #[component] pub fn TabContent( value: String, active: RwSignal, children: Children, ) -> impl IntoView { let is_selected = move || active.get() == value; view! {
{children()}
} } ``` - [ ] **Step 4: Create ui/scroll_area.rs** ```rust // services/frontend-leptos/frontend/src/ui/scroll_area.rs use leptos::*; #[component] pub fn ScrollArea( #[prop(optional)] class: &'static str, #[prop(optional)] style: &'static str, children: Children, ) -> impl IntoView { view! {
{children()}
} } ``` - [ ] **Step 5: Create ui/toast.rs** ```rust // services/frontend-leptos/frontend/src/ui/toast.rs use leptos::*; use std::rc::Rc; use std::cell::RefCell; #[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: Rc>, } impl ToastContext { pub fn new() -> Self { Self { toasts: create_rw_signal(vec![]), next_id: Rc::new(RefCell::new(0)), } } pub fn show(&self, message: &str, toast_type: ToastType) { let id = { let mut n = self.next_id.borrow_mut(); *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::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::>()}
} } ``` - [ ] **Step 6: Verify build** Run: `cd services/frontend-leptos/frontend && cargo check` Expected: Compiles with no errors - [ ] **Step 7: Commit** ```bash cd /mnt/code/bete git add services/frontend-leptos/ git commit -m "feat(leptos): UI primitives — Input, Select, Tabs, ScrollArea, Toast" ``` --- ### Task 9: UI Primitives — Skeleton, StatusBadge, EmptyState, Modal **Files:** - Create: `services/frontend-leptos/frontend/src/ui/skeleton.rs` - Create: `services/frontend-leptos/frontend/src/ui/status_badge.rs` - Create: `services/frontend-leptos/frontend/src/ui/empty_state.rs` - Create: `services/frontend-leptos/frontend/src/ui/modal.rs` - [ ] **Step 1: Create ui/skeleton.rs** ```rust // services/frontend-leptos/frontend/src/ui/skeleton.rs use leptos::*; #[derive(Clone)] pub enum SkeletonShape { 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", }; view! {
} } ``` - [ ] **Step 2: Create ui/status_badge.rs** ```rust // services/frontend-leptos/frontend/src/ui/status_badge.rs use leptos::*; 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"), }; view! { {label} } } ``` - [ ] **Step 3: Create ui/empty_state.rs** ```rust // services/frontend-leptos/frontend/src/ui/empty_state.rs use leptos::*; #[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())}
} } ``` - [ ] **Step 4: Create ui/modal.rs** ```rust // services/frontend-leptos/frontend/src/ui/modal.rs use leptos::*; #[component] pub fn Modal( is_open: RwSignal, #[prop(optional)] title: Option<&'static str>, #[prop(optional)] on_close: Option>, children: Children, ) -> impl IntoView { let handle_close = move |_| { is_open.set(false); if let Some(ref cb) = on_close { cb(); } }; view! { {move || is_open.get().then(|| view! { })} } } ``` - [ ] **Step 5: Verify build** Run: `cd services/frontend-leptos/frontend && cargo check` Expected: Compiles with no errors - [ ] **Step 6: Commit** ```bash cd /mnt/code/bete git add services/frontend-leptos/ git commit -m "feat(leptos): UI primitives — Skeleton, StatusBadge, EmptyState, Modal" ``` --- ### Task 10: Layout components **Files:** - Create: `services/frontend-leptos/frontend/src/layout/mod.rs` - Create: `services/frontend-leptos/frontend/src/layout/dashboard_layout.rs` - Create: `services/frontend-leptos/frontend/src/layout/header.rs` - Create: `services/frontend-leptos/frontend/src/layout/sidebar.rs` - Create: `services/frontend-leptos/frontend/src/layout/tab_strip.rs` - Create: `services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs` - Modify: `services/frontend-leptos/frontend/src/app.rs` (integrate layout components) - [ ] **Step 1: Create layout/mod.rs** ```rust // services/frontend-leptos/frontend/src/layout/mod.rs pub mod dashboard_layout; pub mod header; pub mod sidebar; pub mod tab_strip; pub mod mobile_tab_bar; ``` - [ ] **Step 2: Create layout/header.rs** ```rust // services/frontend-leptos/frontend/src/layout/header.rs use leptos::*; 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; // WS indicator text + color let (indicator_text, indicator_color) = move || match ws_status.get() { WsStatus::Connected => ("Online", "var(--color-success)"), WsStatus::Connecting => ("Menghubungkan...", "var(--color-warning)"), WsStatus::Disconnected => ("Offline", "var(--text-tertiary)"), WsStatus::Error(_) => ("Error", "var(--color-error)"), }; view! {
"IMPHNEN" "Guild Watcher"
// WS connection indicator
{move || indicator_text().0}
} } ``` Note: The `move || ...` closure syntax for style values uses `.0` because Leptos passes signals through a `Memo` wrapper. In Leptos 0.7, `move || indicator_text()` returns a `(&str, &str)` tuple when called from inside a `view!`. Update to proper Leptos 0.7 syntax: ```rust 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)", }); ``` Use these memos in the view. - [ ] **Step 3: Create layout/sidebar.rs** ```rust // services/frontend-leptos/frontend/src/layout/sidebar.rs use leptos::*; 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 is_active = move || ui.active_tab.get() == tab; let handle_click = move |_| ui.active_tab.set(tab); view! { } } ``` - [ ] **Step 4: Create layout/tab_strip.rs** ```rust // services/frontend-leptos/frontend/src/layout/tab_strip.rs use leptos::*; 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 is_active = move || ui.active_tab.get() == tab; view! { } } ``` - [ ] **Step 5: Create layout/mobile_tab_bar.rs** ```rust // services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs use leptos::*; 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 is_active = move || ui.active_tab.get() == tab; view! { } } ``` - [ ] **Step 6: Update lib.rs** ```rust pub mod layout; ``` - [ ] **Step 7: Integrate layout into app.rs** Replace the placeholder layout in `app.rs` with proper components: ```rust use crate::layout::header::Header; use crate::layout::sidebar::Sidebar; use crate::layout::tab_strip::TabStrip; use crate::layout::mobile_tab_bar::MobileTabBar; use crate::ui::toast::ToastProvider; // In App component view!, replace with: view! {
// Auth overlay {move || (!auth.authenticated.get()).then(|| { view! { } })} // Main app shell
{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(), }}
} ``` - [ ] **Step 8: Verify build** Run: `cd services/frontend-leptos/frontend && cargo check` Expected: Compiles with no errors - [ ] **Step 9: Commit** ```bash cd /mnt/code/bete git add services/frontend-leptos/ git commit -m "feat(leptos): layout components — Header, Sidebar, TabStrip, MobileTabBar" ``` --- ### Task 11: Integrate auth with API + polish skeleton **Files:** - Modify: `services/frontend-leptos/frontend/src/auth.rs` (wire up actual login call) - Modify: `services/frontend-leptos/frontend/src/app.rs` (wire auth to context, add WS connect on auth) - [ ] **Step 1: Update auth.rs with login API call** ```rust // services/frontend-leptos/frontend/src/auth.rs use leptos::*; use crate::app::AuthContext; use crate::api::auth as auth_api; #[component] 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: leptos::ev::SubmitEvent| { ev.prevent_default(); let pwd = password.get(); if pwd.is_empty() { set_error.set(Some("Password diperlukan".to_string())); return; } 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); }); }; // ... same view as Task 4 ... view! { } } ``` - [ ] **Step 2: Wire WS connection to auth state in app.rs** In `app.rs`, make WebSocket connect only after authentication: ```rust // Inside App component, after provide_context calls: { let ws = ws.clone(); let auth = auth.clone(); create_effect(move |_| { if auth.authenticated.get() { ws.connect(); } }); } ``` - [ ] **Step 3: Verify build** Run: `cd services/frontend-leptos/frontend && cargo check` Expected: Compiles with no errors - [ ] **Step 4: Commit** ```bash cd /mnt/code/bete git add services/frontend-leptos/ git commit -m "feat(leptos): auth API integration + WS auth gating" ``` --- ## Summary: What We've Built After Phase 1 and Phase 2, we have: - ✅ Rust workspace with shared types (complete port of `@bete/shared`) - ✅ Leptos CSR app scaffold (compiles, builds via trunk) - ✅ Complete CSS design system (custom properties, dark/light, all component classes, animations) - ✅ App shell with sidebar, header, tab strip, mobile nav - ✅ Auth overlay with API login + sessionStorage persistence - ✅ WebSocket singleton with per-event channels (connect, reconnect, binary/JSON dispatch) - ✅ API client with all 20+ endpoint functions - ✅ All 12 shared UI primitives (Button, Badge, Card, Input, Select, Tabs, ScrollArea, Toast, Skeleton, StatusBadge, EmptyState, Modal) - ✅ Layout components (DashboardLayout, Header, Sidebar, TabStrip, MobileTabBar) Next phase: **Feature implementation** — Messages (Phase 3), Live (Phase 4), Dashboard (Phase 5), Polish (Phase 6). Each gets its own implementation plan. ## Phase 3-6 Outline ### Phase 3: Messages Feature - Tasks: MessageFeed (IntersectionObserver), MessageCard/MessageRow (rendering), ImageGrid, ModerationAlertListener, filters, search, reanalyze - Key challenges: Infinite scroll, message merging/dedup, Discord emoji rendering, embed rendering ### Phase 4: Live Feature - Tasks: VoiceConnectionCard, ActiveSpeakers, AudioVisualizer (Canvas 2D), MicLevelMeter, NowPlaying, MusicSubPanel, ScreenSubPanel, RecordingsSubPanel, WaveformPlayer (Canvas + Web Audio API), PCM audio playback - Key challenges: Canvas rendering via web-sys, Web Audio API via web-sys, binary WS frames ### Phase 5: Dashboard Feature - Tasks: StatsOverview, UserSummaryList (+ pagination), UserProfileDetail, ChannelSummaryList, ChannelProfileDetail - Key challenges: Reusable cursor pagination, conditional stats rendering ### Phase 6: Polish - Tasks: ParticleBackground (CSS), MascotChatbot, MascotImage, theme toggle, mobile responsive, edge cases (WS reconnect, stale data, error boundaries)