feat(leptos): Phase 6 polish components
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
+12
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user