diff --git a/Cargo.lock b/Cargo.lock index 726c7c2..f947c7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -787,7 +787,6 @@ dependencies = [ "reqwasm", "serde_json", "sycamore", - "sycamore-router", "wasm-bindgen", "web-sys", ] @@ -924,12 +923,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "mio" version = "0.8.0" @@ -970,17 +963,6 @@ dependencies = [ "twoway", ] -[[package]] -name = "nom" -version = "7.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d11e1ef389c76fe5b81bcaf2ea32cf88b62bc494e19f493d0b30e7a930109" -dependencies = [ - "memchr", - "minimal-lexical", - "version_check", -] - [[package]] name = "ntapi" version = "0.3.7" @@ -1424,31 +1406,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "sycamore-router" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cace57b69d923ef7ac5a1291bee73fa62e7d75b1f3a713db70d30ab0ee032185" -dependencies = [ - "sycamore", - "sycamore-router-macro", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "sycamore-router-macro" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a1f83a4862484dba897a6dc64c4a72b5c808c9af05573f7a55133b4f110ac66" -dependencies = [ - "nom", - "proc-macro2", - "quote", - "syn", - "unicode-xid", -] - [[package]] name = "syn" version = "1.0.86" diff --git a/web/Cargo.toml b/web/Cargo.toml index 853efe5..08a8f49 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -17,7 +17,6 @@ recipes = {path = "../recipes" } reqwasm = "0.5.0" # This makes debugging panics more tractable. console_error_panic_hook = "0.1.7" -sycamore-router = "0.7.1" serde_json = "1.0.79" [dependencies.wasm-bindgen] @@ -26,7 +25,19 @@ version = "0.2.78" [dependencies.web-sys] version = "0.3" -features = [ "Storage", "Window" ] +features = [ + "Event", + "EventTarget", + "History", + "HtmlAnchorElement", + "HtmlBaseElement", + "KeyboardEvent", + "Location", + "PopStateEvent", + "Url", + "Window", + "Storage" +] [dependencies.sycamore] version = "0.7.1" diff --git a/web/src/app_state.rs b/web/src/app_state.rs index 94d3886..fae3448 100644 --- a/web/src/app_state.rs +++ b/web/src/app_state.rs @@ -12,10 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum AppRoutes { Plan, Inventory, Cook, Recipe(usize), + Error(String), + NotFound, +} + +impl Default for AppRoutes { + fn default() -> Self { + Self::Plan + } } diff --git a/web/src/components/tabs.rs b/web/src/components/tabs.rs index 4801469..157174f 100644 --- a/web/src/components/tabs.rs +++ b/web/src/components/tabs.rs @@ -13,11 +13,8 @@ // limitations under the License. use sycamore::prelude::*; -use crate::app_state::AppRoutes; - #[derive(Clone)] pub struct TabState { - pub route: Signal, pub inner: View, } @@ -27,17 +24,11 @@ pub fn tabbed_view(state: TabState) -> View { header(class="no-print") { nav { ul { - li { a(href="#", class="no-print", on:click=cloned!((state) => move |_| { - state.route.set(AppRoutes::Plan); - })) { "Plan" } " > " + li { a(href="#plan", class="no-print") { "Plan" } " > " } - li { a(href="#", class="no-print", on:click=cloned!((state) => move |_| { - state.route.set(AppRoutes::Inventory); - })) { "Inventory" } " > " + li { a(href="#inventory", class="no-print") { "Inventory" } " > " } - li { a(href="#", class="no-print", on:click=cloned!((state) => move |_| { - state.route.set(AppRoutes::Cook); - })) { "Cook" } + li { a(href="#cook", class="no-print") { "Cook" } } } ul { diff --git a/web/src/lib.rs b/web/src/lib.rs index 8034f50..746c73e 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -14,6 +14,7 @@ mod app_state; mod components; mod pages; +mod router_integration; mod service; mod typings; mod web; diff --git a/web/src/pages/cook.rs b/web/src/pages/cook.rs index cdd9c95..fbfb147 100644 --- a/web/src/pages/cook.rs +++ b/web/src/pages/cook.rs @@ -12,20 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. use crate::components::{recipe_list::*, tabs::*}; -use crate::pages::PageState; use sycamore::prelude::*; -#[derive(Clone)] -pub struct CookPageProps { - pub page_state: PageState, -} - #[component(CookPage)] -pub fn cook_page(props: CookPageProps) -> View { +pub fn cook_page() -> View { view! { TabbedView(TabState { - route: props.page_state.route.clone(), inner: view! { RecipeList() }, diff --git a/web/src/pages/inventory.rs b/web/src/pages/inventory.rs index 8b4794b..7f20026 100644 --- a/web/src/pages/inventory.rs +++ b/web/src/pages/inventory.rs @@ -12,20 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. use crate::components::{shopping_list::*, tabs::*}; -use crate::pages::PageState; use sycamore::prelude::*; -#[derive(Clone)] -pub struct InventoryPageProps { - pub page_state: PageState, -} - #[component(InventoryPage)] -pub fn inventory_page(props: InventoryPageProps) -> View { +pub fn inventory_page() -> View { view! { TabbedView(TabState { - route: props.page_state.route.clone(), inner: view! { ShoppingList() }, diff --git a/web/src/pages/mod.rs b/web/src/pages/mod.rs index e65eb54..06984ca 100644 --- a/web/src/pages/mod.rs +++ b/web/src/pages/mod.rs @@ -11,10 +11,6 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -use sycamore::prelude::*; - -use crate::app_state::AppRoutes; - mod cook; mod inventory; mod plan; @@ -24,8 +20,3 @@ pub use cook::*; pub use inventory::*; pub use plan::*; pub use recipe::*; - -#[derive(Clone)] -pub struct PageState { - pub route: Signal, -} diff --git a/web/src/pages/plan.rs b/web/src/pages/plan.rs index 6340868..b4637cb 100644 --- a/web/src/pages/plan.rs +++ b/web/src/pages/plan.rs @@ -12,20 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. use crate::components::{recipe_selector::*, tabs::*}; -use crate::pages::PageState; use sycamore::prelude::*; -#[derive(Clone)] -pub struct PlanPageProps { - pub page_state: PageState, -} - #[component(PlanPage)] -pub fn plan_page(props: PlanPageProps) -> View { +pub fn plan_page() -> View { view! { TabbedView(TabState { - route: props.page_state.route.clone(), inner: view! { RecipeSelector() }, diff --git a/web/src/pages/recipe.rs b/web/src/pages/recipe.rs index 4db9511..79eb8ef 100644 --- a/web/src/pages/recipe.rs +++ b/web/src/pages/recipe.rs @@ -12,12 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. use crate::components::{recipe::Recipe, tabs::*}; -use crate::pages::PageState; use sycamore::prelude::*; pub struct RecipePageProps { - pub page_state: PageState, pub recipe: Signal, } @@ -25,7 +23,6 @@ pub struct RecipePageProps { pub fn recipe_page(props: RecipePageProps) -> View { view! { TabbedView(TabState { - route: props.page_state.route.clone(), inner: view! { Recipe(props.recipe.handle()) } diff --git a/web/src/router_integration.rs b/web/src/router_integration.rs new file mode 100644 index 0000000..ab1c488 --- /dev/null +++ b/web/src/router_integration.rs @@ -0,0 +1,191 @@ +// Copyright 2022 zaphar +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +use std::fmt::Debug; +use std::rc::Rc; +use std::str::FromStr; + +use sycamore::prelude::*; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use web_sys::Event; +use web_sys::{Element, HtmlAnchorElement}; + +use crate::app_state::AppRoutes; +use crate::console_debug; +use crate::console_error; + +#[derive(Clone)] +pub struct BrowserIntegration(Signal<(String, String, String)>); + +impl BrowserIntegration { + pub fn new() -> Self { + let location = web_sys::window().unwrap_throw().location(); + Self(Signal::new(( + location.origin().unwrap_or(String::new()), + location.pathname().unwrap_or(String::new()), + location.hash().unwrap_or(String::new()), + ))) + } + + pub fn click_handler(&self) -> Box { + let route_signal = self.0.clone(); + Box::new(move |ev| { + if let Some(tgt) = ev + .target() + .unwrap_throw() + .unchecked_into::() + .closest("a[href]") + .unwrap_throw() + .map(|e| e.unchecked_into::()) + { + console_debug!("handling navigation event."); + let location = web_sys::window().unwrap_throw().location(); + + if tgt.rel() == "external" { + return; + console_debug!("External Link so ignoring."); + } + + let origin = tgt.origin(); + let tgt_pathname = tgt.pathname(); + let hash = tgt.hash(); + match (location.origin().as_ref() == Ok(&origin), location.pathname().as_ref() == Ok(&tgt_pathname), location.hash().as_ref() == Ok(&hash)) { + (true, true, true) // Same location + | (false, _, _) /* different origin */ => { + // Do nothing this is the same location as we are already at. + } + (true, _, false) // different hash + | (true, false, _) /* different path */ => { + console_debug!("different path or hash"); + ev.prevent_default(); + // Signal the pathname change + let path = format!("{}{}{}", &origin, &tgt_pathname, &hash); + console_debug!("new route: ({}, {}, {})", origin, tgt_pathname, hash); + console_debug!("new path: ({})", &path); + route_signal.set((origin, tgt_pathname, hash)); + // Update History API. + let window = web_sys::window().unwrap_throw(); + let history = window.history().unwrap_throw(); + history + .push_state_with_url(&JsValue::UNDEFINED, "", Some(&path)) + .unwrap_throw(); + window.scroll_to_with_x_and_y(0.0, 0.0); + } + } + } + }) + } +} + +pub struct RouterProps +where + G: GenericNode, + R: DeriveRoute + NotFound + Clone + Default + Debug + 'static, + F: Fn(ReadSignal) -> View + 'static, +{ + pub route: R, + pub route_select: F, + pub browser_integration: BrowserIntegration, +} + +#[component(Router)] +pub fn router(props: RouterProps) -> View +where + R: DeriveRoute + NotFound + Clone + Default + Debug + 'static, + F: Fn(ReadSignal) -> View + 'static, +{ + console_debug!("Setting up router"); + let integration = Rc::new(props.browser_integration); + let route_select = Rc::new(props.route_select); + + let view_signal = Signal::new(View::empty()); + create_effect( + cloned!((view_signal, integration, route_select) => move || { + let path_signal = integration.0.clone(); + console_debug!("new path: {:?}", path_signal.get()); + let path = path_signal.clone(); + let route = R::from(path.get().as_ref()); + console_debug!("new route: {:?}", &route); + // TODO(jwall): this is an unnecessary use of signal. + let view = route_select.as_ref()(Signal::new(route).handle()); + register_click_handler(&view, integration.clone()); + view_signal.set(view); + }), + ); + + // NOTE(jwall): This needs to be a dynamic node so Sycamore knows to rerender it + // based on the results of the effect above. + view! { + (view_signal.get().as_ref()) + } +} + +fn register_click_handler(view: &View, integration: Rc) +where + G: GenericNode, +{ + console_debug!("Registring click handler on node(s)"); + if let Some(node) = view.as_node() { + node.event("click", integration.click_handler()); + } else if let Some(frag) = view.as_fragment() { + console_debug!("Fragment? {:?}", frag); + for n in frag { + register_click_handler(n, integration.clone()); + } + } else if let Some(dyn_node) = view.as_dyn() { + console_debug!("Dynamic node? {:?}", dyn_node); + } else { + console_debug!("Unknown node? {:?}", view); + } +} + +pub trait NotFound { + fn not_found() -> Self; +} + +impl NotFound for AppRoutes { + fn not_found() -> Self { + AppRoutes::NotFound + } +} + +pub trait DeriveRoute { + fn from(input: &(String, String, String)) -> Self; +} + +impl DeriveRoute for AppRoutes { + fn from(input: &(String, String, String)) -> AppRoutes { + console_debug!("routing: {input:?}"); + match input.2.as_str() { + "" => AppRoutes::default(), + "#plan" => AppRoutes::Plan, + "#cook" => AppRoutes::Cook, + "#inventory" => AppRoutes::Inventory, + h => { + // TODO(jwall): Parse the recipe hash + let parts: Vec<&str> = h.splitn(2, "/").collect(); + if let Some(&"#recipe") = parts.get(0) { + if let Some(&idx) = parts.get(1) { + return match usize::from_str(idx) { + Ok(idx) => AppRoutes::Recipe(idx), + Err(e) => AppRoutes::Error(format!("{:?}", e)), + }; + } + } + console_error!("Path not found: [{:?}]", input); + AppRoutes::NotFound + } + } + } +} diff --git a/web/src/web.rs b/web/src/web.rs index c761d1e..75d0733 100644 --- a/web/src/web.rs +++ b/web/src/web.rs @@ -11,8 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -use crate::{app_state::*, components::*, service::AppService}; -use crate::{console_debug, console_error, console_log}; +use crate::pages::*; +use crate::{app_state::*, components::*, router_integration::*, service::AppService}; +use crate::{console_error, console_log}; use sycamore::{ context::{ContextProvider, ContextProviderProps}, @@ -20,25 +21,33 @@ use sycamore::{ prelude::*, }; -use crate::pages::*; - -fn route_switch(page_state: PageState) -> View { - let route = page_state.route.clone(); - cloned!((page_state, route) => view! { - (match route.get().as_ref() { - AppRoutes::Plan => view! { - PlanPage(PlanPageProps { page_state: page_state.clone() }) - }, - AppRoutes::Inventory => view! { - InventoryPage(InventoryPageProps { page_state: page_state.clone() }) - }, - AppRoutes::Cook => view! { - CookPage(CookPageProps { page_state: page_state.clone() }) - }, - AppRoutes::Recipe(idx) => view! { - RecipePage(RecipePageProps { page_state: page_state.clone(), recipe: Signal::new(*idx) }) +fn route_switch(route: ReadSignal) -> View { + // NOTE(jwall): This needs to not be a dynamic node. The rules around + // this are somewhat unclear and underdocumented for Sycamore. But basically + // avoid conditionals in the `view!` macro calls here. + cloned!((route) => match route.get().as_ref() { + AppRoutes::Plan => view! { + PlanPage() + }, + AppRoutes::Inventory => view! { + InventoryPage() + }, + AppRoutes::Cook => view! { + CookPage() + }, + AppRoutes::Recipe(idx) => view! { + RecipePage(RecipePageProps { recipe: Signal::new(*idx) }) + }, + AppRoutes::NotFound => view! { + // TODO(Create a real one) + PlanPage() + }, + AppRoutes::Error(ref e) => { + let e = e.clone(); + view! { + "Error: " (e) } - }) + } }) } @@ -52,11 +61,8 @@ pub fn ui() -> View { ContextProvider(ContextProviderProps { value: app_service.clone(), children: || { - let view = Signal::new(View::empty()); - let route = Signal::new(AppRoutes::Plan); - let page_state = PageState { route: route.clone() }; - create_effect(cloned!((page_state, view) => move || { - spawn_local_in_scope(cloned!((page_state, view) => { + create_effect(move || { + spawn_local_in_scope({ let mut app_service = app_service.clone(); async move { match AppService::fetch_recipes_from_storage() { @@ -68,18 +74,18 @@ pub fn ui() -> View { } Err(msg) => console_error!("Failed to get recipes {}", msg), } - console_debug!("Determining route."); - view.set(route_switch(page_state.clone())); - console_debug!("Created our route view effect."); } - })); - })); + }); + }); + view! { - // NOTE(jwall): The Router component *requires* there to be exactly one node as the root of this view. - // No fragments or missing nodes allowed or it will panic at runtime. div(class="app") { Header() - (view.get().as_ref().clone()) + Router(RouterProps { + route: AppRoutes::Plan, + route_select: route_switch, + browser_integration: BrowserIntegration::new(), + }) } } }