feat(leptos): app shell with auth gate and tab routing
This commit is contained in:
@@ -1,19 +1,109 @@
|
||||
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)]
|
||||
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]
|
||||
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! {
|
||||
<div class="app">
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<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;">
|
||||
"Hello from Leptos"
|
||||
<div data-theme="light">
|
||||
// Auth overlay
|
||||
{move || (!auth.authenticated.get()).then(|| {
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 app;
|
||||
pub mod auth;
|
||||
pub mod layout;
|
||||
pub mod ui;
|
||||
pub mod ws;
|
||||
|
||||
Reference in New Issue
Block a user