feat(leptos): layout components -- Header, Sidebar, TabStrip, MobileTabBar

This commit is contained in:
asepharyana
2026-07-03 18:16:08 +07:00
parent aa20d1a633
commit 5389c82f31
8 changed files with 285 additions and 0 deletions
@@ -1,5 +1,11 @@
use leptos::prelude::*;
// Temporary stub until Task 4 creates the real UiContext
#[derive(Clone)]
pub struct UiContext {
pub active_tab: RwSignal<shared_types::ui_state::Tab>,
}
#[component]
pub fn App() -> impl IntoView {
view! {
@@ -0,0 +1,28 @@
// services/frontend-leptos/frontend/src/layout/dashboard_layout.rs
use leptos::children::Children;
use leptos::prelude::*;
use super::header::Header;
use super::mobile_tab_bar::MobileTabBar;
use super::sidebar::Sidebar;
use super::tab_strip::TabStrip;
#[component]
pub fn DashboardLayout(
children: Children,
) -> impl IntoView {
view! {
<div style="display: flex; flex-direction: column; height: 100vh;">
<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;">
{children()}
</div>
</main>
</div>
<MobileTabBar />
</div>
}
}
@@ -0,0 +1,56 @@
// services/frontend-leptos/frontend/src/layout/header.rs
use leptos::prelude::*;
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;
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)",
});
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">
<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%;"
style:background={move || indicator_color_memo.get()}
></span>
<span>{move || indicator_text_memo.get()}</span>
</div>
</div>
</header>
}
}
@@ -0,0 +1,63 @@
// services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs
use leptos::prelude::*;
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 tab_active = tab.clone();
let tab_click = tab;
view! {
<button
on:click=move |_| ui.active_tab.set(tab_click.clone())
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 ui.active_tab.get() == tab_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>
}
}
@@ -0,0 +1,6 @@
// services/frontend-leptos/frontend/src/layout/mod.rs
pub mod dashboard_layout;
pub mod header;
pub mod mobile_tab_bar;
pub mod sidebar;
pub mod tab_strip;
@@ -0,0 +1,71 @@
// services/frontend-leptos/frontend/src/layout/sidebar.rs
use leptos::prelude::*;
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 tab_bg = tab.clone();
let tab_clr = tab.clone();
let tab_click = tab;
let handle_click = move |_| ui.active_tab.set(tab_click.clone());
view! {
<button
class="btn btn-ghost"
style="
justify-content: flex-start; width: 100%;
margin-bottom: 0.25rem;
"
style:background=move || if ui.active_tab.get() == tab_bg { "var(--surface-overlay)" } else { "" }
style:color=move || if ui.active_tab.get() == tab_clr { "var(--color-primary)" } else { "" }
on:click=handle_click
>
{label}
</button>
}
}
@@ -0,0 +1,54 @@
// services/frontend-leptos/frontend/src/layout/tab_strip.rs
use leptos::prelude::*;
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 tab_color = tab.clone();
let tab_border = tab.clone();
let tab_click = tab;
view! {
<button
on:click=move |_| ui.active_tab.set(tab_click.clone())
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 ui.active_tab.get() == tab_color { "var(--color-primary)" } else { "" }
style:border-bottom-color=move || if ui.active_tab.get() == tab_border { "var(--color-primary)" } else { "transparent" }
>
{label}
</button>
}
}
@@ -1,5 +1,6 @@
pub mod api;
pub mod app;
pub mod layout;
pub mod ui;
pub mod ws;