feat(leptos): layout components -- Header, Sidebar, TabStrip, MobileTabBar
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user