From 8a2e914f08f3287b1ee54a109e4a535284faecc9 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 22:46:58 +0700 Subject: [PATCH] feat(leptos): Phase 5 dashboard feature --- .../frontend/src/api/dashboard.rs | 2 + services/frontend-leptos/frontend/src/app.css | 202 ++++++++++++++++ services/frontend-leptos/frontend/src/app.rs | 5 +- .../components/channel_summary_list.rs | 137 +++++++++++ .../src/features/dashboard/components/mod.rs | 7 + .../dashboard/components/stats_overview.rs | 145 ++++++++++++ .../dashboard/components/user_summary_list.rs | 142 +++++++++++ .../frontend/src/features/dashboard/mod.rs | 223 ++++++++++++++++++ .../frontend/src/features/mod.rs | 1 + .../shared-types/src/dashboard.rs | 12 +- 10 files changed, 870 insertions(+), 6 deletions(-) create mode 100644 services/frontend-leptos/frontend/src/features/dashboard/components/channel_summary_list.rs create mode 100644 services/frontend-leptos/frontend/src/features/dashboard/components/mod.rs create mode 100644 services/frontend-leptos/frontend/src/features/dashboard/components/stats_overview.rs create mode 100644 services/frontend-leptos/frontend/src/features/dashboard/components/user_summary_list.rs create mode 100644 services/frontend-leptos/frontend/src/features/dashboard/mod.rs diff --git a/services/frontend-leptos/frontend/src/api/dashboard.rs b/services/frontend-leptos/frontend/src/api/dashboard.rs index c57d1d0..622b356 100644 --- a/services/frontend-leptos/frontend/src/api/dashboard.rs +++ b/services/frontend-leptos/frontend/src/api/dashboard.rs @@ -24,6 +24,7 @@ pub async fn get_dashboard_users( #[derive(serde::Deserialize)] pub struct PaginatedUsers { pub data: Vec, + #[serde(rename = "nextCursor")] pub next_cursor: Option, } @@ -52,6 +53,7 @@ pub async fn get_dashboard_channels( #[derive(serde::Deserialize)] pub struct PaginatedChannels { pub data: Vec, + #[serde(rename = "nextCursor")] pub next_cursor: Option, } diff --git a/services/frontend-leptos/frontend/src/app.css b/services/frontend-leptos/frontend/src/app.css index 75907ed..3ccef32 100644 --- a/services/frontend-leptos/frontend/src/app.css +++ b/services/frontend-leptos/frontend/src/app.css @@ -844,10 +844,212 @@ img { object-fit: cover; } +/* ── Dashboard Panel ──────────────────────────────────── */ +.dashboard-panel { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.dashboard-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); +} + +.dashboard-stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-4); +} + +.dashboard-metric-card { + padding: var(--space-4); + overflow: hidden; +} + +.dashboard-metric-content { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.dashboard-metric-label { + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 500; +} + +.dashboard-metric-value { + margin-top: var(--space-1); + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.025em; +} + +.dashboard-metric-icon { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: var(--radius-lg); + font-size: 1.25rem; +} + +.dashboard-wide-card { + grid-column: span 2; +} + +.dashboard-top-channels, +.dashboard-summary-list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.dashboard-top-channel-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + background: var(--surface-raised); + color: var(--text-secondary); + font-size: 0.875rem; +} + +.dashboard-moderation-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-3); +} + +.dashboard-queue-card { + padding: var(--space-4); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + background: var(--surface-raised); + text-align: center; +} + +.dashboard-queue-value { + font-size: 1.5rem; + font-weight: 700; +} + +.dashboard-queue-label { + margin-top: var(--space-1); + color: var(--text-secondary); + font-size: 0.75rem; +} + +.dashboard-list-card { + overflow: hidden; +} + +.dashboard-list-toolbar { + margin-bottom: var(--space-4); +} + +.dashboard-summary-row { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + background: var(--surface-base); + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +.dashboard-summary-row:hover { + border-color: var(--color-primary); + background: var(--surface-raised); +} + +.dashboard-summary-avatar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 2.5rem; + height: 2.5rem; + border-radius: var(--radius-full); + background: var(--surface-overlay); + color: var(--text-secondary); + font-weight: 700; + overflow: hidden; +} + +.dashboard-summary-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.dashboard-channel-avatar { + color: var(--color-primary); + background: rgba(35, 161, 235, 0.1); +} + +.dashboard-summary-main { + min-width: 0; + flex: 1; +} + +.dashboard-summary-title { + color: var(--text-primary); + font-size: 0.875rem; + font-weight: 600; +} + +.dashboard-summary-text { + margin-top: var(--space-1); + color: var(--text-secondary); + font-size: 0.8125rem; + line-height: 1.45; +} + +.dashboard-summary-meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-2); + color: var(--text-tertiary); + font-size: 0.75rem; +} + +.dashboard-list-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + padding: var(--space-10) var(--space-4); + text-align: center; +} + +.tone-primary { background: rgba(35, 161, 235, 0.1); color: var(--color-primary); } +.tone-success { background: rgba(34, 197, 94, 0.1); color: var(--color-success); } +.tone-error { background: rgba(239, 68, 68, 0.1); color: var(--color-error); } +.tone-warning { background: rgba(245, 158, 11, 0.1); color: var(--color-warning); } +.tone-info { background: rgba(59, 130, 246, 0.1); color: var(--color-info); } +.tone-secondary { background: var(--surface-overlay); color: var(--text-secondary); } + /* ── Responsive ───────────────────────────────────────── */ +@media (max-width: 1024px) { + .dashboard-stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} @media (max-width: 768px) { .grid-cols-2 { grid-template-columns: 1fr; } .grid-cols-3 { grid-template-columns: 1fr; } + .dashboard-stats-grid { grid-template-columns: 1fr; } + .dashboard-wide-card { grid-column: span 1; } + .dashboard-moderation-grid { grid-template-columns: 1fr; } .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 5fd6570..7556c81 100644 --- a/services/frontend-leptos/frontend/src/app.rs +++ b/services/frontend-leptos/frontend/src/app.rs @@ -2,8 +2,9 @@ use leptos::prelude::*; use shared_types::ui_state::Tab; use crate::auth::AuthOverlay; use crate::ws::context::WsContext; -use crate::features::messages::MessagesPanel; +use crate::features::dashboard::DashboardPanel; use crate::features::live::LivePanel; +use crate::features::messages::MessagesPanel; #[derive(Clone)] pub struct AppConfig { @@ -95,7 +96,7 @@ pub fn App() -> impl IntoView { {move || match ui.active_tab.get() { Tab::Messages => view! { }.into_any(), Tab::Live => view! { }.into_any(), - Tab::Dashboard => view! {
"Dashboard Panel"
}.into_any(), + Tab::Dashboard => view! { }.into_any(), }} diff --git a/services/frontend-leptos/frontend/src/features/dashboard/components/channel_summary_list.rs b/services/frontend-leptos/frontend/src/features/dashboard/components/channel_summary_list.rs new file mode 100644 index 0000000..5e39777 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/dashboard/components/channel_summary_list.rs @@ -0,0 +1,137 @@ +use leptos::prelude::*; +use shared_types::dashboard::DashboardChannel; + +#[component] +pub fn ChannelSummaryList( + channels: Vec, + loading: bool, + error: Option, + search: String, + has_more: bool, + on_search_change: Box, + on_load_more: Box, + on_retry: Box, +) -> impl IntoView { + let search_cb = StoredValue::new(on_search_change); + let load_more_cb = StoredValue::new(on_load_more); + let retry_cb = StoredValue::new(on_retry); + + view! { +
+
+
"Kanal"
+

