feat(leptos): Phase 6 polish components

This commit is contained in:
asepharyana
2026-07-03 22:56:45 +07:00
parent 8a2e914f08
commit 5216f2204e
10 changed files with 571 additions and 11 deletions
@@ -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<MascotChatResponse, ApiError> {
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
}
@@ -3,4 +3,5 @@ pub mod auth;
pub mod messages; pub mod messages;
pub mod voice; pub mod voice;
pub mod dashboard; pub mod dashboard;
pub mod mascot;
pub mod recordings; pub mod recordings;
@@ -844,6 +844,283 @@ img {
object-fit: cover; 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 ──────────────────────────────────── */
.dashboard-panel { .dashboard-panel {
display: flex; display: flex;
@@ -1047,9 +1324,30 @@ img {
@media (max-width: 768px) { @media (max-width: 768px) {
.grid-cols-2 { grid-template-columns: 1fr; } .grid-cols-2 { grid-template-columns: 1fr; }
.grid-cols-3 { 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-stats-grid { grid-template-columns: 1fr; }
.dashboard-wide-card { grid-column: span 1; } .dashboard-wide-card { grid-column: span 1; }
.dashboard-moderation-grid { grid-template-columns: 1fr; } .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; } .hide-mobile { display: none !important; }
} }
@media (min-width: 769px) { @media (min-width: 769px) {
+22 -11
View File
@@ -5,6 +5,8 @@ use crate::ws::context::WsContext;
use crate::features::dashboard::DashboardPanel; use crate::features::dashboard::DashboardPanel;
use crate::features::live::LivePanel; use crate::features::live::LivePanel;
use crate::features::messages::MessagesPanel; use crate::features::messages::MessagesPanel;
use crate::features::polish::{initial_theme, ThemeContext};
use crate::features::polish::components::{MascotChatbot, ParticleBackground, ThemeToggle};
#[derive(Clone)] #[derive(Clone)]
pub struct AppConfig { pub struct AppConfig {
@@ -38,9 +40,13 @@ pub fn App() -> impl IntoView {
active_tab: create_rw_signal(Tab::Messages), active_tab: create_rw_signal(Tab::Messages),
selected_guild: create_rw_signal(None), selected_guild: create_rw_signal(None),
}; };
let theme = ThemeContext {
theme: create_rw_signal(initial_theme()),
};
provide_context(auth.clone()); provide_context(auth.clone());
provide_context(ui.clone()); provide_context(ui.clone());
provide_context(theme.clone());
let config = AppConfig { let config = AppConfig {
monitor_guild_id: None, monitor_guild_id: None,
@@ -68,22 +74,26 @@ pub fn App() -> impl IntoView {
} }
view! { view! {
<div data-theme="light"> <div data-theme=move || theme.theme.get()>
<ParticleBackground />
// Auth overlay // Auth overlay
{move || (!auth.authenticated.get()).then(|| { {move || (!auth.authenticated.get()).then(|| {
view! { <AuthOverlay /> } view! { <AuthOverlay /> }
})} })}
// Main content (minimal for now — filled in later tasks) // Main content
<div style="display: flex; flex-direction: column; height: 100vh;"> <div class="app-shell">
<header style="height: var(--header-height); border-bottom: 1px solid var(--surface-border); display: flex; align-items: center; padding: 0 1rem;"> <header class="app-header">
<span style="font-weight: 700; color: var(--color-primary);">"IMPHNEN"</span> <div class="app-brand">
<span style="margin-left: 0.5rem; color: var(--text-secondary); font-size: 0.875rem;">"Discord Moderation"</span> <span class="app-brand-mark">"IMPHNEN"</span>
<span class="app-brand-subtitle">"Discord Moderation"</span>
</div>
<ThemeToggle />
</header> </header>
<main style="flex: 1; display: flex;"> <main class="app-main">
// Sidebar placeholder <nav class="app-sidebar">
<nav style="width: var(--sidebar-width); border-right: 1px solid var(--surface-border); padding: 1rem;">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<TabButton tab=Tab::Messages ui=ui.clone() label="Pesan & Moderasi" /> <TabButton tab=Tab::Messages ui=ui.clone() label="Pesan & Moderasi" />
<TabButton tab=Tab::Live ui=ui.clone() label="Voice & Media" /> <TabButton tab=Tab::Live ui=ui.clone() label="Voice & Media" />
@@ -91,8 +101,7 @@ pub fn App() -> impl IntoView {
</div> </div>
</nav> </nav>
// Content area <div class="app-content">
<div style="flex: 1; overflow: auto; padding: 1.5rem;">
{move || match ui.active_tab.get() { {move || match ui.active_tab.get() {
Tab::Messages => view! { <MessagesPanel /> }.into_any(), Tab::Messages => view! { <MessagesPanel /> }.into_any(),
Tab::Live => view! { <LivePanel /> }.into_any(), Tab::Live => view! { <LivePanel /> }.into_any(),
@@ -101,6 +110,8 @@ pub fn App() -> impl IntoView {
</div> </div>
</main> </main>
</div> </div>
{move || auth.authenticated.get().then(|| view! { <MascotChatbot /> })}
</div> </div>
} }
} }
@@ -1,3 +1,4 @@
pub mod dashboard; pub mod dashboard;
pub mod live; pub mod live;
pub mod messages; pub mod messages;
pub mod polish;
@@ -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! {
<div class="mascot-widget">
{move || if open.get() {
view! {
<div class=move || if minimized.get() { "mascot-panel minimized" } else { "mascot-panel" }>
<div class="mascot-header">
<div class="flex items-center gap-2">
<div class="mascot-header-icon">"💬"</div>
<div>
<div class="mascot-title">"Mascot IMPHNEN"</div>
<div class="mascot-subtitle">{move || if loading.get() { "Mengetik..." } else { "Online" }}</div>
</div>
</div>
<div class="flex items-center gap-1">
<button class="mascot-icon-button" on:click=move |_| minimized.update(|v| *v = !*v)>
{move || if minimized.get() { "" } else { "" }}
</button>
<button class="mascot-icon-button" on:click=move |_| open.set(false)>"×"</button>
</div>
</div>
{move || (!minimized.get()).then(|| view! {
<>
<div class="mascot-messages">
{messages.get().into_iter().map(|msg| {
let is_user = msg.role == ChatRole::User;
view! {
<div class=if is_user { "mascot-message-row user" } else { "mascot-message-row mascot" }>
{(!is_user).then(|| view! { <div class="mascot-avatar">"🤖"</div> })}
<div class=if is_user { "mascot-bubble user" } else { "mascot-bubble mascot" }>
{msg.content}
</div>
</div>
}
}).collect::<Vec<_>>()}
{loading.get().then(|| view! {
<div class="mascot-message-row mascot">
<div class="mascot-avatar">"🤖"</div>
<div class="mascot-bubble mascot typing">
<span></span><span></span><span></span>
</div>
</div>
})}
</div>
<form class="mascot-form" on:submit=move |ev| {
ev.prevent_default();
send_message();
}>
<input
class="mascot-input"
placeholder="Tanya mascot..."
prop:value=input
on:input=move |ev| input.set(event_target_value(&ev))
disabled=move || loading.get()
/>
<button
class="mascot-send"
type="submit"
disabled=move || loading.get() || input.get().trim().is_empty()
>
""
</button>
</form>
</>
})}
</div>
}.into_any()
} else {
view! {
<button class="mascot-launcher" on:click=move |_| open.set(true) title="Open mascot chat">
<span>"🤖"</span>
</button>
}.into_any()
}}
</div>
}
}
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)
}
}
@@ -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;
@@ -0,0 +1,12 @@
use leptos::prelude::*;
#[component]
pub fn ParticleBackground() -> impl IntoView {
view! {
<div class="particle-bg" aria-hidden="true">
<div class="particle-orb"></div>
<div class="particle-orb"></div>
<div class="particle-orb"></div>
</div>
}
}
@@ -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::<ThemeContext>();
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! {
<button
class="theme-toggle"
on:click=toggle
aria-label="Toggle theme"
title="Toggle theme"
>
{move || if is_dark() { "" } else { "" }}
</button>
}
}
@@ -0,0 +1,22 @@
pub mod components;
use leptos::prelude::*;
#[derive(Clone)]
pub struct ThemeContext {
pub theme: RwSignal<String>,
}
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);
}
}