mirror of
https://github.com/zaphar/kitchen.git
synced 2025-07-22 19:40:14 -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 tracing::{debug, error, info, instrument, warn};
|
||||||
use wasm_bindgen::throw_str;
|
use wasm_bindgen::throw_str;
|
||||||
|
|
||||||
use crate::api::{HttpStore, LocalStore};
|
use crate::{
|
||||||
|
api::{HttpStore, LocalStore},
|
||||||
|
components,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@ -377,6 +380,10 @@ impl MessageMapper<Message, AppState> for StateMachine {
|
|||||||
if let Err(e) = store.store_recipes(vec![entry]).await {
|
if let Err(e) = store.store_recipes(vec![entry]).await {
|
||||||
// FIXME(jwall): We should have a global way to trigger error messages
|
// FIXME(jwall): We should have a global way to trigger error messages
|
||||||
error!(err=?e, "Unable to save Recipe");
|
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());
|
callback.map(|f| f());
|
||||||
});
|
});
|
||||||
@ -389,6 +396,9 @@ impl MessageMapper<Message, AppState> for StateMachine {
|
|||||||
spawn_local_scoped(cx, async move {
|
spawn_local_scoped(cx, async move {
|
||||||
if let Err(err) = store.delete_recipe(&recipe).await {
|
if let Err(err) = store.delete_recipe(&recipe).await {
|
||||||
error!(?err, "Failed to delete recipe");
|
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());
|
callback.map(|f| f());
|
||||||
});
|
});
|
||||||
@ -416,6 +426,7 @@ impl MessageMapper<Message, AppState> for StateMachine {
|
|||||||
&original_copy.modified_amts,
|
&original_copy.modified_amts,
|
||||||
&original_copy.extras,
|
&original_copy.extras,
|
||||||
));
|
));
|
||||||
|
components::toast::message(cx, "Reset Inventory", None);
|
||||||
}
|
}
|
||||||
Message::AddFilteredIngredient(key) => {
|
Message::AddFilteredIngredient(key) => {
|
||||||
original_copy.filtered_ingredients.insert(key);
|
original_copy.filtered_ingredients.insert(key);
|
||||||
@ -448,7 +459,10 @@ impl MessageMapper<Message, AppState> for StateMachine {
|
|||||||
.plan_dates
|
.plan_dates
|
||||||
.insert(original_copy.selected_plan_date.map(|d| d.clone()).unwrap());
|
.insert(original_copy.selected_plan_date.map(|d| d.clone()).unwrap());
|
||||||
if let Err(e) = store.store_app_state(&original_copy).await {
|
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);
|
original.set(original_copy);
|
||||||
f.map(|f| f());
|
f.map(|f| f());
|
||||||
@ -461,14 +475,17 @@ impl MessageMapper<Message, AppState> for StateMachine {
|
|||||||
let store = self.store.clone();
|
let store = self.store.clone();
|
||||||
let local_store = self.local_store.clone();
|
let local_store = self.local_store.clone();
|
||||||
spawn_local_scoped(cx, async move {
|
spawn_local_scoped(cx, async move {
|
||||||
Self::load_state(&store, &local_store, original)
|
if let Err(err) = Self::load_state(&store, &local_store, original).await {
|
||||||
.await
|
error!(?err, "Failed to load user state");
|
||||||
.expect("Failed to load_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((
|
local_store.set_inventory_data((
|
||||||
&original.get().filtered_ingredients,
|
&original.get().filtered_ingredients,
|
||||||
&original.get().modified_amts,
|
&original.get().modified_amts,
|
||||||
&original.get().extras,
|
&original.get().extras,
|
||||||
));
|
));
|
||||||
|
}
|
||||||
f.map(|f| f());
|
f.map(|f| f());
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@ -478,11 +495,13 @@ impl MessageMapper<Message, AppState> for StateMachine {
|
|||||||
let local_store = self.local_store.clone();
|
let local_store = self.local_store.clone();
|
||||||
spawn_local_scoped(cx, async move {
|
spawn_local_scoped(cx, async move {
|
||||||
local_store.set_staples(&content);
|
local_store.set_staples(&content);
|
||||||
store
|
if let Err(err) = store.store_staples(content).await {
|
||||||
.store_staples(content)
|
error!(?err, "Failed to store staples");
|
||||||
.await
|
components::toast::error_message(cx, "Failed to store staples", None);
|
||||||
.expect("Failed to store staples");
|
} else {
|
||||||
|
components::toast::message(cx, "Updated staples", None);
|
||||||
callback.map(|f| f());
|
callback.map(|f| f());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -527,10 +546,14 @@ impl MessageMapper<Message, AppState> for StateMachine {
|
|||||||
let store = self.store.clone();
|
let store = self.store.clone();
|
||||||
let local_store = self.local_store.clone();
|
let local_store = self.local_store.clone();
|
||||||
spawn_local_scoped(cx, async move {
|
spawn_local_scoped(cx, async move {
|
||||||
store
|
if let Err(err) = store.delete_plan_for_date(&date).await {
|
||||||
.delete_plan_for_date(&date)
|
components::toast::error_message(
|
||||||
.await
|
cx,
|
||||||
.expect("Failed to delete meal plan for date");
|
"Failed to delete meal plan for date",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
error!(?err, "Error deleting plan");
|
||||||
|
} else {
|
||||||
local_store.delete_plan();
|
local_store.delete_plan();
|
||||||
|
|
||||||
original_copy.plan_dates.remove(&date);
|
original_copy.plan_dates.remove(&date);
|
||||||
@ -540,8 +563,10 @@ impl MessageMapper<Message, AppState> for StateMachine {
|
|||||||
original_copy.modified_amts = BTreeMap::new();
|
original_copy.modified_amts = BTreeMap::new();
|
||||||
original_copy.extras = Vec::new();
|
original_copy.extras = Vec::new();
|
||||||
original.set(original_copy);
|
original.set(original_copy);
|
||||||
|
components::toast::message(cx, "Deleted Plan", None);
|
||||||
|
|
||||||
callback.map(|f| f());
|
callback.map(|f| f());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ pub mod recipe_selection;
|
|||||||
pub mod shopping_list;
|
pub mod shopping_list;
|
||||||
pub mod staples;
|
pub mod staples;
|
||||||
pub mod tabs;
|
pub mod tabs;
|
||||||
|
pub mod toast;
|
||||||
|
|
||||||
pub use add_recipe::*;
|
pub use add_recipe::*;
|
||||||
pub use categories::*;
|
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::{
|
use crate::{
|
||||||
app_state::StateHandler,
|
app_state::StateHandler,
|
||||||
components::{Footer, Header},
|
components::{toast::Container, Footer, Header},
|
||||||
pages::*,
|
pages::*,
|
||||||
};
|
};
|
||||||
use sycamore::prelude::*;
|
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=move |cx: Scope, route: &ReadSignal<Routes>| {
|
||||||
view!{cx,
|
view!{cx,
|
||||||
div(class="app") {
|
div(class="app") {
|
||||||
|
Container()
|
||||||
Header(sh)
|
Header(sh)
|
||||||
(route_switch(route.get().as_ref(), cx, sh))
|
(route_switch(route.get().as_ref(), cx, sh))
|
||||||
Footer { }
|
Footer { }
|
||||||
|
@ -19,8 +19,20 @@
|
|||||||
--tab-border-style: solid;
|
--tab-border-style: solid;
|
||||||
--tab-border-radius: 15px;
|
--tab-border-radius: 15px;
|
||||||
--unicode-button-size: 2em;
|
--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 {
|
@media print {
|
||||||
|
|
||||||
.no-print,
|
.no-print,
|
||||||
@ -77,3 +89,49 @@ nav>h1 {
|
|||||||
.item-count-inc-dec {
|
.item-count-inc-dec {
|
||||||
font-size: var(--unicode-button-size);
|
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