Calendar is now displayed on home page and recipes can be scheduled without being...
authorGreg Burri <greg.burri@gmail.com>
Sat, 8 Feb 2025 21:31:38 +0000 (22:31 +0100)
committerGreg Burri <greg.burri@gmail.com>
Sat, 8 Feb 2025 21:31:38 +0000 (22:31 +0100)
22 files changed:
Cargo.lock
backend/Cargo.toml
backend/scss/calendar.scss
backend/scss/modal-dialog.scss
backend/sql/version_1.sql
backend/src/data/db/recipe.rs
backend/src/main.rs
backend/src/services/ron.rs
backend/src/services/user.rs
backend/templates/base.html
backend/templates/calendar.html
backend/templates/home.html
backend/templates/recipe_view.html
common/src/ron_api.rs
frontend/Cargo.toml
frontend/src/calendar.rs
frontend/src/home.rs [new file with mode: 0644]
frontend/src/lib.rs
frontend/src/recipe_edit.rs
frontend/src/recipe_scheduler.rs [new file with mode: 0644]
frontend/src/recipe_view.rs
frontend/src/request.rs

index e250eba..5963baa 100644 (file)
@@ -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",
index b52bb93..abaa4d6 100644 (file)
@@ -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"
index c14668b..822325e 100644 (file)
             }
         }
     }
