From: Greg Burri Date: Sat, 8 Feb 2025 21:31:38 +0000 (+0100) Subject: Calendar is now displayed on home page and recipes can be scheduled without being... X-Git-Url: https://git.euphorik.ch/?a=commitdiff_plain;h=37721ac3ea758b9a70958e07a085e3168a055494;p=recipes.git Calendar is now displayed on home page and recipes can be scheduled without being logged --- diff --git a/Cargo.lock b/Cargo.lock index e250eba..5963baa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,6 +212,7 @@ dependencies = [ "axum-core", "bytes", "cookie", + "form_urlencoded", "futures-util", "http 1.2.0", "http-body", @@ -219,6 +220,8 @@ dependencies = [ "mime", "pin-project-lite", "serde", + "serde_html_form", + "serde_path_to_error", "tower", "tower-layer", "tower-service", @@ -333,9 +336,9 @@ checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" [[package]] name = "cc" -version = "1.2.11" +version = "1.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4730490333d58093109dc02c23174c3f4d490998c3fed3cc8e82d57afedb9cf" +checksum = "c7777341816418c02e033934a09f20dc0ccaf65a5201ef8a450ae0105a573fda" dependencies = [ "shlex", ] @@ -374,9 +377,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.27" +version = "4.5.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "769b0145982b4b48713e01ec42d61614425f27b7058bda7180a3a41f30104796" +checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff" dependencies = [ "clap_builder", "clap_derive", @@ -396,9 +399,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.24" +version = "4.5.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c" +checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" dependencies = [ "heck", "proc-macro2", @@ -454,9 +457,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "convert_case" -version = "0.6.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" dependencies = [ "unicode-segmentation", ] @@ -549,18 +552,18 @@ dependencies = [ [[package]] name = "derive_more" -version = "1.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "1.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "convert_case", "proc-macro2", @@ -710,6 +713,7 @@ dependencies = [ "gloo", "ron", "serde", + "serde_html_form", "thiserror 2.0.11", "wasm-bindgen", "wasm-bindgen-futures", @@ -1676,9 +1680,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.2" +version = "1.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" [[package]] name = "overload" @@ -1894,7 +1898,7 @@ checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.0", - "zerocopy 0.8.14", + "zerocopy 0.8.17", ] [[package]] @@ -1933,7 +1937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b08f3c9802962f7e1b25113931d94f43ed9725bebc59db9d0c3e9a23b67e15ff" dependencies = [ "getrandom 0.3.1", - "zerocopy 0.8.14", + "zerocopy 0.8.17", ] [[package]] @@ -2114,9 +2118,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc-hash" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" @@ -2221,6 +2225,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_html_form" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_json" version = "1.0.138" @@ -3433,11 +3450,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.14" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a367f292d93d4eab890745e75a778da40909cab4d6ff8173693812f79c4a2468" +checksum = "aa91407dacce3a68c56de03abe2760159582b846c6a4acd2f456618087f12713" dependencies = [ - "zerocopy-derive 0.8.14", + "zerocopy-derive 0.8.17", ] [[package]] @@ -3453,9 +3470,9 @@ dependencies = [ [[package]] name = "zerocopy-derive" -version = "0.8.14" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3931cb58c62c13adec22e38686b559c86a30565e16ad6e8510a337cedc611e1" +checksum = "06718a168365cad3d5ff0bb133aad346959a2074bd4a85c121255a11304a8626" dependencies = [ "proc-macro2", "quote", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b52bb93..abaa4d6 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" common = { path = "../common" } axum = { version = "0.8", features = ["macros"] } -axum-extra = { version = "0.10", features = ["cookie"] } +axum-extra = { version = "0.10", features = ["cookie", "query"] } tokio = { version = "1", features = ["full"] } tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["fs", "trace"] } @@ -44,5 +44,5 @@ lettre = { version = "0.11", default-features = false, features = [ "tokio1-rustls-tls", ] } -derive_more = { version = "1", features = ["full"] } +derive_more = { version = "2", features = ["full"] } thiserror = "2" diff --git a/backend/scss/calendar.scss b/backend/scss/calendar.scss index c14668b..822325e 100644 --- a/backend/scss/calendar.scss +++ b/backend/scss/calendar.scss @@ -55,4 +55,15 @@ } } } +} + +// Deactivate recipe links in dialog mode. +dialog .calendar .scheduled-recipe { + pointer-events: none; + cursor: text; + text-decoration: none; +} + +#hidden-templates-calendar { + display: none; } \ No newline at end of file diff --git a/backend/scss/modal-dialog.scss b/backend/scss/modal-dialog.scss index 597d842..f21a3e6 100644 --- a/backend/scss/modal-dialog.scss +++ b/backend/scss/modal-dialog.scss @@ -1,8 +1,8 @@ #modal-dialog { // visibility: hidden; color: white; - width: 500px; - margin-left: -250px; + width: 800px; + margin-left: -400px; background-color: black; text-align: center; border-radius: 2px; diff --git a/backend/sql/version_1.sql b/backend/sql/version_1.sql index 122a014..07b67fe 100644 --- a/backend/sql/version_1.sql +++ b/backend/sql/version_1.sql @@ -182,7 +182,7 @@ CREATE INDEX [RecipeScheduled_date_index] ON [RecipeScheduled]([date]); CREATE TABLE [ShoppingEntry] ( [id] INTEGER PRIMARY KEY, [user_id] INTEGER NOT NULL, - -- The linkded ingredient can be deleted or a custom entry can be manually added. + -- The linked ingredient can be deleted or a custom entry can be manually added. -- In both cases [name], [quantity_value] and [quantity_unit] are used to display -- the entry instead of [Ingredient] data. [ingredient_id] INTEGER, diff --git a/backend/src/data/db/recipe.rs b/backend/src/data/db/recipe.rs index 0776a84..3737114 100644 --- a/backend/src/data/db/recipe.rs +++ b/backend/src/data/db/recipe.rs @@ -1,7 +1,7 @@ use chrono::prelude::*; use common::ron_api::Difficulty; use itertools::Itertools; -use sqlx::Error; +use sqlx::{Error, Sqlite}; use super::{Connection, DBError, Result}; use crate::data::model; @@ -64,6 +64,37 @@ ORDER BY [title] .map_err(DBError::from) } + /// Returns titles associated to given ids in the same order. + /// Empty string for unknown id. + pub async fn get_recipe_titles(&self, ids: &[i64]) -> Result> { + let mut query_builder: sqlx::QueryBuilder = + sqlx::QueryBuilder::new("SELECT [id], [title] FROM [Recipe] WHERE [id] IN("); + let mut separated = query_builder.separated(", "); + for id in ids { + separated.push_bind(id); + } + separated.push_unseparated(")"); + let query = query_builder.build_query_as::<(i64, String)>(); + let titles = query.fetch_all(&self.pool).await?; + let mut result = vec![]; + // Warning: O(n^2), OK for small number of ids. + for id in ids { + result.push( + titles + .iter() + .find_map(|(fetched_id, title)| { + if fetched_id == id { + Some(title.clone()) + } else { + None + } + }) + .unwrap_or_default(), + ); + } + Ok(result) + } + pub async fn can_edit_recipe(&self, user_id: i64, recipe_id: i64) -> Result { sqlx::query_scalar( r#" diff --git a/backend/src/main.rs b/backend/src/main.rs index 8a98761..e4c597f 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -107,6 +107,7 @@ async fn main() { // Disabled: update user profile is now made with a post data ('edit_user_post'). // .route("/user/update", put(services::ron::update_user)) .route("/set_lang", put(services::ron::set_lang)) + .route("/recipe/get_titles", get(services::ron::get_titles)) .route("/recipe/set_title", put(services::ron::set_recipe_title)) .route( "/recipe/set_description", diff --git a/backend/src/services/ron.rs b/backend/src/services/ron.rs index 192c65c..3c60ba0 100644 --- a/backend/src/services/ron.rs +++ b/backend/src/services/ron.rs @@ -1,12 +1,14 @@ use axum::{ debug_handler, - extract::{Extension, Query, State}, + extract::{Extension, State}, http::{HeaderMap, StatusCode}, response::{ErrorResponse, IntoResponse, Response, Result}, }; -use axum_extra::extract::cookie::{Cookie, CookieJar}; -use chrono::NaiveDate; -use serde::Deserialize; +use axum_extra::extract::{ + cookie::{Cookie, CookieJar}, + Query, +}; +use serde::{Deserialize, Serialize}; // use tracing::{event, Level}; use crate::{ @@ -20,11 +22,15 @@ use crate::{ const NOT_AUTHORIZED_MESSAGE: &str = "Action not authorized"; #[derive(Deserialize)] -pub struct RecipeId { - #[serde(rename = "recipe_id")] +pub struct Id { id: i64, } +#[derive(Deserialize, Serialize)] +pub struct Ids { + ids: Vec, +} + // #[allow(dead_code)] // #[debug_handler] // pub async fn update_user( @@ -67,6 +73,8 @@ pub async fn set_lang( Ok((jar, StatusCode::OK)) } +/*** Rights ***/ + async fn check_user_rights_recipe( connection: &db::Connection, user: &Option, @@ -200,6 +208,20 @@ async fn check_user_rights_recipe_ingredients( } } +/*** Recipe ***/ + +/// Ask recipe titles associated with each given id. The returned titles are in the same order +/// as the given ids. +#[debug_handler] +pub async fn get_titles( + State(connection): State, + recipe_ids: Query, +) -> Result { + Ok(ron_response_ok(common::ron_api::Strings { + strs: connection.get_recipe_titles(&recipe_ids.ids).await?, + })) +} + #[debug_handler] pub async fn set_recipe_title( State(connection): State, @@ -255,7 +277,7 @@ pub async fn set_estimated_time( #[debug_handler] pub async fn get_tags( State(connection): State, - recipe_id: Query, + recipe_id: Query, ) -> Result { Ok(ron_response_ok(common::ron_api::Tags { recipe_id: recipe_id.id, @@ -395,7 +417,7 @@ impl From for common::ron_api::Ingredient { #[debug_handler] pub async fn get_groups( State(connection): State, - recipe_id: Query, + recipe_id: Query, ) -> Result { // Here we don't check user rights on purpose. Ok(ron_response_ok( @@ -598,17 +620,11 @@ pub async fn set_ingredients_order( /// Calendar /// -#[derive(Deserialize)] -pub struct DateRange { - start_date: NaiveDate, - end_date: NaiveDate, -} - #[debug_handler] pub async fn get_scheduled_recipes( State(connection): State, Extension(user): Extension>, - date_range: Query, + date_range: Query, ) -> Result { if let Some(user) = user { Ok(ron_response_ok(common::ron_api::ScheduledRecipes { diff --git a/backend/src/services/user.rs b/backend/src/services/user.rs index ede66c5..55da879 100644 --- a/backend/src/services/user.rs +++ b/backend/src/services/user.rs @@ -3,14 +3,14 @@ use std::{collections::HashMap, net::SocketAddr}; use axum::{ body::Body, debug_handler, - extract::{ConnectInfo, Extension, Query, Request, State}, + extract::{ConnectInfo, Extension, Request, State}, http::HeaderMap, response::{Html, IntoResponse, Redirect, Response}, Form, }; use axum_extra::extract::{ cookie::{Cookie, CookieJar}, - Host, + Host, Query, }; use chrono::Duration; use lettre::Address; diff --git a/backend/templates/base.html b/backend/templates/base.html index 4991b56..8b24282 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -1,5 +1,5 @@ - + diff --git a/backend/templates/calendar.html b/backend/templates/calendar.html index 9793da8..25f5cf3 100644 --- a/backend/templates/calendar.html +++ b/backend/templates/calendar.html @@ -45,4 +45,8 @@ {% endfor %} {% endfor %} + +
+
X
+
\ No newline at end of file diff --git a/backend/templates/home.html b/backend/templates/home.html index 3177dab..a94dfe7 100644 --- a/backend/templates/home.html +++ b/backend/templates/home.html @@ -3,7 +3,7 @@ {% block content %}
- HOME: TODO + {% include "calendar.html" %}
{% endblock %} \ No newline at end of file diff --git a/backend/templates/recipe_view.html b/backend/templates/recipe_view.html index 1d899b9..42fee49 100644 --- a/backend/templates/recipe_view.html +++ b/backend/templates/recipe_view.html @@ -9,9 +9,10 @@ {% if crate::data::model::can_user_edit_recipe(user, recipe) %} Edit {% endif %} - {{ tr.t(Sentence::CalendarAddToPlanner) }} {% endif %} + {{ tr.t(Sentence::CalendarAddToPlanner) }} +
{% for tag in recipe.tags %} {{ tag }} @@ -81,17 +82,21 @@
{# To create a modal dialog to choose a date and and servings #} - {% if let Some(user) = user %} -
- {% include "calendar.html" %} - - -
- {% endif %} +
+ {% include "calendar.html" %} + + +
{{ tr.t(Sentence::CalendarAddToPlannerSuccess) }} {{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }} diff --git a/common/src/ron_api.rs b/common/src/ron_api.rs index 6b610ec..1dc3b05 100644 --- a/common/src/ron_api.rs +++ b/common/src/ron_api.rs @@ -7,6 +7,8 @@ pub struct SetLang { pub lang: String, } +/*** Generic types ***/ + #[derive(Serialize, Deserialize, Clone)] pub struct Ids { pub ids: Vec, @@ -17,7 +19,18 @@ pub struct Id { pub id: i64, } -/*** RECIPE ***/ +#[derive(Serialize, Deserialize, Clone)] +pub struct Strings { + pub strs: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct DateRange { + pub start_date: NaiveDate, + pub end_date: NaiveDate, +} + +/*** Recipe ***/ #[derive(Serialize, Deserialize, Clone)] pub struct SetRecipeTitle { diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index d07892d..780b299 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -17,6 +17,7 @@ chrono = { version = "0.4", features = ["serde", "unstable-locales"] } ron = "0.8" serde = { version = "1.0", features = ["derive"] } +serde_html_form = "0.2" thiserror = "2" futures = "0.3" @@ -30,12 +31,14 @@ web-sys = { version = "0.3", features = [ "NodeList", "Window", "Location", + "Storage", "EventTarget", "DragEvent", "DataTransfer", "DomRect", "KeyboardEvent", "Element", + "DomStringMap", "HtmlElement", "HtmlDivElement", "HtmlLabelElement", diff --git a/frontend/src/calendar.rs b/frontend/src/calendar.rs index 58e1d4e..62db389 100644 --- a/frontend/src/calendar.rs +++ b/frontend/src/calendar.rs @@ -1,20 +1,21 @@ -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, default, rc::Rc}; -use chrono::{offset::Local, DateTime, Datelike, Days, Months, Weekday}; +use chrono::{offset::Local, DateTime, Datelike, Days, Months, NaiveDate, Weekday}; use common::ron_api; -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; use crate::{ + recipe_scheduler::{self, RecipeScheduler}, request, - utils::{by_id, SelectorExt}, + utils::{by_id, selector, selector_all, SelectorExt}, }; struct CalendarStateInternal { - displayed_date: DateTime, - selected_date: DateTime, + displayed_date: NaiveDate, + selected_date: NaiveDate, } #[derive(Clone)] @@ -24,7 +25,7 @@ pub struct CalendarState { impl CalendarState { pub fn new() -> Self { - let current_date = Local::now(); + let current_date = Local::now().date_naive(); Self { internal_state: Rc::new(RefCell::new(CalendarStateInternal { displayed_date: current_date, @@ -43,33 +44,48 @@ impl CalendarState { state_borrowed.displayed_date = state_borrowed.displayed_date - Months::new(1); } - pub fn get_displayed_date(&self) -> DateTime { + pub fn get_displayed_date(&self) -> NaiveDate { self.internal_state.borrow().displayed_date } - pub fn get_selected_date(&self) -> DateTime { + pub fn get_selected_date(&self) -> NaiveDate { self.internal_state.borrow().selected_date } - pub fn set_selected_date(&self, date: DateTime) { + pub fn set_selected_date(&self, date: NaiveDate) { self.internal_state.borrow_mut().selected_date = date; } } -pub fn setup(calendar: Element) -> CalendarState { +#[derive(Clone, Copy)] +pub struct CalendarOptions { + pub can_select_date: bool, + // pub show_scheduled_recipes: bool, +} + +pub fn setup( + calendar: Element, + options: CalendarOptions, + recipe_scheduler: RecipeScheduler, +) -> CalendarState { let prev: Element = calendar.selector(".prev"); let next: Element = calendar.selector(".next"); let state = CalendarState::new(); - display_month(&calendar, state.clone()); + display_month(&calendar, state.clone(), options, recipe_scheduler); // Click on previous month. let calendar_clone = calendar.clone(); let state_clone = state.clone(); EventListener::new(&prev, "click", move |_event| { state_clone.displayed_date_previous_month(); - display_month(&calendar_clone, state_clone.clone()); + display_month( + &calendar_clone, + state_clone.clone(), + options, + recipe_scheduler, + ); }) .forget(); @@ -78,33 +94,55 @@ pub fn setup(calendar: Element) -> CalendarState { let state_clone = state.clone(); EventListener::new(&next, "click", move |_event| { state_clone.displayed_date_next_month(); - display_month(&calendar_clone, state_clone.clone()); + display_month( + &calendar_clone, + state_clone.clone(), + options, + recipe_scheduler, + ); }) .forget(); // Click on a day of the current month. - let days: Element = calendar.selector(".days"); - let calendar_clone = calendar.clone(); - let state_clone = state.clone(); - EventListener::new(&days, "click", move |event| { - let target: Element = event.target().unwrap().dyn_into().unwrap(); - if target.class_name() == "number" { - let first_day = first_grid_day(state_clone.get_displayed_date()); - let day_grid_id = target.parent_element().unwrap().id(); - let day_offset = day_grid_id[9..10].parse::().unwrap() * 7 - + day_grid_id[10..11].parse::().unwrap(); - state_clone.set_selected_date(first_day + Days::new(day_offset)); - display_month(&calendar_clone, state_clone.clone()); - } - }) - .forget(); + if options.can_select_date { + let days: Element = calendar.selector(".days"); + let calendar_clone = calendar.clone(); + let state_clone = state.clone(); + EventListener::new(&days, "click", move |event| { + let target: Element = event.target().unwrap().dyn_into().unwrap(); + + // log!(event); + + if target.class_name() == "number" { + let first_day = first_grid_day(state_clone.get_displayed_date()); + let day_grid_id = target.parent_element().unwrap().id(); + let day_offset = day_grid_id[9..10].parse::().unwrap() * 7 + + day_grid_id[10..11].parse::().unwrap(); + state_clone.set_selected_date(first_day + Days::new(day_offset)); + display_month( + &calendar_clone, + state_clone.clone(), + options, + recipe_scheduler, + ); + } else if target.class_name() == "remove-scheduled-recipe" { + log!("REMOVE"); // TODO. + } + }) + .forget(); + } state } const NB_CALENDAR_ROW: u64 = 5; -fn display_month(calendar: &Element, state: CalendarState) { +fn display_month( + calendar: &Element, + state: CalendarState, + options: CalendarOptions, + recipe_scheduler: RecipeScheduler, +) { let date = state.get_displayed_date(); calendar @@ -129,6 +167,7 @@ fn display_month(calendar: &Element, state: CalendarState) { for i in 0..NB_CALENDAR_ROW { for j in 0..7 { let day_element: Element = by_id(&format!("day-grid-{}{}", i, j)); + let day_content: Element = day_element.selector(".number"); day_content.set_inner_html(¤t.day().to_string()); @@ -140,13 +179,13 @@ fn display_month(calendar: &Element, state: CalendarState) { } class_name += "current-month"; } - if current.date_naive() == Local::now().date_naive() { + if current == Local::now().date_naive() { if !class_name.is_empty() { class_name += " "; } class_name += "today"; } - if current.date_naive() == state.get_selected_date().date_naive() { + if options.can_select_date && current == state.get_selected_date() { if !class_name.is_empty() { class_name += " "; } @@ -158,30 +197,82 @@ fn display_month(calendar: &Element, state: CalendarState) { } } + // Load and display scheduled recipes. + // if options.show_scheduled_recipes { spawn_local(async move { - let scheduled_recipes: ron_api::ScheduledRecipes = request::get( - "calendar/get_scheduled_recipes", - [ - ("start_date", first_day.date_naive().to_string()), - ( - "end_date", - (first_day + Days::new(NB_CALENDAR_ROW * 7)) - .date_naive() - .to_string(), - ), - ], - ) - .await - .unwrap(); - - for recipe in scheduled_recipes.recipes { - // log!(recipe.1); - // TODO + // let scheduled_recipes: ron_api::ScheduledRecipes = request::get( + // "calendar/get_scheduled_recipes", + // [ + // ("start_date", first_day.date_naive().to_string()), + // ( + // "end_date", + // (first_day + Days::new(NB_CALENDAR_ROW * 7)) + // .date_naive() + // .to_string(), + // ), + // ], + // ) + // .await + // .unwrap(); + + let scheduled_recipes = recipe_scheduler + .get_scheduled_recipes(first_day, first_day + Days::new(NB_CALENDAR_ROW * 7)) + .await + .unwrap(); + + for scheduled_recipe in selector_all::(".scheduled-recipes") { + scheduled_recipe.set_inner_html(""); + } + + if !scheduled_recipes.is_empty() { + let recipe_template: Element = selector("#hidden-templates-calendar .scheduled-recipe"); + for (date, title, recipe_id) in scheduled_recipes { + let id = format!("scheduled-recipe-{}-{}", recipe_id, date); + if document().get_element_by_id(&id).is_some() { + continue; + } + + let delta_from_first_day = (date - first_day).num_days(); + let i = delta_from_first_day / 7; + let j = delta_from_first_day % 7; + let scheduled_recipes_element: Element = + selector(&format!("#day-grid-{}{} .scheduled-recipes", i, j)); + + let recipe_element = recipe_template + .clone_node_with_deep(true) + .unwrap() + .dyn_into::() + .unwrap(); + recipe_element.set_id(&id); + + let recipe_link_element: Element = recipe_element.selector("a"); + + // let recipe_remove_element: Element = + // recipe_element.selector(".remove-scheduled-recipe"); + // + // EventListener::new(&recipe_remove_element, "click", move |_event| { + // log!("CLICK REMOVE"); + // }) + // .forget(); + + recipe_link_element + .set_attribute("href", &format!("/recipe/view/{}", recipe_id)) + .unwrap(); + + recipe_link_element.set_inner_html(&title); + scheduled_recipes_element + .append_child(&recipe_element) + .unwrap(); + + // log!(&title); + // TODO + } } }); + // } } -fn first_grid_day(mut date: DateTime) -> DateTime { +pub fn first_grid_day(mut date: NaiveDate) -> NaiveDate { while (date - Days::new(1)).month() == date.month() { date = date - Days::new(1); } diff --git a/frontend/src/home.rs b/frontend/src/home.rs new file mode 100644 index 0000000..f7ecfe8 --- /dev/null +++ b/frontend/src/home.rs @@ -0,0 +1,29 @@ +use std::str::FromStr; + +use chrono::Locale; +use common::{ron_api, utils::substitute_with_names}; +use gloo::events::EventListener; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::spawn_local; +use web_sys::{Element, HtmlElement, HtmlInputElement}; + +use crate::{ + calendar, modal_dialog, + recipe_scheduler::RecipeScheduler, + request, + toast::{self, Level}, + utils::{get_locale, selector, SelectorExt}, +}; + +pub fn setup_page(is_user_logged: bool) -> Result<(), JsValue> { + let recipe_scheduler = RecipeScheduler::new(!is_user_logged); + + calendar::setup( + selector(".calendar"), + calendar::CalendarOptions { + can_select_date: false, + }, + recipe_scheduler, + ); + Ok(()) +} diff --git a/frontend/src/lib.rs b/frontend/src/lib.rs index 4ac25e3..d9d2365 100644 --- a/frontend/src/lib.rs +++ b/frontend/src/lib.rs @@ -3,12 +3,16 @@ use gloo::{console::log, events::EventListener, utils::window}; use utils::by_id; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::spawn_local; -use web_sys::HtmlSelectElement; +use web_sys::{HtmlElement, HtmlSelectElement}; + +use crate::utils::selector; mod calendar; +mod home; mod modal_dialog; mod on_click; mod recipe_edit; +mod recipe_scheduler; mod recipe_view; mod request; mod toast; @@ -21,6 +25,12 @@ pub fn main() -> Result<(), JsValue> { let location = window().location().pathname()?; let path: Vec<&str> = location.split('/').skip(1).collect(); + let is_user_logged = selector::("html") + .dataset() + .get("userLogged") + .map(|v| v == "true") + .unwrap_or_default(); + // if let ["recipe", "edit", id] = path[..] { match path[..] { ["recipe", "edit", id] => { @@ -31,7 +41,13 @@ pub fn main() -> Result<(), JsValue> { } ["recipe", "view", id] => { let id = id.parse::().unwrap(); // TODO: remove unwrap. - if let Err(error) = recipe_view::setup_page(id) { + if let Err(error) = recipe_view::setup_page(id, is_user_logged) { + log!(error); + } + } + // Home. + [""] => { + if let Err(error) = home::setup_page(is_user_logged) { log!(error); } } diff --git a/frontend/src/recipe_edit.rs b/frontend/src/recipe_edit.rs index 5a8dab5..96e18b2 100644 --- a/frontend/src/recipe_edit.rs +++ b/frontend/src/recipe_edit.rs @@ -157,10 +157,12 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> { // Tags. { spawn_local(async move { - let tags: ron_api::Tags = - request::get("recipe/get_tags", [("recipe_id", &recipe_id.to_string())]) - .await - .unwrap(); + let tags: ron_api::Tags = request::get( + "recipe/get_tags", + ron_api::Id { id: recipe_id }, /*[("id", &recipe_id.to_string())]*/ + ) + .await + .unwrap(); create_tag_elements(recipe_id, &tags.tags); }); @@ -279,7 +281,7 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> { { spawn_local(async move { let groups: Vec = - request::get("recipe/get_groups", [("recipe_id", &recipe_id.to_string())]) + request::get("recipe/get_groups", ron_api::Id { id: recipe_id }) .await .unwrap(); diff --git a/frontend/src/recipe_scheduler.rs b/frontend/src/recipe_scheduler.rs new file mode 100644 index 0000000..5a27e6e --- /dev/null +++ b/frontend/src/recipe_scheduler.rs @@ -0,0 +1,150 @@ +use chrono::{Datelike, Days, Months, NaiveDate}; +use common::ron_api; +use gloo::storage::{LocalStorage, Storage}; +use ron::ser::{to_string_pretty, PrettyConfig}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{calendar, request}; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Request error: {0}")] + Request(#[from] request::Error), +} + +type Result = std::result::Result; + +#[derive(Clone, Copy)] +pub struct RecipeScheduler { + is_local: bool, +} + +pub enum ScheduleRecipeResult { + Ok, + RecipeAlreadyScheduledAtThisDate, +} + +impl From for ScheduleRecipeResult { + fn from(api_res: ron_api::ScheduleRecipeResult) -> Self { + match api_res { + ron_api::ScheduleRecipeResult::Ok => Self::Ok, + ron_api::ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => { + Self::RecipeAlreadyScheduledAtThisDate + } + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct ScheduledRecipesStore { + recipe_id: i64, + date: NaiveDate, +} + +fn build_key(year: i32, month: u32) -> String { + format!("scheduled_recipes-{}-{}", year, month) +} + +fn load_scheduled_recipes(year: i32, month: u32) -> Vec { + LocalStorage::get::>(build_key(year, month)).unwrap_or_default() +} + +fn save_scheduled_recipes(scheduled_recipes: Vec, year: i32, month: u32) { + LocalStorage::set(build_key(year, month), scheduled_recipes).unwrap(); +} + +impl RecipeScheduler { + pub fn new(is_local: bool) -> Self { + Self { is_local } + } + + pub async fn get_scheduled_recipes( + &self, + start_date: NaiveDate, + end_date: NaiveDate, + ) -> Result> { + if self.is_local { + let mut recipe_ids_and_dates = vec![]; + let mut current_date = start_date; + while current_date <= end_date { + current_date = current_date + Months::new(1); + for recipe in load_scheduled_recipes(current_date.year(), current_date.month0()) { + if recipe.date >= start_date && recipe.date <= end_date { + recipe_ids_and_dates.push(recipe); + } + } + } + + if recipe_ids_and_dates.is_empty() { + return Ok(vec![]); + } + + let titles: ron_api::Strings = request::get( + "recipe/get_titles", + ron_api::Ids { + ids: recipe_ids_and_dates + .iter() + .map(|r| r.recipe_id) + .collect::>(), + }, + ) + .await?; + + Ok(recipe_ids_and_dates + .iter() + .zip(titles.strs.into_iter()) + .map(|(id_and_date, title)| (id_and_date.date, title, id_and_date.recipe_id)) + .collect::>()) + } else { + let scheduled_recipes: ron_api::ScheduledRecipes = request::get( + "calendar/get_scheduled_recipes", + ron_api::DateRange { + start_date, + end_date, + }, + ) + .await?; + + Ok(scheduled_recipes.recipes) + } + } + + pub async fn shedule_recipe( + &self, + recipe_id: i64, + date: NaiveDate, + servings: u32, + ) -> Result { + if self.is_local { + // storage.get(format("scheduled_recipes-{}-{}", ) + // storage.set("asd", "hello").unwrap(); + let mut recipe_ids_and_dates = load_scheduled_recipes(date.year(), date.month0()); + for recipe in recipe_ids_and_dates.iter() { + if recipe.recipe_id == recipe_id && recipe.date == date { + return Ok(ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate); + } + } + recipe_ids_and_dates.push(ScheduledRecipesStore { recipe_id, date }); + save_scheduled_recipes(recipe_ids_and_dates, date.year(), date.month0()); + Ok(ScheduleRecipeResult::Ok) + } else { + request::post::( + "calendar/schedule_recipe", + ron_api::ScheduleRecipe { + recipe_id, + date, + servings, + }, + ) + .await + .map_err(Error::from) + .map(From::::from) + } + } + + // pub async fn remove_scheduled_recipe( + // &self, + // recipe_id: i64 + // ) +} diff --git a/frontend/src/recipe_view.rs b/frontend/src/recipe_view.rs index de99be8..8b1062c 100644 --- a/frontend/src/recipe_view.rs +++ b/frontend/src/recipe_view.rs @@ -8,50 +8,56 @@ use wasm_bindgen_futures::spawn_local; use web_sys::{Element, HtmlInputElement}; use crate::{ - calendar, modal_dialog, request, + calendar, modal_dialog, + recipe_scheduler::{RecipeScheduler, ScheduleRecipeResult}, toast::{self, Level}, utils::{get_locale, selector, SelectorExt}, }; -pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> { +pub fn setup_page(recipe_id: i64, is_user_logged: bool) -> Result<(), JsValue> { + let recipe_scheduler = RecipeScheduler::new(!is_user_logged); + let add_to_planner: Element = selector("#recipe-view .add-to-planner"); + EventListener::new(&add_to_planner, "click", move |_event| { spawn_local(async move { 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"), + calendar::CalendarOptions { + can_select_date: true, + }, + recipe_scheduler, + ) + }, |element, calendar_state| { let servings_element: HtmlInputElement = element.selector("#input-servings"); ( - calendar_state.get_selected_date().date_naive(), + calendar_state.get_selected_date(), servings_element.value_as_number() as u32, ) }, ) .await { - if let Ok(result) = request::post::( - "calendar/schedule_recipe", - ron_api::ScheduleRecipe { - recipe_id, - date, - servings, - }, - ) - .await + if let Ok(result) = recipe_scheduler + .shedule_recipe(recipe_id, date, servings) + .await { toast::show_element_and_initialize( match result { - ron_api::ScheduleRecipeResult::Ok => Level::Success, - ron_api::ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => { + ScheduleRecipeResult::Ok => Level::Success, + ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => { Level::Warning } }, match result { - ron_api::ScheduleRecipeResult::Ok => { + ScheduleRecipeResult::Ok => { "#hidden-templates .calendar-add-to-planner-success" } - ron_api::ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => { + ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => { "#hidden-templates .calendar-add-to-planner-already-exists" } }, diff --git a/frontend/src/request.rs b/frontend/src/request.rs index bb3b2c3..26a98b4 100644 --- a/frontend/src/request.rs +++ b/frontend/src/request.rs @@ -1,8 +1,5 @@ use common::ron_api; -use gloo::{ - console::log, - net::http::{Request, RequestBuilder}, -}; +use gloo::net::http::{Request, RequestBuilder}; use serde::{de::DeserializeOwned, Serialize}; use thiserror::Error; @@ -42,20 +39,18 @@ where send_req(request_builder.body(ron_api::to_string(body))?).await } -async fn req_with_params<'a, T, U, V>( +async fn req_with_params( api_name: &str, params: U, method_fn: fn(&str) -> RequestBuilder, ) -> Result where T: DeserializeOwned, - U: IntoIterator, - V: AsRef, + U: Serialize, { - let url = format!("/ron-api/{}", api_name); - let request_builder = method_fn(&url) - .header(CONTENT_TYPE, CONTENT_TYPE_RON) - .query(params); + let mut url = format!("/ron-api/{}?", api_name); + serde_html_form::ser::push_to_string(&mut url, params).unwrap(); + let request_builder = method_fn(&url).header(CONTENT_TYPE, CONTENT_TYPE_RON); send_req(request_builder.build()?).await } @@ -111,28 +106,10 @@ where req_with_body(api_name, body, Request::delete).await } -pub async fn get<'a, T, U, V>(api_name: &str, params: U) -> Result +pub async fn get(api_name: &str, params: U) -> Result where T: DeserializeOwned, - U: IntoIterator, - V: AsRef, + U: Serialize, { req_with_params(api_name, params, Request::get).await } - -// pub async fn api_request_get(api_name: &str, params: QueryParams) -> Result -// where -// T: DeserializeOwned, -// { -// match Request::get(&format!("/ron-api/recipe/{}?{}", api_name, params)) -// .header("Content-Type", "application/ron") -// .send() -// .await -// { -// Err(error) => { -// toast::show(Level::Info, &format!("Internal server error: {}", error)); -// Err(error.to_string()) -// } -// Ok(response) => Ok(ron::de::from_bytes::(&response.binary().await.unwrap()).unwrap()), -// } -// }