"Ringkasan aktivitas, flagged message, dan budaya kanal."

+
+
+
+ +
+ + {move || { + if loading && channels.is_empty() { + view! { }.into_any() + } else if let Some(err) = error.clone() { + view! { +
+
"⚠"
+

{err}

+ +
+ }.into_any() + } else if channels.is_empty() { + view! { +
+
"#"
+

"No channels found."

+
+ }.into_any() + } else { + view! { +
+ {channels.clone().into_iter().map(|channel| view! { + + }).collect::>()} +
+ }.into_any() + } + }} + + {move || { + (has_more && !loading).then(|| view! { +
+ +
+ }) + }} +
+
+ } +} + +#[component] +fn ChannelRow(channel: DashboardChannel) -> impl IntoView { + let name = channel.channel_name.clone().unwrap_or_else(|| channel.channel_id.clone()); + let summary = channel + .culture_summary + .clone() + .unwrap_or_else(|| format!("{} messages", format_number(channel.total_messages))); + let last_seen = channel.last_message_at.map(format_timestamp); + + view! { +
+
+ "#" +
+
+
{format!("#{}", name)}
+
{summary}
+
+ {format!("{} messages", format_number(channel.total_messages))} + {format!("{} flagged", format_number(channel.flagged_count))} + {last_seen.map(|t| view! { {format!("Last: {}", t)} })} +
+
+
+ } +} + +#[component] +fn ListSkeleton() -> impl IntoView { + view! { +
+ {(0..5).map(|_| view! { +
+
+
+
+
+
+
+ }).collect::>()} +
+ } +} + +fn format_number(value: u64) -> String { + let raw = value.to_string(); + let mut out = String::new(); + for (idx, ch) in raw.chars().rev().enumerate() { + if idx > 0 && idx % 3 == 0 { out.push(','); } + out.push(ch); + } + out.chars().rev().collect() +} + +fn format_timestamp(ts: i64) -> String { + let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0)); + d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED).into() +} diff --git a/services/frontend-leptos/frontend/src/features/dashboard/components/mod.rs b/services/frontend-leptos/frontend/src/features/dashboard/components/mod.rs new file mode 100644 index 0000000..131d4ed --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/dashboard/components/mod.rs @@ -0,0 +1,7 @@ +pub mod stats_overview; +pub mod user_summary_list; +pub mod channel_summary_list; + +pub use stats_overview::StatsOverview; +pub use user_summary_list::UserSummaryList; +pub use channel_summary_list::ChannelSummaryList; diff --git a/services/frontend-leptos/frontend/src/features/dashboard/components/stats_overview.rs b/services/frontend-leptos/frontend/src/features/dashboard/components/stats_overview.rs new file mode 100644 index 0000000..657d507 --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/dashboard/components/stats_overview.rs @@ -0,0 +1,145 @@ +use leptos::prelude::*; +use shared_types::dashboard::{DashboardStats, TopChannel}; + +#[component] +pub fn StatsOverview( + stats: Option, + loading: bool, + error: Option, + on_retry: Box, +) -> impl IntoView { + let retry = StoredValue::new(on_retry); + + view! { +
+ {move || { + if loading { + view! { }.into_any() + } else if let Some(err) = error.clone() { + view! { +
+
"⚠"
+

{err}

+ +
+ }.into_any() + } else if let Some(stats) = stats.clone() { + view! { +
+ + + + + + + + + +
+
+
"Top Channels"
+
+
+ +
+
+ +
+
+
"Moderation Queue"
+
+
+
+ + + +
+
+
+
+ }.into_any() + } else { + view! { +
+

"No dashboard data available yet."

+
+ }.into_any() + } + }} +
+ } +} + +#[component] +fn MetricCard(label: &'static str, value: u64, icon: &'static str, tone: &'static str) -> impl IntoView { + view! { +
+
+
+
{label}
+
{format_number(value)}
+
+
{icon}
+
+
+ } +} + +#[component] +fn QueueMetric(label: &'static str, value: u64, tone: &'static str) -> impl IntoView { + view! { +
+
{format_number(value)}
+
{label}
+
+ } +} + +#[component] +fn TopChannels(channels: Vec) -> impl IntoView { + if channels.is_empty() { + return view! {

