From: Greg Burri Date: Tue, 4 Feb 2025 21:29:56 +0000 (+0100) Subject: Recipe can now be scheduled X-Git-Url: https://git.euphorik.ch/?a=commitdiff_plain;h=fbef990022df58a7b47433a09776305e695e9d00;p=recipes.git Recipe can now be scheduled --- diff --git a/Cargo.lock b/Cargo.lock index 01a5811..e250eba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -356,6 +356,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "pure-rust-locales", "serde", "wasm-bindgen", "windows-targets 0.52.6", @@ -1853,6 +1854,12 @@ dependencies = [ "cc", ] +[[package]] +name = "pure-rust-locales" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1190fd18ae6ce9e137184f207593877e70f39b015040156b1e05081cdfe3733a" + [[package]] name = "quote" version = "1.0.38" diff --git a/backend/scss/toast.scss b/backend/scss/toast.scss index e6b4582..a0eeb34 100644 --- a/backend/scss/toast.scss +++ b/backend/scss/toast.scss @@ -15,7 +15,7 @@ #toast.show { visibility: visible; - animation: fadein 0.5s, fadeout 0.5s 3.6s; + animation: fadein 0.5s, fadeout 0.5s 9.6s; animation-iteration-count: 1; } diff --git a/backend/sql/version_1.sql b/backend/sql/version_1.sql index 07d7d60..122a014 100644 --- a/backend/sql/version_1.sql +++ b/backend/sql/version_1.sql @@ -1,3 +1,6 @@ +-- Datetimes are stored as 'ISO 8601' text format. +-- For example: '2025-01-07T10:41:05.697884837+00:00'. + -- Version 1 is the initial structure. CREATE TABLE [Version] ( [id] INTEGER PRIMARY KEY, @@ -165,9 +168,11 @@ CREATE TABLE [RecipeScheduled] ( [id] INTEGER PRIMARY KEY, [user_id] INTEGER NOT NULL, [recipe_id] INTEGER NOT NULL, - [date] TEXT NOT NULL, + [date] TEXT NOT NULL, -- In form of 'YYYY-MM-DD'. [servings] INTEGER, -- If NULL use [recipe].[servings]. + UNIQUE([user_id], [recipe_id], [date]), + FOREIGN KEY([user_id]) REFERENCES [User]([id]) ON DELETE CASCADE, FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE ); diff --git a/backend/src/data/db/recipe.rs b/backend/src/data/db/recipe.rs index d7b8978..4db47da 100644 --- a/backend/src/data/db/recipe.rs +++ b/backend/src/data/db/recipe.rs @@ -752,7 +752,7 @@ VALUES ($1, $2) Ok(()) } - pub async fn add_schedule_recipe( + pub async fn add_scheduled_recipe( &self, user_id: i64, recipe_id: i64, @@ -775,7 +775,7 @@ VALUES ($1, $2, $3, $4) .map_err(DBError::from) } - pub async fn remove_scheduled_recipe( + pub async fn rm_scheduled_recipe( &self, user_id: i64, recipe_id: i64, @@ -964,13 +964,13 @@ VALUES let tomorrow = today + Days::new(1); connection - .add_schedule_recipe(user_id, recipe_id_1, today, 4) + .add_scheduled_recipe(user_id, recipe_id_1, today, 4) .await?; connection - .add_schedule_recipe(user_id, recipe_id_2, yesterday, 4) + .add_scheduled_recipe(user_id, recipe_id_2, yesterday, 4) .await?; connection - .add_schedule_recipe(user_id, recipe_id_1, tomorrow, 4) + .add_scheduled_recipe(user_id, recipe_id_1, tomorrow, 4) .await?; assert_eq!( @@ -1008,13 +1008,13 @@ VALUES ); connection - .remove_scheduled_recipe(user_id, recipe_id_1, today) + .rm_scheduled_recipe(user_id, recipe_id_1, today) .await?; connection - .remove_scheduled_recipe(user_id, recipe_id_2, yesterday) + .rm_scheduled_recipe(user_id, recipe_id_2, yesterday) .await?; connection - .remove_scheduled_recipe(user_id, recipe_id_1, tomorrow) + .rm_scheduled_recipe(user_id, recipe_id_1, tomorrow) .await?; assert_eq!( diff --git a/backend/src/main.rs b/backend/src/main.rs index 5ac03eb..8a98761 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -181,6 +181,14 @@ async fn main() { "/calendar/get_scheduled_recipes", get(services::ron::get_scheduled_recipes), ) + .route( + "/calendar/schedule_recipe", + post(services::ron::schedule_recipe), + ) + .route( + "/calendar/remove_scheduled_recipe", + delete(services::ron::rm_scheduled_recipe), + ) .fallback(services::ron::not_found); let fragments_routes = Router::new().route( diff --git a/backend/src/services/ron.rs b/backend/src/services/ron.rs index bedccb1..cfc4650 100644 --- a/backend/src/services/ron.rs +++ b/backend/src/services/ron.rs @@ -631,6 +631,36 @@ pub async fn get_scheduled_recipes( } } +#[debug_handler] +pub async fn schedule_recipe( + State(connection): State, + Extension(user): Extension>, + ExtractRon(ron): ExtractRon, +) -> Result { + check_user_rights_recipe(&connection, &user, ron.recipe_id).await?; + if let Some(user) = user { + connection + .add_scheduled_recipe(user.id, ron.recipe_id, ron.date, ron.servings) + .await?; + } + Ok(StatusCode::OK) +} + +#[debug_handler] +pub async fn rm_scheduled_recipe( + State(connection): State, + Extension(user): Extension>, + ExtractRon(ron): ExtractRon, +) -> Result { + check_user_rights_recipe(&connection, &user, ron.recipe_id).await?; + if let Some(user) = user { + connection + .rm_scheduled_recipe(user.id, ron.recipe_id, ron.date) + .await?; + } + Ok(StatusCode::OK) +} + /// 404 /// #[debug_handler] diff --git a/backend/src/translation.rs b/backend/src/translation.rs index 1e272cb..189cf8c 100644 --- a/backend/src/translation.rs +++ b/backend/src/translation.rs @@ -141,6 +141,9 @@ pub enum Sentence { CalendarOctober, CalendarNovember, CalendarDecember, + CalendarAddToPlanner, + CalendarAddToPlannerSuccess, + CalendarDateFormat, // See https://docs.rs/chrono/latest/chrono/format/strftime/index.html. } pub const DEFAULT_LANGUAGE_CODE: &str = "en"; @@ -181,6 +184,10 @@ impl Tr { pub fn current_lang_code(&self) -> &str { &self.lang.code } + + pub fn current_lang_and_territory_code(&self) -> String { + format!("{}-{}", self.lang.code, self.lang.territory) + } } // #[macro_export] @@ -200,6 +207,7 @@ impl Tr { #[derive(Debug, Deserialize)] struct StoredLanguage { code: String, + territory: String, name: String, translation: Vec<(Sentence, String)>, } @@ -207,6 +215,7 @@ struct StoredLanguage { #[derive(Debug)] struct Language { code: String, + territory: String, name: String, translation: Vec, } @@ -215,6 +224,7 @@ impl Language { pub fn from_stored_language(stored_language: StoredLanguage) -> Self { Self { code: stored_language.code, + territory: stored_language.territory, name: stored_language.name, translation: { let mut translation = vec![String::new(); Sentence::COUNT]; diff --git a/backend/templates/base.html b/backend/templates/base.html index 8da3d5d..4991b56 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -1,5 +1,5 @@ - + diff --git a/backend/templates/recipe_view.html b/backend/templates/recipe_view.html index 231f85c..f32c8a3 100644 --- a/backend/templates/recipe_view.html +++ b/backend/templates/recipe_view.html @@ -9,7 +9,7 @@ {% if crate::data::model::can_user_edit_recipe(user, recipe) %} Edit {% endif %} - Add to planner + {{ tr.t(Sentence::CalendarAddToPlanner) }} {% endif %}
@@ -92,6 +92,9 @@ value="{{ user.default_servings }}"/>
{% endif %} + + {{ tr.t(Sentence::CalendarAddToPlannerSuccess) }} + {{ tr.t(Sentence::CalendarDateFormat) }} diff --git a/backend/translation.ron b/backend/translation.ron index f99b3a3..6b4d939 100644 --- a/backend/translation.ron +++ b/backend/translation.ron @@ -1,6 +1,7 @@ [ ( code: "en", + territory: "US", name: "English", translation: [ (MainTitle, "Cooking Recipes"), @@ -124,10 +125,14 @@ (CalendarOctober, "October"), (CalendarNovember, "November"), (CalendarDecember, "December"), + (CalendarAddToPlanner, "Add to planner"), + (CalendarAddToPlannerSuccess, "Recipe {} has been scheduled for {}"), + (CalendarDateFormat, "%A, %-d %B, %C%y"), ] ), ( code: "fr", + territory: "FR", name: "Français", translation: [ (MainTitle, "Recettes de Cuisine"), @@ -251,6 +256,9 @@ (CalendarOctober, "Octobre"), (CalendarNovember, "Novembre"), (CalendarDecember, "Décembre"), + (CalendarAddToPlanner, "Ajouter au planificateur"), + (CalendarAddToPlannerSuccess, "La recette {} a été agendée pour le {}"), + (CalendarDateFormat, "%A %-d %B %C%y"), ] ) ] \ No newline at end of file diff --git a/common/src/ron_api.rs b/common/src/ron_api.rs index 5ca697f..189cabd 100644 --- a/common/src/ron_api.rs +++ b/common/src/ron_api.rs @@ -17,7 +17,7 @@ pub struct Id { pub id: i64, } -/// RECIPE /// +/*** RECIPE ***/ #[derive(Serialize, Deserialize, Clone)] pub struct SetRecipeTitle { @@ -159,7 +159,7 @@ pub struct Ingredient { pub quantity_unit: String, } -/// PROFILE /// +/*** PROFILE ***/ #[derive(Serialize, Deserialize, Clone)] pub struct UpdateProfile { @@ -168,6 +168,29 @@ pub struct UpdateProfile { pub password: Option, } +/*** Calendar ***/ + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ScheduledRecipes { + // (Scheduled date, recipe title, recipe id). + pub recipes: Vec<(NaiveDate, String, i64)>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ScheduleRecipe { + pub recipe_id: i64, + pub date: NaiveDate, + pub servings: u32, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ScheduledRecipe { + pub recipe_id: i64, + pub date: NaiveDate, +} + +/*** Misc ***/ + pub fn to_string(ron: T) -> String where T: Serialize, @@ -175,11 +198,3 @@ where // TODO: handle'unwrap'. to_string_pretty(&ron, PrettyConfig::new()).unwrap() } - -/// Calendar /// - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct ScheduledRecipes { - // (Scheduled date, recipe title, recipe id). - pub recipes: Vec<(NaiveDate, String, i64)>, -} diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index adc06e1..d07892d 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -13,7 +13,7 @@ default = ["console_error_panic_hook"] [dependencies] common = { path = "../common" } -chrono = { version = "0.4", features = ["serde"] } +chrono = { version = "0.4", features = ["serde", "unstable-locales"] } ron = "0.8" serde = { version = "1.0", features = ["derive"] } diff --git a/frontend/src/calendar.rs b/frontend/src/calendar.rs index 64c16e5..58e1d4e 100644 --- a/frontend/src/calendar.rs +++ b/frontend/src/calendar.rs @@ -18,7 +18,7 @@ struct CalendarStateInternal { } #[derive(Clone)] -struct CalendarState { +pub struct CalendarState { internal_state: Rc>, } @@ -56,7 +56,7 @@ impl CalendarState { } } -pub fn setup(calendar: Element) { +pub fn setup(calendar: Element) -> CalendarState { let prev: Element = calendar.selector(".prev"); let next: Element = calendar.selector(".next"); @@ -98,6 +98,8 @@ pub fn setup(calendar: Element) { } }) .forget(); + + state } const NB_CALENDAR_ROW: u64 = 5; diff --git a/frontend/src/modal_dialog.rs b/frontend/src/modal_dialog.rs index 477df12..9095c9b 100644 --- a/frontend/src/modal_dialog.rs +++ b/frontend/src/modal_dialog.rs @@ -7,12 +7,26 @@ use crate::{ }; pub async fn show(element_selector: &str) -> bool { - show_and_initialize(element_selector, async |_| {}).await + show_and_initialize(element_selector, async |_| Some(())) + .await + .is_some() } -pub async fn show_and_initialize(element_selector: &str, initializer: T) -> bool +pub async fn show_and_initialize(element_selector: &str, initializer: T) -> Option where - T: AsyncFn(Element), + T: AsyncFn(Element) -> U, +{ + show_and_initialize_with_ok(element_selector, initializer, |_, result| result).await +} + +pub async fn show_and_initialize_with_ok( + element_selector: &str, + initializer: T, + ok: V, +) -> Option +where + T: AsyncFn(Element) -> U, + V: Fn(Element, U) -> W, { let dialog: HtmlDialogElement = by_id("modal-dialog"); @@ -24,7 +38,7 @@ where let element: Element = selector_and_clone(element_selector); content_element.set_inner_html(""); content_element.append_child(&element).unwrap(); - initializer(element).await; + let init_result = initializer(element.clone()).await; dialog.show_modal().unwrap(); @@ -34,8 +48,8 @@ where pin_mut!(click_ok, click_cancel); let result = select! { - () = click_ok => true, - () = click_cancel => false, + () = click_ok => Some(ok(element, init_result)), + () = click_cancel => None, }; dialog.close(); diff --git a/frontend/src/recipe_edit.rs b/frontend/src/recipe_edit.rs index d9c2c5a..5a8dab5 100644 --- a/frontend/src/recipe_edit.rs +++ b/frontend/src/recipe_edit.rs @@ -19,11 +19,6 @@ use crate::{ utils::{by_id, selector, selector_and_clone, SelectorExt}, }; -use futures::{ - future::{FutureExt, Ready}, - pin_mut, select, Future, -}; - pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> { // Title. { @@ -265,6 +260,7 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> { }, ) .await + .is_some() { let body = ron_api::Id { id: recipe_id }; let _ = request::delete::<(), _>("recipe/remove", body).await; @@ -400,6 +396,7 @@ fn create_group_element(group: &ron_api::Group) -> Element { }, ) .await + .is_some() { let body = ron_api::Id { id: group_id }; let _ = request::delete::<(), _>("recipe/remove_group", body).await; @@ -542,6 +539,7 @@ fn create_step_element(group_element: &Element, step: &ron_api::Step) -> Element }, ) .await + .is_some() { let body = ron_api::Id { id: step_id }; let _ = request::delete::<(), _>("recipe/remove_step", body).await; @@ -696,6 +694,7 @@ fn create_ingredient_element(step_element: &Element, ingredient: &ron_api::Ingre }, ) .await + .is_some() { let body = ron_api::Id { id: ingredient_id }; let _ = request::delete::<(), _>("recipe/remove_ingredient", body).await; @@ -717,7 +716,7 @@ async fn reload_recipes_list(current_recipe_id: i64) { .await { Err(error) => { - toast::show(Level::Info, &format!("Internal server error: {}", error)); + toast::show_message(Level::Info, &format!("Internal server error: {}", error)); } Ok(response) => { let list = document().get_element_by_id("recipes-list").unwrap(); diff --git a/frontend/src/recipe_view.rs b/frontend/src/recipe_view.rs index bee05c6..26b9699 100644 --- a/frontend/src/recipe_view.rs +++ b/frontend/src/recipe_view.rs @@ -1,8 +1,9 @@ -use std::future::Future; +use std::{cell::RefCell, future::Future, rc::Rc, str::FromStr}; -use common::ron_api; +use chrono::Locale; +use common::{ron_api, utils::substitute}; use gloo::{ - console::console, + console::log, events::EventListener, net::http::Request, utils::{document, window}, @@ -24,13 +25,59 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> { let add_to_planner: Element = selector("#recipe-view .add-to-planner"); EventListener::new(&add_to_planner, "click", move |_event| { spawn_local(async move { - modal_dialog::show_and_initialize( + if let Some((date, servings)) = modal_dialog::show_and_initialize_with_ok( "#hidden-templates .date-and-servings", - async |element| { - calendar::setup(element.selector(".calendar")); + async |element| calendar::setup(element.selector(".calendar")), + |element, calendar_state| { + let servings_element: HtmlInputElement = element.selector("#input-servings"); + ( + calendar_state.get_selected_date().date_naive(), + servings_element.value_as_number() as u32, + ) }, ) - .await; + .await + { + if request::post::<(), _>( + "calendar/schedule_recipe", + ron_api::ScheduleRecipe { + recipe_id, + date, + servings, + }, + ) + .await + .is_ok() + { + toast::show_element_and_initialize( + Level::Success, + "#hidden-templates .calendar-add-to-planner-success", + |element| { + let title = + selector::("#recipe-view .recipe-title").inner_html(); + let date_format = + selector::("#hidden-templates .calendar-date-format") + .inner_html(); + let locale = { + let lang_and_territory = selector::("html") + .get_attribute("lang") + .unwrap() + .replace("-", "_"); + Locale::from_str(&lang_and_territory).unwrap_or_default() + }; + + element.set_inner_html(&substitute( + &element.inner_html(), + "{}", + &[ + &title, + &date.format_localized(&date_format, locale).to_string(), + ], + )); + }, + ); + } + } }); }) .forget(); diff --git a/frontend/src/request.rs b/frontend/src/request.rs index 306f96b..bb3b2c3 100644 --- a/frontend/src/request.rs +++ b/frontend/src/request.rs @@ -1,5 +1,8 @@ use common::ron_api; -use gloo::net::http::{Request, RequestBuilder}; +use gloo::{ + console::log, + net::http::{Request, RequestBuilder}, +}; use serde::{de::DeserializeOwned, Serialize}; use thiserror::Error; @@ -62,19 +65,23 @@ where { match request.send().await { Err(error) => { - toast::show(Level::Info, &format!("Internal server error: {}", error)); + toast::show_message(Level::Info, &format!("Internal server error: {}", error)); Err(Error::Gloo(error)) } Ok(response) => { if !response.ok() { - toast::show( + toast::show_message( Level::Info, &format!("HTTP error: {}", response.status_text()), ); Err(Error::Http(response.status_text())) } else { - // Ok(()) - Ok(ron::de::from_bytes::(&response.binary().await?)?) + let mut r = response.binary().await?; + // An empty response is considered to be an unit value. + if r.is_empty() { + r = b"()".to_vec(); + } + Ok(ron::de::from_bytes::(&r)?) } } } diff --git a/frontend/src/toast.rs b/frontend/src/toast.rs index 1c30a71..b58cbbd 100644 --- a/frontend/src/toast.rs +++ b/frontend/src/toast.rs @@ -1,4 +1,7 @@ use gloo::{timers::callback::Timeout, utils::document}; +use web_sys::Element; + +use crate::utils::{by_id, selector_and_clone, SelectorExt}; pub enum Level { Success, @@ -7,12 +10,36 @@ pub enum Level { Warning, } -pub fn show(level: Level, message: &str) { +const TIME_DISPLAYED: u32 = 10_000; // [ms]. + +pub fn show_message(level: Level, message: &str) { let toast_element = document().get_element_by_id("toast").unwrap(); toast_element.set_inner_html(message); toast_element.set_class_name("show"); - Timeout::new(4_000, move || { + Timeout::new(TIME_DISPLAYED, move || { + toast_element.set_class_name(""); + }) + .forget(); +} + +pub fn show_element(level: Level, selector: &str) { + show_element_and_initialize(level, selector, |_| {}) +} + +pub fn show_element_and_initialize(level: Level, selector: &str, initializer: T) +where + T: Fn(Element), +{ + let toast_element = document().get_element_by_id("toast").unwrap(); + + 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"); + + Timeout::new(TIME_DISPLAYED, move || { toast_element.set_class_name(""); }) .forget();