Add recipe page

This commit is contained in:
Jeremy Wall 2022-11-01 20:38:14 -04:00
parent 7e3b94261a
commit 2926d9042a
19 changed files with 272 additions and 311 deletions

42
Cargo.lock generated
View File

@ -1210,6 +1210,7 @@ dependencies = [
"reqwasm", "reqwasm",
"serde_json", "serde_json",
"sycamore", "sycamore",
"sycamore-router",
"tracing", "tracing",
"tracing-browser-subscriber", "tracing-browser-subscriber",
"wasm-bindgen", "wasm-bindgen",
@ -1998,7 +1999,7 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
[[package]] [[package]]
name = "sycamore" name = "sycamore"
version = "0.8.2" version = "0.8.2"
source = "git+https://github.com/sycamore-rs/sycamore/?rev=20b6069c470a51d2ba6197bb322036e8324ff297#20b6069c470a51d2ba6197bb322036e8324ff297" source = "git+https://github.com/sycamore-rs/sycamore/?rev=5d49777b4a66fb5730c40898fd2ee8cde15bcdc3#5d49777b4a66fb5730c40898fd2ee8cde15bcdc3"
dependencies = [ dependencies = [
"ahash", "ahash",
"futures", "futures",
@ -2018,7 +2019,7 @@ dependencies = [
[[package]] [[package]]
name = "sycamore-core" name = "sycamore-core"
version = "0.8.2" version = "0.8.2"
source = "git+https://github.com/sycamore-rs/sycamore/?rev=20b6069c470a51d2ba6197bb322036e8324ff297#20b6069c470a51d2ba6197bb322036e8324ff297" source = "git+https://github.com/sycamore-rs/sycamore/?rev=5d49777b4a66fb5730c40898fd2ee8cde15bcdc3#5d49777b4a66fb5730c40898fd2ee8cde15bcdc3"
dependencies = [ dependencies = [
"ahash", "ahash",
"sycamore-reactive", "sycamore-reactive",
@ -2027,7 +2028,7 @@ dependencies = [
[[package]] [[package]]
name = "sycamore-futures" name = "sycamore-futures"
version = "0.8.0" version = "0.8.0"
source = "git+https://github.com/sycamore-rs/sycamore/?rev=20b6069c470a51d2ba6197bb322036e8324ff297#20b6069c470a51d2ba6197bb322036e8324ff297" source = "git+https://github.com/sycamore-rs/sycamore/?rev=5d49777b4a66fb5730c40898fd2ee8cde15bcdc3#5d49777b4a66fb5730c40898fd2ee8cde15bcdc3"
dependencies = [ dependencies = [
"futures", "futures",
"sycamore-reactive", "sycamore-reactive",
@ -2038,7 +2039,7 @@ dependencies = [
[[package]] [[package]]
name = "sycamore-macro" name = "sycamore-macro"
version = "0.8.2" version = "0.8.2"
source = "git+https://github.com/sycamore-rs/sycamore/?rev=20b6069c470a51d2ba6197bb322036e8324ff297#20b6069c470a51d2ba6197bb322036e8324ff297" source = "git+https://github.com/sycamore-rs/sycamore/?rev=5d49777b4a66fb5730c40898fd2ee8cde15bcdc3#5d49777b4a66fb5730c40898fd2ee8cde15bcdc3"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"proc-macro2", "proc-macro2",
@ -2049,7 +2050,7 @@ dependencies = [
[[package]] [[package]]
name = "sycamore-reactive" name = "sycamore-reactive"
version = "0.8.1" version = "0.8.1"
source = "git+https://github.com/sycamore-rs/sycamore/?rev=20b6069c470a51d2ba6197bb322036e8324ff297#20b6069c470a51d2ba6197bb322036e8324ff297" source = "git+https://github.com/sycamore-rs/sycamore/?rev=5d49777b4a66fb5730c40898fd2ee8cde15bcdc3#5d49777b4a66fb5730c40898fd2ee8cde15bcdc3"
dependencies = [ dependencies = [
"ahash", "ahash",
"bumpalo", "bumpalo",
@ -2059,10 +2060,33 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "sycamore-router"
version = "0.8.0"
source = "git+https://github.com/sycamore-rs/sycamore/?rev=5d49777b4a66fb5730c40898fd2ee8cde15bcdc3#5d49777b4a66fb5730c40898fd2ee8cde15bcdc3"
dependencies = [
"sycamore",
"sycamore-router-macro",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "sycamore-router-macro"
version = "0.8.0"
source = "git+https://github.com/sycamore-rs/sycamore/?rev=5d49777b4a66fb5730c40898fd2ee8cde15bcdc3#5d49777b4a66fb5730c40898fd2ee8cde15bcdc3"
dependencies = [
"nom",
"proc-macro2",
"quote",
"syn",
"unicode-xid",
]
[[package]] [[package]]
name = "sycamore-web" name = "sycamore-web"
version = "0.8.2" version = "0.8.2"
source = "git+https://github.com/sycamore-rs/sycamore/?rev=20b6069c470a51d2ba6197bb322036e8324ff297#20b6069c470a51d2ba6197bb322036e8324ff297" source = "git+https://github.com/sycamore-rs/sycamore/?rev=5d49777b4a66fb5730c40898fd2ee8cde15bcdc3#5d49777b4a66fb5730c40898fd2ee8cde15bcdc3"
dependencies = [ dependencies = [
"html-escape", "html-escape",
"indexmap", "indexmap",
@ -2378,6 +2402,12 @@ version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99"
[[package]]
name = "unicode-xid"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
[[package]] [[package]]
name = "unicode_categories" name = "unicode_categories"
version = "0.1.1" version = "0.1.1"

View File

@ -3,7 +3,8 @@ members = [ "recipes", "kitchen", "web" ]
[patch.crates-io] [patch.crates-io]
# TODO(jwall): When the fix for RcSignal Binding is released we can drop this patch. # TODO(jwall): When the fix for RcSignal Binding is released we can drop this patch.
sycamore = { git = "https://github.com/sycamore-rs/sycamore/", rev = "20b6069c470a51d2ba6197bb322036e8324ff297" } sycamore = { git = "https://github.com/sycamore-rs/sycamore/", rev = "5d49777b4a66fb5730c40898fd2ee8cde15bcdc3" }
sycamore-router = { git = "https://github.com/sycamore-rs/sycamore/", rev = "5d49777b4a66fb5730c40898fd2ee8cde15bcdc3" }
# NOTE(jwall): We are maintaining a patch to remove the unstable async_std_feature. It breaks in our project on # NOTE(jwall): We are maintaining a patch to remove the unstable async_std_feature. It breaks in our project on
# Rust v1.64 # Rust v1.64
sqlx = { git = "https://github.com/zaphar/sqlx", branch = "remove_unstable_async_std_feature" } sqlx = { git = "https://github.com/zaphar/sqlx", branch = "remove_unstable_async_std_feature" }

View File

@ -53,10 +53,18 @@ impl Mealplan {
pub struct RecipeEntry(pub String, pub String); pub struct RecipeEntry(pub String, pub String);
impl RecipeEntry { impl RecipeEntry {
pub fn set_recipe_id<S: Into<String>>(&mut self, id: S) {
self.0 = id.into();
}
pub fn recipe_id(&self) -> &str { pub fn recipe_id(&self) -> &str {
self.0.as_str() self.0.as_str()
} }
pub fn set_recipe_text<S: Into<String>>(&mut self, text: S) {
self.1 = text.into();
}
pub fn recipe_text(&self) -> &str { pub fn recipe_text(&self) -> &str {
self.1.as_str() self.1.as_str()
} }

View File

@ -21,6 +21,7 @@ tracing = "0.1.35"
tracing-browser-subscriber = "0.1.0" tracing-browser-subscriber = "0.1.0"
async-trait = "0.1.57" async-trait = "0.1.57"
base64 = "0.13.0" base64 = "0.13.0"
sycamore-router = "0.8"
[dependencies.reqwasm] [dependencies.reqwasm]
version = "0.5.0" version = "0.5.0"

View File

@ -113,6 +113,12 @@ impl From<String> for Error {
} }
} }
impl From<&'static str> for Error {
fn from(item: &'static str) -> Self {
Error(item.to_owned())
}
}
impl From<std::string::FromUtf8Error> for Error { impl From<std::string::FromUtf8Error> for Error {
fn from(item: std::string::FromUtf8Error) -> Self { fn from(item: std::string::FromUtf8Error) -> Self {
Error(format!("{:?}", item)) Error(format!("{:?}", item))
@ -258,6 +264,9 @@ impl HttpStore {
path.push_str("/recipes"); path.push_str("/recipes");
let storage = js_lib::get_storage(); let storage = js_lib::get_storage();
for r in recipes.iter() { for r in recipes.iter() {
if r.recipe_id().is_empty() {
return Err("Recipe Ids can not be empty".into());
}
storage.set( storage.set(
&recipe_key(r.recipe_id()), &recipe_key(r.recipe_id()),
&to_string(&r).expect("Unable to serialize recipe entries"), &to_string(&r).expect("Unable to serialize recipe entries"),

View File

@ -18,23 +18,6 @@ use tracing::{debug, instrument, warn};
use recipes::{Ingredient, IngredientAccumulator, Recipe}; use recipes::{Ingredient, IngredientAccumulator, Recipe};
#[derive(Debug, Clone)]
pub enum Routes {
Plan,
Inventory,
Cook,
Recipe(String),
Categories,
Login,
NotFound,
}
impl Default for Routes {
fn default() -> Self {
Self::Plan
}
}
pub struct State { pub struct State {
pub recipe_counts: RcSignal<BTreeMap<String, usize>>, pub recipe_counts: RcSignal<BTreeMap<String, usize>>,
pub extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>, pub extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>,

View File

@ -40,21 +40,25 @@ fn check_recipe_parses(text: &str, error_text: &Signal<String>) -> bool {
} }
#[component] #[component]
fn Editor<G: Html>(cx: Scope, recipe: RecipeEntry) -> View<G> { pub fn Editor<G: Html>(cx: Scope, recipe: RecipeEntry) -> View<G> {
let id = create_signal(cx, recipe.recipe_id().to_owned()); let id = create_signal(cx, recipe.recipe_id().to_owned());
let text = create_signal(cx, recipe.recipe_text().to_owned()); let text = create_signal(cx, recipe.recipe_text().to_owned());
let error_text = create_signal(cx, String::new()); let error_text = create_signal(cx, String::new());
let save_signal = create_signal(cx, ()); let save_signal = create_signal(cx, ());
let dirty = create_signal(cx, false); let dirty = create_signal(cx, false);
debug!("Creating effect");
create_effect(cx, move || { create_effect(cx, move || {
save_signal.track(); save_signal.track();
if !*dirty.get() { if !*dirty.get_untracked() {
debug!("Recipe text is unchanged");
return; return;
} }
debug!("Recipe text is changed");
spawn_local_scoped(cx, { spawn_local_scoped(cx, {
let store = crate::api::HttpStore::get_from_context(cx); let store = crate::api::HttpStore::get_from_context(cx);
async move { async move {
debug!("Attempting to save recipe");
if let Err(e) = store if let Err(e) = store
.save_recipes(vec![RecipeEntry( .save_recipes(vec![RecipeEntry(
id.get_untracked().as_ref().clone(), id.get_untracked().as_ref().clone(),
@ -71,6 +75,7 @@ fn Editor<G: Html>(cx: Scope, recipe: RecipeEntry) -> View<G> {
}); });
}); });
debug!("creating dialog_view");
let dialog_view = view! {cx, let dialog_view = view! {cx,
dialog(id="error-dialog") { dialog(id="error-dialog") {
article{ article{
@ -88,6 +93,7 @@ fn Editor<G: Html>(cx: Scope, recipe: RecipeEntry) -> View<G> {
} }
}; };
debug!("creating editor view");
view! {cx, view! {cx,
(dialog_view) (dialog_view)
textarea(bind:value=text, rows=20, on:change=move |_| { textarea(bind:value=text, rows=20, on:change=move |_| {

View File

@ -18,7 +18,7 @@ use tracing::{debug, instrument};
use crate::app_state; use crate::app_state;
#[derive(Prop)] #[derive(Props)]
pub struct RecipeCheckBoxProps<'ctx> { pub struct RecipeCheckBoxProps<'ctx> {
pub i: String, pub i: String,
pub title: &'ctx ReadSignal<String>, pub title: &'ctx ReadSignal<String>,

View File

@ -14,7 +14,7 @@
use sycamore::prelude::*; use sycamore::prelude::*;
use tracing::debug; use tracing::debug;
#[derive(Prop)] #[derive(Props)]
pub struct TabState<'a, G: Html> { pub struct TabState<'a, G: Html> {
pub children: Children<'a, G>, pub children: Children<'a, G>,
pub selected: Option<String>, pub selected: Option<String>,
@ -28,25 +28,28 @@ pub fn TabbedView<'a, G: Html>(cx: Scope<'a>, state: TabState<'a, G>) -> View<G>
selected, selected,
tablist, tablist,
} = state; } = state;
let tablist = create_signal(cx, tablist.clone());
let children = children.call(cx); let children = children.call(cx);
let menu = View::new_fragment(
tablist
.iter()
.map(|&(href, show)| {
debug!(?selected, show, "identifying tab");
let class = if selected.as_ref().map_or(false, |selected| selected == show) {
"no-print selected"
} else {
"no-print"
};
view! {cx,
li(class=class) { a(href=href) { (show) } }
}
// TODO
})
.collect(),
);
view! {cx, view! {cx,
nav { nav {
ul(class="tabs") { ul(class="tabs") {
Indexed( (menu)
iterable=tablist,
view=move |cx, (href, show)| {
debug!(?selected, show, "identifying tab");
let class = if selected.as_ref().map_or(false, |selected| selected == show) {
"no-print selected"
} else {
"no-print"
};
view! {cx,
li(class=class) { a(href=href) { (show) } }
}
}
)
} }
} }
main(class=".conatiner-fluid") { main(class=".conatiner-fluid") {

View File

@ -11,9 +11,16 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use wasm_bindgen::JsCast; use wasm_bindgen::{JsCast, JsValue};
use web_sys::{window, Element, Storage}; use web_sys::{window, Element, Storage};
pub fn navigate_to_path(path: &str) -> Result<(), JsValue> {
window()
.expect("No window present")
.location()
.set_pathname(path)
}
pub fn get_element_by_id<E>(id: &str) -> Result<Option<E>, Element> pub fn get_element_by_id<E>(id: &str) -> Result<Option<E>, Element>
where where
E: JsCast, E: JsCast,

View File

@ -16,7 +16,7 @@ mod app_state;
mod components; mod components;
mod js_lib; mod js_lib;
mod pages; mod pages;
mod router_integration; mod routing;
mod web; mod web;
use sycamore::prelude::*; use sycamore::prelude::*;

View File

@ -0,0 +1,78 @@
// Copyright 2022 Jeremy Wall (Jeremy@marzhilsltudios.com)
//
// 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 sycamore::{futures::spawn_local_scoped, prelude::*};
use recipes::RecipeEntry;
const STARTER_RECIPE: &'static str = "title: Title Here
Description here.
step:
1 ingredient
Instructions here
";
#[component]
pub fn AddRecipePage<G: Html>(cx: Scope) -> View<G> {
let entry = create_signal(cx, RecipeEntry(String::new(), String::from(STARTER_RECIPE)));
let recipe_id = create_signal(cx, String::new());
let create_recipe_signal = create_signal(cx, ());
let dirty = create_signal(cx, false);
create_effect(cx, || {
let mut entry_for_edit = entry.get_untracked().as_ref().clone();
// TODO(jwall): This can probably be done more efficiently.
let id = recipe_id
.get()
.as_ref()
.replace(" ", "_")
.replace("\n", "")
.replace("\r", "");
entry_for_edit.set_recipe_id(id);
entry.set(entry_for_edit);
});
create_effect(cx, move || {
create_recipe_signal.track();
if !*dirty.get_untracked() {
return;
}
spawn_local_scoped(cx, {
let store = crate::api::HttpStore::get_from_context(cx);
async move {
let entry = entry.get_untracked();
// TODO(jwall): Better error reporting here.
// TODO(jwall): Ensure that this id doesn't already exist.
store
.save_recipes(vec![entry.as_ref().clone()])
.await
.expect("Unable to save New Recipe");
crate::js_lib::navigate_to_path(&format!("/ui/recipe/{}", entry.recipe_id()))
.expect("Unable to navigate to recipe");
}
});
});
view! {cx,
label(for="recipe_id") { "Recipe Id" }
input(bind:value=recipe_id, type="text", name="recipe_id", id="recipe_id", on:change=move |_| {
dirty.set(true);
})
button(on:click=move |_| {
create_recipe_signal.trigger_subscribers();
}) { "Create" }
}
}

View File

@ -14,9 +14,10 @@
use crate::components::tabs::*; use crate::components::tabs::*;
use sycamore::prelude::*; use sycamore::prelude::*;
pub mod add_recipe;
pub mod categories; pub mod categories;
#[derive(Prop)] #[derive(Props)]
pub struct PageState<'a, G: Html> { pub struct PageState<'a, G: Html> {
pub children: Children<'a, G>, pub children: Children<'a, G>,
pub selected: Option<String>, pub selected: Option<String>,
@ -26,7 +27,10 @@ pub struct PageState<'a, G: Html> {
pub fn ManagePage<'a, G: Html>(cx: Scope<'a>, state: PageState<'a, G>) -> View<G> { pub fn ManagePage<'a, G: Html>(cx: Scope<'a>, state: PageState<'a, G>) -> View<G> {
let PageState { children, selected } = state; let PageState { children, selected } = state;
let children = children.call(cx); let children = children.call(cx);
let manage_tabs: Vec<(&'static str, &'static str)> = vec![("/ui/categories", "Categories")]; let manage_tabs: Vec<(&'static str, &'static str)> = vec![
("/ui/categories", "Categories"),
("/ui/new_recipe", "New Recipe"),
];
view! {cx, view! {cx,
TabbedView( TabbedView(

View File

@ -17,6 +17,7 @@ mod planning;
mod recipe; mod recipe;
pub use login::*; pub use login::*;
pub use manage::add_recipe::*;
pub use manage::categories::*; pub use manage::categories::*;
pub use planning::cook::*; pub use planning::cook::*;
pub use planning::inventory::*; pub use planning::inventory::*;

View File

@ -18,7 +18,7 @@ pub mod cook;
pub mod inventory; pub mod inventory;
pub mod plan; pub mod plan;
#[derive(Prop)] #[derive(Props)]
pub struct PageState<'a, G: Html> { pub struct PageState<'a, G: Html> {
pub children: Children<'a, G>, pub children: Children<'a, G>,
pub selected: Option<String>, pub selected: Option<String>,

View File

@ -16,7 +16,7 @@ use crate::components::recipe::Recipe;
use sycamore::prelude::*; use sycamore::prelude::*;
use tracing::instrument; use tracing::instrument;
#[derive(Debug, Prop)] #[derive(Debug, Props)]
pub struct RecipePageProps { pub struct RecipePageProps {
pub recipe: String, pub recipe: String,
} }

View File

@ -1,224 +0,0 @@
// 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 sycamore::prelude::*;
use tracing::{debug, error, info, instrument};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::Event;
use web_sys::{Element, HtmlAnchorElement};
use crate::app_state::Routes;
#[derive(Clone, Debug)]
pub struct BrowserIntegration(RcSignal<(String, String, String)>);
impl BrowserIntegration {
pub fn new() -> Self {
let location = web_sys::window().unwrap_throw().location();
Self(create_rc_signal((
location.origin().unwrap_or(String::new()),
location.pathname().unwrap_or(String::new()),
location.hash().unwrap_or(String::new()),
)))
}
#[instrument(skip(self, f))]
pub fn register_post_state_handler(&self, f: Box<dyn FnMut()>) {
let closure = Closure::wrap(f);
web_sys::window()
.unwrap_throw()
.add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref())
.unwrap_throw();
closure.forget();
}
#[instrument(skip(self))]
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>())
{
debug!("handling navigation event.");
let location = web_sys::window().unwrap_throw().location();
if tgt.rel() == "external" {
debug!("External Link so ignoring.");
return;
}
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 */ => {
debug!("different path or hash");
ev.prevent_default();
// Signal the pathname change
let path = format!("{}{}{}", &origin, &tgt_pathname, &hash);
debug!("new route: ({}, {}, {})", origin, tgt_pathname, hash);
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);
}
}
}
})
}
}
#[derive(Debug)]
pub struct RouterProps<R, F, G>
where
G: GenericNode,
R: DeriveRoute + NotFound + Clone + Default + Debug + 'static,
F: Fn(Scope, &ReadSignal<R>) -> View<G> + 'static,
{
pub route: R,
pub route_select: F,
pub browser_integration: BrowserIntegration,
}
#[instrument(fields(?props.route,
origin=props.browser_integration.0.get().0,
pathn=props.browser_integration.0.get().1,
hash=props.browser_integration.0.get().2),
skip(props))]
#[component]
pub fn Router<'ctx, G, R, F>(cx: Scope, props: RouterProps<R, F, G>) -> View<G>
where
G: Html,
R: DeriveRoute + NotFound + Clone + Default + Debug + 'static,
F: Fn(Scope, &ReadSignal<R>) -> View<G> + 'static,
{
debug!("Setting up router");
let integration = Rc::new(props.browser_integration);
let route_select = Rc::new(props.route_select);
let view_signal = create_signal(cx, View::empty());
create_effect(cx, {
let integration = integration.clone();
move || {
let path_signal = integration.0.clone();
debug!(origin=%path_signal.get().0, path=%path_signal.get().1, hash=%path_signal.get().2, "new path");
let path = path_signal.clone();
let route = R::from(path.get().as_ref());
debug!(?route, "new route");
// TODO(jwall): this is an unnecessary use of signal.
let view = route_select.as_ref()(cx, &*create_signal(cx, route));
register_click_handler(cx, &view, integration.clone());
view_signal.set(view);
}
});
let path_signal = integration.0.clone();
integration.register_post_state_handler(Box::new(move || {
let location = web_sys::window().unwrap_throw().location();
path_signal.set((
location.origin().unwrap_throw(),
location.pathname().unwrap_throw(),
location.hash().unwrap_throw(),
));
}));
// NOTE(jwall): This needs to be a dynamic node so Sycamore knows to rerender it
// based on the results of the effect above.
view! {cx,
(view_signal.get().as_ref())
}
}
#[instrument(skip_all)]
fn register_click_handler<G>(cx: Scope, view: &View<G>, integration: Rc<BrowserIntegration>)
where
G: GenericNode<EventType = Event>,
{
debug!("Registring click handler on node(s)");
if let Some(node) = view.as_node() {
node.event(cx, "click", integration.click_handler());
} else if let Some(frag) = view.as_fragment() {
debug!(fragment=?frag);
for n in frag {
register_click_handler(cx, n, integration.clone());
}
} else if let Some(dyn_node) = view.as_dyn() {
debug!(dynamic_node=?dyn_node);
} else {
debug!(node=?view, "Unknown node");
}
}
pub trait NotFound {
fn not_found() -> Self;
}
impl NotFound for Routes {
fn not_found() -> Self {
Routes::NotFound
}
}
pub trait DeriveRoute {
fn from(input: &(String, String, String)) -> Self;
}
impl DeriveRoute for Routes {
#[instrument]
fn from(input: &(String, String, String)) -> Routes {
debug!(origin=%input.0, path=%input.1, hash=%input.2, "routing");
let (_origin, path, _hash) = input;
let route = match path.as_str() {
"" | "/" | "/ui/" => Routes::default(),
"/ui/login" => Routes::Login,
"/ui/plan" => Routes::Plan,
"/ui/cook" => Routes::Cook,
"/ui/inventory" => Routes::Inventory,
"/ui/categories" => Routes::Categories,
h => {
if h.starts_with("/ui/recipe/") {
let parts: Vec<&str> = h.split("/").collect();
debug!(?parts, "found recipe path");
if let Some(&"recipe") = parts.get(2) {
if let Some(&idx) = parts.get(3) {
return Routes::Recipe(idx.to_owned());
}
}
}
error!(origin=%input.0, path=%input.1, hash=%input.2, "Path not found");
Routes::NotFound
}
};
info!(route=?route, "Route identified");
route
}
}

89
web/src/routing/mod.rs Normal file
View File

@ -0,0 +1,89 @@
// 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 sycamore::prelude::*;
//use sycamore_router::{HistoryIntegration, Route, Router};
use sycamore_router::{HistoryIntegration, Route, Router};
use tracing::instrument;
use crate::pages::*;
//mod router;
//use router::{HistoryIntegration, Router};
#[instrument]
fn route_switch<'a, G: Html>(cx: Scope<'a>, route: &'a ReadSignal<Routes>) -> 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.
view! {cx,
(match route.get().as_ref() {
Routes::Plan => view! {cx,
PlanPage()
},
Routes::Inventory => view! {cx,
InventoryPage()
},
Routes::Login => view! {cx,
LoginPage()
},
Routes::Cook => view! {cx,
CookPage()
},
Routes::Recipe(idx) => view! {cx,
RecipePage(recipe=idx.clone())
},
Routes::Categories => view! {cx,
CategoryPage()
},
Routes::NewRecipe => view! {cx,
AddRecipePage()
},
Routes::NotFound => view! {cx,
// TODO(Create a real one)
PlanPage()
},
})
}
}
#[derive(Route, Debug)]
pub enum Routes {
#[to("/ui/plan")]
Plan,
#[to("/ui/inventory")]
Inventory,
#[to("/ui/cook")]
Cook,
#[to("/ui/recipe/<id>")]
Recipe(String),
#[to("/ui/add_recipe")]
NewRecipe,
#[to("/ui/categories")]
Categories,
#[to("/ui/login")]
Login,
#[not_found]
NotFound,
}
#[component]
pub fn Handler<G: Html>(cx: Scope) -> View<G> {
view! {cx,
Router(
integration=HistoryIntegration::new(),
view=route_switch,
)
}
}

View File

@ -15,39 +15,7 @@ use sycamore::{futures::spawn_local_scoped, prelude::*};
use tracing::{error, info, instrument}; use tracing::{error, info, instrument};
use crate::components::Header; use crate::components::Header;
use crate::pages::*; use crate::{api, routing::Handler as RouteHandler};
use crate::{api, app_state::*, router_integration::*};
#[instrument]
fn route_switch<G: Html>(cx: Scope, route: &ReadSignal<Routes>) -> 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.
match route.get().as_ref() {
Routes::Plan => view! {cx,
PlanPage()
},
Routes::Inventory => view! {cx,
InventoryPage()
},
Routes::Login => view! {cx,
LoginPage()
},
Routes::Cook => view! {cx,
CookPage()
},
Routes::Recipe(idx) => view! {cx,
RecipePage(recipe=idx.clone())
},
Routes::Categories => view! {cx,
CategoryPage()
},
Routes::NotFound => view! {cx,
// TODO(Create a real one)
PlanPage()
},
}
}
#[instrument] #[instrument]
#[component] #[component]
@ -66,14 +34,11 @@ pub fn UI<G: Html>(cx: Scope) -> View<G> {
if let Err(err) = api::init_page_state(store.as_ref(), state.as_ref()).await { if let Err(err) = api::init_page_state(store.as_ref(), state.as_ref()).await {
error!(?err); error!(?err);
}; };
// TODO(jwall): This needs to be moved into the RouteHandler
view.set(view! { cx, view.set(view! { cx,
div(class="app") { div(class="app") {
Header { } Header { }
Router(RouterProps { RouteHandler()
route: Routes::Plan,
route_select: route_switch,
browser_integration: BrowserIntegration::new(),
})
} }
}); });
} }