+}
+
+// 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
index 597d842..f21a3e6 100644 (file)
@@ -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;
index 122a014..07b67fe 100644 (file)
@@ -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,
index 0776a84..3737114 100644 (file)
@@ -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<Vec<String>> {
+        let mut query_builder: sqlx::QueryBuilder<Sqlite> =
+            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<bool> {
         sqlx::query_scalar(
             r#"
index 8a98761..e4c597f 100644 (file)
@@ -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",
index 192c65c..3c60ba0 100644 (file)
@@ -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<i64>,
+}
+
 // #[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<model::User>,
@@ -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<db::Connection>,
+    recipe_ids: Query<Ids>,
+) -> Result<impl IntoResponse> {
+    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<db::Connection>,
@@ -255,7 +277,7 @@ pub async fn set_estimated_time(
 #[debug_handler]
 pub async fn get_tags(
     State(connection): State<db::Connection>,
-    recipe_id: Query<RecipeId>,
+    recipe_id: Query<Id>,
 ) -> Result<impl IntoResponse> {
     Ok(ron_response_ok(common::ron_api::Tags {
         recipe_id: recipe_id.id,
@@ -395,7 +417,7 @@ impl From<model::Ingredient> for common::ron_api::Ingredient {
 #[debug_handler]
 pub async fn get_groups(
     State(connection): State<db::Connection>,
-    recipe_id: Query<RecipeId>,
+    recipe_id: Query<Id>,
 ) -> Result<impl IntoResponse> {
     // 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<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
-    date_range: Query<DateRange>,
+    date_range: Query<common::ron_api::DateRange>,
 ) -> Result<impl IntoResponse> {
     if let Some(user) = user {
         Ok(ron_response_ok(common::ron_api::ScheduledRecipes {
index ede66c5..55da879 100644 (file)
@@ -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;
index 4991b56..8b24282 100644 (file)
@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html lang="{{ tr.current_lang_and_territory_code() }}">
+<html lang="{{ tr.current_lang_and_territory_code() }}" data-user-logged="{{ user.is_some() }}" >
     <head>
         <meta charset="utf-8" />
         <meta name="viewport" content="width=device-width, initial-scale=1.0" />
index 9793da8..25f5cf3 100644 (file)
@@ -45,4 +45,8 @@
             {% endfor %}
         {% endfor %}
     </ul>
+
+    <div id="hidden-templates-calendar">
+        <div class="scheduled-recipe"><a></a><span class="remove-scheduled-recipe">X</span></div>
+    </div>
 </div>
\ No newline at end of file
index 3177dab..a94dfe7 100644 (file)
@@ -3,7 +3,7 @@
 {% block content %}
 
 <div class="content" id="home">
-    HOME: TODO
+    {% include "calendar.html" %}
 </div>
 
 {% endblock %}
\ No newline at end of file
index 1d899b9..42fee49 100644 (file)
@@ -9,9 +9,10 @@
         {% if crate::data::model::can_user_edit_recipe(user, recipe) %}
             <a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a>
         {% endif %}
-        <span class="add-to-planner">{{ tr.t(Sentence::CalendarAddToPlanner) }}</span>
     {% endif %}
 
+    <span class="add-to-planner">{{ tr.t(Sentence::CalendarAddToPlanner) }}</span>
+
     <div class="tags">
     {% for tag in recipe.tags %}
         <span class="tag">{{ tag }}</span>
 
     <div id="hidden-templates">
         {# To create a modal dialog to choose a date and and servings #}
-        {% if let Some(user) = user %}
-            <div class="date-and-servings" >
-                {% include "calendar.html" %}
-                <label for="input-servings">{{ tr.t(Sentence::RecipeServings) }}</label>
-                <input
-                    id="input-servings"
-                    type="number"
-                    step="1" min="1" max="100"
-                    value="{{ user.default_servings }}"/>
-            </div>
-        {% endif %}
+        <div class="date-and-servings" >
+            {% include "calendar.html" %}
+            <label for="input-servings">{{ tr.t(Sentence::RecipeServings) }}</label>
+            <input
+                id="input-servings"
+                type="number"
+                step="1" min="1" max="100"
+                value="
+                {% if let Some(user) = user %}
+                    {{ user.default_servings }}
+                {% else %}
+                    4
+                {% endif %}
+                "/>
+        </div>
 
         <span class="calendar-add-to-planner-success">{{ tr.t(Sentence::CalendarAddToPlannerSuccess) }}</span>
         <span class="calendar-add-to-planner-already-exists">{{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }}</span>
index 6b610ec..1dc3b05 100644 (file)
@@ -7,6 +7,8 @@ pub struct SetLang {
     pub lang: String,
 }
 
+/*** Generic types ***/
+
 #[derive(Serialize, Deserialize, Clone)]
 pub struct Ids {
     pub ids: Vec<i64>,
@@ -17,7 +19,18 @@ pub struct Id {
     pub id: i64,
 }
 
-/*** RECIPE ***/
+#[derive(Serialize, Deserialize, Clone)]
+pub struct Strings {
+    pub strs: Vec<String>,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct DateRange {
+    pub start_date: NaiveDate,
+    pub end_date: NaiveDate,
+}
+
+/*** Recipe ***/
 
 #[derive(Serialize, Deserialize, Clone)]
 pub struct SetRecipeTitle {
index d07892d..780b299 100644 (file)
@@ -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",
index 58e1d4e..62db389 100644 (file)
@@ -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<Local>,
-    selected_date: DateTime<Local>,
+    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<Local> {
+    pub fn get_displayed_date(&self) -> NaiveDate {
         self.internal_state.borrow().displayed_date
     }
 
-    pub fn get_selected_date(&self) -> DateTime<Local> {
+    pub fn get_selected_date(&self) -> NaiveDate {
         self.internal_state.borrow().selected_date
     }
 
-    pub fn set_selected_date(&self, date: DateTime<Local>) {
+    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::<u64>().unwrap() * 7
-                + day_grid_id[10..11].parse::<u64>().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::<u64>().unwrap() * 7
+                    + day_grid_id[10..11].parse::<u64>().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(&current.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::<Element>(".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::<Element>()
+                    .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<Local>) -> DateTime<Local> {
+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 (file)
index 0000000..f7ecfe8
--- /dev/null
@@ -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(())
+}
index 4ac25e3..d9d2365 100644 (file)
@@ -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::<HtmlElement>("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::<i64>().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);
             }
         }
index 5a8dab5..96e18b2 100644 (file)
@@ -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<common::ron_api::Group> =
-                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 (file)
index 0000000..5a27e6e
--- /dev/null
@@ -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<T> = std::result::Result<T, Error>;
+
+#[derive(Clone, Copy)]
+pub struct RecipeScheduler {
+    is_local: bool,
+}
+
+pub enum ScheduleRecipeResult {
+    Ok,
+    RecipeAlreadyScheduledAtThisDate,
+}
+
+impl From<ron_api::ScheduleRecipeResult> 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<ScheduledRecipesStore> {
+    LocalStorage::get::<Vec<ScheduledRecipesStore>>(build_key(year, month)).unwrap_or_default()
+}
+
+fn save_scheduled_recipes(scheduled_recipes: Vec<ScheduledRecipesStore>, 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<Vec<(NaiveDate, String, i64)>> {
+        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::<Vec<_>>(),
+                },
+            )
+            .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::<Vec<_>>())
+        } 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<ScheduleRecipeResult> {
+        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::<ron_api::ScheduleRecipeResult, _>(
+                "calendar/schedule_recipe",
+                ron_api::ScheduleRecipe {
+                    recipe_id,
+                    date,
+                    servings,
+                },
+            )
+            .await
+            .map_err(Error::from)
+            .map(From::<ron_api::ScheduleRecipeResult>::from)
+        }
+    }
+
+    // pub async fn remove_scheduled_recipe(
+    //     &self,
+    //     recipe_id: i64
+    // )
+}
index de99be8..8b1062c 100644 (file)
@@ -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::<ron_api::ScheduleRecipeResult, _>(
-                    "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"
                             }
                         },
index bb3b2c3..26a98b4 100644 (file)
@@ -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<T, U>(
     api_name: &str,
     params: U,
     method_fn: fn(&str) -> RequestBuilder,
 ) -> Result<T>
 where
     T: DeserializeOwned,
-    U: IntoIterator<Item = (&'a str, V)>,
-    V: AsRef<str>,
+    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<T>
+pub async fn get<T, U>(api_name: &str, params: U) -> Result<T>
 where
     T: DeserializeOwned,
-    U: IntoIterator<Item = (&'a str, V)>,
-    V: AsRef<str>,
+    U: Serialize,
 {
     req_with_params(api_name, params, Request::get).await
 }
-
-// pub async fn api_request_get<T>(api_name: &str, params: QueryParams) -> Result<T, String>
-// 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::<T>(&response.binary().await.unwrap()).unwrap()),
-//     }
-// }