Files
GMW/docs/superpowers/plans/2026-07-03-leptos-rewrite-phase1-2.md
T

115 KiB
Raw Blame History

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

# services/frontend-leptos/Cargo.toml
[workspace]
resolver = "2"
members = ["shared-types", "frontend"]
  • Step 2: Create rust-toolchain.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
# 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
// 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.

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<Vec<StickerInfo>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub attachments: Option<Vec<AttachmentRef>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub embeds: Option<Vec<EmbedInfo>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub channel: Option<ChannelRef>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StickerInfo {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
}

#[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<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbedInfo {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image: Option<EmbedMedia>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub thumbnail: Option<EmbedMedia>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbedMedia {
    pub url: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub width: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub height: Option<u32>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelRef {
    pub channel_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub channel_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub thread_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub thread_name: Option<String>,
}

// ── 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<String>,
    pub user_id: String,
    pub username: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub avatar_url: Option<String>,
    pub content: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub edited_content: Option<String>,
    #[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<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub deleted_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ai_status: Option<AiStatus>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ai_severity: Option<AiSeverity>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ai_confidence: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ai_moderation_flags: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ai_moderation_score: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ai_analysis: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ai_categories: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ai_recommended_action: Option<AiRecommendedAction>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ai_error: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ai_analyzed_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata: Option<MessageMetadata>,
}

// ── Pagination ────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageResult<T> {
    pub data: Vec<T>,
    #[serde(rename = "nextCursor")]
    pub next_cursor: Option<String>,
}

// ── 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<String>,
    pub upload_status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub upload_error: Option<String>,
    pub created_at: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub uploaded_at: Option<String>,
}
  • Step 7: Create shared-types/src/guild.rs
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<String>,
}

#[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<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_id: Option<String>,
}

#[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<String>,
}
  • Step 8: Create shared-types/src/voice.rs
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<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub active_channel_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub active_channel_name: Option<String>,
    pub connections: Vec<GuildVoiceEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveSpeaker {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    pub user_id: String,
    pub username: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub avatar: Option<String>,
    pub speaking: bool,
}
  • Step 9: Create shared-types/src/media.rs
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<String>,
    pub source: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mode: Option<MediaMode>,
    #[serde(rename = "durationMs")]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub duration_ms: Option<u64>,
    #[serde(rename = "thumbnailUrl")]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub thumbnail_url: Option<String>,
}

#[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<MediaItem>,
    pub queue: Vec<MediaItem>,
}
  • Step 10: Create shared-types/src/dashboard.rs
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<TopChannel>,
    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<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub profile_summary: Option<String>,
    pub total_messages: u64,
    pub flagged_count: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_message_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub trust_score: Option<f64>,
}

#[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<String>,
    pub clean_message_streak: u64,
    pub total_infractions: u64,
    pub clean_count: u64,
    pub recent_messages: Vec<super::message::MessageRecord>,
}

#[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<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub culture_summary: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_analyzed_at: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardChannelDetail {
    #[serde(flatten)]
    pub channel: DashboardChannel,
    pub clean_count: u64,
    pub recent_messages: Vec<super::message::MessageRecord>,
}
  • Step 11: Create shared-types/src/recording.rs
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<String>,
    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<String>,
    pub upload_status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub upload_error: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub transcription: Option<String>,
    pub created_at: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub uploaded_at: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceRecordingListResponse {
    pub items: Vec<VoiceRecording>,
    #[serde(rename = "nextCursor")]
    pub next_cursor: Option<String>,
    pub has_more: bool,
}
  • Step 12: Create shared-types/src/ui_state.rs
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<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub selected_voice_guild: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub selected_voice_channel: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub selected_text_guild: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub selected_text_channel: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub active_tab: Option<Tab>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_listening: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_streaming: Option<bool>,
}
  • 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
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

# 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
# services/frontend-leptos/frontend/Trunk.toml
[build]
target = "index.html"
dist = "dist"

[serve]
port = 8080
open = false
  • Step 3: Create index.html (entry point)
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta name="theme-color" content="#23a1eb" />
  <title>IMPHNEN -- Discord Moderation</title>
  <link data-trunk rel="rust" data-bin="frontend" />
  <link data-trunk rel="copy-dir" href="public/" />
  <!-- Poppins font -->
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
  <!-- Preload WASM (Trunk inlines this, but just in case) -->
  <link rel="preload" href="/frontend.wasm" as="fetch" crossorigin="anonymous" />
</head>
<body>
  <!-- Leptos CSR mounts to document.body by default -->
</body>
</html>
  • Step 4: Create main.rs (CSR entry point)
// 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)
// services/frontend-leptos/frontend/src/lib.rs
pub mod app;
  • Step 6: Create app.rs (empty app placeholder)