"No channel data yet."

}.into_any(); + } + + view! { +
+ {channels.into_iter().map(|ch| { + let name = ch.channel_name.unwrap_or_else(|| ch.channel_id.clone()); + view! { +
+ {format!("#{}", name)} + {format_number(ch.message_count)} +
+ } + }).collect::>()} +
+ }.into_any() +} + +#[component] +fn StatsSkeleton() -> impl IntoView { + view! { +
+ {(0..8).map(|_| view! { +
+
+
+
+ }).collect::>()} +
+ } +} + +fn format_number(value: u64) -> String { + let raw = value.to_string(); + let mut out = String::new(); + for (idx, ch) in raw.chars().rev().enumerate() { + if idx > 0 && idx % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out.chars().rev().collect() +} diff --git a/services/frontend-leptos/frontend/src/features/dashboard/components/user_summary_list.rs b/services/frontend-leptos/frontend/src/features/dashboard/components/user_summary_list.rs new file mode 100644 index 0000000..997f8ac --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/dashboard/components/user_summary_list.rs @@ -0,0 +1,142 @@ +use leptos::prelude::*; +use shared_types::dashboard::DashboardUser; + +#[component] +pub fn UserSummaryList( + users: Vec, + loading: bool, + error: Option, + search: String, + has_more: bool, + on_search_change: Box, + on_load_more: Box, + on_retry: Box, +) -> impl IntoView { + let search_cb = StoredValue::new(on_search_change); + let load_more_cb = StoredValue::new(on_load_more); + let retry_cb = StoredValue::new(on_retry); + + view! { +
+
+
"Pengguna"
+

"Ringkasan aktivitas dan trust score pengguna."

+
+
+
+ +
+ + {move || { + if loading && users.is_empty() { + view! { }.into_any() + } else if let Some(err) = error.clone() { + view! { +
+
"⚠"
+

{err}

+ +
+ }.into_any() + } else if users.is_empty() { + view! { +
+
"👤"
+

"No users found."

+
+ }.into_any() + } else { + view! { +
+ {users.clone().into_iter().map(|user| view! { + + }).collect::>()} +
+ }.into_any() + } + }} + + {move || { + (has_more && !loading).then(|| view! { +
+ +
+ }) + }} +
+
+ } +} + +#[component] +fn UserRow(user: DashboardUser) -> impl IntoView { + let name = user.username.clone().unwrap_or_else(|| user.user_id.clone()); + let summary = user + .profile_summary + .clone() + .unwrap_or_else(|| format!("{} messages", format_number(user.total_messages))); + let trust = user.trust_score.map(|score| format!("Trust: {:.2}", score)); + let last_seen = user.last_message_at.map(format_timestamp); + + view! { +
+
+ {if let Some(url) = user.avatar_url.clone() { + view! { }.into_any() + } else { + view! { "👤" }.into_any() + }} +
+
+
{name}
+
{summary}
+
+ {format!("{} flagged", format_number(user.flagged_count))} + {trust.map(|t| view! { {t} })} + {last_seen.map(|t| view! { {format!("Last: {}", t)} })} +
+
+
+ } +} + +#[component] +fn ListSkeleton() -> impl IntoView { + view! { +
+ {(0..5).map(|_| view! { +
+
+
+
+
+
+
+ }).collect::>()} +
+ } +} + +fn format_number(value: u64) -> String { + let raw = value.to_string(); + let mut out = String::new(); + for (idx, ch) in raw.chars().rev().enumerate() { + if idx > 0 && idx % 3 == 0 { out.push(','); } + out.push(ch); + } + out.chars().rev().collect() +} + +fn format_timestamp(ts: i64) -> String { + let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0)); + d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED).into() +} diff --git a/services/frontend-leptos/frontend/src/features/dashboard/mod.rs b/services/frontend-leptos/frontend/src/features/dashboard/mod.rs new file mode 100644 index 0000000..6aa086f --- /dev/null +++ b/services/frontend-leptos/frontend/src/features/dashboard/mod.rs @@ -0,0 +1,223 @@ +pub mod components; + +use components::{ChannelSummaryList, StatsOverview, UserSummaryList}; +use leptos::prelude::*; +use shared_types::dashboard::{DashboardChannel, DashboardStats, DashboardUser}; +use std::sync::Arc; +use wasm_bindgen_futures::spawn_local; + +#[derive(Clone, PartialEq)] +enum DashboardTab { + Stats, + Users, + Channels, +} + +#[component] +pub fn DashboardPanel() -> impl IntoView { + let active_tab = RwSignal::new(DashboardTab::Stats); + + let stats = RwSignal::new(None::); + let stats_loading = RwSignal::new(false); + let stats_error = RwSignal::new(None::); + + let users = RwSignal::new(Vec::::new()); + let users_loading = RwSignal::new(false); + let users_error = RwSignal::new(None::); + let users_search = RwSignal::new(String::new()); + let users_cursor = RwSignal::new(None::); + + let channels = RwSignal::new(Vec::::new()); + let channels_loading = RwSignal::new(false); + let channels_error = RwSignal::new(None::); + let channels_search = RwSignal::new(String::new()); + let channels_cursor = RwSignal::new(None::); + + let fetch_stats: Arc = Arc::new(move || { + stats_loading.set(true); + stats_error.set(None); + spawn_local(async move { + match crate::api::dashboard::get_dashboard_stats().await { + Ok(data) => stats.set(Some(data)), + Err(err) => stats_error.set(Some(format!("Failed to load stats: {}", err))), + } + stats_loading.set(false); + }); + }); + + let fetch_users: Arc = Arc::new(move |reset: bool| { + if users_loading.get() { + return; + } + users_loading.set(true); + users_error.set(None); + + let cursor = if reset { None } else { users_cursor.get() }; + let search = users_search.get(); + spawn_local(async move { + let search_ref = (!search.trim().is_empty()).then_some(search.trim()); + match crate::api::dashboard::get_dashboard_users(Some(20), cursor.as_deref(), search_ref).await { + Ok(page) => { + if reset { + users.set(page.data); + } else { + let mut current = users.get(); + current.extend(page.data); + users.set(current); + } + users_cursor.set(page.next_cursor); + } + Err(err) => users_error.set(Some(format!("Failed to load users: {}", err))), + } + users_loading.set(false); + }); + }); + + let fetch_channels: Arc = Arc::new(move |reset: bool| { + if channels_loading.get() { + return; + } + channels_loading.set(true); + channels_error.set(None); + + let cursor = if reset { None } else { channels_cursor.get() }; + let search = channels_search.get(); + spawn_local(async move { + let search_ref = (!search.trim().is_empty()).then_some(search.trim()); + match crate::api::dashboard::get_dashboard_channels(Some(20), cursor.as_deref(), search_ref, None).await { + Ok(page) => { + if reset { + channels.set(page.data); + } else { + let mut current = channels.get(); + current.extend(page.data); + channels.set(current); + } + channels_cursor.set(page.next_cursor); + } + Err(err) => channels_error.set(Some(format!("Failed to load channels: {}", err))), + } + channels_loading.set(false); + }); + }); + + { + let fetch_stats = fetch_stats.clone(); + let fetch_users = fetch_users.clone(); + let fetch_channels = fetch_channels.clone(); + create_effect(move |_| { + fetch_stats(); + fetch_users(true); + fetch_channels(true); + }); + } + + view! { +
+
+
+

