feat(leptos): app shell with auth gate and tab routing
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user