From cf9c6b2a3ffa812199b17a1bec63d1e4b1b97b82 Mon Sep 17 00:00:00 2001 From: Greg Burri Date: Sun, 27 Apr 2025 12:49:39 +0200 Subject: [PATCH] Refactor toast notifications and modal dialog implementation - Updated SCSS for toast notifications to support multiple toast types (success, info, warning, error) and improved layout. - Added new SVG icons for error, info, success, and warning notifications. - Created separate HTML templates for toast notifications and modal dialogs. - Enhanced the dev panel with buttons to test different toast notifications and modal dialogs. --- backend/scss/toast.scss | 44 +++++++++---- backend/static/error.svg | 54 ++++++++++++++++ backend/static/info.svg | 54 ++++++++++++++++ backend/static/success.svg | 54 ++++++++++++++++ backend/static/warning.svg | 53 +++++++++++++++ backend/templates/base.html | 11 +--- backend/templates/dev_panel.html | 21 +++++- backend/templates/modal_dialog.html | 6 ++ backend/templates/toast.html | 8 +++ frontend/Cargo.toml | 1 + frontend/src/pages/dev_panel.rs | 52 ++++++++++++++- frontend/src/pages/recipe_edit.rs | 4 +- frontend/src/pages/recipe_view.rs | 2 +- frontend/src/request.rs | 6 +- frontend/src/toast.rs | 99 ++++++++++++++++++----------- 15 files changed, 399 insertions(+), 70 deletions(-) create mode 100644 backend/static/error.svg create mode 100644 backend/static/info.svg create mode 100644 backend/static/success.svg create mode 100644 backend/static/warning.svg create mode 100644 backend/templates/modal_dialog.html create mode 100644 backend/templates/toast.html diff --git a/backend/scss/toast.scss b/backend/scss/toast.scss index f094e8e..dd35068 100644 --- a/backend/scss/toast.scss +++ b/backend/scss/toast.scss @@ -1,21 +1,39 @@ @use 'constants' as consts; -#toast { - visibility: hidden; - width: 300px; - margin-left: -125px; - - border: 0.1em solid consts.$color-3; - border-radius: 0.5em; - background-color: consts.$color-2; - - text-align: center; - padding: consts.$margin; +#toasts { position: fixed; z-index: 1; left: 50%; - top: 30px; - box-shadow: -1px 1px 10px rgba(0, 0, 0, 0.3); + top: 15px; + transform: translate(-50%, 0%); + + display: flex; + flex-direction: column; + + .toast { + // visibility: hidden; + display: none; + width: fit-content; + align-self: center; + + border: 0.1em solid consts.$color-3; + border-radius: 0.5em; + background-color: consts.$color-2; + + text-align: center; + padding: calc(2 * consts.$margin) calc(4 * consts.$margin); + box-shadow: -1px 1px 10px rgba(0, 0, 0, 0.3); + + margin: consts.$margin; + + .content { + display: inline-block; + } + + img { + vertical-align: text-bottom; + } + } } // #toast.show { diff --git a/backend/static/error.svg b/backend/static/error.svg new file mode 100644 index 0000000..0c934d2 --- /dev/null +++ b/backend/static/error.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + diff --git a/backend/static/info.svg b/backend/static/info.svg new file mode 100644 index 0000000..742525f --- /dev/null +++ b/backend/static/info.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + diff --git a/backend/static/success.svg b/backend/static/success.svg new file mode 100644 index 0000000..3c735e7 --- /dev/null +++ b/backend/static/success.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + diff --git a/backend/static/warning.svg b/backend/static/warning.svg new file mode 100644 index 0000000..10cc428 --- /dev/null +++ b/backend/static/warning.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + diff --git a/backend/templates/base.html b/backend/templates/base.html index 7a1b929..783efa3 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -23,15 +23,8 @@ dispatchEvent(new CustomEvent("TrunkApplicationStarted", {detail: {wasm}})); -
-
-
- - -
- - -
+ {% include "toast.html" %} + {% include "modal_dialog.html" %} {% block body_container %}{% endblock %} diff --git a/backend/templates/dev_panel.html b/backend/templates/dev_panel.html index e76a146..7573fc4 100644 --- a/backend/templates/dev_panel.html +++ b/backend/templates/dev_panel.html @@ -3,14 +3,31 @@ {% block content %}
- - +
+ + + + + + + + +
+ +
+ +
+ +
+ Item 1 + Item 2 +
{% endblock %} \ No newline at end of file diff --git a/backend/templates/modal_dialog.html b/backend/templates/modal_dialog.html new file mode 100644 index 0000000..49578a9 --- /dev/null +++ b/backend/templates/modal_dialog.html @@ -0,0 +1,6 @@ +{# Needed by the frontend modal_dialog module. #} + +
+ + +
\ No newline at end of file diff --git a/backend/templates/toast.html b/backend/templates/toast.html new file mode 100644 index 0000000..abffc0c --- /dev/null +++ b/backend/templates/toast.html @@ -0,0 +1,8 @@ +{# Needed by the frontend toast module. #} +
+
+ +
+ +
+
\ No newline at end of file diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 3c02071..b00b241 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -44,6 +44,7 @@ web-sys = { version = "0.3", features = [ "HtmlDivElement", "HtmlLabelElement", "HtmlInputElement", + "HtmlImageElement", "HtmlTextAreaElement", "HtmlSelectElement", "HtmlDialogElement", diff --git a/frontend/src/pages/dev_panel.rs b/frontend/src/pages/dev_panel.rs index e1fbab8..0b59e75 100644 --- a/frontend/src/pages/dev_panel.rs +++ b/frontend/src/pages/dev_panel.rs @@ -1,7 +1,7 @@ -use gloo::{console::log, events::EventListener}; +use gloo::{console::log, events::EventListener, utils::document}; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::spawn_local; -use web_sys::{Element, HtmlInputElement}; +use web_sys::{Element, HtmlElement, HtmlInputElement}; use crate::{ calendar, modal_dialog, @@ -13,10 +13,56 @@ use crate::{ pub fn setup_page() { EventListener::new(&by_id("test-toast"), "click", move |_event| { - toast::show_message(Level::Info, "This is a message"); + toast::show_message("This is a message"); }) .forget(); + EventListener::new(&by_id("test-toast-success"), "click", move |_event| { + toast::show_message_level(Level::Success, "This is a success message"); + }) + .forget(); + + EventListener::new(&by_id("test-toast-error"), "click", move |_event| { + toast::show_message_level(Level::Error, "This is a error message"); + }) + .forget(); + + EventListener::new(&by_id("test-toast-info"), "click", move |_event| { + toast::show_message_level(Level::Info, "This is an info message"); + }) + .forget(); + + EventListener::new(&by_id("test-toast-warning"), "click", move |_event| { + toast::show_message_level(Level::Warning, "This is a warning message"); + }) + .forget(); + + EventListener::new( + &by_id("test-toast-success-content"), + "click", + move |_event| { + toast::show_element_level(Level::Success, "#hidden-templates .toast-test-content"); + }, + ) + .forget(); + + EventListener::new( + &by_id("test-toast-success-content-initializer"), + "click", + move |_event| { + toast::show_element_level_and_initialize( + Level::Success, + "#hidden-templates .toast-test-content", + |element| { + let new_span = document().create_element("span").unwrap(); + new_span.set_inner_html("Item 3"); + element.append_child(&new_span).unwrap(); + }, + ); + }, + ) + .forget(); + EventListener::new(&by_id("test-modal-dialog"), "click", move |_event| { spawn_local(async move { modal_dialog::show("#hidden-templates").await; diff --git a/frontend/src/pages/recipe_edit.rs b/frontend/src/pages/recipe_edit.rs index 858825b..1bb6f91 100644 --- a/frontend/src/pages/recipe_edit.rs +++ b/frontend/src/pages/recipe_edit.rs @@ -76,7 +76,6 @@ pub fn setup_page(recipe_id: i64) { let servings = if n.is_nan() { None } else { - // TODO: Find a better way to validate integer numbers. let n = n as u32; servings.set_value_as_number(n as f64); Some(n) @@ -108,7 +107,6 @@ pub fn setup_page(recipe_id: i64) { let time = if n.is_nan() { None } else { - // TODO: Find a better way to validate integer numbers. let n = n as u32; estimated_time.set_value_as_number(n as f64); Some(n) @@ -708,7 +706,7 @@ async fn reload_recipes_list(current_recipe_id: i64) { .await { Err(error) => { - toast::show_message(Level::Info, &format!("Internal server error: {}", error)); + toast::show_message_level(Level::Error, &format!("Internal server error: {}", error)); } Ok(response) => { let list = document().get_element_by_id("recipes-list").unwrap(); diff --git a/frontend/src/pages/recipe_view.rs b/frontend/src/pages/recipe_view.rs index 6158c64..4eb825c 100644 --- a/frontend/src/pages/recipe_view.rs +++ b/frontend/src/pages/recipe_view.rs @@ -52,7 +52,7 @@ pub fn setup_page(recipe_id: i64, is_user_logged: bool, first_day_of_the_week: W .shedule_recipe(recipe_id, date, servings, add_ingredients_to_shopping_list) .await { - toast::show_element_and_initialize( + toast::show_element_level_and_initialize( match result { ScheduleRecipeResult::Ok => Level::Success, ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => { diff --git a/frontend/src/request.rs b/frontend/src/request.rs index 3579440..707126c 100644 --- a/frontend/src/request.rs +++ b/frontend/src/request.rs @@ -64,13 +64,13 @@ where { match request.send().await { Err(error) => { - toast::show_message(Level::Info, &format!("Internal server error: {}", error)); + toast::show_message_level(Level::Error, &format!("Internal server error: {}", error)); Err(Error::Gloo(error)) } Ok(response) => { if !response.ok() { - toast::show_message( - Level::Info, + toast::show_message_level( + Level::Error, &format!("HTTP error: {}", response.status_text()), ); Err(Error::Http(response.status_text())) diff --git a/frontend/src/toast.rs b/frontend/src/toast.rs index eda5d85..c3a12fd 100644 --- a/frontend/src/toast.rs +++ b/frontend/src/toast.rs @@ -1,5 +1,5 @@ -use gloo::{timers::callback::Timeout, utils::document}; -use web_sys::{Element, HtmlElement}; +use gloo::{events::EventListener, timers::callback::Timeout}; +use web_sys::{Element, HtmlElement, HtmlImageElement}; use crate::utils::{SelectorExt, by_id, selector_and_clone}; @@ -8,55 +8,82 @@ pub enum Level { Error, Info, Warning, + Unknown, } -/* -TODO: - - Stack multiple toast messages (see #toasts) by cloning #toast - - User can close message by clicking a button - - Implement level display with icons -*/ - const TIME_ANIMATION: u32 = 500; // [ms]. const TIME_DISPLAYED: u32 = 5_000; // [ms]. -pub fn show_message(level: Level, message: &str) { - let toast_element: HtmlElement = by_id("toast"); - toast_element.set_inner_html(message); - toast_element.style().set_css_text(&format!( - "visibility: visible; - animation: - fadein {}ms, - fadeout {}ms {}ms;", - TIME_ANIMATION, - TIME_ANIMATION, - TIME_DISPLAYED - TIME_ANIMATION - )); - - Timeout::new(TIME_DISPLAYED, move || { - toast_element.style().set_css_text(""); - }) - .forget(); +pub fn show_message(message: &str) { + show_message_level(Level::Unknown, message); } -pub fn show_element(level: Level, selector: &str) { - show_element_and_initialize(level, selector, |_| {}) +pub fn show_message_level(level: Level, message: &str) { + show_message_content(level, Content::Message(message)) } -pub fn show_element_and_initialize(level: Level, selector: &str, initializer: T) +pub fn show_element_level(level: Level, selector: &str) { + show_element_level_and_initialize(level, selector, |_| {}) +} + +pub fn show_element_level_and_initialize(level: Level, selector: &str, initializer: T) where T: Fn(Element), { - let toast_element = document().get_element_by_id("toast").unwrap(); + let content_element: Element = selector_and_clone(selector); + initializer(content_element.clone()); - let element: Element = selector_and_clone(selector); - toast_element.set_inner_html(""); - toast_element.append_child(&element).unwrap(); - initializer(element.clone()); - toast_element.set_class_name("show"); + show_message_content(level, Content::Element(content_element)) +} + +enum Content<'a> { + Message(&'a str), + Element(Element), +} + +fn show_message_content(level: Level, content: Content) { + let toast_element: HtmlElement = selector_and_clone("#toasts .toast"); + + let toast_icon: HtmlImageElement = toast_element.selector(".icon"); + let toast_content: HtmlElement = toast_element.selector(".content"); + + match level { + Level::Success => toast_icon.set_src("/static/success.svg"), + Level::Error => toast_icon.set_src("/static/error.svg"), + Level::Info => toast_icon.set_src("/static/info.svg"), + Level::Warning => toast_icon.set_src("/static/warning.svg"), + Level::Unknown => toast_icon.remove(), + } + + match content { + Content::Message(message) => toast_content.set_inner_html(message), + Content::Element(element) => { + let _ = toast_content.append_child(&element); + } + } + + toast_element.style().set_css_text(&format!( + "display: block; + animation: + fadein {}ms, + fadeout {}ms {}ms;", + TIME_ANIMATION, TIME_ANIMATION, TIME_DISPLAYED + )); + + // FIXME: Here the two events will leak memory. How to fix that? + // Save them in a global vec variable and remove them manually? + let close_button: HtmlElement = toast_element.selector(".close"); + let toast_element_cloned = toast_element.clone(); + EventListener::once(&close_button, "click", move |_event| { + toast_element_cloned.remove(); + }) + .forget(); + + let toasts: HtmlElement = by_id("toasts"); + toasts.append_child(&toast_element).unwrap(); Timeout::new(TIME_DISPLAYED, move || { - toast_element.set_class_name(""); + toast_element.remove(); }) .forget(); }