"Dashboard Guild"

+

+ "Pantau statistik, profil pengguna, dan aktivitas kanal komunitas IMPHNEN." +

+
+
+ +
+
+ + + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ } +} + +#[component] +fn DashboardTabButton( + tab: DashboardTab, + active_tab: RwSignal, + label: &'static str, + icon: &'static str, +) -> impl IntoView { + let tab_for_class = tab.clone(); + let tab_for_aria = tab.clone(); + let tab_for_click = tab; + + view! { + + } +} diff --git a/services/frontend-leptos/frontend/src/features/mod.rs b/services/frontend-leptos/frontend/src/features/mod.rs index e660cd8..4f0da73 100644 --- a/services/frontend-leptos/frontend/src/features/mod.rs +++ b/services/frontend-leptos/frontend/src/features/mod.rs @@ -1,2 +1,3 @@ +pub mod dashboard; pub mod live; pub mod messages; diff --git a/services/frontend-leptos/shared-types/src/dashboard.rs b/services/frontend-leptos/shared-types/src/dashboard.rs index 33eafbb..e2be098 100644 --- a/services/frontend-leptos/shared-types/src/dashboard.rs +++ b/services/frontend-leptos/shared-types/src/dashboard.rs @@ -20,7 +20,8 @@ pub struct DashboardStats { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TopChannel { pub channel_id: String, - pub channel_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_name: Option, pub message_count: u64, } @@ -34,7 +35,8 @@ pub struct ModerationOverview { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DashboardUser { pub user_id: String, - pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, #[serde(skip_serializing_if = "Option::is_none")] pub avatar_url: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -62,8 +64,10 @@ pub struct DashboardUserDetail { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DashboardChannel { pub channel_id: String, - pub channel_name: String, - pub guild_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub guild_id: Option, pub total_messages: u64, pub flagged_count: u64, #[serde(skip_serializing_if = "Option::is_none")]