From 7f6063909d4102f1d91111b2d4e1d277fbe505f5 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 06:38:39 +0700 Subject: [PATCH] docs: implementation plan for leptos rewrite phase 1 & 2 --- .../2026-07-03-leptos-rewrite-phase1-2.md | 3816 +++++++++++++++++ 1 file changed, 3816 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-leptos-rewrite-phase1-2.md diff --git a/docs/superpowers/plans/2026-07-03-leptos-rewrite-phase1-2.md b/docs/superpowers/plans/2026-07-03-leptos-rewrite-phase1-2.md new file mode 100644 index 0000000..a790536 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-leptos-rewrite-phase1-2.md @@ -0,0 +1,3816 @@ +# 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)