mirror of
https://github.com/zaphar/kitchen.git
synced 2025-07-22 19:40:14 -04:00
Homegrown Router
This commit is contained in:
parent
24fd30af8b
commit
5ffe339626
43
Cargo.lock
generated
43
Cargo.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -13,11 +13,8 @@
|
||||
// limitations under the License.
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::app_state::AppRoutes;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TabState<G: GenericNode> {
|
||||
pub route: Signal<AppRoutes>,
|
||||
pub inner: View<G>,
|
||||
}
|
||||
|
||||
@ -27,17 +24,11 @@ pub fn tabbed_view(state: TabState<G>) -> View<G> {
|
||||
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 {
|
||||
|
@ -14,6 +14,7 @@
|
||||
mod app_state;
|
||||
mod components;
|
||||
mod pages;
|
||||
mod router_integration;
|
||||
mod service;
|
||||
mod typings;
|
||||
mod web;
|
||||
|
@ -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<G>)]
|
||||
pub fn cook_page(props: CookPageProps) -> View<G> {
|
||||
pub fn cook_page() -> View<G> {
|
||||
view! {
|
||||
TabbedView(TabState {
|
||||
route: props.page_state.route.clone(),
|
||||
inner: view! {
|
||||
RecipeList()
|
||||
},
|
||||
|
@ -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<G>)]
|
||||
pub fn inventory_page(props: InventoryPageProps) -> View<G> {
|
||||
pub fn inventory_page() -> View<G> {
|
||||
view! {
|
||||
TabbedView(TabState {
|
||||
route: props.page_state.route.clone(),
|
||||
inner: view! {
|
||||
ShoppingList()
|
||||
},
|
||||
|
@ -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<AppRoutes>,
|
||||
}
|
||||
|
@ -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<G>)]
|
||||
pub fn plan_page(props: PlanPageProps) -> View<G> {
|
||||
pub fn plan_page() -> View<G> {
|
||||
view! {
|
||||
TabbedView(TabState {
|
||||
route: props.page_state.route.clone(),
|
||||
inner: view! {
|
||||
RecipeSelector()
|
||||
},
|
||||
|
@ -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<usize>,
|
||||
}
|
||||
|
||||
@ -25,7 +23,6 @@ pub struct RecipePageProps {
|
||||
pub fn recipe_page(props: RecipePageProps) -> View<G> {
|
||||
view! {
|
||||
TabbedView(TabState {
|
||||
route: props.page_state.route.clone(),
|
||||
inner: view! {
|
||||
Recipe(props.recipe.handle())
|
||||
}
|
||||
|
191
web/src/router_integration.rs
Normal file
191
web/src/router_integration.rs
Normal file
@ -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<dyn Fn(web_sys::Event)> {
|
||||
let route_signal = self.0.clone();
|
||||
Box::new(move |ev| {
|
||||
if let Some(tgt) = ev
|
||||
.target()
|
||||
.unwrap_throw()
|
||||
.unchecked_into::<Element>()
|
||||
.closest("a[href]")
|
||||
.unwrap_throw()
|
||||
.map(|e| e.unchecked_into::<HtmlAnchorElement>())
|
||||
{
|
||||
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<R, F, G>
|
||||
where
|
||||
G: GenericNode,
|
||||
R: DeriveRoute + NotFound + Clone + Default + Debug + 'static,
|
||||
F: Fn(ReadSignal<R>) -> View<G> + 'static,
|
||||
{
|
||||
pub route: R,
|
||||
pub route_select: F,
|
||||
pub browser_integration: BrowserIntegration,
|
||||
}
|
||||
|
||||
#[component(Router<G>)]
|
||||
pub fn router<R, F>(props: RouterProps<R, F, G>) -> View<G>
|
||||
where
|
||||
R: DeriveRoute + NotFound + Clone + Default + Debug + 'static,
|
||||
F: Fn(ReadSignal<R>) -> View<G> + '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<G>(view: &View<G>, integration: Rc<BrowserIntegration>)
|
||||
where
|
||||
G: GenericNode<EventType = Event>,
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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<G: Html>(page_state: PageState) -> View<G> {
|
||||
let route = page_state.route.clone();
|
||||
cloned!((page_state, route) => view! {
|
||||
(match route.get().as_ref() {
|
||||
fn route_switch<G: Html>(route: ReadSignal<AppRoutes>) -> View<G> {
|
||||
// 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(PlanPageProps { page_state: page_state.clone() })
|
||||
PlanPage()
|
||||
},
|
||||
AppRoutes::Inventory => view! {
|
||||
InventoryPage(InventoryPageProps { page_state: page_state.clone() })
|
||||
InventoryPage()
|
||||
},
|
||||
AppRoutes::Cook => view! {
|
||||
CookPage(CookPageProps { page_state: page_state.clone() })
|
||||
CookPage()
|
||||
},
|
||||
AppRoutes::Recipe(idx) => view! {
|
||||
RecipePage(RecipePageProps { page_state: page_state.clone(), recipe: Signal::new(*idx) })
|
||||
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<G> {
|
||||
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<G> {
|
||||
}
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user