Messaging mechanism

This commit is contained in:
Jeremy Wall 2023-03-25 08:55:32 -04:00
parent c2b8e79288
commit 4f3cf8d825
5 changed files with 193 additions and 29 deletions

View File

@ -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<Message, AppState> 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<Message, AppState> 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<Message, AppState> 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<Message, AppState> 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<Message, AppState> 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<Message, AppState> 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<Message, AppState> 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;
}

View File

@ -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::*;

View File

@ -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<G> {
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<chrono::Duration>) {
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::<web_sys::HtmlElement>(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::<web_sys::HtmlElement>(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<chrono::Duration>) {
show_toast(cx, msg, "toast", timeout);
}
pub fn error_message<'a>(cx: Scope<'a>, msg: &str, timeout: Option<chrono::Duration>) {
show_toast(cx, msg, "toast error", timeout);
}

View File

@ -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<Routes>| {
view!{cx,
div(class="app") {
Container()
Header(sh)
(route_switch(route.get().as_ref(), cx, sh))
Footer { }

View File

@ -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
}
}