feat(leptos): app shell with auth gate and tab routing

This commit is contained in:
asepharyana
2026-07-03 18:22:28 +07:00
parent 5389c82f31
commit 2520faad47
3 changed files with 153 additions and 6 deletions
+96 -6
View File
@@ -1,19 +1,109 @@
use leptos::prelude::*; use leptos::prelude::*;
use shared_types::ui_state::Tab;
use crate::auth::AuthOverlay;
// ── Contexts ────────────────────────────────────────────
#[derive(Clone)]
pub struct AuthContext {
pub authenticated: RwSignal<bool>,
pub password: RwSignal<String>,
}
// Temporary stub until Task 4 creates the real UiContext
#[derive(Clone)] #[derive(Clone)]
pub struct UiContext { pub struct UiContext {
pub active_tab: RwSignal<shared_types::ui_state::Tab>, pub active_tab: RwSignal<Tab>,
pub selected_guild: RwSignal<Option<String>>,
} }
// ── App ─────────────────────────────────────────────────
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
// Initialize contexts
let auth = AuthContext {
authenticated: create_rw_signal(false),
password: create_rw_signal(String::new()),
};
let ui = UiContext {
active_tab: create_rw_signal(Tab::Messages),
selected_guild: create_rw_signal(None),
};
provide_context(auth.clone());
provide_context(ui.clone());
// Auth check: redirect "live" tab to "messages" if not authenticated
create_effect(move |_| {
if !auth.authenticated.get() && ui.active_tab.get() == Tab::Live {
ui.active_tab.set(Tab::Messages);
}
});
view! { view! {
<div class="app"> <div data-theme="light">
<link rel="stylesheet" href="/app.css" /> // Auth overlay
<div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: 'Poppins', sans-serif; font-size: 1.5rem; background: #0f172a; color: #e2e8f0;"> {move || (!auth.authenticated.get()).then(|| {
"Hello from Leptos" view! { <AuthOverlay /> }
})}
// Main content (minimal for now — filled in later tasks)
<div style="display: flex; flex-direction: column; height: 100vh;">
<header style="height: var(--header-height); border-bottom: 1px solid var(--surface-border); display: flex; align-items: center; padding: 0 1rem;">
<span style="font-weight: 700; color: var(--color-primary);">"IMPHNEN"</span>
<span style="margin-left: 0.5rem; color: var(--text-secondary); font-size: 0.875rem;">"Discord Moderation"</span>
</header>
<main style="flex: 1; display: flex;">
// Sidebar placeholder
<nav style="width: var(--sidebar-width); border-right: 1px solid var(--surface-border); padding: 1rem;">
<div class="flex flex-col gap-2">
<TabButton tab=Tab::Messages ui=ui.clone() label="Pesan & Moderasi" />
<TabButton tab=Tab::Live ui=ui.clone() label="Voice & Media" />
<TabButton tab=Tab::Dashboard ui=ui.clone() label="Dashboard Guild" />
</div>
</nav>
// Content area
<div style="flex: 1; overflow: auto; padding: 1.5rem;">
{move || match ui.active_tab.get() {
Tab::Messages => view! { <div>"Messages Panel"</div> }.into_view(),
Tab::Live => view! { <div>"Live Panel"</div> }.into_view(),
Tab::Dashboard => view! { <div>"Dashboard Panel"</div> }.into_view(),
}}
</div>
</main>
</div> </div>
</div> </div>
} }
} }
// ── Tab Button Helper ───────────────────────────────────
#[component]
fn TabButton(
tab: Tab,
ui: UiContext,
label: &'static str,
) -> impl IntoView {
let active_tab = ui.active_tab.clone();
let tab1 = tab.clone();
let tab2 = tab.clone();
let tab3 = tab.clone();
let tab4 = tab;
view! {
<button
class:btn=true
class:btn-ghost=true
class:btn-active=move || active_tab.get() == tab1
on:click=move |_| active_tab.set(tab4.clone())
style:background=move || if active_tab.get() == tab2 { "var(--surface-overlay)" } else { "" }
style:color=move || if active_tab.get() == tab3 { "var(--color-primary)" } else { "" }
style:width="100%"
style:justify-content="flex-start"
>
{label}
</button>
}
}
@@ -0,0 +1,56 @@
use leptos::prelude::*;
use leptos::ev::SubmitEvent;
#[component]
pub fn AuthOverlay(
/// Called when password is submitted — parent handles the actual API call
#[prop(default = ())]
on_submit: (),
) -> impl IntoView {
let (password, set_password) = create_signal(String::new());
let (error, set_error) = create_signal(Option::<String>::None);
let (loading, set_loading) = create_signal(false);
let handle_submit = move |ev: SubmitEvent| {
ev.prevent_default();
if password.get().is_empty() {
set_error.set(Some("Password diperlukan".to_string()));
return;
}
// TODO: actual login call (wired in Task 7)
set_loading.set(true);
};
view! {
<div class="modal-overlay">
<div class="modal-content" style="width: 380px;">
<div class="modal-body" style="text-align: center;">
<div style="font-size: 3rem; margin-bottom: 1rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem;">"Akses Dashboard"</h2>
<p style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 1.5rem;">
"Masukkan password admin untuk melanjutkan"
</p>
<form on:submit=handle_submit style="display: flex; flex-direction: column; gap: 0.75rem;">
<input
type="password"
class="input"
placeholder="Password"
prop:value=password
on:input=move |ev| set_password.set(event_target_value(&ev))
/>
{move || error.get().map(|e| view! { <p style="color: var(--color-error); font-size: 0.75rem;">{e}</p> })}
<button
type="submit"
class="btn btn-primary w-full btn-lg"
disabled=move || loading.get()
>
{move || if loading.get() { "Memproses..." } else { "Masuk" }}
</button>
</form>
</div>
</div>
</div>
}
}
@@ -1,5 +1,6 @@
pub mod api; pub mod api;
pub mod app; pub mod app;
pub mod auth;
pub mod layout; pub mod layout;
pub mod ui; pub mod ui;
pub mod ws; pub mod ws;