From 4f3cf8d825bc4d7ddf83e74197f6578a94ac4e3c Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Sat, 25 Mar 2023 08:55:32 -0400 Subject: [PATCH] Messaging mechanism --- web/src/app_state.rs | 81 ++++++++++++++++++++++++------------- web/src/components/mod.rs | 1 + web/src/components/toast.rs | 79 ++++++++++++++++++++++++++++++++++++ web/src/routing/mod.rs | 3 +- web/static/app.css | 58 ++++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 29 deletions(-) create mode 100644 web/src/components/toast.rs diff --git a/web/src/app_state.rs b/web/src/app_state.rs index 4b895e4..2840913 100644 --- a/web/src/app_state.rs +++ b/web/src/app_state.rs @@ -25,7 +25,10 @@ use sycamore_state::{Handler, MessageMapper}; use tracing::{debug, error, info, instrument, warn}; use wasm_bindgen::throw_str; -use crate::api::{HttpStore, LocalStore}; +use crate::{ + api::{HttpStore, LocalStore}, + components, +}; #[derive(Debug, Clone, PartialEq)] pub struct AppState { @@ -377,6 +380,10 @@ impl MessageMapper for StateMachine { if let Err(e) = store.store_recipes(vec![entry]).await { // FIXME(jwall): We should have a global way to trigger error messages error!(err=?e, "Unable to save Recipe"); + // FIXME(jwall): This should be an error message + components::toast::error_message(cx, "Failed to save Recipe", None); + } else { + components::toast::message(cx, "Saved Recipe", None); } callback.map(|f| f()); }); @@ -389,6 +396,9 @@ impl MessageMapper for StateMachine { spawn_local_scoped(cx, async move { if let Err(err) = store.delete_recipe(&recipe).await { error!(?err, "Failed to delete recipe"); + components::toast::error_message(cx, "Unable to delete recipe", None); + } else { + components::toast::message(cx, "Deleted Recipe", None); } callback.map(|f| f()); }); @@ -416,6 +426,7 @@ impl MessageMapper for StateMachine { &original_copy.modified_amts, &original_copy.extras, )); + components::toast::message(cx, "Reset Inventory", None); } Message::AddFilteredIngredient(key) => { original_copy.filtered_ingredients.insert(key); @@ -448,7 +459,10 @@ impl MessageMapper for StateMachine { .plan_dates .insert(original_copy.selected_plan_date.map(|d| d.clone()).unwrap()); if let Err(e) = store.store_app_state(&original_copy).await { - error!(err=?e, "Error saving app state") + error!(err=?e, "Error saving app state"); + components::toast::error_message(cx, "Failed to save user state", None); + } else { + components::toast::message(cx, "Saved user state", None); }; original.set(original_copy); f.map(|f| f()); @@ -461,14 +475,17 @@ impl MessageMapper for StateMachine { let store = self.store.clone(); let local_store = self.local_store.clone(); spawn_local_scoped(cx, async move { - Self::load_state(&store, &local_store, original) - .await - .expect("Failed to load_state."); - local_store.set_inventory_data(( - &original.get().filtered_ingredients, - &original.get().modified_amts, - &original.get().extras, - )); + if let Err(err) = Self::load_state(&store, &local_store, original).await { + error!(?err, "Failed to load user state"); + components::toast::error_message(cx, "Failed to load_state.", None); + } else { + components::toast::message(cx, "Loaded user state", None); + local_store.set_inventory_data(( + &original.get().filtered_ingredients, + &original.get().modified_amts, + &original.get().extras, + )); + } f.map(|f| f()); }); return; @@ -478,11 +495,13 @@ impl MessageMapper for StateMachine { let local_store = self.local_store.clone(); spawn_local_scoped(cx, async move { local_store.set_staples(&content); - store - .store_staples(content) - .await - .expect("Failed to store staples"); - callback.map(|f| f()); + if let Err(err) = store.store_staples(content).await { + error!(?err, "Failed to store staples"); + components::toast::error_message(cx, "Failed to store staples", None); + } else { + components::toast::message(cx, "Updated staples", None); + callback.map(|f| f()); + } }); return; } @@ -527,21 +546,27 @@ impl MessageMapper for StateMachine { let store = self.store.clone(); let local_store = self.local_store.clone(); spawn_local_scoped(cx, async move { - store - .delete_plan_for_date(&date) - .await - .expect("Failed to delete meal plan for date"); - local_store.delete_plan(); + if let Err(err) = store.delete_plan_for_date(&date).await { + components::toast::error_message( + cx, + "Failed to delete meal plan for date", + None, + ); + error!(?err, "Error deleting plan"); + } else { + local_store.delete_plan(); - original_copy.plan_dates.remove(&date); - // Reset all meal planning state; - let _ = original_copy.recipe_counts.iter_mut().map(|(_, v)| *v = 0); - original_copy.filtered_ingredients = BTreeSet::new(); - original_copy.modified_amts = BTreeMap::new(); - original_copy.extras = Vec::new(); - original.set(original_copy); + original_copy.plan_dates.remove(&date); + // Reset all meal planning state; + let _ = original_copy.recipe_counts.iter_mut().map(|(_, v)| *v = 0); + original_copy.filtered_ingredients = BTreeSet::new(); + original_copy.modified_amts = BTreeMap::new(); + original_copy.extras = Vec::new(); + original.set(original_copy); + components::toast::message(cx, "Deleted Plan", None); - callback.map(|f| f()); + callback.map(|f| f()); + } }); return; } diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index e0ad4df..82e1096 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -24,6 +24,7 @@ pub mod recipe_selection; pub mod shopping_list; pub mod staples; pub mod tabs; +pub mod toast; pub use add_recipe::*; pub use categories::*; diff --git a/web/src/components/toast.rs b/web/src/components/toast.rs new file mode 100644 index 0000000..1a86319 --- /dev/null +++ b/web/src/components/toast.rs @@ -0,0 +1,79 @@ +// Copyright 2023 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::{easing, motion, prelude::*}; +use tracing::debug; +use wasm_bindgen::UnwrapThrowExt; + +const SECTION_ID: &'static str = "toast-container"; + +#[component] +pub fn Container<'a, G: Html>(cx: Scope<'a>) -> View { + view! {cx, + section(id=SECTION_ID) { } + } +} + +pub fn create_output_element(msg: &str, class: &str) -> web_sys::Element { + let document = web_sys::window() + .expect("No window present") + .document() + .expect("No document in window"); + let output = document.create_element("output").unwrap_throw(); + let message_node = document.create_text_node(msg); + output.set_attribute("class", class).unwrap_throw(); + output.set_attribute("role", "status").unwrap_throw(); + output.append_child(&message_node).unwrap_throw(); + output +} + +fn show_toast<'a>(cx: Scope<'a>, msg: &str, class: &str, timeout: Option) { + let timeout = timeout.unwrap_or_else(|| chrono::Duration::seconds(3)); + // Insert a toast output element into the container. + let tweened = motion::create_tweened_signal( + cx, + 0.0 as f32, + timeout + .to_std() + .expect("Failed to convert timeout duration."), + easing::quad_in, + ); + tweened.set(1.0); + create_effect_scoped(cx, move |_cx| { + if !tweened.is_tweening() { + debug!("Detected message timeout."); + let container = crate::js_lib::get_element_by_id::(SECTION_ID) + .expect("Failed to get toast-container") + .expect("No toast-container"); + if let Some(node_to_remove) = container.first_element_child() { + // Always remove the first child if there is one. + container.remove_child(&node_to_remove).unwrap_throw(); + } + } + }); + let output_element = create_output_element(msg, class); + crate::js_lib::get_element_by_id::(SECTION_ID) + .expect("Failed to get toast-container") + .expect("No toast-container") + // Always append after the last child. + .append_child(&output_element) + .unwrap_throw(); +} + +pub fn message<'a>(cx: Scope<'a>, msg: &str, timeout: Option) { + show_toast(cx, msg, "toast", timeout); +} + +pub fn error_message<'a>(cx: Scope<'a>, msg: &str, timeout: Option) { + show_toast(cx, msg, "toast error", timeout); +} diff --git a/web/src/routing/mod.rs b/web/src/routing/mod.rs index 5108c27..2bf4143 100644 --- a/web/src/routing/mod.rs +++ b/web/src/routing/mod.rs @@ -14,7 +14,7 @@ use crate::{ app_state::StateHandler, - components::{Footer, Header}, + components::{toast::Container, Footer, Header}, pages::*, }; use sycamore::prelude::*; @@ -137,6 +137,7 @@ pub fn Handler<'ctx, G: Html>(cx: Scope<'ctx>, props: HandlerProps<'ctx>) -> Vie view=move |cx: Scope, route: &ReadSignal| { view!{cx, div(class="app") { + Container() Header(sh) (route_switch(route.get().as_ref(), cx, sh)) Footer { } diff --git a/web/static/app.css b/web/static/app.css index ea0c443..8173a29 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -19,8 +19,20 @@ --tab-border-style: solid; --tab-border-radius: 15px; --unicode-button-size: 2em; + --toast-anim-duration: 3s; + --toast-travel-distance: 0; + --notification-font-size: calc(var(--font-size) / 2); + --error-message-color: rgba(255, 98, 0, 0.797); + --error-message-bg: grey; } +@media (prefers-reduced-motion: no-preference) { + .gui-toast { + --toast-travel-distance: 5vh; + } +} + + @media print { .no-print, @@ -76,4 +88,50 @@ nav>h1 { .item-count-inc-dec { font-size: var(--unicode-button-size); +} + +#toast-container { + position: fixed; + z-index: 1; + inset-block-start: 0; + inset-inline: 0; + padding-block-start: 5vh; + + display: grid; + justify-items: center; + justify-content: center; + gap: 1vh; + + font-size: var(--notification-font-size); + + pointer-events: none; +} + +.toast-container .error { + color: var(--error-message-color); + background-color: var(--error-message-bg); +} + +.toast { + max-inline-size: min(25ch, 90vw); + padding-block: .5ch; + padding-inline: 1ch; + border-radius: 3px; + + will-change: transform; + animation: + fade-in 1s ease, + fade-out .5s ease var(--toast-anim-duration); +} + +@keyframes fade-in { + from { + opacity: 0 + } +} + +@keyframes fade-out { + to { + opacity: 0 + } } \ No newline at end of file