feat(leptos): layout components -- Header, Sidebar, TabStrip, MobileTabBar
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
use leptos::prelude::*;
|
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]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
view! {
|
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 api;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod layout;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user