mirror of
https://github.com/zaphar/kitchen.git
synced 2025-07-21 19:29:49 -04:00
Messaging mechanism
This commit is contained in:
parent
c2b8e79288
commit
4f3cf8d825
@ -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;
|
||||
}
|
||||
|
@ -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::*;
|
||||
|
79
web/src/components/toast.rs
Normal file
79
web/src/components/toast.rs
Normal 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);
|
||||
}
|
@ -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 { }
|
||||
|
@ -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
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user