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

3817 lines
115 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Leptos Frontend Rewrite — Phase 1 & 2 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Scaffold the Leptos CSR WASM project, port all shared types, build the CSS design system, app shell, auth, WebSocket infrastructure, API client, and all shared UI primitives.
**Architecture:** Workspace with two crates (`shared-types` for Rust types, `frontend` for Leptos app). TypeScript types ported 1:1 to Rust structs with serde. WebSocket singleton with per-event channels. Leptos signals for reactive state.
**Tech Stack:** Leptos 0.7 CSR, plain CSS, `lucide-leptos`, `gloo-net`, `web-sys`, `trunk` builder.
## Global Constraints
- Leptos 0.7 CSR mode only (no SSR)
- Plain CSS only — no Tailwind, no CSS framework
- All icons from `lucide-leptos` crate
- All browser APIs through `web-sys` crate
- Rust types match TypeScript types exactly (same field names, same optional/nullable patterns)
- All API endpoints and WebSocket protocol unchanged from current React app
- Build verification: `cargo check` for types, `trunk build` for WASM output
---
## File Structure
```
services/frontend-leptos/
├── Cargo.toml
├── rust-toolchain.toml
├── .env
├── shared-types/
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ ├── message.rs
│ ├── guild.rs
│ ├── voice.rs
│ ├── media.rs
│ ├── dashboard.rs
│ ├── recording.rs
│ └── ui_state.rs
└── frontend/
├── Cargo.toml
├── Trunk.toml
├── index.html
└── src/
├── main.rs
├── lib.rs
├── app.rs
├── app.css
├── auth.rs
├── ws/
│ ├── mod.rs
│ ├── socket.rs
│ └── context.rs
├── api/
│ ├── mod.rs
│ ├── client.rs
│ ├── auth.rs
│ ├── messages.rs
│ ├── voice.rs
│ ├── dashboard.rs
│ └── recordings.rs
├── ui/
│ ├── mod.rs
│ ├── button.rs
│ ├── badge.rs
│ ├── card.rs
│ ├── input.rs
│ ├── select.rs
│ ├── tabs.rs
│ ├── scroll_area.rs
│ ├── toast.rs
│ ├── skeleton.rs
│ ├── status_badge.rs
│ ├── empty_state.rs
│ └── modal.rs
├── layout/
│ ├── mod.rs
│ ├── dashboard_layout.rs
│ ├── header.rs
│ ├── sidebar.rs
│ ├── tab_strip.rs
│ └── mobile_tab_bar.rs
└── features/
├── mod.rs
└── (Phases 3-6)
```
---
## Phase 1 — Foundation
### Task 1: Workspace scaffold + shared-types crate
**Files:**
- Create: `services/frontend-leptos/Cargo.toml`
- Create: `services/frontend-leptos/rust-toolchain.toml`
- Create: `services/frontend-leptos/.env`
- Create: `services/frontend-leptos/shared-types/Cargo.toml`
- Create: `services/frontend-leptos/shared-types/src/lib.rs`
- Create: `services/frontend-leptos/shared-types/src/message.rs`
- Create: `services/frontend-leptos/shared-types/src/guild.rs`
- Create: `services/frontend-leptos/shared-types/src/voice.rs`
- Create: `services/frontend-leptos/shared-types/src/media.rs`
- Create: `services/frontend-leptos/shared-types/src/dashboard.rs`
- Create: `services/frontend-leptos/shared-types/src/recording.rs`
- Create: `services/frontend-leptos/shared-types/src/ui_state.rs`
**Interfaces:**
- Consumes: nothing (first task)
- Produces: `shared_types::*` — all Rust structs with serde Serialize/Deserialize
- [ ] **Step 1: Create workspace Cargo.toml**
```toml
# services/frontend-leptos/Cargo.toml
[workspace]
resolver = "2"
members = ["shared-types", "frontend"]
```
- [ ] **Step 2: Create rust-toolchain.toml**
```toml
# services/frontend-leptos/rust-toolchain.toml
[toolchain]
channel = "nightly-2026-06-01"
components = ["rust-src", "rustc-dev"]
targets = ["wasm32-unknown-unknown"]
```
- [ ] **Step 3: Create .env template**
```
# services/frontend-leptos/.env
# API/WS endpoints — set these before building
VITE_BE_API_URL=http://localhost:3001
VITE_BE_WS_URL=ws://localhost:3001/ws
```
- [ ] **Step 4: Create shared-types Cargo.toml**
```toml
# services/frontend-leptos/shared-types/Cargo.toml
[package]
name = "shared-types"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
```
- [ ] **Step 5: Create shared-types/src/lib.rs**
```rust
// services/frontend-leptos/shared-types/src/lib.rs
pub mod message;
pub mod guild;
pub mod voice;
pub mod media;
pub mod dashboard;
pub mod recording;
pub mod ui_state;
```
- [ ] **Step 6: Create shared-types/src/message.rs**
Port of `@bete/shared` MessageRecord + related types and `services/frontend/src/entities/message/types.ts`.
```rust
use serde::{Deserialize, Serialize};
// ── AI Status ─────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AiStatus {
Pending,
Processing,
Clean,
Warn,
Flagged,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AiSeverity {
None,
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AiRecommendedAction {
None,
Monitor,
Warn,
Review,
Delete,
Escalate,
}
// ── Message Metadata ──────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MessageMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub stickers: Option<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**
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Guild {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<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**
```rust
use serde::{Deserialize, Serialize};
use crate::guild::GuildVoiceEntry;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceStatus {
pub connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_guild_id: Option<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**
```rust
use serde::{Deserialize, Serialize};
pub type MediaMode = String; // "music" | "screen"
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<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**
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardStats {
pub total_messages: u64,
pub total_users: u64,
pub total_flagged: u64,
pub total_clean: u64,
pub total_warned: u64,
pub total_error: u64,
pub total_voice_recordings: u64,
pub total_profiles: u64,
pub today_messages: u64,
pub today_flagged: u64,
pub active_users_24h: u64,
pub top_channels: Vec<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**
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceRecording {
pub id: String,
pub user_id: String,
pub username: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<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**
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Tab {
Messages,
Live,
Dashboard,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiState {
#[serde(skip_serializing_if = "Option::is_none")]
pub selected_guild: Option<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**
```bash
cd /mnt/code/bete
git add services/frontend-leptos/
git commit -m "feat(leptos): scaffold workspace + shared-types port"
```
---
### Task 2: Frontend crate scaffold + empty Leptos app
**Files:**
- Create: `services/frontend-leptos/frontend/Cargo.toml`
- Create: `services/frontend-leptos/frontend/Trunk.toml`
- Create: `services/frontend-leptos/frontend/index.html`
- Create: `services/frontend-leptos/frontend/src/main.rs`
- Create: `services/frontend-leptos/frontend/src/lib.rs`
- Create: `services/frontend-leptos/frontend/src/app.rs`
**Interfaces:**
- Consumes: `shared-types` crate
- Produces: A working `trunk serve` that shows an HTML page with "Hello from Leptos" text
- [ ] **Step 1: Create frontend Cargo.toml**
```toml
# services/frontend-leptos/frontend/Cargo.toml
[package]
name = "frontend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
leptos = { version = "0.7", features = ["csr"] }
leptos-use = "0.14"
lucide-leptos = "0.5"
shared-types = { path = "../shared-types" }
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"WebSocket",
"MessageEvent",
"CloseEvent",
"CanvasRenderingContext2d",
"AudioContext",
"AudioBuffer",
"AudioBufferSourceNode",
"Window",
"Document",
"Element",
"HtmlElement",
"KeyboardEvent",
"Storage",
"IntersectionObserver",
"ResizeObserver",
"Url",
"Headers",
"Request",
"RequestInit",
"Response",
"HtmlInputElement",
"HtmlAudioElement",
"HtmlCanvasElement",
"MediaDevices",
"MediaStream",
"Navigator",
"DataView",
"ArrayBuffer",
"console",
] }
gloo-net = "0.6"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
wasm-logger = "0.2"
console_error_panic_hook = "0.1"
```
- [ ] **Step 2: Create Trunk.toml**
```toml
# services/frontend-leptos/frontend/Trunk.toml
[build]
target = "index.html"
dist = "dist"
[serve]
port = 8080
open = false
```
- [ ] **Step 3: Create index.html (entry point)**
```html
<!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)**
```rust
// services/frontend-leptos/frontend/src/main.rs
use frontend::app::App;
fn main() {
_ = console_error_panic_hook::set_once();
wasm_logger::init(wasm_logger::Config::default());
leptos::mount_to_body(App);
}
```
- [ ] **Step 5: Create lib.rs (module declarations)**
```rust
// services/frontend-leptos/frontend/src/lib.rs
pub mod app;
```
- [ ] **Step 6: Create app.rs (empty app placeholder)**
```rust
// services/frontend-leptos/frontend/src/app.rs
use leptos::*;
#[component]
pub fn App() -> impl IntoView {
view! {
<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**
```bash
cd /mnt/code/bete
git add services/frontend-leptos/
git commit -m "feat(leptos): frontend scaffold with empty app"
```
---
## Phase 2 — Skeleton
### Task 3: CSS Design System
**Files:**
- Create: `services/frontend-leptos/frontend/src/app.css` (~700 lines)
**Interfaces:**
- Consumes: nothing (standalone CSS file)
- Produces: Complete CSS design system with custom properties, component classes, animations, dark theme
- [ ] **Step 1: Define CSS custom properties (`:root` + `[data-theme="dark"]`)**
```css
/* services/frontend-leptos/frontend/src/app.css */
/* ── Design Tokens ────────────────────────────────────── */
:root {
/* Brand colors */
--color-primary: #23a1eb;
--color-primary-hover: #1d8fd1;
--color-secondary: #1877f2;
--color-tertiary: #5865f2;
/* Surface colors (light) */
--surface-base: #ffffff;
--surface-raised: #f8fafc;
--surface-overlay: #f1f5f9;
--surface-border: #e2e8f0;
--surface-hover: #f1f5f9;
/* Text colors (light) */
--text-primary: #0f172a;
--text-secondary: #475569;
--text-tertiary: #94a3b8;
--text-inverse: #ffffff;
/* Semantic colors */
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
/* AI semantic colors */
--color-ai-flagged: #ef4444;
--color-ai-clean: #22c55e;
--color-ai-warn: #f59e0b;
--color-ai-pending: #94a3b8;
--color-ai-processing: #3b82f6;
--color-ai-error: #dc2626;
--color-ai-deleted: #6b7280;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);
/* Glows */
--glow-primary: 0 0 20px rgba(35, 161, 235, 0.3);
--glow-error: 0 0 20px rgba(239, 68, 68, 0.3);
/* Border radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Sidebar */
--sidebar-width: 256px;
--sidebar-collapsed-width: 64px;
/* Header */
--header-height: 56px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 400ms ease;
/* Z-index layers */
--z-sidebar: 30;
--z-header: 20;
--z-overlay: 40;
--z-modal: 50;
--z-toast: 60;
}
[data-theme="dark"] {
--surface-base: #0f172a;
--surface-raised: #1e293b;
--surface-overlay: #334155;
--surface-border: #334155;
--surface-hover: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-tertiary: #64748b;
--text-inverse: #0f172a;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.4);
--glow-primary: 0 0 20px rgba(35, 161, 235, 0.15);
--glow-error: 0 0 20px rgba(239, 68, 68, 0.15);
}
```
- [ ] **Step 2: Add base/reset styles and typography**
```css
/* ── Base ─────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--surface-base);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
overflow-x: hidden;
}
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
max-width: 100%;
height: auto;
}
/* ── Scrollbar ────────────────────────────────────────── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-primary);
border-radius: var(--radius-full);
}
```
- [ ] **Step 3: Add layout utility classes**
```css
/* ── Layout Utilities ─────────────────────────────────── */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.gap-1 { gap: var(--space-1); }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
.gap-6 { gap: var(--space-6); }
.gap-8 { gap: var(--space-8); }
.grid { display: grid; }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.w-full { width: 100%; }
.h-full { height: 100%; }
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-center { text-align: center; }
.text-sm { font-size: 0.875rem; }
.text-xs { font-size: 0.75rem; }
.text-lg { font-size: 1.125rem; }
.text-xl { font-size: 1.25rem; }
.text-2xl { font-size: 1.5rem; }
.text-3xl { font-size: 1.875rem; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-primary-color { color: var(--color-primary); }
.text-error { color: var(--color-error); }
.text-success { color: var(--color-success); }
.text-warning { color: var(--color-warning); }
.ml-auto { margin-left: auto; }
.mr-2 { margin-right: var(--space-2); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-4 { margin-bottom: var(--space-4); }
.mb-6 { margin-bottom: var(--space-6); }
.mt-2 { margin-top: var(--space-2); }
.mt-4 { margin-top: var(--space-4); }
.p-2 { padding: var(--space-2); }
.p-3 { padding: var(--space-3); }
.p-4 { padding: var(--space-4); }
.p-6 { padding: var(--space-6); }
.px-3 { padding-left: var(--space-3); padding-right: var(--space-3); }
.px-4 { padding-left: var(--space-4); padding-right: var(--space-4); }
.py-2 { padding-top: var(--space-2); padding-bottom: var(--space-2); }
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.overflow-auto { overflow: auto; }
.overflow-hidden { overflow: hidden; }
.overflow-y-auto { overflow-y: auto; }
.hidden { display: none; }
.invisible { visibility: hidden; }
.cursor-pointer { cursor: pointer; }
.select-none { user-select: none; }
```
- [ ] **Step 4: Add all component classes**
```css
/* ── Button ───────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 0.5rem 1rem;
border: 1px solid transparent;
border-radius: var(--radius-md);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
user-select: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.btn-secondary {
background: var(--surface-raised);
color: var(--text-primary);
border-color: var(--surface-border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--color-primary);
}
.btn-tertiary {
background: transparent;
color: var(--text-primary);
}
.btn-tertiary:hover:not(:disabled) {
background: var(--surface-hover);
}
.btn-destructive {
background: var(--color-error);
color: white;
}
.btn-destructive:hover:not(:disabled) {
background: #dc2626;
}
.btn-outline {
background: transparent;
border-color: var(--surface-border);
color: var(--text-primary);
}
.btn-outline:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--color-primary);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: none;
}
.btn-ghost:hover:not(:disabled) {
background: var(--surface-hover);
color: var(--text-primary);
}
.btn-link {
background: none;
border: none;
color: var(--color-primary);
padding: 0;
text-decoration: none;
}
.btn-link:hover:not(:disabled) {
text-decoration: underline;
}
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn-lg { padding: 0.75rem 1.5rem; font-size: 1rem; }
.btn-icon { padding: 0.5rem; }
.btn-icon-sm { padding: 0.25rem; }
/* ── Badge ────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.625rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.25rem;
background: var(--surface-raised);
color: var(--text-secondary);
border: 1px solid var(--surface-border);
}
.badge-primary { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); border-color: transparent; }
.badge-success { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); border-color: transparent; }
.badge-warning { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); border-color: transparent; }
.badge-destructive { background: color-mix(in srgb, var(--color-error) 15%, transparent); color: var(--color-error); border-color: transparent; }
.badge-outline { background: transparent; border-color: var(--surface-border); }
.badge-info { background: color-mix(in srgb, var(--color-info) 15%, transparent); color: var(--color-info); border-color: transparent; }
/* ── Card ─────────────────────────────────────────────── */
.card {
background: var(--surface-base);
border: 1px solid var(--surface-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.card-elevated {
box-shadow: var(--shadow-md);
}
.card-bordered {
border-width: 1px;
}
.card-header {
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--surface-border);
}
.card-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.card-description {
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: var(--space-1);
}
.card-content {
padding: var(--space-6);
}
.card-footer {
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--surface-border);
}
/* ── Input ────────────────────────────────────────────── */
.input {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--surface-raised);
border: 1px solid var(--surface-border);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: inherit;
font-size: 0.875rem;
line-height: 1.25rem;
transition: all var(--transition-fast);
outline: none;
}
.input::placeholder {
color: var(--text-tertiary);
}
.input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.15);
}
.input-soft {
background: var(--surface-overlay);
border-color: transparent;
}
.input-soft:focus {
background: var(--surface-base);
border-color: var(--color-primary);
}
.input[aria-invalid="true"],
.input-error {
border-color: var(--color-error);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
}
.select {
display: block;
width: 100%;
padding: 0.5rem 2rem 0.5rem 0.75rem;
background: var(--surface-raised);
border: 1px solid var(--surface-border);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: inherit;
font-size: 0.875rem;
line-height: 1.25rem;
cursor: pointer;
transition: all var(--transition-fast);
outline: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
}
.select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.15);
}
/* ── Skeleton ─────────────────────────────────────────── */
.skeleton {
background: linear-gradient(90deg, var(--surface-overlay) 25%, var(--surface-hover) 50%, var(--surface-overlay) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-md);
}
.skeleton-circular {
border-radius: 50%;
}
.skeleton-rectangular {
border-radius: 0;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ── Tabs ─────────────────────────────────────────────── */
.tabs { display: flex; flex-direction: column; }
.tab-list {
display: flex;
border-bottom: 1px solid var(--surface-border);
gap: 0;
}
.tab-trigger {
padding: 0.5rem 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.tab-trigger:hover {
color: var(--text-primary);
}
.tab-trigger[aria-selected="true"] {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.tab-content {
padding-top: var(--space-4);
}
/* ── ScrollArea ───────────────────────────────────────── */
.scroll-area {
overflow: auto;
scrollbar-width: thin;
scrollbar-color: var(--color-primary) transparent;
}
/* ── Status Badge ─────────────────────────────────────── */
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0.125rem 0.5rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 500;
}
.status-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-badge-flagged {
background: color-mix(in srgb, var(--color-ai-flagged) 15%, transparent);
color: var(--color-ai-flagged);
}
.status-badge-flagged::before { background: var(--color-ai-flagged); }
.status-badge-clean {
background: color-mix(in srgb, var(--color-ai-clean) 15%, transparent);
color: var(--color-ai-clean);
}
.status-badge-clean::before { background: var(--color-ai-clean); }
.status-badge-warn {
background: color-mix(in srgb, var(--color-ai-warn) 15%, transparent);
color: var(--color-ai-warn);
}
.status-badge-warn::before { background: var(--color-ai-warn); }
.status-badge-pending {
background: color-mix(in srgb, var(--color-ai-pending) 15%, transparent);
color: var(--color-ai-pending);
}
.status-badge-pending::before { background: var(--color-ai-pending); }
.status-badge-processing {
background: color-mix(in srgb, var(--color-ai-processing) 15%, transparent);
color: var(--color-ai-processing);
}
.status-badge-processing::before {
background: var(--color-ai-processing);
animation: pulse-dot 1.5s ease-in-out infinite;
}
.status-badge-error {
background: color-mix(in srgb, var(--color-ai-error) 15%, transparent);
color: var(--color-ai-error);
}
.status-badge-error::before { background: var(--color-ai-error); }
.status-badge-deleted {
background: color-mix(in srgb, var(--color-ai-deleted) 15%, transparent);
color: var(--color-ai-deleted);
}
.status-badge-deleted::before { background: var(--color-ai-deleted); }
.status-badge-none {
background: var(--surface-raised);
color: var(--text-tertiary);
}
.status-badge-none::before { background: var(--text-tertiary); }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── Toast ────────────────────────────────────────────── */
.toast-container {
position: fixed;
top: var(--space-4);
right: var(--space-4);
z-index: var(--z-toast);
display: flex;
flex-direction: column;
gap: var(--space-2);
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0.75rem 1rem;
background: var(--surface-base);
border: 1px solid var(--surface-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 300px;
max-width: 420px;
pointer-events: auto;
animation: slide-in-right var(--transition-normal) ease-out;
}
.toast-success { border-left: 3px solid var(--color-success); }
.toast-error { border-left: 3px solid var(--color-error); }
.toast-warning { border-left: 3px solid var(--color-warning); }
.toast-info { border-left: 3px solid var(--color-info); }
.toast-close {
margin-left: auto;
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 0.25rem;
}
/* ── Modal ────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
animation: fade-in var(--transition-fast) ease-out;
}
.modal-content {
background: var(--surface-base);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
max-width: 90vw;
max-height: 85vh;
overflow: auto;
animation: scale-in var(--transition-normal) ease-out;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--surface-border);
}
.modal-body {
padding: var(--space-6);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--surface-border);
}
/* ── Empty State ──────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-12);
text-align: center;
color: var(--text-tertiary);
}
.empty-state-icon {
font-size: 2.5rem;
margin-bottom: var(--space-4);
opacity: 0.5;
}
.empty-state-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.empty-state-description {
font-size: 0.875rem;
max-width: 300px;
}
```
- [ ] **Step 5: Add animation keyframes**
```css
/* ── Animations ───────────────────────────────────────── */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scale-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes slide-in-right {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes bar-pulse {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(0.6); }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: var(--glow-primary); }
50% { box-shadow: 0 0 30px rgba(35, 161, 235, 0.5); }
}
@keyframes notification-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes mascot-wiggle {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-5deg); }
75% { transform: rotate(5deg); }
}
.animate-fade-in { animation: fade-in var(--transition-normal) ease-out; }
.animate-fade-in-up { animation: fade-in-up var(--transition-normal) ease-out; }
.animate-scale-in { animation: scale-in var(--transition-normal) ease-out; }
.animate-slide-in-right { animation: slide-in-right var(--transition-normal) ease-out; }
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ── Grid Pattern Background ──────────────────────────── */
.grid-pattern {
background-image:
linear-gradient(var(--surface-border) 1px, transparent 1px),
linear-gradient(90deg, var(--surface-border) 1px, transparent 1px);
background-size: 40px 40px;
}
/* ── Particle Background ──────────────────────────────── */
.particle-bg {
position: fixed;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.particle-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.15;
}
.particle-orb:nth-child(1) {
width: 500px;
height: 500px;
top: -100px;
right: -100px;
background: var(--color-primary);
}
.particle-orb:nth-child(2) {
width: 400px;
height: 400px;
bottom: -100px;
left: -100px;
background: var(--color-tertiary);
}
.particle-orb:nth-child(3) {
width: 300px;
height: 300px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--color-secondary);
}
@media (max-width: 768px) {
.particle-bg { display: none; }
}
/* ── Responsive ───────────────────────────────────────── */
@media (max-width: 768px) {
.grid-cols-2 { grid-template-columns: 1fr; }
.grid-cols-3 { grid-template-columns: 1fr; }
.hide-mobile { display: none !important; }
}
@media (min-width: 769px) {
.hide-desktop { display: none !important; }
}
```
- [ ] **Step 6: Update app.rs to import CSS**
Add `#[component]` import and CSS import in `app.rs`:
```rust
use leptos::*;
#[component]
pub fn App() -> impl IntoView {
view! {
<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**
```bash
cd /mnt/code/bete
git add services/frontend-leptos/
git commit -m "feat(leptos): CSS design system with component classes and animations"
```
---
### Task 4: App shell + auth gate + tab routing
**Files:**
- Create: `services/frontend-leptos/frontend/src/auth.rs`
- Modify: `services/frontend-leptos/frontend/src/app.rs` (full app shell)
- Modify: `services/frontend-leptos/frontend/src/lib.rs` (add module declarations)
**Interfaces:**
- Consumes: `shared_types::ui_state::Tab`
- Produces: `App` component with auth gate, tab-based routing, and context providers
- [ ] **Step 1: Create auth.rs**
```rust
// services/frontend-leptos/frontend/src/auth.rs
use leptos::*;
#[component]
pub fn AuthOverlay(
/// Called when password is submitted — parent handles the actual API call
on_submit: leptos::html::InputEvent,
) -> impl IntoView {
let (password, set_password) = create_signal(String::new());
let (error, set_error) = create_signal(Option::<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**
```rust
// services/frontend-leptos/frontend/src/app.rs
use leptos::*;
use shared_types::ui_state::Tab;
use crate::auth::AuthOverlay;
// ── Contexts ────────────────────────────────────────────
#[derive(Clone)]
pub struct AuthContext {
pub authenticated: RwSignal<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**
```rust
// services/frontend-leptos/frontend/src/lib.rs
pub mod app;
pub mod auth;
```
- [ ] **Step 4: Verify build**
Run: `cd services/frontend-leptos/frontend && cargo check`
Expected: Compiles with no errors
- [ ] **Step 5: Commit**
```bash
cd /mnt/code/bete
git add services/frontend-leptos/
git commit -m "feat(leptos): app shell with auth gate and tab routing"
```
---
### Task 5: WebSocket infrastructure
**Files:**
- Create: `services/frontend-leptos/frontend/src/ws/mod.rs`
- Create: `services/frontend-leptos/frontend/src/ws/socket.rs`
- Create: `services/frontend-leptos/frontend/src/ws/context.rs`
**Interfaces:**
- Produces: `WsHandle` struct with `status: ReadSignal<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**
```rust
// services/frontend-leptos/frontend/src/ws/mod.rs
pub mod socket;
pub mod context;
```
- [ ] **Step 2: Create ws/socket.rs**
```rust
// services/frontend-leptos/frontend/src/ws/socket.rs
use leptos::*;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{WebSocket, MessageEvent, CloseEvent, ErrorEvent};
#[derive(Debug, Clone, PartialEq)]
pub enum WsStatus {
Disconnected,
Connecting,
Connected,
Error(String),
}
#[derive(Debug, Clone)]
pub enum WsEvent {
Text(String),
Binary(Vec<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**
```rust
// services/frontend-leptos/frontend/src/ws/context.rs
use leptos::*;
use crate::ws::socket::{WsHandle, WsStatus, WsEvent};
use shared_types::message::MessageRecord;
use shared_types::voice::ActiveSpeaker;
use shared_types::media::MediaState;
use shared_types::recording::VoiceRecording;
#[derive(Clone)]
pub struct WsContext {
pub handle: std::rc::Rc<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**
```rust
pub mod app;
pub mod auth;
pub mod ws;
```
- [ ] **Step 5: Update app.rs to provide WsContext**
Add to `app.rs`:
```rust
use crate::ws::context::WsContext;
// Inside App component, after UiContext:
const WS_URL: &str = "ws://localhost:3001/ws"; // TODO: make configurable at build time
let ws = WsContext::new(WS_URL);
provide_context(ws.clone());
// Connect WS on mount
let ws_connect = ws.clone();
create_effect(move |_| {
ws_connect.connect();
});
```
- [ ] **Step 6: Verify build**
Run: `cd services/frontend-leptos/frontend && cargo check`
Expected: Compiles with no errors
- [ ] **Step 7: Commit**
```bash
cd /mnt/code/bete
git add services/frontend-leptos/
git commit -m "feat(leptos): WebSocket singleton with event dispatch"
```
---
### Task 6: API client
**Files:**
- Create: `services/frontend-leptos/frontend/src/api/mod.rs`
- Create: `services/frontend-leptos/frontend/src/api/client.rs`
- Create: `services/frontend-leptos/frontend/src/api/auth.rs`
- Create: `services/frontend-leptos/frontend/src/api/messages.rs`
- Create: `services/frontend-leptos/frontend/src/api/voice.rs`
- Create: `services/frontend-leptos/frontend/src/api/dashboard.rs`
- Create: `services/frontend-leptos/frontend/src/api/recordings.rs`
- Modify: `services/frontend-leptos/frontend/src/lib.rs`
**Interfaces:**
- Produces: `ApiClient` with typed methods for all 20+ API endpoints
- Produces: `ApiError` type for error handling
- [ ] **Step 1: Create api/mod.rs**
```rust
// services/frontend-leptos/frontend/src/api/mod.rs
pub mod client;
pub mod auth;
pub mod messages;
pub mod voice;
pub mod dashboard;
pub mod recordings;
```
- [ ] **Step 2: Create api/client.rs**
```rust
// services/frontend-leptos/frontend/src/api/client.rs
use serde::de::DeserializeOwned;
use wasm_bindgen::prelude::*;
use web_sys::{Request, RequestInit, RequestMode, Headers, Response};
use wasm_bindgen_futures::JsFuture;
#[derive(Debug)]
pub struct ApiError {
pub message: String,
pub status_code: u16,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "API error {}: {}", self.status_code, self.message)
}
}
impl std::error::Error for ApiError {}
fn get_base_url() -> String {
// Try to read from a JS global set by index.html, or fall back to localhost
let default = "http://localhost:3001";
js_sys::global()
.unchecked_ref::<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**
```rust
// services/frontend-leptos/frontend/src/api/auth.rs
use crate::api::client::{request, ApiError};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct LoginPayload {
password: String,
}
#[derive(Deserialize)]
struct LoginResponse {
ok: bool,
}
pub async fn login(password: &str) -> Result<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**
```rust
// services/frontend-leptos/frontend/src/api/messages.rs
use crate::api::client::{request, ApiError};
use shared_types::message::{MessageRecord, PageResult};
/// GET /api/messages?guildId=&limit=&channelId=&cursor=
pub async fn get_messages(
guild_id: &str,
limit: Option<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**
```rust
// services/frontend-leptos/frontend/src/api/voice.rs
use crate::api::client::{request, request_no_body, ApiError};
use shared_types::voice::VoiceStatus;
use shared_types::media::MediaState;
use shared_types::guild::{Guild, Channel};
use serde::Serialize;
/// GET /api/guilds
pub async fn get_guilds() -> Result<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**
```rust
// services/frontend-leptos/frontend/src/api/dashboard.rs
use crate::api::client::{request, ApiError};
use shared_types::dashboard::*;
/// GET /api/dashboard/stats
pub async fn get_dashboard_stats() -> Result<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**
```rust
// services/frontend-leptos/frontend/src/api/recordings.rs
use crate::api::client::{request, request_no_body, ApiError};
use shared_types::recording::VoiceRecordingListResponse;
/// GET /api/recordings?limit=&cursor=
pub async fn get_recordings(
limit: Option<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**
```rust
pub mod app;
pub mod auth;
pub mod ws;
pub mod api;
```
- [ ] **Step 9: Verify build**
Run: `cd services/frontend-leptos/frontend && cargo check`
Expected: Compiles with no errors
- [ ] **Step 10: Commit**
```bash
cd /mnt/code/bete
git add services/frontend-leptos/
git commit -m "feat(leptos): API client with all endpoint functions"
```
---
### Task 7: UI Primitives — Button, Badge, Card
**Files:**
- Create: `services/frontend-leptos/frontend/src/ui/mod.rs`
- Create: `services/frontend-leptos/frontend/src/ui/button.rs`
- Create: `services/frontend-leptos/frontend/src/ui/badge.rs`
- Create: `services/frontend-leptos/frontend/src/ui/card.rs`
- [ ] **Step 1: Create ui/mod.rs**
```rust
// services/frontend-leptos/frontend/src/ui/mod.rs
pub mod button;
pub mod badge;
pub mod card;
pub mod input;
pub mod select;
pub mod tabs;
pub mod scroll_area;
pub mod toast;
pub mod skeleton;
pub mod status_badge;
pub mod empty_state;
pub mod modal;
```
- [ ] **Step 2: Create ui/button.rs**
```rust
// services/frontend-leptos/frontend/src/ui/button.rs
use leptos::*;
#[derive(Clone)]
pub enum ButtonVariant {
Primary,
Secondary,
Tertiary,
Destructive,
Outline,
Ghost,
Link,
}
#[derive(Clone)]
pub enum ButtonSize {
Default,
Sm,
Lg,
Icon,
IconSm,
}
#[component]
pub fn Button(
#[prop(optional)] variant: ButtonVariant,
#[prop(optional)] size: ButtonSize,
#[prop(optional)] disabled: bool,
#[prop(optional)] class: &'static str,
#[prop(optional)] on_click: Option<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**
```rust
// services/frontend-leptos/frontend/src/ui/badge.rs
use leptos::*;
#[derive(Clone)]
pub enum BadgeVariant {
Default,
Primary,
Success,
Warning,
Destructive,
Outline,
Info,
}
#[component]
pub fn Badge(
#[prop(optional)] variant: BadgeVariant,
children: Children,
) -> impl IntoView {
let variant_class = match variant {
BadgeVariant::Default => "",
BadgeVariant::Primary => "badge-primary",
BadgeVariant::Success => "badge-success",
BadgeVariant::Warning => "badge-warning",
BadgeVariant::Destructive => "badge-destructive",
BadgeVariant::Outline => "badge-outline",
BadgeVariant::Info => "badge-info",
};
view! {
<span class="badge" class=variant_class>
{children()}
</span>
}
}
```
- [ ] **Step 4: Create ui/card.rs**
```rust
// services/frontend-leptos/frontend/src/ui/card.rs
use leptos::*;
#[component]
pub fn Card(
#[prop(optional)] elevated: bool,
#[prop(optional)] bordered: bool,
#[prop(optional)] class: &'static str,
children: Children,
) -> impl IntoView {
view! {
<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**
```rust
pub mod ui;
```
- [ ] **Step 6: Verify build**
Run: `cd services/frontend-leptos/frontend && cargo check`
Expected: Compiles with no errors
- [ ] **Step 7: Commit**
```bash
cd /mnt/code/bete
git add services/frontend-leptos/
git commit -m "feat(leptos): UI primitives — Button, Badge, Card"
```
---
### Task 8: UI Primitives — Input, Select, Tabs, ScrollArea, Toast
**Files:**
- Create: `services/frontend-leptos/frontend/src/ui/input.rs`
- Create: `services/frontend-leptos/frontend/src/ui/select.rs`
- Create: `services/frontend-leptos/frontend/src/ui/tabs.rs`
- Create: `services/frontend-leptos/frontend/src/ui/scroll_area.rs`
- Create: `services/frontend-leptos/frontend/src/ui/toast.rs`
- [ ] **Step 1: Create ui/input.rs**
```rust
// services/frontend-leptos/frontend/src/ui/input.rs
use leptos::*;
#[component]
pub fn Input(
#[prop(optional)] input_type: &'static str,
#[prop(optional)] placeholder: &'static str,
#[prop(optional)] value: RwSignal<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**
```rust
// services/frontend-leptos/frontend/src/ui/select.rs
use leptos::*;
/// Simple select — values and labels are the same
/// For options with different value/label, use `SelectOptions`
#[component]
pub fn Select(
#[prop(optional)] value: RwSignal<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**
```rust
// 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**
```rust
// services/frontend-leptos/frontend/src/ui/scroll_area.rs
use leptos::*;
#[component]
pub fn ScrollArea(
#[prop(optional)] class: &'static str,
#[prop(optional)] style: &'static str,
children: Children,
) -> impl IntoView {
view! {
<div class="scroll-area" class=class style=style>
{children()}
</div>
}
}
```
- [ ] **Step 5: Create ui/toast.rs**
```rust
// services/frontend-leptos/frontend/src/ui/toast.rs
use leptos::*;
use std::rc::Rc;
use std::cell::RefCell;
#[derive(Clone)]
pub enum ToastType {
Info,
Success,
Error,
Warning,
}
#[derive(Clone)]
pub struct ToastMessage {
pub id: u64,
pub message: String,
pub toast_type: ToastType,
}
#[derive(Clone)]
pub struct ToastContext {
pub toasts: RwSignal<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**
```bash
cd /mnt/code/bete
git add services/frontend-leptos/
git commit -m "feat(leptos): UI primitives — Input, Select, Tabs, ScrollArea, Toast"
```
---
### Task 9: UI Primitives — Skeleton, StatusBadge, EmptyState, Modal
**Files:**
- Create: `services/frontend-leptos/frontend/src/ui/skeleton.rs`
- Create: `services/frontend-leptos/frontend/src/ui/status_badge.rs`
- Create: `services/frontend-leptos/frontend/src/ui/empty_state.rs`
- Create: `services/frontend-leptos/frontend/src/ui/modal.rs`
- [ ] **Step 1: Create ui/skeleton.rs**
```rust
// services/frontend-leptos/frontend/src/ui/skeleton.rs
use leptos::*;
#[derive(Clone)]
pub enum SkeletonShape {
Rounded,
Circular,
Rectangular,
}
#[component]
pub fn Skeleton(
#[prop(optional)] width: &'static str,
#[prop(optional)] height: &'static str,
#[prop(optional)] shape: SkeletonShape,
) -> impl IntoView {
let shape_class = match shape {
SkeletonShape::Rounded => "",
SkeletonShape::Circular => "skeleton-circular",
SkeletonShape::Rectangular => "skeleton-rectangular",
};
view! {
<div
class="skeleton"
class=shape_class
style=format!("width: {}; height: {};", width, height)
></div>
}
}
```
- [ ] **Step 2: Create ui/status_badge.rs**
```rust
// services/frontend-leptos/frontend/src/ui/status_badge.rs
use leptos::*;
use shared_types::message::AiStatus;
#[component]
pub fn StatusBadge(status: AiStatus) -> impl IntoView {
let (class, label) = match status {
AiStatus::Flagged => ("status-badge-flagged", "Flagged"),
AiStatus::Clean => ("status-badge-clean", "Clean"),
AiStatus::Warn => ("status-badge-warn", "Warned"),
AiStatus::Pending => ("status-badge-pending", "Pending"),
AiStatus::Processing => ("status-badge-processing", "Processing"),
AiStatus::Error => ("status-badge-error", "Error"),
};
view! {
<span class="status-badge" class=class>
{label}
</span>
}
}
```
- [ ] **Step 3: Create ui/empty_state.rs**
```rust
// services/frontend-leptos/frontend/src/ui/empty_state.rs
use leptos::*;
#[component]
pub fn EmptyState(
#[prop(optional)] icon: Option<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**
```rust
// 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**
```bash
cd /mnt/code/bete
git add services/frontend-leptos/
git commit -m "feat(leptos): UI primitives — Skeleton, StatusBadge, EmptyState, Modal"
```
---
### Task 10: Layout components
**Files:**
- Create: `services/frontend-leptos/frontend/src/layout/mod.rs`
- Create: `services/frontend-leptos/frontend/src/layout/dashboard_layout.rs`
- Create: `services/frontend-leptos/frontend/src/layout/header.rs`
- Create: `services/frontend-leptos/frontend/src/layout/sidebar.rs`
- Create: `services/frontend-leptos/frontend/src/layout/tab_strip.rs`
- Create: `services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs`
- Modify: `services/frontend-leptos/frontend/src/app.rs` (integrate layout components)
- [ ] **Step 1: Create layout/mod.rs**
```rust
// services/frontend-leptos/frontend/src/layout/mod.rs
pub mod dashboard_layout;
pub mod header;
pub mod sidebar;
pub mod tab_strip;
pub mod mobile_tab_bar;
```
- [ ] **Step 2: Create layout/header.rs**
```rust
// services/frontend-leptos/frontend/src/layout/header.rs
use leptos::*;
use crate::ws::context::WsContext;
use crate::ws::socket::WsStatus;
#[component]
pub fn Header() -> impl IntoView {
let ws = use_context::<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:
```rust
let indicator_text_memo = create_memo(move |_| match ws_status.get() {
WsStatus::Connected => "Online",
WsStatus::Connecting => "Menghubungkan...",
WsStatus::Disconnected => "Offline",
WsStatus::Error(_) => "Error",
});
let indicator_color_memo = create_memo(move |_| match ws_status.get() {
WsStatus::Connected => "var(--color-success)",
WsStatus::Connecting => "var(--color-warning)",
WsStatus::Disconnected => "var(--text-tertiary)",
WsStatus::Error(_) => "var(--color-error)",
});
```
Use these memos in the view.
- [ ] **Step 3: Create layout/sidebar.rs**
```rust
// services/frontend-leptos/frontend/src/layout/sidebar.rs
use leptos::*;
use shared_types::ui_state::Tab;
use crate::app::UiContext;
#[component]
pub fn Sidebar() -> impl IntoView {
let ui = use_context::<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**
```rust
// services/frontend-leptos/frontend/src/layout/tab_strip.rs
use leptos::*;
use shared_types::ui_state::Tab;
use crate::app::UiContext;
#[component]
pub fn TabStrip() -> impl IntoView {
let ui = use_context::<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**
```rust
// services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs
use leptos::*;
use shared_types::ui_state::Tab;
use crate::app::UiContext;
#[component]
pub fn MobileTabBar() -> impl IntoView {
let ui = use_context::<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**
```rust
pub mod layout;
```
- [ ] **Step 7: Integrate layout into app.rs**
Replace the placeholder layout in `app.rs` with proper components:
```rust
use crate::layout::header::Header;
use crate::layout::sidebar::Sidebar;
use crate::layout::tab_strip::TabStrip;
use crate::layout::mobile_tab_bar::MobileTabBar;
use crate::ui::toast::ToastProvider;
// In App component view!, replace with:
view! {
<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**
```bash
cd /mnt/code/bete
git add services/frontend-leptos/
git commit -m "feat(leptos): layout components — Header, Sidebar, TabStrip, MobileTabBar"
```
---
### Task 11: Integrate auth with API + polish skeleton
**Files:**
- Modify: `services/frontend-leptos/frontend/src/auth.rs` (wire up actual login call)
- Modify: `services/frontend-leptos/frontend/src/app.rs` (wire auth to context, add WS connect on auth)
- [ ] **Step 1: Update auth.rs with login API call**
```rust
// services/frontend-leptos/frontend/src/auth.rs
use leptos::*;
use crate::app::AuthContext;
use crate::api::auth as auth_api;
#[component]
pub fn AuthOverlay() -> impl IntoView {
let auth = use_context::<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:
```rust
// Inside App component, after provide_context calls:
{
let ws = ws.clone();
let auth = auth.clone();
create_effect(move |_| {
if auth.authenticated.get() {
ws.connect();
}
});
}
```
- [ ] **Step 3: Verify build**
Run: `cd services/frontend-leptos/frontend && cargo check`
Expected: Compiles with no errors
- [ ] **Step 4: Commit**
```bash
cd /mnt/code/bete
git add services/frontend-leptos/
git commit -m "feat(leptos): auth API integration + WS auth gating"
```
---
## Summary: What We've Built
After Phase 1 and Phase 2, we have:
- ✅ Rust workspace with shared types (complete port of `@bete/shared`)
- ✅ Leptos CSR app scaffold (compiles, builds via trunk)
- ✅ Complete CSS design system (custom properties, dark/light, all component classes, animations)
- ✅ App shell with sidebar, header, tab strip, mobile nav
- ✅ Auth overlay with API login + sessionStorage persistence
- ✅ WebSocket singleton with per-event channels (connect, reconnect, binary/JSON dispatch)
- ✅ API client with all 20+ endpoint functions
- ✅ All 12 shared UI primitives (Button, Badge, Card, Input, Select, Tabs, ScrollArea, Toast, Skeleton, StatusBadge, EmptyState, Modal)
- ✅ Layout components (DashboardLayout, Header, Sidebar, TabStrip, MobileTabBar)
Next phase: **Feature implementation** — Messages (Phase 3), Live (Phase 4), Dashboard (Phase 5), Polish (Phase 6). Each gets its own implementation plan.
## Phase 3-6 Outline
### Phase 3: Messages Feature
- Tasks: MessageFeed (IntersectionObserver), MessageCard/MessageRow (rendering), ImageGrid, ModerationAlertListener, filters, search, reanalyze
- Key challenges: Infinite scroll, message merging/dedup, Discord emoji rendering, embed rendering
### Phase 4: Live Feature
- Tasks: VoiceConnectionCard, ActiveSpeakers, AudioVisualizer (Canvas 2D), MicLevelMeter, NowPlaying, MusicSubPanel, ScreenSubPanel, RecordingsSubPanel, WaveformPlayer (Canvas + Web Audio API), PCM audio playback
- Key challenges: Canvas rendering via web-sys, Web Audio API via web-sys, binary WS frames
### Phase 5: Dashboard Feature
- Tasks: StatsOverview, UserSummaryList (+ pagination), UserProfileDetail, ChannelSummaryList, ChannelProfileDetail
- Key challenges: Reusable cursor pagination, conditional stats rendering
### Phase 6: Polish
- Tasks: ParticleBackground (CSS), MascotChatbot, MascotImage, theme toggle, mobile responsive, edge cases (WS reconnect, stale data, error boundaries)