| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162 |
- //! Application shell: skip link, header, router, footer.
- use leptos::prelude::*;
- use leptos_router::components::{A, Redirect, Route, Router, Routes};
- use leptos_router::path;
- use web::model::SessionProbe;
- use web::viewmodel::{NAV_ITEMS, PRODUCT_NAME, dashboard_nav_visible};
- use super::components::dashboard::Dashboard;
- use super::components::deployment::ConfigResource;
- use super::components::feedback::SessionSkeleton;
- use super::components::icons::{Icon, IconKind};
- use super::components::landing::Landing;
- use super::components::theme::ThemeToggle;
- /// Root component, mounted to `<body>` by `main.rs`.
- #[component]
- pub fn App() -> impl IntoView {
- // One app-level session probe, shared with the header and the `/dashboard`
- // guard so navigation gating and route guarding read a single source of
- // truth. `LocalResource` is `Copy`, so context provision is cheap.
- let session = LocalResource::new(|| async { crate::api::probe_session().await });
- provide_context(session);
- // One app-level config resource, shared with the landing CTA and the
- // dashboard deployment card so both read a single `/api/config` fetch.
- let config: ConfigResource = LocalResource::new(|| async { crate::api::fetch_config().await });
- provide_context(config);
- view! {
- <Router>
- <div class="flex min-h-dvh flex-col bg-canvas text-ink">
- <a
- href="#main"
- class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:bg-raised focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg"
- >
- "Skip to content"
- </a>
- <header class="sticky top-0 z-20 border-b border-line/70 bg-canvas/85 backdrop-blur-md">
- <div class="mx-auto flex h-16 w-full max-w-6xl items-center justify-between gap-4 px-4 sm:px-6">
- <A href="/" attr:class="group flex items-center gap-2.5 rounded-lg">
- <span
- aria-hidden="true"
- class="grid size-8 place-items-center rounded-lg bg-btn text-btn-ink shadow-sm transition-colors group-hover:bg-btn-hover"
- >
- <Icon kind=IconKind::Shield class="size-4" />
- </span>
- <span class="text-sm font-semibold tracking-tight">{PRODUCT_NAME}</span>
- </A>
- <div class="flex items-center gap-2">
- <PrimaryNav />
- <ThemeToggle />
- </div>
- </div>
- </header>
- <main
- id="main"
- tabindex="-1"
- class="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6 sm:py-14"
- >
- <Routes fallback=|| view! { <NotFound /> }>
- <Route path=path!("/") view=Landing />
- <Route path=path!("/dashboard") view=DashboardRoute />
- </Routes>
- </main>
- <footer class="border-t border-line">
- <div class="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-2 px-4 py-5 text-xs text-muted sm:px-6">
- <span>"OAuth 2.1 · PKCE S256 · DPoP · PAR · opaque tokens"</span>
- <span>"Secrets stay on the server"</span>
- </div>
- </footer>
- </div>
- </Router>
- }
- }
- /// Header navigation: `Landing` is always rendered; `Dashboard` appears only
- /// once the shared probe reports [`SessionProbe::Active`].
- #[component]
- fn PrimaryNav() -> impl IntoView {
- let session = use_context::<LocalResource<SessionProbe>>();
- let dashboard_visible = Signal::derive(move || {
- session
- .and_then(|resource| resource.get())
- .is_some_and(|probe| dashboard_nav_visible(&probe))
- });
- view! {
- <nav aria-label="Primary" class="flex items-center gap-1 text-sm">
- {NAV_ITEMS
- .iter()
- .map(|(label, href)| {
- let visible = if *href == "/dashboard" {
- dashboard_visible
- } else {
- Signal::derive(|| true)
- };
- view! {
- <Show when=move || visible.get()>
- <A
- href=*href
- attr:class="rounded-md px-3 py-1.5 text-muted transition-colors hover:bg-sunken hover:text-ink"
- >
- {*label}
- </A>
- </Show>
- }
- })
- .collect_view()}
- </nav>
- }
- }
- /// `/dashboard` guard: an active session renders the dashboard, a definitive
- /// [`SessionProbe::Unauthenticated`] redirects to the landing page, and
- /// loading/error states keep rendering the dashboard's own skeleton and typed
- /// error cards so a transient BFF failure is never misread as logged-out.
- #[component]
- fn DashboardRoute() -> impl IntoView {
- let session = use_context::<LocalResource<SessionProbe>>();
- // Coarse memo: `Active(_)` -> `Active(_)` refetches must not remount the
- // dashboard, or its refresh/log-out state would reset underneath the user.
- let redirect_home = Memo::new(move |_| {
- session.is_some_and(|resource| matches!(resource.get(), Some(SessionProbe::Unauthenticated)))
- });
- view! {
- {move || {
- let Some(resource) = session else {
- return view! { <SessionSkeleton /> }.into_any();
- };
- if redirect_home.get() {
- view! { <Redirect path="/" /> }.into_any()
- } else {
- view! { <Dashboard probe=resource /> }.into_any()
- }
- }}
- }
- }
- /// Fallback for unmatched routes.
- #[component]
- fn NotFound() -> impl IntoView {
- view! {
- <section class="mx-auto max-w-md py-24 text-center">
- <p class=super::components::styles::EYEBROW>"404"</p>
- <h1 class="mt-2 text-title font-semibold text-ink">"Page not found"</h1>
- <p class="mt-3 text-sm leading-6 text-muted">
- "That route does not exist in this client."
- </p>
- <A
- href="/"
- attr:class="mt-7 inline-flex rounded-lg bg-btn px-4 py-2 text-sm font-semibold text-btn-ink transition-colors hover:bg-btn-hover"
- >
- "Back to landing"
- </A>
- </section>
- }
- }
|