// services/frontend-leptos/frontend/src/app.rs
use leptos::*;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: 'Poppins', sans-serif; font-size: 1.5rem; background: #0f172a; color: #e2e8f0;">
            "Hello from Leptos"
        </div>
    }
}
  • 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
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"])

/* 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
/* ── 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
/* ── 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
/* ── 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
/* ── 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:

use leptos::*;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <div class="app">
            <link rel="stylesheet" href="/app.css" />
            <div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: 'Poppins', sans-serif; font-size: 1.5rem; background: #0f172a; color: #e2e8f0;">
                "Hello from Leptos"
            </div>
        </div>
    }
}

Note: Leptos CSR builds the CSS file alongside the WASM. Trunk copies the CSS into dist/ via <link data-trunk> — but since we reference it from the same directory, <link rel="stylesheet" href="/app.css"> 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
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

// 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::<String>::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! {
        <div class="modal-overlay">
            <div class="modal-content" style="width: 380px;">
                <div class="modal-body" style="text-align: center;">
                    <div style="font-size: 3rem; margin-bottom: 1rem;">
                        <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
                    </div>
                    <h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem;">"Akses Dashboard"</h2>
                    <p style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 1.5rem;">
                        "Masukkan password admin untuk melanjutkan"
                    </p>
                    <form on:submit=handle_submit style="display: flex; flex-direction: column; gap: 0.75rem;">
                        <input
                            type="password"
                            class="input"
                            placeholder="Password"
                            prop:value=password
                            on:input=move |ev| set_password.set(event_target_value(&ev))
                        />
                        {move || error.get().map(|e| view! { <p style="color: var(--color-error); font-size: 0.75rem;">{e}</p> })}
                        <button
                            type="submit"
                            class="btn btn-primary w-full btn-lg"
                            disabled=move || loading.get()
                        >
                            {move || if loading.get() { "Memproses..." } else { "Masuk" }}
                        </button>
                    </form>
                </div>
            </div>
        </div>
    }
}
  • Step 2: Rewrite app.rs with full shell
// 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<bool>,
    pub password: RwSignal<String>,
}

#[derive(Clone)]
pub struct UiContext {
    pub active_tab: RwSignal<Tab>,
    pub selected_guild: RwSignal<Option<String>>,
}

// ── 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! {
        <div data-theme="light">
            // Auth overlay
            {move || (!auth.authenticated.get()).then(|| {
                view! { <AuthOverlay /> }
            })}

            // Main content (minimal for now — filled in later tasks)
            <div style="display: flex; flex-direction: column; height: 100vh;">
                <header style="height: var(--header-height); border-bottom: 1px solid var(--surface-border); display: flex; align-items: center; padding: 0 1rem;">
                    <span style="font-weight: 700; color: var(--color-primary);">"IMPHNEN"</span>
                    <span style="margin-left: 0.5rem; color: var(--text-secondary); font-size: 0.875rem;">"Discord Moderation"</span>
                </header>

                <main style="flex: 1; display: flex;">
                    // Sidebar placeholder
                    <nav style="width: var(--sidebar-width); border-right: 1px solid var(--surface-border); padding: 1rem;">
                        <div class="flex flex-col gap-2">
                            <TabButton tab=Tab::Messages ui=ui.clone() label="Pesan & Moderasi" />
                            <TabButton tab=Tab::Live ui=ui.clone() label="Voice & Media" />
                            <TabButton tab=Tab::Dashboard ui=ui.clone() label="Dashboard Guild" />
                        </div>
                    </nav>

                    // Content area
                    <div style="flex: 1; overflow: auto; padding: 1.5rem;">
                        {move || match ui.active_tab.get() {
                            Tab::Messages => view! { <div>"Messages Panel"</div> }.into_view(),
                            Tab::Live => view! { <div>"Live Panel"</div> }.into_view(),
                            Tab::Dashboard => view! { <div>"Dashboard Panel"</div> }.into_view(),
                        }}
                    </div>
                </main>
            </div>
        </div>
    }
}

// ── 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! {
        <button
            class:btn=true
            class:btn-ghost=true
            class:btn-active=is_active
            on:click=handle_click
            style:background=move || if is_active() { "var(--surface-overlay)" } else { "" }
            style:color=move || if is_active() { "var(--color-primary)" } else { "" }
            style:width="100%"
            style:justify-content="flex-start"
        >
            {label}
        </button>
    }
}
  • Step 3: Update lib.rs
// 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
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<WsStatus>, 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

// services/frontend-leptos/frontend/src/ws/mod.rs
pub mod socket;
pub mod context;
  • Step 2: Create ws/socket.rs
// 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<u8>),
}

pub struct WsHandle {
    pub status: ReadSignal<WsStatus>,
    set_status: WriteSignal<WsStatus>,
    ws: std::cell::RefCell<Option<WebSocket>>,
    on_event: std::cell::RefCell<Option<Box<dyn Fn(WsEvent)>>>,
    url: String,
    reconnect_attempt: std::cell::Cell<u32>,
}

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<F>(&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<Option<Box<dyn Fn(WsEvent)>>> = self.on_event.clone();
        let ws_holder = &self.ws as *const std::cell::RefCell<Option<WebSocket>>;
        let reconnect_attempt = &self.reconnect_attempt as *const std::cell::Cell<u32>;

        // 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::<dyn Fn(web_sys::ProgressEvent)>::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::<dyn Fn(CloseEvent)>::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::<dyn Fn(ErrorEvent)>::new(move |e| {
                    let msg = e
                        .as_ref()
                        .unchecked_ref::<web_sys::HtmlElement>()
                        .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::<dyn Fn(MessageEvent)>::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::<js_sys::ArrayBuffer>() {
                            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::<web_sys::Blob>();
                            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
// 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<WsHandle>,
    pub status: ReadSignal<WsStatus>,
    // Per-event callbacks (set externally by feature components)
    pub on_message_created: std::cell::RefCell<Option<Box<dyn Fn(MessageRecord)>>>,
    pub on_message_updated: std::cell::RefCell<Option<Box<dyn Fn(MessageRecord)>>>,
    pub on_message_deleted: std::cell::RefCell<Option<Box<dyn Fn(String)>>>,
    pub on_message_analyzed: std::cell::RefCell<Option<Box<dyn Fn(MessageRecord)>>>,
    pub on_voice_active_user: std::cell::RefCell<Option<Box<dyn Fn(ActiveSpeaker)>>>,
    pub on_voice_recording_uploaded: std::cell::RefCell<Option<Box<dyn Fn(VoiceRecording)>>>,
    pub on_media_state: std::cell::RefCell<Option<Box<dyn Fn(MediaState)>>>,
    pub on_binary: std::cell::RefCell<Option<Box<dyn Fn(Vec<u8>)>>>,
}

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::<serde_json::Value>(&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::<MessageRecord>(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::<MessageRecord>(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::<MessageRecord>(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::<ActiveSpeaker>(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::<VoiceRecording>(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::<MediaState>(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
pub mod app;
pub mod auth;
pub mod ws;
  • Step 5: Update app.rs to provide WsContext

Add to app.rs:

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
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

// 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
// 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::<web_sys::Window>()
        .location()
        .hostname()
        .ok()
        .map(|_| format!("http://localhost:3001"))
        .unwrap_or_else(|| default.to_string())
}

fn get_auth_header() -> Option<String> {
    // Read password from sessionStorage
    let storage = web_sys::window()?.local_storage().ok()??;
    storage.get_item("admin-password").ok()?
}

pub async fn request<T: DeserializeOwned>(
    method: &str,
    path: &str,
    body: Option<&str>,
) -> Result<T, ApiError> {
    let url = format!("{}{}", get_base_url(), path);

    let mut headers = Headers::new().map_err(|_| ApiError {
        message: "Failed to create headers".to_string(),
        status_code: 0,
    })?;

    if let Some(password) = get_auth_header() {
        headers.set("X-Admin-Password", &password).ok();
    }

    let mut opts = RequestInit::new();
    opts.set_method(method);
    opts.set_headers(&headers);
    opts.set_mode(RequestMode::Cors);

    if let Some(json_body) = body {
        headers.set("Content-Type", "application/json").ok();
        opts.set_body(&JsValue::from_str(json_body));
    }

    let request = Request::new_with_str_and_init(&url, &opts).map_err(|e| ApiError {
        message: format!("Failed to create request: {:?}", e),
        status_code: 0,
    })?;

    let window = web_sys::window().ok_or(ApiError {
        message: "No window".to_string(),
        status_code: 0,
    })?;

    let resp_value = JsFuture::from(window.fetch_with_request(&request))
        .await
        .map_err(|e| ApiError {
            message: format!("Fetch failed: {:?}", e),
            status_code: 0,
        })?;

    let response: Response = resp_value.dyn_into().map_err(|_| ApiError {
        message: "Invalid response".to_string(),
        status_code: 0,
    })?;

    let status = response.status();
    if status >= 400 {
        let text = JsFuture::from(
            response.text().map_err(|_| ApiError {
                message: "Failed to read error body".to_string(),
                status_code: status,
            })?
        )
        .await
        .ok()
        .and_then(|v| v.as_string())
        .unwrap_or_default();

        return Err(ApiError {
            message: text,
            status_code: status,
        });
    }

    let text = JsFuture::from(
        response.text().map_err(|_| ApiError {
            message: "Failed to read response body".to_string(),
            status_code: status,
        })?
    )
    .await
    .map_err(|_| ApiError {
        message: "Failed to await response".to_string(),
        status_code: status,
    })?
    .as_string()
    .ok_or(ApiError {
        message: "Response is not text".to_string(),
        status_code: status,
    })?;

    serde_json::from_str(&text).map_err(|e| ApiError {
        message: format!("JSON parse error: {} — body: {}", e, &text[..text.len().min(200)]),
        status_code: status,
    })
}

pub async fn request_no_body(method: &str, path: &str) -> Result<(), ApiError> {
    request::<serde_json::Value>(method, path, None).await?;
    Ok(())
}
  • Step 3: Create api/auth.rs
// 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<bool, ApiError> {
    let payload = LoginPayload {
        password: password.to_string(),
    };
    let body = serde_json::to_string(&payload).unwrap();
    let resp: LoginResponse = request("POST", "/api/auth/login", Some(&body)).await?;
    Ok(resp.ok)
}
  • Step 4: Create api/messages.rs
// 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<u32>,
    channel_id: Option<&str>,
    cursor: Option<&str>,
) -> Result<PageResult<MessageRecord>, ApiError> {
    let mut path = format!("/api/messages?guildId={}", guild_id);
    if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); }
    if let Some(c) = channel_id { path.push_str(&format!("&channelId={}", c)); }
    if let Some(c) = cursor { path.push_str(&format!("&cursor={}", c)); }
    request("GET", &path, None).await
}

/// GET /api/review?params
pub async fn get_review_messages(
    guild_id: &str,
    limit: Option<u32>,
    channel_id: Option<&str>,
) -> Result<PageResult<MessageRecord>, ApiError> {
    let mut path = format!("/api/review?guildId={}", guild_id);
    if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); }
    if let Some(c) = channel_id { path.push_str(&format!("&channelId={}", c)); }
    request("GET", &path, None).await
}

/// GET /api/messages/detail/{id}
pub async fn get_message_detail(id: &str) -> Result<Option<MessageRecord>, ApiError> {
    request("GET", &format!("/api/messages/detail/{}", id), None).await
}

/// POST /api/messages/{id}/reanalyze
pub async fn reanalyze_message(id: &str) -> Result<(), ApiError> {
    let _: serde_json::Value = request("POST", &format!("/api/messages/{}/reanalyze", id), Some("{}")).await?;
    Ok(())
}

/// POST /api/messages/reanalyze-batch
pub async fn reanalyze_batch() -> Result<u64, ApiError> {
    #[derive(serde::Deserialize)]
    struct BatchResp { ok: bool, count: u64 }
    let resp: BatchResp = request("POST", "/api/messages/reanalyze-batch", Some("{}")).await?;
    Ok(resp.count)
}

/// GET /api/analysis/search?q=&limit=
pub async fn search_messages(query: &str, limit: Option<u32>) -> Result<Vec<MessageRecord>, ApiError> {
    #[derive(serde::Deserialize)]
    struct SearchResult { results: Vec<MessageRecord> }
    let mut path = format!("/api/analysis/search?q={}", query);
    if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); }
    let resp: SearchResult = request("GET", &path, None).await?;
    Ok(resp.results)
}
  • Step 5: Create api/voice.rs
// 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<Vec<Guild>, ApiError> {
    request("GET", "/api/guilds", None).await
}

/// GET /api/guilds/{guildId}/voice-channels
pub async fn get_voice_channels(guild_id: &str) -> Result<Vec<Channel>, ApiError> {
    request("GET", &format!("/api/guilds/{}/voice-channels", guild_id), None).await
}

/// GET /api/guilds/{guildId}/channels
pub async fn get_text_channels(guild_id: &str) -> Result<Vec<Channel>, ApiError> {
    request("GET", &format!("/api/guilds/{}/channels", guild_id), None).await
}

/// GET /api/voice/status
pub async fn get_voice_status() -> Result<VoiceStatus, ApiError> {
    request("GET", "/api/voice/status", None).await
}

/// POST /api/voice/connect { guildId, channelId }
#[derive(Serialize)]
struct ConnectPayload {
    guild_id: String,
    channel_id: String,
}
pub async fn connect_voice(guild_id: &str, channel_id: &str) -> Result<VoiceStatus, ApiError> {
    let body = serde_json::to_string(&ConnectPayload {
        guild_id: guild_id.to_string(),
        channel_id: channel_id.to_string(),
    }).unwrap();
    request("POST", "/api/voice/connect", Some(&body)).await
}

/// POST /api/voice/disconnect
pub async fn disconnect_voice() -> Result<VoiceStatus, ApiError> {
    request("POST", "/api/voice/disconnect", Some("{}")).await
}

/// GET /api/media/status
pub async fn get_media_status() -> Result<MediaState, ApiError> {
    request("GET", "/api/media/status", None).await
}

/// POST /api/media/queue { source, mode }
#[derive(Serialize)]
struct MediaQueuePayload {
    source: String,
    mode: String,
}
pub async fn media_queue(source: &str, mode: &str) -> Result<MediaState, ApiError> {
    let body = serde_json::to_string(&MediaQueuePayload {
        source: source.to_string(),
        mode: mode.to_string(),
    }).unwrap();
    request("POST", "/api/media/queue", Some(&body)).await
}

/// POST /api/media/skip
pub async fn media_skip() -> Result<MediaState, ApiError> {
    request("POST", "/api/media/skip", Some("{}")).await
}

/// POST /api/media/stop
pub async fn media_stop() -> Result<MediaState, ApiError> {
    request("POST", "/api/media/stop", Some("{}")).await
}

/// POST /api/media/volume { volume }
#[derive(Serialize)]
struct VolumePayload { volume: f64 }
pub async fn media_volume(volume: f64) -> Result<MediaState, ApiError> {
    let body = serde_json::to_string(&VolumePayload { volume }).unwrap();
    request("POST", "/api/media/volume", Some(&body)).await
}
  • Step 6: Create api/dashboard.rs
// 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<DashboardStats, ApiError> {
    request("GET", "/api/dashboard/stats", None).await
}

/// GET /api/dashboard/users?limit=&cursor=&search=
pub async fn get_dashboard_users(
    limit: Option<u32>,
    cursor: Option<&str>,
    search: Option<&str>,
) -> Result<PaginatedUsers, ApiError> {
    let mut path = "/api/dashboard/users".to_string();
    let mut params = vec![];
    if let Some(l) = limit { params.push(format!("limit={}", l)); }
    if let Some(c) = cursor { params.push(format!("cursor={}", c)); }
    if let Some(s) = search { params.push(format!("search={}", s)); }
    if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); }
    request("GET", &path, None).await
}

#[derive(serde::Deserialize)]
pub struct PaginatedUsers {
    pub data: Vec<DashboardUser>,
    pub next_cursor: Option<String>,
}

/// GET /api/dashboard/users/{userId}
pub async fn get_dashboard_user_detail(user_id: &str) -> Result<DashboardUserDetail, ApiError> {
    request("GET", &format!("/api/dashboard/users/{}", user_id), None).await
}

/// GET /api/dashboard/channels?limit=&cursor=&search=&guild_id=
pub async fn get_dashboard_channels(
    limit: Option<u32>,
    cursor: Option<&str>,
    search: Option<&str>,
    guild_id: Option<&str>,
) -> Result<PaginatedChannels, ApiError> {
    let mut path = "/api/dashboard/channels".to_string();
    let mut params = vec![];
    if let Some(l) = limit { params.push(format!("limit={}", l)); }
    if let Some(c) = cursor { params.push(format!("cursor={}", c)); }
    if let Some(s) = search { params.push(format!("search={}", s)); }
    if let Some(g) = guild_id { params.push(format!("guild_id={}", g)); }
    if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); }
    request("GET", &path, None).await
}

#[derive(serde::Deserialize)]
pub struct PaginatedChannels {
    pub data: Vec<DashboardChannel>,
    pub next_cursor: Option<String>,
}

/// GET /api/dashboard/channels/{channelId}
pub async fn get_dashboard_channel_detail(channel_id: &str) -> Result<DashboardChannelDetail, ApiError> {
    request("GET", &format!("/api/dashboard/channels/{}", channel_id), None).await
}
  • Step 7: Create api/recordings.rs
// 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<u32>,
    cursor: Option<&str>,
) -> Result<VoiceRecordingListResponse, ApiError> {
    let mut path = "/api/recordings".to_string();
    let mut params = vec![];
    if let Some(l) = limit { params.push(format!("limit={}", l)); }
    if let Some(c) = cursor { params.push(format!("cursor={}", c)); }
    if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); }
    request("GET", &path, None).await
}

/// DELETE /api/recordings/{id}
pub async fn delete_recording(id: &str) -> Result<(), ApiError> {
    request_no_body("DELETE", &format!("/api/recordings/{}", id)).await
}
  • Step 8: Update lib.rs
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
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

// 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
// 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<Box<dyn Fn(leptos::ev::MouseEvent)>>,
    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! {
        <button
            class:btn=true
            class=variant_class
            class=size_class
            class=class
            disabled=disabled
            on:click=move |ev| { if let Some(ref cb) = on_click { cb(ev); } }
        >
            {children()}
        </button>
    }
}
  • Step 3: Create ui/badge.rs
// 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! {
        <span class="badge" class=variant_class>
            {children()}
        </span>
    }
}
  • Step 4: Create ui/card.rs
// 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! {
        <div
            class="card"
            class:card-elevated=elevated
            class:card-bordered=bordered
            class=class
        >
            {children()}
        </div>
    }
}

#[component]
pub fn CardHeader(children: Children) -> impl IntoView {
    view! { <div class="card-header">{children()}</div> }
}

#[component]
pub fn CardTitle(children: Children) -> impl IntoView {
    view! { <h3 class="card-title">{children()}</h3> }
}

#[component]
pub fn CardDescription(children: Children) -> impl IntoView {
    view! { <p class="card-description">{children()}</p> }
}

#[component]
pub fn CardContent(children: Children) -> impl IntoView {
    view! { <div class="card-content">{children()}</div> }
}

#[component]
pub fn CardFooter(children: Children) -> impl IntoView {
    view! { <div class="card-footer">{children()}</div> }
}
  • Step 5: Update lib.rs
pub mod ui;
  • Step 6: Verify build

Run: cd services/frontend-leptos/frontend && cargo check Expected: Compiles with no errors

  • Step 7: Commit
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

// 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<String>,
    #[prop(optional)] soft: bool,
    #[prop(optional)] error: bool,
    #[prop(optional)] class: &'static str,
    #[prop(optional)] on_input: Option<Box<dyn Fn(String)>>,
) -> impl IntoView {
    view! {
        <input
            type=input_type
            class="input"
            class:input-soft=soft
            class:input-error=error
            class=class
            placeholder=placeholder
            prop:value=move || value.get()
            on:input=move |ev| {
                let val = event_target_value(&ev);
                value.set(val.clone());
                if let Some(ref cb) = on_input { cb(val); }
            }
        />
    }
}

#[component]
pub fn TextArea(
    #[prop(optional)] placeholder: &'static str,
    #[prop(optional)] value: RwSignal<String>,
    #[prop(optional)] rows: u32,
    #[prop(optional)] class: &'static str,
) -> impl IntoView {
    view! {
        <textarea
            class="input"
            class=class
            placeholder=placeholder
            prop:value=move || value.get()
            on:input=move |ev| value.set(event_target_value(&ev))
            rows=rows
        ></textarea>
    }
}
  • Step 2: Create ui/select.rs
// 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<String>,
    options: Vec<(&'static str, &'static str)>, // (value, label)
    #[prop(optional)] placeholder: &'static str,
    #[prop(optional)] class: &'static str,
    #[prop(optional)] on_change: Option<Box<dyn Fn(String)>>,
) -> impl IntoView {
    view! {
        <select
            class="select"
            class=class
            prop:value=move || value.get()
            on:change=move |ev| {
                let val = event_target_value(&ev);
                value.set(val.clone());
                if let Some(ref cb) = on_change { cb(val); }
            }
        >
            <option value="" disabled=placeholder.len() > 0>{placeholder}</option>
            {options.into_iter().map(|(val, label)| view! {
                <option value=val selected=move || value.get() == val>{label}</option>
            }).collect::<Vec<_>>()}
        </select>
    }
}
  • Step 3: Create ui/tabs.rs
// services/frontend-leptos/frontend/src/ui/tabs.rs
use leptos::*;

#[component]
pub fn Tabs(
    active: RwSignal<String>,
    #[prop(optional)] class: &'static str,
    children: Children,
) -> impl IntoView {
    view! {
        <div class="tabs" class=class>
            {children()}
        </div>
    }
}

#[component]
pub fn TabList(
    #[prop(optional)] class: &'static str,
    children: Children,
) -> impl IntoView {
    view! {
        <div class="tab-list" class=class role="tablist">
            {children()}
        </div>
    }
}

#[component]
pub fn TabTrigger(
    value: String,
    active: RwSignal<String>,
    children: Children,
) -> impl IntoView {
    let is_selected = move || active.get() == value;
    view! {
        <button
            class="tab-trigger"
            class:active=is_selected
            role="tab"
            aria-selected=move || if is_selected() { "true" } else { "false" }
            on:click=move |_| active.set(value.clone())
        >
            {children()}
        </button>
    }
}

#[component]
pub fn TabContent(
    value: String,
    active: RwSignal<String>,
    children: Children,
) -> impl IntoView {
    let is_selected = move || active.get() == value;
    view! {
        <div
            class="tab-content"
            role="tabpanel"
            style:display=move || if is_selected() { "block" } else { "none" }
        >
            {children()}
        </div>
    }
}
  • Step 4: Create ui/scroll_area.rs
// 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! {
        <div class="scroll-area" class=class style=style>
            {children()}
        </div>
    }
}
  • Step 5: Create ui/toast.rs
// 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<Vec<ToastMessage>>,
    next_id: Rc<RefCell<u64>>,
}

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()}
        <div class="toast-container">
            {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! {
                    <div class="toast" class=type_class>
                        <span>{msg.message}</span>
                        <button class="toast-close" on:click=move |_| {
                            toasts.update(|t| t.retain(|m| m.id != msg.id));
                        }>"×"</button>
                    </div>
                }
            }).collect::<Vec<_>>()}
        </div>
    }
}
  • Step 6: Verify build

Run: cd services/frontend-leptos/frontend && cargo check Expected: Compiles with no errors

  • Step 7: Commit
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

// 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! {
        <div
            class="skeleton"
            class=shape_class
            style=format!("width: {}; height: {};", width, height)
        ></div>
    }
}
  • Step 2: Create ui/status_badge.rs
// 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! {
        <span class="status-badge" class=class>
            {label}
        </span>
    }
}
  • Step 3: Create ui/empty_state.rs
// services/frontend-leptos/frontend/src/ui/empty_state.rs
use leptos::*;

#[component]
pub fn EmptyState(
    #[prop(optional)] icon: Option<leptos::HtmlElement<html::Div>>,
    title: &'static str,
    #[prop(optional)] description: Option<&'static str>,
    #[prop(optional)] children: Option<Children>,
) -> impl IntoView {
    view! {
        <div class="empty-state">
            {icon.map(|i| view! { <div class="empty-state-icon">{i}</div> })}
            <div class="empty-state-title">{title}</div>
            {description.map(|d| view! { <p class="empty-state-description">{d}</p> })}
            {children.map(|c| c())}
        </div>
    }
}
  • Step 4: Create ui/modal.rs
// services/frontend-leptos/frontend/src/ui/modal.rs
use leptos::*;

#[component]
pub fn Modal(
    is_open: RwSignal<bool>,
    #[prop(optional)] title: Option<&'static str>,
    #[prop(optional)] on_close: Option<Box<dyn Fn()>>,
    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! {
            <div class="modal-overlay" on:click=handle_close>
                <div class="modal-content" on:click=|ev| ev.stop_propagation()>
                    {title.map(|t| view! {
                        <div class="modal-header">
                            <h3>{t}</h3>
                            <button class="btn btn-ghost btn-icon-sm" on:click=handle_close>"×"</button>
                        </div>
                    })}
                    <div class="modal-body">
                        {children()}
                    </div>
                </div>
            </div>
        })}
    }
}
  • Step 5: Verify build

Run: cd services/frontend-leptos/frontend && cargo check Expected: Compiles with no errors

  • Step 6: Commit
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

// 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
// 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::<WsContext>().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! {
        <header style="
            height: var(--header-height);
            border-bottom: 1px solid var(--surface-border);
            display: flex;
            align-items: center;
            padding: 0 1.5rem;
            background: var(--surface-base);
            position: sticky;
            top: 0;
            z-index: var(--z-header);
        ">
            <div class="flex items-center gap-2">
                <span style="font-weight: 700; font-size: 1.125rem; color: var(--color-primary);">
                    "IMPHNEN"
                </span>
                <span style="color: var(--text-secondary); font-size: 0.75rem; padding: 0.125rem 0.375rem; background: var(--surface-overlay); border-radius: var(--radius-sm);">
                    "Guild Watcher"
                </span>
            </div>

            <div class="flex items-center gap-4 ml-auto">
                // WS connection indicator
                <div class="flex items-center gap-1" style="font-size: 0.75rem; color: var(--text-secondary);">
                    <span style="
                        width: 8px; height: 8px; border-radius: 50%;
                        background: {move || indicator_color().0};
                    "></span>
                    <span>{move || indicator_text().0}</span>
                </div>
            </div>
        </header>
    }
}

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:

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
// 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::<UiContext>().expect("UiContext not provided");
    let (collapsed, _set_collapsed) = create_signal(false);

    view! {
        <nav style:width=move || if collapsed.get() { "var(--sidebar-collapsed-width)" } else { "var(--sidebar-width)" }
            style="
                border-right: 1px solid var(--surface-border);
                display: flex;
                flex-direction: column;
                padding: 1rem 0.5rem;
                transition: width var(--transition-normal);
                overflow: hidden;
                flex-shrink: 0;
            "
        >
            <NavItem
                icon="message-square"
                label="Pesan & Moderasi"
                tab=Tab::Messages
                ui=ui.clone()
            />
            <NavItem
                icon="radio"
                label="Voice & Media"
                tab=Tab::Live
                ui=ui.clone()
            />
            <NavItem
                icon="shield"
                label="Dashboard Guild"
                tab=Tab::Dashboard
                ui=ui.clone()
            />
        </nav>
    }
}

#[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! {
        <button
            class="btn btn-ghost"
            style="
                justify-content: flex-start; width: 100%;
                margin-bottom: 0.25rem;
            "
            style:background=move || if is_active() { "var(--surface-overlay)" } else { "" }
            style:color=move || if is_active() { "var(--color-primary)" } else { "" }
            on:click=handle_click
        >
            {label}
        </button>
    }
}
  • Step 4: Create layout/tab_strip.rs
// 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::<UiContext>().expect("UiContext not provided");

    view! {
        <div style="
            display: flex;
            border-bottom: 1px solid var(--surface-border);
            padding: 0 1rem;
            background: var(--surface-base);
        ">
            <TabItem label="Pesan & Moderasi" tab=Tab::Messages ui=ui.clone() />
            <TabItem label="Voice & Media" tab=Tab::Live ui=ui.clone() />
            <TabItem label="Dashboard Guild" tab=Tab::Dashboard ui=ui.clone() />
        </div>
    }
}

#[component]
fn TabItem(
    label: &'static str,
    tab: Tab,
    ui: UiContext,
) -> impl IntoView {
    let is_active = move || ui.active_tab.get() == tab;
    view! {
        <button
            on:click=move |_| ui.active_tab.set(tab)
            style="
                padding: 0.75rem 1rem;
                background: none;
                border: none;
                border-bottom: 2px solid transparent;
                font-family: inherit;
                font-size: 0.875rem;
                font-weight: 500;
                cursor: pointer;
                color: var(--text-secondary);
                transition: all var(--transition-fast);
            "
            style:color=move || if is_active() { "var(--color-primary)" } else { "" }
            style:border-bottom-color=move || if is_active() { "var(--color-primary)" } else { "transparent" }
        >
            {label}
        </button>
    }
}
  • Step 5: Create layout/mobile_tab_bar.rs
// 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::<UiContext>().expect("UiContext not provided");

    view! {
        <div class="hide-desktop" style="
            position: fixed;
            bottom: 0;
            left: 0;
            right: 0;
            display: flex;
            background: var(--surface-base);
            border-top: 1px solid var(--surface-border);
            z-index: var(--z-overlay);
        ">
            <MobileTabItem icon="message-square" label="Pesan" tab=Tab::Messages ui=ui.clone() />
            <MobileTabItem icon="radio" label="Voice" tab=Tab::Live ui=ui.clone() />
            <MobileTabItem icon="shield" label="Dashboard" tab=Tab::Dashboard ui=ui.clone() />
        </div>
    }
}

#[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! {
        <button
            on:click=move |_| ui.active_tab.set(tab)
            style="
                flex: 1;
                display: flex;
                flex-direction: column;
                align-items: center;
                gap: 0.25rem;
                padding: 0.5rem;
                background: none;
                border: none;
                font-family: inherit;
                font-size: 0.625rem;
                cursor: pointer;
                transition: color var(--transition-fast);
            "
            style:color=move || if is_active() { "var(--color-primary)" } else { "var(--text-tertiary)" }
        >
            <span style="font-size: 1.25rem;">
                // In Phase 2, replace text with lucide-leptos icons
                {icon}
            </span>
            <span>{label}</span>
        </button>
    }
}
  • Step 6: Update lib.rs
pub mod layout;
  • Step 7: Integrate layout into app.rs

Replace the placeholder layout in app.rs with proper components:

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! {
    <ToastProvider>
        <div data-theme="light" style="display: flex; flex-direction: column; height: 100vh;">
            // Auth overlay
            {move || (!auth.authenticated.get()).then(|| {
                view! { <AuthOverlay /> }
            })}

            // Main app shell
            <Header />
            <div style="flex: 1; display: flex; overflow: hidden;">
                <Sidebar />
                <main style="flex: 1; overflow: auto;">
                    <TabStrip />
                    <div style="padding: 1.5rem; max-width: 1280px;">
                        {move || match ui.active_tab.get() {
                            Tab::Messages => view! { <div>"Messages Panel"</div> }.into_view(),
                            Tab::Live => view! { <div>"Live Panel"</div> }.into_view(),
                            Tab::Dashboard => view! { <div>"Dashboard Panel"</div> }.into_view(),
                        }}
                    </div>
                </main>
            </div>
            <MobileTabBar />
        </div>
    </ToastProvider>
}
  • Step 8: Verify build

Run: cd services/frontend-leptos/frontend && cargo check Expected: Compiles with no errors

  • Step 9: Commit
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

// 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::<AuthContext>().expect("AuthContext not provided");
    let (password, set_password) = create_signal(String::new());
    let (error, set_error) = create_signal(Option::<String>::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! {
        <div class="modal-overlay">
            <div class="modal-content" style="width: 380px;">
                <div class="modal-body" style="text-align: center;">
                    <div style="font-size: 3rem; margin-bottom: 1rem;">
                        <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2">
                            <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
                            <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
                        </svg>
                    </div>
                    <h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem;">
                        "Akses Dashboard"
                    </h2>
                    <p style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 1.5rem;">
                        "Masukkan password admin untuk melanjutkan"
                    </p>
                    <form on:submit=handle_submit style="display: flex; flex-direction: column; gap: 0.75rem;">
                        <input
                            type="password"
                            class="input"
                            placeholder="Password"
                            prop:value=password
                            on:input=move |ev| set_password.set(event_target_value(&ev))
                        />
                        {move || error.get().map(|e| view! {
                            <p style="color: var(--color-error); font-size: 0.75rem;">{e}</p>
                        })}
                        <button
                            type="submit"
                            class="btn btn-primary w-full btn-lg"
                            disabled=move || loading.get()
                        >
                            {move || if loading.get() { "Memproses..." } else { "Masuk" }}
                        </button>
                    </form>
                </div>
            </div>
        </div>
    }
}
  • Step 2: Wire WS connection to auth state in app.rs

In app.rs, make WebSocket connect only after authentication:

// 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
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)