From 5216f2204e230a48efc904748cfb41f518be2a6e Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 22:56:45 +0700 Subject: [PATCH] feat(leptos): Phase 6 polish components --- .../frontend/src/api/mascot.rs | 21 ++ .../frontend-leptos/frontend/src/api/mod.rs | 1 + services/frontend-leptos/frontend/src/app.css | 298 ++++++++++++++++++ services/frontend-leptos/frontend/src/app.rs | 33 +- .../frontend/src/features/mod.rs | 1 + .../polish/components/mascot_chatbot.rs | 152 +++++++++ .../src/features/polish/components/mod.rs | 7 + .../polish/components/particle_background.rs | 12 + .../polish/components/theme_toggle.rs | 35 ++ .../frontend/src/features/polish/mod.rs | 22 ++ 10 files changed, 571 insertions(+), 11 deletions(-) create mode 100644 services/frontend-leptos/frontend/src/api/mascot.rs create mode 100644 services/frontend-leptos/frontend/src/features/polish/components/mascot_chatbot.rs create mode 100644 services/frontend-leptos/frontend/src/features/polish/components/mod.rs create mode 100644 services/frontend-leptos/frontend/src/features/polish/components/particle_background.rs create mode 100644 services/frontend-leptos/frontend/src/features/polish/components/theme_toggle.rs create mode 100644 services/frontend-leptos/frontend/src/features/polish/mod.rs diff --git a/services/frontend-leptos/frontend/src/api/mascot.rs b/services/frontend-leptos/frontend/src/api/mascot.rs new file mode 100644 index 0000000..3b1d266 --- /dev/null +++ b/services/frontend-leptos/frontend/src/api/mascot.rs @@ -0,0 +1,21 @@ +use crate::api::client::{request, ApiError}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +struct MascotChatRequest<'a> { + message: &'a str, +} + +#[derive(Debug, Deserialize)] +pub struct MascotChatResponse { + pub response: String, + pub timestamp: String, +} + +pub async fn send_mascot_message(message: &str) -> Result { + let body = serde_json::to_string(&MascotChatRequest { message }).map_err(|err| ApiError { + message: format!("Failed to serialize mascot request: {}", err), + status_code: 0, + })?; + request("POST", "/api/mascot/chat", Some(&body)).await +} diff --git a/services/frontend-leptos/frontend/src/api/mod.rs b/services/frontend-leptos/frontend/src/api/mod.rs index 8af7ab5..843970d 100644 --- a/services/frontend-leptos/frontend/src/api/mod.rs +++ b/services/frontend-leptos/frontend/src/api/mod.rs @@ -3,4 +3,5 @@ pub mod auth; pub mod messages; pub mod voice; pub mod dashboard; +pub mod mascot; pub mod recordings; diff --git a/services/frontend-leptos/frontend/src/app.css b/services/frontend-leptos/frontend/src/app.css index 3ccef32..ac1e117 100644 --- a/services/frontend-leptos/frontend/src/app.css +++ b/services/frontend-leptos/frontend/src/app.css @@ -844,6 +844,283 @@ img { object-fit: cover; } +/* ── App Shell Polish ─────────────────────────────────── */ +.app-shell { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + height: 100vh; + background: color-mix(in srgb, var(--surface-base) 92%, transparent); +} + +.app-header { + height: var(--header-height); + border-bottom: 1px solid var(--surface-border); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: 0 var(--space-4); + background: color-mix(in srgb, var(--surface-base) 82%, transparent); + backdrop-filter: blur(14px); +} + +.app-brand { + display: flex; + align-items: baseline; + gap: var(--space-2); + min-width: 0; +} + +.app-brand-mark { + font-weight: 800; + color: var(--color-primary); + letter-spacing: -0.025em; +} + +.app-brand-subtitle { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.app-main { + flex: 1; + min-height: 0; + display: flex; +} + +.app-sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + border-right: 1px solid var(--surface-border); + padding: var(--space-4); + background: color-mix(in srgb, var(--surface-base) 78%, transparent); +} + +.app-content { + flex: 1; + min-width: 0; + overflow: auto; + padding: var(--space-6); +} + +.theme-toggle { + width: 2.25rem; + height: 2.25rem; + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + background: var(--surface-raised); + color: var(--text-secondary); + cursor: pointer; + font-size: 1rem; + transition: all var(--transition-fast); +} + +.theme-toggle:hover { + color: var(--color-primary); + border-color: var(--color-primary); + box-shadow: var(--glow-primary); +} + +/* ── Mascot Chatbot ───────────────────────────────────── */ +.mascot-widget { + position: fixed; + right: var(--space-6); + bottom: var(--space-6); + z-index: var(--z-toast); +} + +.mascot-launcher { + width: 3.5rem; + height: 3.5rem; + border: none; + border-radius: var(--radius-full); + background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); + color: white; + box-shadow: var(--shadow-xl), var(--glow-primary); + cursor: pointer; + font-size: 1.5rem; + transition: transform var(--transition-fast); +} + +.mascot-launcher:hover { + transform: translateY(-2px) scale(1.03); +} + +.mascot-panel { + width: min(24rem, calc(100vw - 2rem)); + height: min(32.5rem, calc(100vh - 7rem)); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--surface-border); + border-radius: var(--radius-xl); + background: var(--surface-base); + box-shadow: var(--shadow-xl); + animation: fade-in-up var(--transition-normal) ease-out; +} + +.mascot-panel.minimized { + height: 3.5rem; +} + +.mascot-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3); + background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); + color: white; + flex-shrink: 0; +} + +.mascot-header-icon { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--radius-md); + background: rgba(255,255,255,0.18); +} + +.mascot-title { + font-size: 0.875rem; + font-weight: 700; + line-height: 1.15; +} + +.mascot-subtitle { + margin-top: 0.125rem; + font-size: 0.6875rem; + opacity: 0.78; +} + +.mascot-icon-button { + width: 1.75rem; + height: 1.75rem; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: white; + cursor: pointer; +} + +.mascot-icon-button:hover { + background: rgba(255,255,255,0.18); +} + +.mascot-messages { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); +} + +.mascot-message-row { + display: flex; + gap: var(--space-2); +} + +.mascot-message-row.user { + justify-content: flex-end; +} + +.mascot-avatar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--radius-full); + background: var(--surface-overlay); + font-size: 0.875rem; +} + +.mascot-bubble { + max-width: 17.5rem; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-xl); + font-size: 0.875rem; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.mascot-bubble.user { + color: white; + background: var(--color-primary); + border-bottom-right-radius: var(--radius-sm); +} + +.mascot-bubble.mascot { + color: var(--text-primary); + background: var(--surface-raised); + border-bottom-left-radius: var(--radius-sm); +} + +.mascot-bubble.typing { + display: flex; + gap: 0.25rem; + padding-top: 0.7rem; + padding-bottom: 0.7rem; +} + +.mascot-bubble.typing span { + width: 0.4rem; + height: 0.4rem; + border-radius: var(--radius-full); + background: var(--text-tertiary); + animation: notification-pulse 0.8s infinite; +} + +.mascot-bubble.typing span:nth-child(2) { animation-delay: 0.1s; } +.mascot-bubble.typing span:nth-child(3) { animation-delay: 0.2s; } + +.mascot-form { + display: flex; + gap: var(--space-2); + padding: var(--space-3); + border-top: 1px solid var(--surface-border); +} + +.mascot-input { + flex: 1; + min-width: 0; + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + padding: var(--space-2) var(--space-3); + background: var(--surface-base); + color: var(--text-primary); + font-size: 0.875rem; +} + +.mascot-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.14); +} + +.mascot-send { + width: 2.25rem; + border: none; + border-radius: var(--radius-lg); + background: var(--color-primary); + color: white; + cursor: pointer; +} + +.mascot-send:disabled, +.mascot-input:disabled { + cursor: not-allowed; + opacity: 0.55; +} + /* ── Dashboard Panel ──────────────────────────────────── */ .dashboard-panel { display: flex; @@ -1047,9 +1324,30 @@ img { @media (max-width: 768px) { .grid-cols-2 { grid-template-columns: 1fr; } .grid-cols-3 { grid-template-columns: 1fr; } + .app-main { flex-direction: column; } + .app-sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--surface-border); + padding: var(--space-3); + } + .app-sidebar .flex-col { + flex-direction: row; + overflow-x: auto; + } + .app-content { padding: var(--space-4); } + .app-brand-subtitle { display: none; } .dashboard-stats-grid { grid-template-columns: 1fr; } .dashboard-wide-card { grid-column: span 1; } .dashboard-moderation-grid { grid-template-columns: 1fr; } + .mascot-widget { + right: var(--space-4); + bottom: var(--space-4); + } + .mascot-panel { + width: calc(100vw - 2rem); + height: min(32.5rem, calc(100vh - 5rem)); + } .hide-mobile { display: none !important; } } @media (min-width: 769px) { diff --git a/services/frontend-leptos/frontend/src/app.rs b/services/frontend-leptos/frontend/src/app.rs index 7556c81..a232869 100644 --- a/services/frontend-leptos/frontend/src/app.rs +++ b/services/frontend-leptos/frontend/src/app.rs @@ -5,6 +5,8 @@ use crate::ws::context::WsContext; use crate::features::dashboard::DashboardPanel; use crate::features::live::LivePanel; use crate::features::messages::MessagesPanel; +use crate::features::polish::{initial_theme, ThemeContext}; +use crate::features::polish::components::{MascotChatbot, ParticleBackground, ThemeToggle}; #[derive(Clone)] pub struct AppConfig { @@ -38,9 +40,13 @@ pub fn App() -> impl IntoView { active_tab: create_rw_signal(Tab::Messages), selected_guild: create_rw_signal(None), }; + let theme = ThemeContext { + theme: create_rw_signal(initial_theme()), + }; provide_context(auth.clone()); provide_context(ui.clone()); + provide_context(theme.clone()); let config = AppConfig { monitor_guild_id: None, @@ -68,22 +74,26 @@ pub fn App() -> impl IntoView { } view! { -
+
+ + // Auth overlay {move || (!auth.authenticated.get()).then(|| { view! { } })} - // Main content (minimal for now — filled in later tasks) -
-
- "IMPHNEN" - "Discord Moderation" + // Main content +
+
+
+ "IMPHNEN" + "Discord Moderation" +
+
-
- // Sidebar placeholder -
+ + {move || auth.authenticated.get().then(|| view! { })}
} } diff --git a/services/frontend-leptos/frontend/src/features/mod.rs b/services/frontend-leptos/frontend/src/features/mod.rs index 4f0da73..ff99f81 100644 --- a/services/frontend-leptos/frontend/src/features/mod.rs +++ b/services/frontend-leptos/frontend/src/features/mod.rs @@ -1,3 +1,4 @@ pub mod dashboard; pub mod live; pub mod messages; +pub mod polish; diff --git a/services/frontend-leptos/frontend/src/features/polish/components/mascot_chatbot.rs b/services/frontend-leptos/frontend/src/features/polish/components/mascot_chatbot.rs new file mode 100644 index 0000000..bafee16 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/polish/components/mascot_chatbot.rs @@ -0,0 +1,152 @@ +use leptos::prelude::*; +use wasm_bindgen_futures::spawn_local; + +#[derive(Clone, PartialEq)] +enum ChatRole { + User, + Mascot, +} + +#[derive(Clone)] +struct ChatMessage { + id: String, + role: ChatRole, + content: String, +} + +#[component] +pub fn MascotChatbot() -> impl IntoView { + let open = RwSignal::new(false); + let minimized = RwSignal::new(false); + let loading = RwSignal::new(false); + let input = RwSignal::new(String::new()); + let messages = RwSignal::new(vec![ChatMessage { + id: "init-1".to_string(), + role: ChatRole::Mascot, + content: "Halo! 👋 Aku mascot IMPHNEN. Tanya aku soal analytics, pesan, atau moderation queue.".to_string(), + }]); + + let send_message = move || { + let text = input.get().trim().to_string(); + if text.is_empty() || loading.get() { + return; + } + + let now = js_sys::Date::now() as u64; + messages.update(|list| list.push(ChatMessage { + id: format!("user-{}", now), + role: ChatRole::User, + content: text.clone(), + })); + input.set(String::new()); + loading.set(true); + + spawn_local(async move { + let response = match crate::api::mascot::send_mascot_message(&text).await { + Ok(resp) => resp.response, + Err(_) => fallback_response(&text), + }; + + messages.update(|list| list.push(ChatMessage { + id: format!("mascot-{}", js_sys::Date::now() as u64), + role: ChatRole::Mascot, + content: response, + })); + loading.set(false); + }); + }; + + view! { +
+ {move || if open.get() { + view! { +
+
+
+
"💬"
+
+
"Mascot IMPHNEN"
+
{move || if loading.get() { "Mengetik..." } else { "Online" }}
+
+
+
+ + +
+
+ + {move || (!minimized.get()).then(|| view! { + <> +
+ {messages.get().into_iter().map(|msg| { + let is_user = msg.role == ChatRole::User; + view! { +
+ {(!is_user).then(|| view! {
"🤖"
})} +
+ {msg.content} +
+
+ } + }).collect::>()} + + {loading.get().then(|| view! { +
+
"🤖"
+
+ +
+
+ })} +
+ +
+ + +
+ + })} +
+ }.into_any() + } else { + view! { + + }.into_any() + }} +
+ } +} + +fn fallback_response(input: &str) -> String { + let lower = input.to_lowercase(); + if lower.contains("halo") || lower.contains("hai") { + "Halo juga! 👋 Aku siap bantu baca kondisi server.".to_string() + } else if lower.contains("pesan") || lower.contains("message") { + "Cek tab Messages untuk live capture dan hasil AI moderation terbaru.".to_string() + } else if lower.contains("voice") || lower.contains("audio") { + "Tab Voice & Media punya voice bridge, speakers, media controls, dan recordings.".to_string() + } else if lower.contains("dashboard") || lower.contains("stat") { + "Dashboard Guild merangkum total pesan, user aktif, channel teratas, dan moderation queue.".to_string() + } else { + format!("Menarik: \"{}\". Kalau backend mascot offline, aku tetap bisa bantu arahkan ke Messages, Voice, atau Dashboard. 😊", input) + } +} diff --git a/services/frontend-leptos/frontend/src/features/polish/components/mod.rs b/services/frontend-leptos/frontend/src/features/polish/components/mod.rs new file mode 100644 index 0000000..2bd9b1f --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/polish/components/mod.rs @@ -0,0 +1,7 @@ +pub mod mascot_chatbot; +pub mod particle_background; +pub mod theme_toggle; + +pub use mascot_chatbot::MascotChatbot; +pub use particle_background::ParticleBackground; +pub use theme_toggle::ThemeToggle; diff --git a/services/frontend-leptos/frontend/src/features/polish/components/particle_background.rs b/services/frontend-leptos/frontend/src/features/polish/components/particle_background.rs new file mode 100644 index 0000000..6d9de95 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/polish/components/particle_background.rs @@ -0,0 +1,12 @@ +use leptos::prelude::*; + +#[component] +pub fn ParticleBackground() -> impl IntoView { + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/features/polish/components/theme_toggle.rs b/services/frontend-leptos/frontend/src/features/polish/components/theme_toggle.rs new file mode 100644 index 0000000..0f7a995 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/polish/components/theme_toggle.rs @@ -0,0 +1,35 @@ +use leptos::prelude::*; +use crate::features::polish::{persist_theme, ThemeContext}; + +#[component] +pub fn ThemeToggle() -> impl IntoView { + let theme_ctx = use_context::(); + let theme_for_label = theme_ctx.clone(); + let theme_for_toggle = theme_ctx.clone(); + + let is_dark = move || { + theme_for_label + .as_ref() + .map(|ctx| ctx.theme.get() == "dark") + .unwrap_or(false) + }; + + let toggle = move |_| { + if let Some(ctx) = theme_for_toggle.as_ref() { + let next = if ctx.theme.get() == "dark" { "light" } else { "dark" }; + ctx.theme.set(next.to_string()); + persist_theme(next); + } + }; + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/features/polish/mod.rs b/services/frontend-leptos/frontend/src/features/polish/mod.rs new file mode 100644 index 0000000..8c1ded8 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/polish/mod.rs @@ -0,0 +1,22 @@ +pub mod components; + +use leptos::prelude::*; + +#[derive(Clone)] +pub struct ThemeContext { + pub theme: RwSignal, +} + +pub fn initial_theme() -> String { + web_sys::window() + .and_then(|window| window.local_storage().ok().flatten()) + .and_then(|storage| storage.get_item("imphnen-theme").ok().flatten()) + .filter(|value| value == "dark" || value == "light") + .unwrap_or_else(|| "light".to_string()) +} + +pub fn persist_theme(theme: &str) { + if let Some(storage) = web_sys::window().and_then(|window| window.local_storage().ok().flatten()) { + let _ = storage.set_item("imphnen-theme", theme); + } +}