9ef0f30dac
Replace sidebar with a fixed bottom nav bar on mobile (<768px) with icon links for Home, Species, Cultivars, Companions, Search plus a language toggle. CSS already updated in previous commit with: - sidebar hidden on mobile, bottom-nav shown - tables/calendars horizontally scrollable - filter bars stacked vertically - detail pages single-column - stats grid 2-column on mobile
196 lines
7.6 KiB
Rust
196 lines
7.6 KiB
Rust
use dioxus::prelude::*;
|
|
use gloo_storage::{LocalStorage, Storage};
|
|
|
|
use crate::api;
|
|
use crate::i18n::t;
|
|
use crate::types::MeResponse;
|
|
|
|
/// Global language signal shared via context. Values: "de" or "en".
|
|
#[derive(Clone, Copy)]
|
|
pub struct Lang(pub Signal<String>);
|
|
|
|
#[derive(Routable, Clone, Debug, PartialEq)]
|
|
#[rustfmt::skip]
|
|
pub enum Route {
|
|
#[layout(Layout)]
|
|
#[route("/")]
|
|
Home {},
|
|
#[route("/families")]
|
|
FamilyList {},
|
|
#[route("/families/:slug")]
|
|
FamilyDetail { slug: String },
|
|
#[route("/species")]
|
|
SpeciesList {},
|
|
#[route("/species/:slug")]
|
|
SpeciesDetail { slug: String },
|
|
#[route("/cultivars")]
|
|
CultivarList {},
|
|
#[route("/cultivars/:slug")]
|
|
CultivarDetail { slug: String },
|
|
#[route("/suppliers")]
|
|
SupplierList {},
|
|
#[route("/suppliers/:slug")]
|
|
SupplierDetail { slug: String },
|
|
#[route("/companions")]
|
|
CompanionList {},
|
|
#[route("/search")]
|
|
SearchPage {},
|
|
#[route("/sources")]
|
|
Sources {},
|
|
#[end_layout]
|
|
#[route("/:..segments")]
|
|
NotFound { segments: Vec<String> },
|
|
}
|
|
|
|
#[component]
|
|
pub fn App() -> Element {
|
|
rsx! {
|
|
Router::<Route> {}
|
|
}
|
|
}
|
|
|
|
#[component]
|
|
fn Layout() -> Element {
|
|
// Language state: load from localStorage, default "de"
|
|
let lang_signal = use_signal(|| {
|
|
LocalStorage::get::<String>("herbapi_lang").unwrap_or_else(|_| "de".to_string())
|
|
});
|
|
use_context_provider(|| Lang(lang_signal));
|
|
|
|
let mut lang = use_context::<Lang>().0;
|
|
let current_lang = lang.read().clone();
|
|
|
|
// Try to get current user (may be None for public access)
|
|
let auth = use_resource(|| async { api::get_current_user().await.ok() });
|
|
let user: Option<MeResponse> = auth.read().as_ref().and_then(|r| r.clone());
|
|
|
|
let l = &*current_lang;
|
|
|
|
rsx! {
|
|
div { class: "app-layout",
|
|
nav { class: "sidebar",
|
|
div { class: "sidebar-brand",
|
|
span { class: "brand-icon", "\u{1F33F}" }
|
|
div { class: "brand-text-group",
|
|
span { class: "brand-text", "HerbAPI" }
|
|
span { class: "brand-sub", "{t(l, \"brand.sub\")}" }
|
|
}
|
|
}
|
|
div { class: "sidebar-nav",
|
|
NavLink { to: Route::Home {}, label: t(l, "nav.home") }
|
|
NavLink { to: Route::FamilyList {}, label: t(l, "nav.families") }
|
|
NavLink { to: Route::SpeciesList {}, label: t(l, "nav.species") }
|
|
NavLink { to: Route::CultivarList {}, label: t(l, "nav.cultivars") }
|
|
NavLink { to: Route::SupplierList {}, label: t(l, "nav.suppliers") }
|
|
NavLink { to: Route::CompanionList {}, label: t(l, "nav.companions") }
|
|
NavLink { to: Route::SearchPage {}, label: t(l, "nav.search") }
|
|
NavLink { to: Route::Sources {}, label: t(l, "nav.sources") }
|
|
}
|
|
div { class: "sidebar-lang",
|
|
div { class: "lang-toggle",
|
|
button {
|
|
class: if current_lang == "de" { "lang-btn lang-btn-active" } else { "lang-btn" },
|
|
onclick: move |_| {
|
|
lang.set("de".to_string());
|
|
let _ = LocalStorage::set("herbapi_lang", "de".to_string());
|
|
},
|
|
"DE"
|
|
}
|
|
button {
|
|
class: if current_lang == "en" { "lang-btn lang-btn-active" } else { "lang-btn" },
|
|
onclick: move |_| {
|
|
lang.set("en".to_string());
|
|
let _ = LocalStorage::set("herbapi_lang", "en".to_string());
|
|
},
|
|
"EN"
|
|
}
|
|
}
|
|
}
|
|
div { class: "sidebar-user",
|
|
if let Some(ref u) = user {
|
|
span { class: "user-name", "{u.nickname.as_deref().or(u.name.as_deref()).unwrap_or(&u.email)}" }
|
|
a { class: "logout-link", href: "/auth/oidc/logout", "{t(l, \"btn.logout\")}" }
|
|
} else {
|
|
a { class: "login-link", href: "/auth/oidc/login", "{t(l, \"btn.login\")}" }
|
|
}
|
|
}
|
|
}
|
|
main { class: "content",
|
|
Outlet::<Route> {}
|
|
}
|
|
|
|
// Bottom navigation — visible only on mobile via CSS
|
|
nav { class: "bottom-nav",
|
|
Link { to: Route::Home {}, class: "bottom-nav-link",
|
|
span { class: "bottom-nav-icon", "\u{1f3e0}" }
|
|
span { class: "bottom-nav-label", "{t(l, \"nav.home\")}" }
|
|
}
|
|
Link { to: Route::SpeciesList {}, class: "bottom-nav-link",
|
|
span { class: "bottom-nav-icon", "\u{1f33f}" }
|
|
span { class: "bottom-nav-label", "{t(l, \"nav.species\")}" }
|
|
}
|
|
Link { to: Route::CultivarList {}, class: "bottom-nav-link",
|
|
span { class: "bottom-nav-icon", "\u{1f331}" }
|
|
span { class: "bottom-nav-label", "{t(l, \"nav.cultivars\")}" }
|
|
}
|
|
Link { to: Route::CompanionList {}, class: "bottom-nav-link",
|
|
span { class: "bottom-nav-icon", "\u{1f91d}" }
|
|
span { class: "bottom-nav-label", "{t(l, \"nav.companions\")}" }
|
|
}
|
|
Link { to: Route::SearchPage {}, class: "bottom-nav-link",
|
|
span { class: "bottom-nav-icon", "\u{1f50d}" }
|
|
span { class: "bottom-nav-label", "{t(l, \"nav.search\")}" }
|
|
}
|
|
div { class: "lang-toggle",
|
|
button {
|
|
class: if current_lang == "de" { "lang-btn lang-btn-active" } else { "lang-btn" },
|
|
onclick: move |_| {
|
|
lang.set("de".to_string());
|
|
let _ = LocalStorage::set("herbapi_lang", "de".to_string());
|
|
},
|
|
"DE"
|
|
}
|
|
button {
|
|
class: if current_lang == "en" { "lang-btn lang-btn-active" } else { "lang-btn" },
|
|
onclick: move |_| {
|
|
lang.set("en".to_string());
|
|
let _ = LocalStorage::set("herbapi_lang", "en".to_string());
|
|
},
|
|
"EN"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[component]
|
|
fn NavLink(to: Route, label: &'static str) -> Element {
|
|
rsx! {
|
|
Link { to: to, class: "nav-link",
|
|
span { class: "nav-label", "{label}" }
|
|
}
|
|
}
|
|
}
|
|
|
|
#[component]
|
|
fn NotFound(segments: Vec<String>) -> Element {
|
|
rsx! {
|
|
div { class: "not-found",
|
|
h1 { "404" }
|
|
p { "Page not found: /{segments.join(\"/\")}" }
|
|
Link { to: Route::Home {}, "Back to Home" }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-export page components for the router
|
|
pub use crate::pages::companions::CompanionList;
|
|
pub use crate::pages::cultivars::{CultivarDetail, CultivarList};
|
|
pub use crate::pages::families::{FamilyDetail, FamilyList};
|
|
pub use crate::pages::home::Home;
|
|
pub use crate::pages::search::SearchPage;
|
|
pub use crate::pages::sources::Sources;
|
|
pub use crate::pages::species::{SpeciesDetail, SpeciesList};
|
|
pub use crate::pages::suppliers::{SupplierDetail, SupplierList};
|