* Support for lang in URL as /fr/recipe/view/42
authorGreg Burri <greg.burri@gmail.com>
Wed, 26 Mar 2025 00:49:02 +0000 (01:49 +0100)
committerGreg Burri <greg.burri@gmail.com>
Wed, 26 Mar 2025 00:49:02 +0000 (01:49 +0100)
* Create a pages module in the frontend crate

15 files changed:
Cargo.lock
TODO.md
backend/src/main.rs
backend/src/services/recipe.rs
deploy.nu
frontend/src/home.rs [deleted file]
frontend/src/lib.rs
frontend/src/pages/home.rs [new file with mode: 0644]
frontend/src/pages/mod.rs [new file with mode: 0644]
frontend/src/pages/recipe_edit.rs [new file with mode: 0644]
frontend/src/pages/recipe_view.rs [new file with mode: 0644]
frontend/src/recipe_edit.rs [deleted file]
frontend/src/recipe_scheduler.rs
frontend/src/recipe_view.rs [deleted file]
frontend/src/utils.rs

index ccf8b95..5d3c8fb 100644 (file)
@@ -330,9 +330,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
 
 [[package]]
 name = "cc"
-version = "1.2.16"
+version = "1.2.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c"
+checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a"
 dependencies = [
  "shlex",
 ]
@@ -537,9 +537,9 @@ dependencies = [
 
 [[package]]
 name = "deranged"
-version = "0.4.0"
+version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
+checksum = "28cfac68e08048ae1883171632c2aef3ebc555621ae56fbccce1cbf22dd7f058"
 dependencies = [
  "powerfmt",
 ]
@@ -1226,14 +1226,15 @@ dependencies = [
 
 [[package]]
 name = "iana-time-zone"
-version = "0.1.61"
+version = "0.1.62"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220"
+checksum = "b2fd658b06e56721792c5df4475705b6cda790e9298d19d2f8af083457bcd127"
 dependencies = [
  "android_system_properties",
  "core-foundation-sys",
  "iana-time-zone-haiku",
  "js-sys",
+ "log",
  "wasm-bindgen",
  "windows-core",
 ]
@@ -1512,9 +1513,9 @@ dependencies = [
 
 [[package]]
 name = "log"
-version = "0.4.26"
+version = "0.4.27"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e"
+checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
 
 [[package]]
 name = "matchers"
@@ -1829,7 +1830,7 @@ version = "0.2.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
 dependencies = [
- "zerocopy 0.8.23",
+ "zerocopy 0.8.24",
 ]
 
 [[package]]
@@ -1906,7 +1907,7 @@ checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
 dependencies = [
  "rand_chacha 0.9.0",
  "rand_core 0.9.3",
- "zerocopy 0.8.23",
+ "zerocopy 0.8.24",
 ]
 
 [[package]]
@@ -2165,9 +2166,9 @@ checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c"
 
 [[package]]
 name = "rustls-webpki"
-version = "0.103.0"
+version = "0.103.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0aa4eeac2588ffff23e9d7a7e9b3f971c5fb5b7ebc9452745e0c232c64f83b2f"
+checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03"
 dependencies = [
  "ring",
  "rustls-pki-types",
@@ -2664,9 +2665,9 @@ dependencies = [
 
 [[package]]
 name = "tempfile"
-version = "3.19.0"
+version = "3.19.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "488960f40a3fd53d72c2a29a58722561dee8afdd175bd88e3db4677d7b2ba600"
+checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf"
 dependencies = [
  "fastrand",
  "getrandom 0.3.2",
@@ -2727,9 +2728,9 @@ dependencies = [
 
 [[package]]
 name = "time"
-version = "0.3.40"
+version = "0.3.41"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d9c75b47bdff86fa3334a3db91356b8d7d86a9b839dab7d0bdc5c3d3a077618"
+checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
 dependencies = [
  "deranged",
  "itoa",
@@ -2748,9 +2749,9 @@ checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
 
 [[package]]
 name = "time-macros"
-version = "0.2.21"
+version = "0.2.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29aa485584182073ed57fd5004aa09c371f021325014694e432313345865fd04"
+checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
 dependencies = [
  "num-conv",
  "time-core",
@@ -3194,9 +3195,9 @@ dependencies = [
 
 [[package]]
 name = "whoami"
-version = "1.5.2"
+version = "1.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d"
+checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7"
 dependencies = [
  "redox_syscall",
  "wasite",
@@ -3462,11 +3463,11 @@ dependencies = [
 
 [[package]]
 name = "zerocopy"
-version = "0.8.23"
+version = "0.8.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6"
+checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879"
 dependencies = [
- "zerocopy-derive 0.8.23",
+ "zerocopy-derive 0.8.24",
 ]
 
 [[package]]
@@ -3482,9 +3483,9 @@ dependencies = [
 
 [[package]]
 name = "zerocopy-derive"
-version = "0.8.23"
+version = "0.8.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154"
+checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be"
 dependencies = [
  "proc-macro2",
  "quote",
diff --git a/TODO.md b/TODO.md
index a4aab03..899e215 100644 (file)
--- a/TODO.md
+++ b/TODO.md
@@ -8,13 +8,15 @@
     * Default number is the user setting user.default_servings
     * A symbol show the native recipe servings number
 * Check position of message error in profile/sign in/sign up with flex grid layout
+* Replace Rinja by Askama when Askma 0.13 is out (Rinja has been merged with Askama)
 * Define the UI (mockups).
     * Two CSS: one for desktop and one for mobile
     * Use CSS flex/grid to define a good design/layout
 * CSS for dark mode + autodetect
 * CSS for toast and modal dialog
 * Calendar: Choose the first day of the week
-* i18n: prefix uri with the language: /fr/recipe/view/2
+* i18n: prefix uri with the language: /fr/recipe/view/2 (do it for all intern href)
+    * Redirect with the correct prefix when the current language is changed
 * Make a search page
     Use FTS5:
         https://sqlite.org/fts5.html
index efee562..06ff7c4 100644 (file)
@@ -1,10 +1,10 @@
 use std::{net::SocketAddr, path::Path};
 
 use axum::{
-    BoxError, Router,
+    BoxError, Router, ServiceExt,
     error_handling::HandleErrorLayer,
     extract::{ConnectInfo, Extension, FromRef, Request, State},
-    http::StatusCode,
+    http::{StatusCode, Uri},
     middleware::{self, Next},
     response::Response,
     routing::{delete, get, patch, post, put},
@@ -14,6 +14,7 @@ use chrono::prelude::*;
 use clap::Parser;
 use config::Config;
 use itertools::Itertools;
+use tower::layer::Layer;
 use tower::{ServiceBuilder, buffer::BufferLayer, limit::RateLimitLayer};
 use tower_http::{
     services::{ServeDir, ServeFile},
@@ -290,13 +291,20 @@ async fn main() {
         .with_state(state)
         .nest_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
         .nest_service("/static", ServeDir::new("static"))
-        .layer(TraceLayer::new_for_http())
-        .into_make_service_with_connect_info::<SocketAddr>();
+        .layer(TraceLayer::new_for_http());
+
+    let url_rewriting_middleware = tower::util::MapRequestLayer::new(url_rewriting);
+    let app_with_url_rewriting = url_rewriting_middleware.layer(app);
 
     let addr = SocketAddr::from(([0, 0, 0, 0], port));
     let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
 
-    axum::serve(listener, app).await.unwrap();
+    axum::serve(
+        listener,
+        app_with_url_rewriting.into_make_service_with_connect_info::<SocketAddr>(),
+    )
+    .await
+    .unwrap();
 }
 
 async fn user_authentication(
@@ -312,54 +320,65 @@ async fn user_authentication(
     Ok(next.run(req).await)
 }
 
+#[derive(Debug, Clone)]
+struct Lang(Option<String>);
+
+fn url_rewriting(mut req: Request) -> Request {
+    // Here we are extracting the language from the url then rewriting it.
+    // For example:
+    // "/fr/recipe/view/1"
+    //  lang = "fr" and uri rewritten as = "/recipe/view/1"
+    let lang_and_new_uri = 'lang_and_new_uri: {
+        if let Some(path_query) = req.uri().path_and_query() {
+            let mut parts = path_query.path().split('/');
+            let _ = parts.next(); // Empty part due to the first '/'.
+            if let Some(lang) = parts.next() {
+                let available_codes = translation::available_codes();
+                if available_codes.contains(&lang) {
+                    let mut rest: String = String::from("");
+                    for part in parts {
+                        rest.push('/');
+                        rest.push_str(part);
+                    }
+                    if let Some(query) = path_query.query() {
+                        rest.push('?');
+                        rest.push_str(query);
+                    }
+
+                    if let Ok(new_uri) = rest.parse::<Uri>() {
+                        break 'lang_and_new_uri Some((lang.to_string(), new_uri));
+                    }
+                }
+            }
+        }
+        None
+    };
+
+    if let Some((lang, new_uri)) = lang_and_new_uri {
+        *req.uri_mut() = new_uri;
+        req.extensions_mut().insert(Lang(Some(lang)));
+    } else {
+        req.extensions_mut().insert(Lang(None));
+    }
+
+    req
+}
+
 /// The language of the current HTTP request is defined in the current order:
-/// - Extraction from the url: like in '/fr/recipe/view/42' (Not yet implemented).
+/// - Extraction from the url: like in '/fr/recipe/view/42'
 /// - Get from the user database record.
 /// - Get from the cookie.
 /// - Get from the HTTP header `accept-language`.
 /// - Set as `translation::DEFAULT_LANGUAGE_CODE`.
 async fn translation(
+    Extension(lang): Extension<Lang>,
     Extension(user): Extension<Option<model::User>>,
     mut req: Request,
     next: Next,
 ) -> Result<Response> {
-    // Here we are extracting the language from the url then rewriting it.
-    // For example:
-    // "/fr/recipe/view/1"
-    //  lang = "fr" and uri rewritten as = "/recipe/view/1"
-    // Disable because it doesn't work at this level, see:
-    // https://docs.rs/axum/latest/axum/middleware/index.html#rewriting-request-uri-in-middleware
-
-    // let lang_and_new_uri = 'lang_from_uri: {
-    //     if let Some(path_query) = req.uri().path_and_query() {
-    //         event!(Level::INFO, "path: {:?}", path_query.path());
-    //         let mut parts = path_query.path().split('/');
-    //         let _ = parts.next(); // Empty part due to the first '/'.
-    //         if let Some(lang) = parts.next() {
-    //             let available_codes = translation::available_codes();
-    //             if available_codes.contains(&lang) {
-    //                 let mut rest: String = String::from("");
-    //                 for part in parts {
-    //                     rest.push('/');
-    //                     rest.push_str(part);
-    //                 }
-    //                 // let uri_builder = Uri::builder()
-    //                 if let Ok(new_uri) = rest.parse::<Uri>() {
-    //                     event!(Level::INFO, "path rewrite: {:?}", new_uri.path());
-    //                     break 'lang_from_uri Some((lang.to_string(), new_uri));
-    //                 }
-    //             }
-    //         }
-    //     }
-    //     None
-    // };
-    // let language = if let Some((lang, uri)) = lang_and_new_uri {
-    //     *req.uri_mut() = uri; // Replace the URI without the language.
-    //     event!(Level::INFO, "URI: {:?}", req.uri());
-    //     lang
-    // } else
-
-    let language = if let Some(user) = user {
+    let language = if let Some(lang) = lang.0 {
+        lang
+    } else if let Some(user) = user {
         user.lang
     } else {
         let available_codes = translation::available_codes();
index 799ffee..9f5c1f2 100644 (file)
@@ -7,10 +7,10 @@ use rinja::Template;
 // use tracing::{event, Level};
 
 use crate::{
+    Result,
     data::{db, model},
     html_templates::*,
     translation::{self, Sentence},
-    Result,
 };
 
 #[debug_handler]
index 4fe6404..3972ce0 100644 (file)
--- a/deploy.nu
+++ b/deploy.nu
@@ -18,8 +18,6 @@ def main [host: string, destination: string, ssh_key: path] {
     }
 
     cd frontend
-    # source frontend/deploy.nu
-    # main true
     trunk build --release
     cd ..
 
diff --git a/frontend/src/home.rs b/frontend/src/home.rs
deleted file mode 100644 (file)
index 986bd9f..0000000
+++ /dev/null
@@ -1,110 +0,0 @@
-use gloo::events::EventListener;
-use wasm_bindgen::prelude::*;
-use wasm_bindgen_futures::spawn_local;
-use web_sys::{Element, HtmlInputElement};
-
-use crate::{
-    calendar,
-    recipe_scheduler::RecipeScheduler,
-    shopping_list::ShoppingList,
-    utils::{SelectorExt, by_id, get_locale, selector},
-};
-
-pub fn setup_page(is_user_logged: bool) {
-    let recipe_scheduler = RecipeScheduler::new(!is_user_logged);
-
-    calendar::setup(
-        selector(".calendar"),
-        calendar::CalendarOptions {
-            can_select_date: false,
-            with_link_and_remove: true,
-        },
-        recipe_scheduler,
-    );
-
-    let shopping_list = ShoppingList::new(!is_user_logged);
-
-    spawn_local(async move {
-        let item_template: Element = selector("#hidden-templates .shopping-item");
-        let container: Element = by_id("shopping-list");
-        let container_checked: Element = by_id("shopping-list-checked");
-        let date_format =
-            selector::<Element>("#hidden-templates .calendar-date-format").inner_html();
-        for item in shopping_list.get_items().await.unwrap() {
-            let item_element = item_template.deep_clone();
-
-            // item_element.set_id(format!("shopping-item-{}", ));
-
-            item_element
-                .selector::<Element>(".item-name")
-                .set_inner_html(&item.name);
-
-            if let Some(quantity_value) = item.quantity_value {
-                item_element
-                    .selector::<Element>(".item-quantity")
-                    .set_inner_html(&format!("{} {}", quantity_value, item.quantity_unit));
-            }
-
-            // Display associated sheduled recipe information if it exists.
-            if let (Some(recipe_id), Some(recipe_title), Some(date)) =
-                (item.recipe_id, item.recipe_title, item.date)
-            {
-                let recipe_element = item_element.selector::<Element>(".item-scheduled-recipe a");
-                recipe_element.set_inner_html(&format!(
-                    "{} @ {}",
-                    recipe_title,
-                    date.format_localized(&date_format, get_locale()),
-                ));
-                recipe_element
-                    .set_attribute("href", &format!("/recipe/view/{}", recipe_id))
-                    .unwrap();
-            }
-
-            EventListener::new(
-                // TODO: Find the right place to move the item based on:
-                // 1) recipe id, 2) name, 3) shopping entry id
-                // Se shopping_list.rs@L30
-                &item_element.selector(".item-is-checked"),
-                "change",
-                move |event| {
-                    let input: HtmlInputElement = event.target().unwrap().dyn_into().unwrap();
-                    spawn_local(async move {
-                        shopping_list
-                            .set_item_checked(item.id, input.checked())
-                            .await
-                            .unwrap();
-                        let item_element = input.parent_element().unwrap();
-                        item_element.remove();
-                        // TODO: Find the correct place to insert the element.
-                        if input.checked() {
-                            by_id::<Element>("shopping-list-checked")
-                                .append_child(&item_element)
-                                .unwrap();
-                        } else {
-                            by_id::<Element>("shopping-list")
-                                .append_child(&item_element)
-                                .unwrap();
-                        }
-                    });
-                },
-            )
-            .forget();
-
-            EventListener::new(&item_element, "click", move |event| {
-                let target: Element = event.target().unwrap().dyn_into().unwrap();
-
-                // if target.class_name() == "item-is-checked"
-            })
-            .forget();
-
-            if item.is_checked {
-                item_element
-                    .selector::<HtmlInputElement>(".item-is-checked")
-                    .set_checked(true);
-                container_checked.append_child(&item_element).unwrap();
-            } else {
-                container.append_child(&item_element).unwrap();
-            }
-        }
-    });
-}
index 0323e37..11dba8d 100644 (file)
@@ -9,12 +9,10 @@ use crate::utils::selector;
 
 mod calendar;
 mod error;
-mod home;
 mod modal_dialog;
 mod on_click;
-mod recipe_edit;
+mod pages;
 mod recipe_scheduler;
-mod recipe_view;
 mod request;
 mod shopping_list;
 mod toast;
@@ -24,8 +22,14 @@ mod utils;
 pub fn main() -> Result<(), JsValue> {
     console_error_panic_hook::set_once();
 
+    let lang = utils::get_current_lang();
+
     let location = window().location().pathname()?;
-    let path: Vec<&str> = location.split('/').skip(1).collect();
+    let path: Vec<&str> = location
+        .split('/')
+        .skip(1)
+        .skip_while(|part| *part == lang)
+        .collect();
 
     let is_user_logged = selector::<HtmlElement>("html")
         .dataset()
@@ -36,14 +40,14 @@ pub fn main() -> Result<(), JsValue> {
     match path[..] {
         ["recipe", "edit", id] => {
             let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
-            recipe_edit::setup_page(id)
+            pages::recipe_edit::setup_page(id)
         }
         ["recipe", "view", id] => {
             let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
-            recipe_view::setup_page(id, is_user_logged)
+            pages::recipe_view::setup_page(id, is_user_logged)
         }
         // Home.
-        [""] => home::setup_page(is_user_logged),
+        [""] => pages::home::setup_page(is_user_logged),
         _ => log!("Path unknown: ", location),
     }
 
diff --git a/frontend/src/pages/home.rs b/frontend/src/pages/home.rs
new file mode 100644 (file)
index 0000000..986bd9f
--- /dev/null
@@ -0,0 +1,110 @@
+use gloo::events::EventListener;
+use wasm_bindgen::prelude::*;
+use wasm_bindgen_futures::spawn_local;
+use web_sys::{Element, HtmlInputElement};
+
+use crate::{
+    calendar,
+    recipe_scheduler::RecipeScheduler,
+    shopping_list::ShoppingList,
+    utils::{SelectorExt, by_id, get_locale, selector},
+};
+
+pub fn setup_page(is_user_logged: bool) {
+    let recipe_scheduler = RecipeScheduler::new(!is_user_logged);
+
+    calendar::setup(
+        selector(".calendar"),
+        calendar::CalendarOptions {
+            can_select_date: false,
+            with_link_and_remove: true,
+        },
+        recipe_scheduler,
+    );
+
+    let shopping_list = ShoppingList::new(!is_user_logged);
+
+    spawn_local(async move {
+        let item_template: Element = selector("#hidden-templates .shopping-item");
+        let container: Element = by_id("shopping-list");
+        let container_checked: Element = by_id("shopping-list-checked");
+        let date_format =
+            selector::<Element>("#hidden-templates .calendar-date-format").inner_html();
+        for item in shopping_list.get_items().await.unwrap() {
+            let item_element = item_template.deep_clone();
+
+            // item_element.set_id(format!("shopping-item-{}", ));
+
+            item_element
+                .selector::<Element>(".item-name")
+                .set_inner_html(&item.name);
+
+            if let Some(quantity_value) = item.quantity_value {
+                item_element
+                    .selector::<Element>(".item-quantity")
+                    .set_inner_html(&format!("{} {}", quantity_value, item.quantity_unit));
+            }
+
+            // Display associated sheduled recipe information if it exists.
+            if let (Some(recipe_id), Some(recipe_title), Some(date)) =
+                (item.recipe_id, item.recipe_title, item.date)
+            {
+                let recipe_element = item_element.selector::<Element>(".item-scheduled-recipe a");
+                recipe_element.set_inner_html(&format!(
+                    "{} @ {}",
+                    recipe_title,
+                    date.format_localized(&date_format, get_locale()),
+                ));
+                recipe_element
+                    .set_attribute("href", &format!("/recipe/view/{}", recipe_id))
+                    .unwrap();
+            }
+
+            EventListener::new(
+                // TODO: Find the right place to move the item based on:
+                // 1) recipe id, 2) name, 3) shopping entry id
+                // Se shopping_list.rs@L30
+                &item_element.selector(".item-is-checked"),
+                "change",
+                move |event| {
+                    let input: HtmlInputElement = event.target().unwrap().dyn_into().unwrap();
+                    spawn_local(async move {
+                        shopping_list
+                            .set_item_checked(item.id, input.checked())
+                            .await
+                            .unwrap();
+                        let item_element = input.parent_element().unwrap();
+                        item_element.remove();
+                        // TODO: Find the correct place to insert the element.
+                        if input.checked() {
+                            by_id::<Element>("shopping-list-checked")
+                                .append_child(&item_element)
+                                .unwrap();
+                        } else {
+                            by_id::<Element>("shopping-list")
+                                .append_child(&item_element)
+                                .unwrap();
+                        }
+                    });
+                },
+            )
+            .forget();
+
+            EventListener::new(&item_element, "click", move |event| {
+                let target: Element = event.target().unwrap().dyn_into().unwrap();
+
+                // if target.class_name() == "item-is-checked"
+            })
+            .forget();
+
+            if item.is_checked {
+                item_element
+                    .selector::<HtmlInputElement>(".item-is-checked")
+                    .set_checked(true);
+                container_checked.append_child(&item_element).unwrap();
+            } else {
+                container.append_child(&item_element).unwrap();
+            }
+        }
+    });
+}
diff --git a/frontend/src/pages/mod.rs b/frontend/src/pages/mod.rs
new file mode 100644 (file)
index 0000000..2db264d
--- /dev/null
@@ -0,0 +1,3 @@
+pub mod home;
+pub mod recipe_edit;
+pub mod recipe_view;
diff --git a/frontend/src/pages/recipe_edit.rs b/frontend/src/pages/recipe_edit.rs
new file mode 100644 (file)
index 0000000..9fbf10e
--- /dev/null
@@ -0,0 +1,1063 @@
+use std::{cell::RefCell, rc, sync::Mutex};
+
+use common::{ron_api, utils::substitute};
+use gloo::{
+    events::{EventListener, EventListenerOptions},
+    net::http::Request,
+    utils::{document, window},
+};
+use wasm_bindgen::prelude::*;
+use wasm_bindgen_futures::spawn_local;
+use web_sys::{
+    DragEvent, Element, HtmlDivElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement,
+    KeyboardEvent,
+};
+
+use crate::{
+    modal_dialog, request,
+    toast::{self, Level},
+    utils::{SelectorExt, by_id, selector, selector_and_clone},
+};
+
+pub fn setup_page(recipe_id: i64) {
+    // Title.
+    {
+        let title: HtmlInputElement = by_id("input-title");
+
+        // Check if the recipe has been loaded.
+
+        let mut current_title = title.value();
+        EventListener::new(&title.clone(), "blur", move |_event| {
+            if title.value() != current_title {
+                current_title = title.value();
+                let body = ron_api::SetRecipeTitle {
+                    recipe_id,
+                    title: title.value(),
+                };
+                spawn_local(async move {
+                    let _ = request::patch::<(), _>("recipe/set_title", body).await;
+                    reload_recipes_list(recipe_id).await;
+                });
+            }
+        })
+        .forget();
+    }
+
+    // Description.
+    {
+        let description: HtmlTextAreaElement = by_id("text-area-description");
+        let mut current_description = description.value();
+
+        EventListener::new(&description.clone(), "blur", move |_event| {
+            if description.value() != current_description {
+                current_description = description.value();
+                let body = ron_api::SetRecipeDescription {
+                    recipe_id,
+                    description: description.value(),
+                };
+                spawn_local(async move {
+                    let _ = request::patch::<(), _>("recipe/set_description", body).await;
+                });
+            }
+        })
+        .forget();
+    }
+
+    // Servings.
+    {
+        let servings: HtmlInputElement = by_id("input-servings");
+        let mut current_servings = servings.value_as_number();
+        EventListener::new(&servings.clone(), "input", move |_event| {
+            let n = servings.value_as_number();
+            if n.is_nan() {
+                servings.set_value("");
+            }
+            if n != current_servings {
+                let servings = if n.is_nan() {
+                    None
+                } else {
+                    // TODO: Find a better way to validate integer numbers.
+                    let n = n as u32;
+                    servings.set_value_as_number(n as f64);
+                    Some(n)
+                };
+                current_servings = n;
+                let body = ron_api::SetRecipeServings {
+                    recipe_id,
+                    servings,
+                };
+                spawn_local(async move {
+                    let _ = request::patch::<(), _>("recipe/set_servings", body).await;
+                });
+            }
+        })
+        .forget();
+    }
+
+    // Estimated time.
+    {
+        let estimated_time: HtmlInputElement = by_id("input-estimated-time");
+        let mut current_time = estimated_time.value_as_number();
+
+        EventListener::new(&estimated_time.clone(), "input", move |_event| {
+            let n = estimated_time.value_as_number();
+            if n.is_nan() {
+                estimated_time.set_value("");
+            }
+            if n != current_time {
+                let time = if n.is_nan() {
+                    None
+                } else {
+                    // TODO: Find a better way to validate integer numbers.
+                    let n = n as u32;
+                    estimated_time.set_value_as_number(n as f64);
+                    Some(n)
+                };
+                current_time = n;
+                let body = ron_api::SetRecipeEstimatedTime {
+                    recipe_id,
+                    estimated_time: time,
+                };
+                spawn_local(async move {
+                    let _ = request::patch::<(), _>("recipe/set_estimated_time", body).await;
+                });
+            }
+        })
+        .forget();
+    }
+
+    // Difficulty.
+    {
+        let difficulty: HtmlSelectElement = by_id("select-difficulty");
+        let mut current_difficulty = difficulty.value();
+
+        EventListener::new(&difficulty.clone(), "blur", move |_event| {
+            if difficulty.value() != current_difficulty {
+                current_difficulty = difficulty.value();
+
+                let body = ron_api::SetRecipeDifficulty {
+                    recipe_id,
+                    difficulty: ron_api::Difficulty::try_from(
+                        current_difficulty.parse::<u32>().unwrap(),
+                    )
+                    .unwrap(),
+                };
+                spawn_local(async move {
+                    let _ = request::patch::<(), _>("recipe/set_difficulty", body).await;
+                });
+            }
+        })
+        .forget();
+    }
+
+    // Tags.
+    {
+        spawn_local(async move {
+            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);
+        });
+
+        fn add_tags(recipe_id: i64, tags: String) {
+            spawn_local(async move {
+                let tag_list: Vec<String> =
+                    tags.split_whitespace().map(str::to_lowercase).collect();
+                if !tag_list.is_empty() {
+                    let body = ron_api::Tags {
+                        recipe_id,
+                        tags: tag_list.clone(),
+                    };
+                    let _ = request::post::<(), _>("recipe/add_tags", body).await;
+                    create_tag_elements(recipe_id, &tag_list);
+                }
+                by_id::<HtmlInputElement>("input-tags").set_value("");
+            });
+        }
+
+        let input_tags: HtmlInputElement = by_id("input-tags");
+        EventListener::new(&input_tags.clone(), "input", move |_event| {
+            let tags = input_tags.value();
+            if tags.ends_with(' ') {
+                add_tags(recipe_id, tags);
+            }
+        })
+        .forget();
+
+        let input_tags: HtmlInputElement = by_id("input-tags");
+        EventListener::new(&input_tags.clone(), "keypress", move |event| {
+            if let Some(keyboard_event) = event.dyn_ref::<KeyboardEvent>() {
+                if keyboard_event.key_code() == 13 {
+                    let tags = input_tags.value();
+                    add_tags(recipe_id, tags);
+                }
+            }
+        })
+        .forget();
+
+        let input_tags: HtmlInputElement = by_id("input-tags");
+        EventListener::new(&input_tags.clone(), "blur", move |_event| {
+            let tags = input_tags.value();
+            add_tags(recipe_id, tags);
+        })
+        .forget();
+    }
+
+    // Language.
+    {
+        let language: HtmlSelectElement = by_id("select-language");
+        let mut current_language = language.value();
+        EventListener::new(&language.clone(), "blur", move |_event| {
+            if language.value() != current_language {
+                current_language = language.value();
+
+                let body = ron_api::SetRecipeLanguage {
+                    recipe_id,
+                    lang: language.value(),
+                };
+                spawn_local(async move {
+                    let _ = request::patch::<(), _>("recipe/set_language", body).await;
+                });
+            }
+        })
+        .forget();
+    }
+
+    // Is published.
+    {
+        let is_published: HtmlInputElement = by_id("input-is-published");
+        EventListener::new(&is_published.clone(), "input", move |_event| {
+            let body = ron_api::SetIsPublished {
+                recipe_id,
+                is_published: is_published.checked(),
+            };
+            spawn_local(async move {
+                let _ = request::patch::<(), _>("recipe/set_is_published", body).await;
+                reload_recipes_list(recipe_id).await;
+            });
+        })
+        .forget();
+    }
+
+    // Delete recipe button.
+    let delete_button: HtmlInputElement = by_id("input-delete");
+    EventListener::new(&delete_button, "click", move |_event| {
+        spawn_local(async move {
+            if modal_dialog::show_and_initialize(
+                "#hidden-templates .recipe-delete-confirmation",
+                async |element| {
+                    let title: HtmlInputElement = by_id("input-title");
+                    element.set_inner_html(&substitute(
+                        &element.inner_html(),
+                        "{}",
+                        &[&title.value()],
+                    ));
+                },
+            )
+            .await
+            .is_some()
+            {
+                let body = ron_api::Id { id: recipe_id };
+                let _ = request::delete::<(), _>("recipe/remove", body).await;
+                window().location().set_href("/").unwrap();
+            }
+        });
+    })
+    .forget();
+
+    // Load initial groups, steps and ingredients.
+    {
+        spawn_local(async move {
+            let groups: Vec<common::ron_api::Group> =
+                request::get("recipe/get_groups", ron_api::Id { id: recipe_id })
+                    .await
+                    .unwrap();
+
+            for group in groups {
+                let group_element = create_group_element(&group);
+
+                for step in group.steps {
+                    let step_element =
+                        create_step_element(&group_element.selector(".steps"), &step);
+
+                    for ingredient in step.ingredients {
+                        create_ingredient_element(
+                            &step_element.selector(".ingredients"),
+                            &ingredient,
+                        );
+                    }
+                }
+            }
+        });
+    }
+
+    // Add a new group.
+    {
+        let button_add_group: HtmlInputElement = by_id("input-add-group");
+        EventListener::new(&button_add_group, "click", move |_event| {
+            let body = ron_api::Id { id: recipe_id };
+            spawn_local(async move {
+                let response: ron_api::Id = request::post("recipe/add_group", body).await.unwrap();
+                create_group_element(&ron_api::Group {
+                    id: response.id,
+                    name: "".to_string(),
+                    comment: "".to_string(),
+                    steps: vec![],
+                });
+            });
+        })
+        .forget();
+    }
+}
+
+fn create_group_element(group: &ron_api::Group) -> Element {
+    let group_id = group.id;
+    let group_element: Element = selector_and_clone("#hidden-templates .group");
+    group_element.set_id(&format!("group-{}", group.id));
+
+    let groups_container: Element = by_id("groups-container");
+    groups_container.append_child(&group_element).unwrap();
+
+    set_draggable(&group_element, "group", |_element| {
+        spawn_local(async move {
+            let ids = by_id::<Element>("groups-container")
+                .selector_all::<Element>(".group")
+                .into_iter()
+                .map(|e| e.id()[6..].parse::<i64>().unwrap())
+                .collect();
+
+            let body = ron_api::Ids { ids };
+            let _ = request::patch::<(), _>("recipe/set_groups_order", body).await;
+        });
+    });
+
+    // Group name.
+    let name = group_element.selector::<HtmlInputElement>(".input-group-name");
+    name.set_value(&group.name);
+    let mut current_name = group.name.clone();
+    EventListener::new(&name.clone(), "blur", move |_event| {
+        if name.value() != current_name {
+            current_name = name.value();
+            let body = ron_api::SetGroupName {
+                group_id,
+                name: name.value(),
+            };
+            spawn_local(async move {
+                let _ = request::patch::<(), _>("recipe/set_group_name", body).await;
+            })
+        }
+    })
+    .forget();
+
+    // Group comment.
+    let comment: HtmlInputElement = group_element.selector(".input-group-comment");
+    comment.set_value(&group.comment);
+    let mut current_comment = group.comment.clone();
+    EventListener::new(&comment.clone(), "blur", move |_event| {
+        if comment.value() != current_comment {
+            current_comment = comment.value();
+            let body = ron_api::SetGroupComment {
+                group_id,
+                comment: comment.value(),
+            };
+            spawn_local(async move {
+                let _ = request::patch::<(), _>("recipe/set_group_comment", body).await;
+            });
+        }
+    })
+    .forget();
+
+    // Delete button.
+    let group_element_cloned = group_element.clone();
+    let delete_button: HtmlInputElement = group_element.selector(".input-group-delete");
+    EventListener::new(&delete_button, "click", move |_event| {
+        // FIXME: How to avoid cloning twice?
+        let group_element_cloned = group_element_cloned.clone();
+        spawn_local(async move {
+            if modal_dialog::show_and_initialize(
+                "#hidden-templates .recipe-group-delete-confirmation",
+                async move |element| {
+                    let name = group_element_cloned
+                        .selector::<HtmlInputElement>(".input-group-name")
+                        .value();
+                    element.set_inner_html(&substitute(&element.inner_html(), "{}", &[&name]));
+                },
+            )
+            .await
+            .is_some()
+            {
+                let body = ron_api::Id { id: group_id };
+                let _ = request::delete::<(), _>("recipe/remove_group", body).await;
+                let group_element = by_id::<Element>(&format!("group-{}", group_id));
+                group_element.next_element_sibling().unwrap().remove();
+                group_element.remove();
+            }
+        });
+    })
+    .forget();
+
+    // Add step button.
+    let add_step_button: HtmlInputElement = group_element.selector(".input-add-step");
+    EventListener::new(&add_step_button, "click", move |_event| {
+        spawn_local(async move {
+            let body = ron_api::Id { id: group_id };
+            let response: ron_api::Id = request::post("recipe/add_step", body).await.unwrap();
+            create_step_element(
+                &selector::<Element>(&format!("#group-{} .steps", group_id)),
+                &ron_api::Step {
+                    id: response.id,
+                    action: "".to_string(),
+                    ingredients: vec![],
+                },
+            );
+        });
+    })
+    .forget();
+
+    group_element
+}
+
+fn create_tag_elements<T>(recipe_id: i64, tags: &[T])
+where
+    T: AsRef<str>,
+{
+    let tags_span: Element = selector("#container-tags .tags");
+
+    // Collect current tags to avoid re-adding an existing tag.
+    let mut current_tags: Vec<String> = vec![];
+    let mut current_tag_element = tags_span.first_child();
+    while let Some(element) = current_tag_element {
+        current_tags.push(
+            element
+                .dyn_ref::<Element>()
+                .unwrap()
+                .text_content()
+                .unwrap(),
+        );
+        current_tag_element = element.next_sibling();
+    }
+
+    for tag in tags {
+        let tag = tag.as_ref().to_string();
+        if current_tags.contains(&tag) {
+            continue;
+        }
+        let tag_span = document().create_element("span").unwrap();
+        tag_span.set_inner_html(&tag);
+        let delete_tag_button: HtmlInputElement = document()
+            .create_element("input")
+            .unwrap()
+            .dyn_into()
+            .unwrap();
+        delete_tag_button.set_attribute("type", "button").unwrap();
+        delete_tag_button.set_attribute("value", "X").unwrap();
+        tag_span.append_child(&delete_tag_button).unwrap();
+        tags_span.append_child(&tag_span).unwrap();
+
+        EventListener::new(&delete_tag_button, "click", move |_event| {
+            let tag_span = tag_span.clone();
+            let tag = tag.clone();
+            spawn_local(async move {
+                let body = ron_api::Tags {
+                    recipe_id,
+                    tags: vec![tag],
+                };
+                let _ = request::delete::<(), _>("recipe/rm_tags", body).await;
+                tag_span.remove();
+            });
+        })
+        .forget();
+    }
+}
+
+fn create_step_element(group_element: &Element, step: &ron_api::Step) -> Element {
+    let step_id = step.id;
+    let step_element: Element = selector_and_clone("#hidden-templates .step");
+    step_element.set_id(&format!("step-{}", step.id));
+    group_element.append_child(&step_element).unwrap();
+
+    set_draggable(&step_element, "step", |element| {
+        let element = element.clone();
+        spawn_local(async move {
+            let ids = element
+                .parent_element()
+                .unwrap()
+                .selector_all::<Element>(".step")
+                .into_iter()
+                .map(|e| e.id()[5..].parse::<i64>().unwrap())
+                .collect();
+
+            let body = ron_api::Ids { ids };
+            let _ = request::patch::<(), _>("recipe/set_steps_order", body).await;
+        });
+    });
+
+    // Step action.
+    let action: HtmlTextAreaElement = step_element.selector(".text-area-step-action");
+    action.set_value(&step.action);
+    let mut current_action = step.action.clone();
+    EventListener::new(&action.clone(), "blur", move |_event| {
+        if action.value() != current_action {
+            current_action = action.value();
+            let body = ron_api::SetStepAction {
+                step_id,
+                action: action.value(),
+            };
+            spawn_local(async move {
+                let _ = request::patch::<(), _>("recipe/set_step_action", body).await;
+            });
+        }
+    })
+    .forget();
+
+    // Delete button.
+    let step_element_cloned = step_element.clone();
+    let delete_button: HtmlInputElement = step_element.selector(".input-step-delete");
+    EventListener::new(&delete_button, "click", move |_event| {
+        // FIXME: How to avoid cloning twice?
+        let step_element_cloned = step_element_cloned.clone();
+        spawn_local(async move {
+            if modal_dialog::show_and_initialize(
+                "#hidden-templates .recipe-step-delete-confirmation",
+                async move |element| {
+                    let action = step_element_cloned
+                        .selector::<HtmlTextAreaElement>(".text-area-step-action")
+                        .value();
+                    element.set_inner_html(&substitute(&element.inner_html(), "{}", &[&action]));
+                },
+            )
+            .await
+            .is_some()
+            {
+                let body = ron_api::Id { id: step_id };
+                let _ = request::delete::<(), _>("recipe/remove_step", body).await;
+                let step_element = by_id::<Element>(&format!("step-{}", step_id));
+                step_element.next_element_sibling().unwrap().remove();
+                step_element.remove();
+            }
+        });
+    })
+    .forget();
+
+    // Add ingredient button.
+    let add_ingredient_button: HtmlInputElement = step_element.selector(".input-add-ingredient");
+    EventListener::new(&add_ingredient_button, "click", move |_event| {
+        spawn_local(async move {
+            let body = ron_api::Id { id: step_id };
+            let response: ron_api::Id = request::post("recipe/add_ingredient", body).await.unwrap();
+            create_ingredient_element(
+                &selector::<Element>(&format!("#step-{} .ingredients", step_id)),
+                &ron_api::Ingredient {
+                    id: response.id,
+                    name: "".to_string(),
+                    comment: "".to_string(),
+                    quantity_value: None,
+                    quantity_unit: "".to_string(),
+                },
+            );
+        });
+    })
+    .forget();
+
+    step_element
+}
+
+fn create_ingredient_element(step_element: &Element, ingredient: &ron_api::Ingredient) -> Element {
+    let ingredient_id = ingredient.id;
+    let ingredient_element: Element = selector_and_clone("#hidden-templates .ingredient");
+    ingredient_element.set_id(&format!("ingredient-{}", ingredient.id));
+    step_element.append_child(&ingredient_element).unwrap();
+
+    set_draggable(&ingredient_element, "ingredient", |element| {
+        let element = element.clone();
+        spawn_local(async move {
+            let ids = element
+                .parent_element()
+                .unwrap()
+                .selector_all::<Element>(".ingredient")
+                .into_iter()
+                .map(|e| e.id()[11..].parse::<i64>().unwrap())
+                .collect();
+
+            let body = ron_api::Ids { ids };
+            let _ = request::patch::<(), _>("recipe/set_ingredients_order", body).await;
+        });
+    });
+
+    // Ingredient name.
+    let name: HtmlInputElement = ingredient_element.selector(".input-ingredient-name");
+    name.set_value(&ingredient.name);
+    let mut current_name = ingredient.name.clone();
+    EventListener::new(&name.clone(), "blur", move |_event| {
+        if name.value() != current_name {
+            current_name = name.value();
+            let body = ron_api::SetIngredientName {
+                ingredient_id,
+                name: name.value(),
+            };
+            spawn_local(async move {
+                let _ = request::patch::<(), _>("recipe/set_ingredient_name", body).await;
+            });
+        }
+    })
+    .forget();
+
+    // Ingredient comment.
+    let comment: HtmlInputElement = ingredient_element.selector(".input-ingredient-comment");
+    comment.set_value(&ingredient.comment);
+    let mut current_comment = ingredient.comment.clone();
+    EventListener::new(&comment.clone(), "blur", move |_event| {
+        if comment.value() != current_comment {
+            current_comment = comment.value();
+            let body = ron_api::SetIngredientComment {
+                ingredient_id,
+                comment: comment.value(),
+            };
+            spawn_local(async move {
+                let _ = request::patch::<(), _>("recipe/set_ingredient_comment", body).await;
+            });
+        }
+    })
+    .forget();
+
+    // Ingredient quantity.
+    let quantity: HtmlInputElement = ingredient_element.selector(".input-ingredient-quantity");
+    quantity.set_value(
+        &ingredient
+            .quantity_value
+            .map_or("".to_string(), |q| q.to_string()),
+    );
+    let mut current_quantity = quantity.value_as_number();
+    EventListener::new(&quantity.clone(), "input", move |_event| {
+        let n = quantity.value_as_number();
+        if n.is_nan() {
+            quantity.set_value("");
+        }
+        if n != current_quantity {
+            let q = if n.is_nan() { None } else { Some(n) };
+            current_quantity = n;
+            let body = ron_api::SetIngredientQuantity {
+                ingredient_id,
+                quantity: q,
+            };
+            spawn_local(async move {
+                let _ = request::patch::<(), _>("recipe/set_ingredient_quantity", body).await;
+            });
+        }
+    })
+    .forget();
+
+    // Ingredient unit.
+    let unit: HtmlInputElement = ingredient_element.selector(".input-ingredient-unit");
+    unit.set_value(&ingredient.quantity_unit);
+    let mut current_unit = ingredient.quantity_unit.clone();
+    EventListener::new(&unit.clone(), "blur", move |_event| {
+        if unit.value() != current_unit {
+            current_unit = unit.value();
+            let body = ron_api::SetIngredientUnit {
+                ingredient_id,
+                unit: unit.value(),
+            };
+            spawn_local(async move {
+                let _ = request::patch::<(), _>("recipe/set_ingredient_unit", body).await;
+            });
+        }
+    })
+    .forget();
+
+    // Delete button.
+    let ingredient_element_cloned = ingredient_element.clone();
+    let delete_button: HtmlInputElement = ingredient_element.selector(".input-ingredient-delete");
+    EventListener::new(&delete_button, "click", move |_event| {
+        // FIXME: How to avoid cloning twice?
+        let ingredient_element_cloned = ingredient_element_cloned.clone();
+        spawn_local(async move {
+            if modal_dialog::show_and_initialize(
+                "#hidden-templates .recipe-ingredient-delete-confirmation",
+                async move |element| {
+                    let name = ingredient_element_cloned
+                        .selector::<HtmlInputElement>(".input-ingredient-name")
+                        .value();
+                    element.set_inner_html(&substitute(&element.inner_html(), "{}", &[&name]));
+                },
+            )
+            .await
+            .is_some()
+            {
+                let body = ron_api::Id { id: ingredient_id };
+                let _ = request::delete::<(), _>("recipe/remove_ingredient", body).await;
+                let ingredient_element = by_id::<Element>(&format!("ingredient-{}", ingredient_id));
+                ingredient_element.next_element_sibling().unwrap().remove();
+                ingredient_element.remove();
+            }
+        });
+    })
+    .forget();
+
+    ingredient_element
+}
+
+async fn reload_recipes_list(current_recipe_id: i64) {
+    match Request::get("/fragments/recipes_list")
+        .query([("current_recipe_id", current_recipe_id.to_string())])
+        .send()
+        .await
+    {
+        Err(error) => {
+            toast::show_message(Level::Info, &format!("Internal server error: {}", error));
+        }
+        Ok(response) => {
+            let list = document().get_element_by_id("recipes-list").unwrap();
+            list.set_outer_html(&response.text().await.unwrap());
+        }
+    }
+}
+
+enum CursorPosition {
+    UpperPart,
+    LowerPart,
+}
+
+fn get_cursor_position(mouse_y: f64, element: &Element) -> CursorPosition {
+    let element_y = element.get_bounding_client_rect().y();
+    // Between 0 (top) and 1 (bottom).
+    let y_relative_pos = (mouse_y - element_y) / element.get_bounding_client_rect().height();
+    if y_relative_pos < 0.5 {
+        CursorPosition::UpperPart
+    } else {
+        CursorPosition::LowerPart
+    }
+}
+
+fn get_parent_with_id_starting_with(mut element: Element, prefix: &str) -> Element {
+    while !element.id().starts_with(prefix) {
+        element = element.parent_element().unwrap();
+    }
+    element
+}
+
+// It replaces 'event.data_transfer().unwrap().get_data()/set_data()' because
+// Chrome prevent to read this during draghover event which is the correct behavior
+// according the specifications:
+// * https://html.spec.whatwg.org/multipage/dnd.html#the-drag-data-store
+static DATA_DRAGGED: Mutex<RefCell<String>> = Mutex::new(RefCell::new(String::new()));
+
+/// Set an element as draggable and add an element before and after
+/// cloned from "#hidden-templates .dropzone".
+/// All elements set as draggable in a given container can be dragged
+/// After or before another element.
+/// 'element' must have a sub-element with the class '.drag-handle' which
+/// will be used to drag the element.
+fn set_draggable<T>(element: &Element, prefix: &str, dropped: T)
+where
+    T: Fn(&Element) + 'static,
+{
+    let dropped = rc::Rc::new(dropped);
+
+    // Add a drop zone before the given element if there is none.
+    if element.previous_element_sibling().is_none() {
+        let dropzone = selector_and_clone::<Element>("#hidden-templates .dropzone");
+        element.before_with_node_1(&dropzone).unwrap();
+        setup_dragzone_events(&dropzone, prefix, dropped.clone());
+    }
+
+    let dropzone = selector_and_clone::<Element>("#hidden-templates .dropzone");
+    element.after_with_node_1(&dropzone).unwrap();
+    setup_dragzone_events(&dropzone, prefix, dropped.clone());
+
+    // DRAGOVER.
+    let prefix_copied = prefix.to_string();
+    EventListener::new_with_options(
+        element,
+        "dragover",
+        EventListenerOptions::enable_prevent_default(),
+        move |event| {
+            let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
+
+            let drag_data_lock = DATA_DRAGGED.lock().unwrap();
+            let drag_data = drag_data_lock.borrow();
+
+            if drag_data.starts_with(&prefix_copied) {
+                let element: Element = by_id(&drag_data);
+                let element_target = event
+                    .current_target()
+                    .unwrap()
+                    .dyn_into::<Element>()
+                    .unwrap();
+
+                if element.parent_element() != element_target.parent_element() {
+                    return;
+                }
+
+                event.prevent_default();
+
+                let cursor_position = get_cursor_position(event.client_y() as f64, &element_target);
+
+                element_target
+                    .previous_element_sibling()
+                    .unwrap()
+                    .set_class_name(match cursor_position {
+                        CursorPosition::UpperPart => "dropzone hover",
+                        CursorPosition::LowerPart => "dropzone active",
+                    });
+
+                element_target
+                    .next_element_sibling()
+                    .unwrap()
+                    .set_class_name(match cursor_position {
+                        CursorPosition::UpperPart => "dropzone active",
+                        CursorPosition::LowerPart => "dropzone hover",
+                    });
+            }
+        },
+    )
+    .forget();
+
+    // DRAGLEAVE.
+    let prefix_copied = prefix.to_string();
+    EventListener::new(element, "dragleave", move |event| {
+        let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
+
+        let drag_data_lock = DATA_DRAGGED.lock().unwrap();
+        let drag_data = drag_data_lock.borrow();
+
+        if drag_data.starts_with(&prefix_copied) {
+            let element: Element = by_id(&drag_data);
+            let element_target = event
+                .current_target()
+                .unwrap()
+                .dyn_into::<Element>()
+                .unwrap();
+
+            if element.parent_element() != element_target.parent_element() {
+                return;
+            }
+
+            element_target
+                .previous_element_sibling()
+                .unwrap()
+                .set_class_name("dropzone active");
+
+            element_target
+                .next_element_sibling()
+                .unwrap()
+                .set_class_name("dropzone active");
+        }
+    })
+    .forget();
+
+    // DROP.
+    let prefix_copied = prefix.to_string();
+    EventListener::new(element, "drop", move |event| {
+        let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
+
+        let drag_data_lock = DATA_DRAGGED.lock().unwrap();
+        let drag_data = drag_data_lock.borrow();
+
+        if drag_data.starts_with(&prefix_copied) {
+            let target: Element = event.current_target().unwrap().dyn_into().unwrap();
+            let element: Element = by_id(&drag_data);
+            let dropzone: Element = element.next_element_sibling().unwrap();
+
+            if element.parent_element() != target.parent_element() {
+                return;
+            }
+
+            match get_cursor_position(event.client_y() as f64, &element) {
+                CursorPosition::UpperPart => {
+                    target
+                        .previous_element_sibling()
+                        .unwrap()
+                        .after_with_node_1(&element)
+                        .unwrap();
+                    element.after_with_node_1(&dropzone).unwrap();
+                }
+                CursorPosition::LowerPart => {
+                    target
+                        .next_element_sibling()
+                        .unwrap()
+                        .after_with_node_1(&element)
+                        .unwrap();
+                    element.after_with_node_1(&dropzone).unwrap();
+                }
+            }
+
+            dropped(&element);
+        }
+    })
+    .forget();
+
+    // MOUSEDOWN.
+    let drag_handle: Element = element.selector(".drag-handle");
+    EventListener::new(&drag_handle, "mousedown", |event| {
+        event
+            .current_target()
+            .unwrap()
+            .dyn_into::<Element>()
+            .unwrap()
+            .parent_element()
+            .unwrap()
+            .set_attribute("draggable", "true")
+            .unwrap();
+    })
+    .forget();
+
+    // MOUSEUP.
+    EventListener::new(&drag_handle, "mouseup", |event| {
+        event
+            .current_target()
+            .unwrap()
+            .dyn_into::<Element>()
+            .unwrap()
+            .parent_element()
+            .unwrap()
+            .set_attribute("draggable", "false")
+            .unwrap();
+    })
+    .forget();
+
+    // DRAGSTART.
+    let prefix_copied = prefix.to_string();
+    EventListener::new(element, "dragstart", move |event| {
+        let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
+        let target_element: Element = event.current_target().unwrap().dyn_into().unwrap();
+
+        if target_element.id().starts_with(&prefix_copied) {
+            event.stop_propagation();
+
+            // Highlight where the group can be droppped.
+            for dp in target_element
+                .parent_element()
+                .unwrap()
+                .selector_all::<HtmlDivElement>(".dropzone")
+            {
+                if dp.parent_element() == target_element.parent_element() {
+                    dp.set_class_name("dropzone active");
+                }
+            }
+
+            let drag_data_lock = DATA_DRAGGED.lock().unwrap();
+            drag_data_lock.replace(target_element.id());
+
+            event.data_transfer().unwrap().set_effect_allowed("move");
+        }
+    })
+    .forget();
+
+    // DRAGEND.
+    let prefix_copied = prefix.to_string();
+    EventListener::new(element, "dragend", move |event| {
+        let target_element: Element = event.current_target().unwrap().dyn_into().unwrap();
+        target_element.set_attribute("draggable", "false").unwrap();
+        if target_element.id().starts_with(&prefix_copied) {
+            for dp in target_element
+                .parent_element()
+                .unwrap()
+                .selector_all::<HtmlDivElement>(".dropzone")
+            {
+                dp.set_class_name("dropzone");
+            }
+        }
+    })
+    .forget();
+
+    fn setup_dragzone_events<T>(dropzone: &Element, prefix: &str, dropped: rc::Rc<T>)
+    where
+        T: Fn(&Element) + 'static,
+    {
+        // DRAGOVER (dropzone).
+        let prefix_copied = prefix.to_string();
+        EventListener::new_with_options(
+            dropzone,
+            "dragover",
+            EventListenerOptions::enable_prevent_default(),
+            move |event| {
+                let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
+
+                let drag_data_lock = DATA_DRAGGED.lock().unwrap();
+                let drag_data = drag_data_lock.borrow();
+
+                if drag_data.starts_with(&prefix_copied) {
+                    let element: Element = by_id(&drag_data);
+                    let element_target = event
+                        .current_target()
+                        .unwrap()
+                        .dyn_into::<Element>()
+                        .unwrap();
+
+                    if element.parent_element() != element_target.parent_element() {
+                        return;
+                    }
+
+                    event.prevent_default();
+
+                    event
+                        .current_target()
+                        .unwrap()
+                        .dyn_into::<Element>()
+                        .unwrap()
+                        .set_class_name("dropzone hover");
+                }
+            },
+        )
+        .forget();
+
+        // DRAGLEAVE (dropzone).
+        let prefix_copied = prefix.to_string();
+        EventListener::new(dropzone, "dragleave", move |event| {
+            let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
+
+            let drag_data_lock = DATA_DRAGGED.lock().unwrap();
+            let drag_data = drag_data_lock.borrow();
+
+            if drag_data.starts_with(&prefix_copied) {
+                let element: Element = by_id(&drag_data);
+                let element_target = event
+                    .current_target()
+                    .unwrap()
+                    .dyn_into::<Element>()
+                    .unwrap();
+
+                if element.parent_element() != element_target.parent_element() {
+                    return;
+                }
+
+                event
+                    .current_target()
+                    .unwrap()
+                    .dyn_into::<Element>()
+                    .unwrap()
+                    .set_class_name("dropzone active");
+            }
+        })
+        .forget();
+
+        // DROP (dropzone).
+        let prefix_copied = prefix.to_string();
+        EventListener::new(dropzone, "drop", move |event| {
+            let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
+
+            let drag_data_lock = DATA_DRAGGED.lock().unwrap();
+            let drag_data = drag_data_lock.borrow();
+
+            if drag_data.starts_with(&prefix_copied) {
+                let target: Element = event.current_target().unwrap().dyn_into().unwrap();
+                let element: Element = by_id(&drag_data);
+                let dropzone: Element = element.next_element_sibling().unwrap();
+
+                if element.parent_element() != target.parent_element() {
+                    return;
+                }
+
+                target.after_with_node_1(&element).unwrap();
+                element.after_with_node_1(&dropzone).unwrap();
+
+                dropped(&element);
+            }
+        })
+        .forget();
+    }
+}
diff --git a/frontend/src/pages/recipe_view.rs b/frontend/src/pages/recipe_view.rs
new file mode 100644 (file)
index 0000000..899f924
--- /dev/null
@@ -0,0 +1,92 @@
+use common::utils::substitute_with_names;
+use gloo::events::EventListener;
+use wasm_bindgen_futures::spawn_local;
+use web_sys::{Element, HtmlInputElement};
+
+use crate::{
+    calendar, modal_dialog,
+    recipe_scheduler::{RecipeScheduler, ScheduleRecipeResult},
+    toast::{self, Level},
+    utils::{SelectorExt, get_locale, selector},
+};
+
+pub fn setup_page(recipe_id: i64, is_user_logged: bool) {
+    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, add_ingredients_to_shopping_list)) =
+                modal_dialog::show_and_initialize_with_ok(
+                    "#hidden-templates .date-and-servings",
+                    async |element| {
+                        calendar::setup(
+                            element.selector(".calendar"),
+                            calendar::CalendarOptions {
+                                can_select_date: true,
+                                with_link_and_remove: false,
+                            },
+                            recipe_scheduler,
+                        )
+                    },
+                    |element, calendar_state| {
+                        let servings_element: HtmlInputElement =
+                            element.selector("#input-servings");
+
+                        let add_ingredients_element: HtmlInputElement =
+                            element.selector("#input-add-ingredients-to-shopping-list");
+
+                        (
+                            calendar_state.get_selected_date(),
+                            servings_element.value_as_number() as u32,
+                            add_ingredients_element.checked(),
+                        )
+                    },
+                )
+                .await
+            {
+                if let Ok(result) = recipe_scheduler
+                    .shedule_recipe(recipe_id, date, servings, add_ingredients_to_shopping_list)
+                    .await
+                {
+                    toast::show_element_and_initialize(
+                        match result {
+                            ScheduleRecipeResult::Ok => Level::Success,
+                            ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => {
+                                Level::Warning
+                            }
+                        },
+                        match result {
+                            ScheduleRecipeResult::Ok => {
+                                "#hidden-templates .calendar-add-to-planner-success"
+                            }
+                            ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => {
+                                "#hidden-templates .calendar-add-to-planner-already-exists"
+                            }
+                        },
+                        |element| {
+                            let title =
+                                selector::<Element>("#recipe-view .recipe-title").inner_html();
+                            let date_format =
+                                selector::<Element>("#hidden-templates .calendar-date-format")
+                                    .inner_html();
+
+                            element.set_inner_html(&substitute_with_names(
+                                &element.inner_html(),
+                                &["{title}", "{date}"],
+                                &[
+                                    &title,
+                                    &date
+                                        .format_localized(&date_format, get_locale())
+                                        .to_string(),
+                                ],
+                            ));
+                        },
+                    );
+                }
+            }
+        });
+    })
+    .forget();
+}
diff --git a/frontend/src/recipe_edit.rs b/frontend/src/recipe_edit.rs
deleted file mode 100644 (file)
index 9fbf10e..0000000
+++ /dev/null
@@ -1,1063 +0,0 @@
-use std::{cell::RefCell, rc, sync::Mutex};
-
-use common::{ron_api, utils::substitute};
-use gloo::{
-    events::{EventListener, EventListenerOptions},
-    net::http::Request,
-    utils::{document, window},
-};
-use wasm_bindgen::prelude::*;
-use wasm_bindgen_futures::spawn_local;
-use web_sys::{
-    DragEvent, Element, HtmlDivElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement,
-    KeyboardEvent,
-};
-
-use crate::{
-    modal_dialog, request,
-    toast::{self, Level},
-    utils::{SelectorExt, by_id, selector, selector_and_clone},
-};
-
-pub fn setup_page(recipe_id: i64) {
-    // Title.
-    {
-        let title: HtmlInputElement = by_id("input-title");
-
-        // Check if the recipe has been loaded.
-
-        let mut current_title = title.value();
-        EventListener::new(&title.clone(), "blur", move |_event| {
-            if title.value() != current_title {
-                current_title = title.value();
-                let body = ron_api::SetRecipeTitle {
-                    recipe_id,
-                    title: title.value(),
-                };
-                spawn_local(async move {
-                    let _ = request::patch::<(), _>("recipe/set_title", body).await;
-                    reload_recipes_list(recipe_id).await;
-                });
-            }
-        })
-        .forget();
-    }
-
-    // Description.
-    {
-        let description: HtmlTextAreaElement = by_id("text-area-description");
-        let mut current_description = description.value();
-
-        EventListener::new(&description.clone(), "blur", move |_event| {
-            if description.value() != current_description {
-                current_description = description.value();
-                let body = ron_api::SetRecipeDescription {
-                    recipe_id,
-                    description: description.value(),
-                };
-                spawn_local(async move {
-                    let _ = request::patch::<(), _>("recipe/set_description", body).await;
-                });
-            }
-        })
-        .forget();
-    }
-
-    // Servings.
-    {
-        let servings: HtmlInputElement = by_id("input-servings");
-        let mut current_servings = servings.value_as_number();
-        EventListener::new(&servings.clone(), "input", move |_event| {
-            let n = servings.value_as_number();
-            if n.is_nan() {
-                servings.set_value("");
-            }
-            if n != current_servings {
-                let servings = if n.is_nan() {
-                    None
-                } else {
-                    // TODO: Find a better way to validate integer numbers.
-                    let n = n as u32;
-                    servings.set_value_as_number(n as f64);
-                    Some(n)
-                };
-                current_servings = n;
-                let body = ron_api::SetRecipeServings {
-                    recipe_id,
-                    servings,
-                };
-                spawn_local(async move {
-                    let _ = request::patch::<(), _>("recipe/set_servings", body).await;
-                });
-            }
-        })
-        .forget();
-    }
-
-    // Estimated time.
-    {
-        let estimated_time: HtmlInputElement = by_id("input-estimated-time");
-        let mut current_time = estimated_time.value_as_number();
-
-        EventListener::new(&estimated_time.clone(), "input", move |_event| {
-            let n = estimated_time.value_as_number();
-            if n.is_nan() {
-                estimated_time.set_value("");
-            }
-            if n != current_time {
-                let time = if n.is_nan() {
-                    None
-                } else {
-                    // TODO: Find a better way to validate integer numbers.
-                    let n = n as u32;
-                    estimated_time.set_value_as_number(n as f64);
-                    Some(n)
-                };
-                current_time = n;
-                let body = ron_api::SetRecipeEstimatedTime {
-                    recipe_id,
-                    estimated_time: time,
-                };
-                spawn_local(async move {
-                    let _ = request::patch::<(), _>("recipe/set_estimated_time", body).await;
-                });
-            }
-        })
-        .forget();
-    }
-
-    // Difficulty.
-    {
-        let difficulty: HtmlSelectElement = by_id("select-difficulty");
-        let mut current_difficulty = difficulty.value();
-
-        EventListener::new(&difficulty.clone(), "blur", move |_event| {
-            if difficulty.value() != current_difficulty {
-                current_difficulty = difficulty.value();
-
-                let body = ron_api::SetRecipeDifficulty {
-                    recipe_id,
-                    difficulty: ron_api::Difficulty::try_from(
-                        current_difficulty.parse::<u32>().unwrap(),
-                    )
-                    .unwrap(),
-                };
-                spawn_local(async move {
-                    let _ = request::patch::<(), _>("recipe/set_difficulty", body).await;
-                });
-            }
-        })
-        .forget();
-    }
-
-    // Tags.
-    {
-        spawn_local(async move {
-            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);
-        });
-
-        fn add_tags(recipe_id: i64, tags: String) {
-            spawn_local(async move {
-                let tag_list: Vec<String> =
-                    tags.split_whitespace().map(str::to_lowercase).collect();
-                if !tag_list.is_empty() {
-                    let body = ron_api::Tags {
-                        recipe_id,
-                        tags: tag_list.clone(),
-                    };
-                    let _ = request::post::<(), _>("recipe/add_tags", body).await;
-                    create_tag_elements(recipe_id, &tag_list);
-                }
-                by_id::<HtmlInputElement>("input-tags").set_value("");
-            });
-        }
-
-        let input_tags: HtmlInputElement = by_id("input-tags");
-        EventListener::new(&input_tags.clone(), "input", move |_event| {
-            let tags = input_tags.value();
-            if tags.ends_with(' ') {
-                add_tags(recipe_id, tags);
-            }
-        })
-        .forget();
-
-        let input_tags: HtmlInputElement = by_id("input-tags");
-        EventListener::new(&input_tags.clone(), "keypress", move |event| {
-            if let Some(keyboard_event) = event.dyn_ref::<KeyboardEvent>() {
-                if keyboard_event.key_code() == 13 {
-                    let tags = input_tags.value();
-                    add_tags(recipe_id, tags);
-                }
-            }
-        })
-        .forget();
-
-        let input_tags: HtmlInputElement = by_id("input-tags");
-        EventListener::new(&input_tags.clone(), "blur", move |_event| {
-            let tags = input_tags.value();
-            add_tags(recipe_id, tags);
-        })
-        .forget();
-    }
-
-    // Language.
-    {
-        let language: HtmlSelectElement = by_id("select-language");
-        let mut current_language = language.value();
-        EventListener::new(&language.clone(), "blur", move |_event| {
-            if language.value() != current_language {
-                current_language = language.value();
-
-                let body = ron_api::SetRecipeLanguage {
-                    recipe_id,
-                    lang: language.value(),
-                };
-                spawn_local(async move {
-                    let _ = request::patch::<(), _>("recipe/set_language", body).await;
-                });
-            }
-        })
-        .forget();
-    }
-
-    // Is published.
-    {
-        let is_published: HtmlInputElement = by_id("input-is-published");
-        EventListener::new(&is_published.clone(), "input", move |_event| {
-            let body = ron_api::SetIsPublished {
-                recipe_id,
-                is_published: is_published.checked(),
-            };
-            spawn_local(async move {
-                let _ = request::patch::<(), _>("recipe/set_is_published", body).await;
-                reload_recipes_list(recipe_id).await;
-            });
-        })
-        .forget();
-    }
-
-    // Delete recipe button.
-    let delete_button: HtmlInputElement = by_id("input-delete");
-    EventListener::new(&delete_button, "click", move |_event| {
-        spawn_local(async move {
-            if modal_dialog::show_and_initialize(
-                "#hidden-templates .recipe-delete-confirmation",
-                async |element| {
-                    let title: HtmlInputElement = by_id("input-title");
-                    element.set_inner_html(&substitute(
-                        &element.inner_html(),
-                        "{}",
-                        &[&title.value()],
-                    ));
-                },
-            )
-            .await
-            .is_some()
-            {
-                let body = ron_api::Id { id: recipe_id };
-                let _ = request::delete::<(), _>("recipe/remove", body).await;
-                window().location().set_href("/").unwrap();
-            }
-        });
-    })
-    .forget();
-
-    // Load initial groups, steps and ingredients.
-    {
-        spawn_local(async move {
-            let groups: Vec<common::ron_api::Group> =
-                request::get("recipe/get_groups", ron_api::Id { id: recipe_id })
-                    .await
-                    .unwrap();
-
-            for group in groups {
-                let group_element = create_group_element(&group);
-
-                for step in group.steps {
-                    let step_element =
-                        create_step_element(&group_element.selector(".steps"), &step);
-
-                    for ingredient in step.ingredients {
-                        create_ingredient_element(
-                            &step_element.selector(".ingredients"),
-                            &ingredient,
-                        );
-                    }
-                }
-            }
-        });
-    }
-
-    // Add a new group.
-    {
-        let button_add_group: HtmlInputElement = by_id("input-add-group");
-        EventListener::new(&button_add_group, "click", move |_event| {
-            let body = ron_api::Id { id: recipe_id };
-            spawn_local(async move {
-                let response: ron_api::Id = request::post("recipe/add_group", body).await.unwrap();
-                create_group_element(&ron_api::Group {
-                    id: response.id,
-                    name: "".to_string(),
-                    comment: "".to_string(),
-                    steps: vec![],
-                });
-            });
-        })
-        .forget();
-    }
-}
-
-fn create_group_element(group: &ron_api::Group) -> Element {
-    let group_id = group.id;
-    let group_element: Element = selector_and_clone("#hidden-templates .group");
-    group_element.set_id(&format!("group-{}", group.id));
-
-    let groups_container: Element = by_id("groups-container");
-    groups_container.append_child(&group_element).unwrap();
-
-    set_draggable(&group_element, "group", |_element| {
-        spawn_local(async move {
-            let ids = by_id::<Element>("groups-container")
-                .selector_all::<Element>(".group")
-                .into_iter()
-                .map(|e| e.id()[6..].parse::<i64>().unwrap())
-                .collect();
-
-            let body = ron_api::Ids { ids };
-            let _ = request::patch::<(), _>("recipe/set_groups_order", body).await;
-        });
-    });
-
-    // Group name.
-    let name = group_element.selector::<HtmlInputElement>(".input-group-name");
-    name.set_value(&group.name);
-    let mut current_name = group.name.clone();
-    EventListener::new(&name.clone(), "blur", move |_event| {
-        if name.value() != current_name {
-            current_name = name.value();
-            let body = ron_api::SetGroupName {
-                group_id,
-                name: name.value(),
-            };
-            spawn_local(async move {
-                let _ = request::patch::<(), _>("recipe/set_group_name", body).await;
-            })
-        }
-    })
-    .forget();
-
-    // Group comment.
-    let comment: HtmlInputElement = group_element.selector(".input-group-comment");
-    comment.set_value(&group.comment);
-    let mut current_comment = group.comment.clone();
-    EventListener::new(&comment.clone(), "blur", move |_event| {
-        if comment.value() != current_comment {
-            current_comment = comment.value();
-            let body = ron_api::SetGroupComment {
-                group_id,
-                comment: comment.value(),
-            };
-            spawn_local(async move {
-                let _ = request::patch::<(), _>("recipe/set_group_comment", body).await;
-            });
-        }
-    })
-    .forget();
-
-    // Delete button.
-    let group_element_cloned = group_element.clone();
-    let delete_button: HtmlInputElement = group_element.selector(".input-group-delete");
-    EventListener::new(&delete_button, "click", move |_event| {
-        // FIXME: How to avoid cloning twice?
-        let group_element_cloned = group_element_cloned.clone();
-        spawn_local(async move {
-            if modal_dialog::show_and_initialize(
-                "#hidden-templates .recipe-group-delete-confirmation",
-                async move |element| {
-                    let name = group_element_cloned
-                        .selector::<HtmlInputElement>(".input-group-name")
-                        .value();
-                    element.set_inner_html(&substitute(&element.inner_html(), "{}", &[&name]));
-                },
-            )
-            .await
-            .is_some()
-            {
-                let body = ron_api::Id { id: group_id };
-                let _ = request::delete::<(), _>("recipe/remove_group", body).await;
-                let group_element = by_id::<Element>(&format!("group-{}", group_id));
-                group_element.next_element_sibling().unwrap().remove();
-                group_element.remove();
-            }
-        });
-    })
-    .forget();
-
-    // Add step button.
-    let add_step_button: HtmlInputElement = group_element.selector(".input-add-step");
-    EventListener::new(&add_step_button, "click", move |_event| {
-        spawn_local(async move {
-            let body = ron_api::Id { id: group_id };
-            let response: ron_api::Id = request::post("recipe/add_step", body).await.unwrap();
-            create_step_element(
-                &selector::<Element>(&format!("#group-{} .steps", group_id)),
-                &ron_api::Step {
-                    id: response.id,
-                    action: "".to_string(),
-                    ingredients: vec![],
-                },
-            );
-        });
-    })
-    .forget();
-
-    group_element
-}
-
-fn create_tag_elements<T>(recipe_id: i64, tags: &[T])
-where
-    T: AsRef<str>,
-{
-    let tags_span: Element = selector("#container-tags .tags");
-
-    // Collect current tags to avoid re-adding an existing tag.
-    let mut current_tags: Vec<String> = vec![];
-    let mut current_tag_element = tags_span.first_child();
-    while let Some(element) = current_tag_element {
-        current_tags.push(
-            element
-                .dyn_ref::<Element>()
-                .unwrap()
-                .text_content()
-                .unwrap(),
-        );
-        current_tag_element = element.next_sibling();
-    }
-
-    for tag in tags {
-        let tag = tag.as_ref().to_string();
-        if current_tags.contains(&tag) {
-            continue;
-        }
-        let tag_span = document().create_element("span").unwrap();
-        tag_span.set_inner_html(&tag);
-        let delete_tag_button: HtmlInputElement = document()
-            .create_element("input")
-            .unwrap()
-            .dyn_into()
-            .unwrap();
-        delete_tag_button.set_attribute("type", "button").unwrap();
-        delete_tag_button.set_attribute("value", "X").unwrap();
-        tag_span.append_child(&delete_tag_button).unwrap();
-        tags_span.append_child(&tag_span).unwrap();
-
-        EventListener::new(&delete_tag_button, "click", move |_event| {
-            let tag_span = tag_span.clone();
-            let tag = tag.clone();
-            spawn_local(async move {
-                let body = ron_api::Tags {
-                    recipe_id,
-                    tags: vec![tag],
-                };
-                let _ = request::delete::<(), _>("recipe/rm_tags", body).await;
-                tag_span.remove();
-            });
-        })
-        .forget();
-    }
-}
-
-fn create_step_element(group_element: &Element, step: &ron_api::Step) -> Element {
-    let step_id = step.id;
-    let step_element: Element = selector_and_clone("#hidden-templates .step");
-    step_element.set_id(&format!("step-{}", step.id));
-    group_element.append_child(&step_element).unwrap();
-
-    set_draggable(&step_element, "step", |element| {
-        let element = element.clone();
-        spawn_local(async move {
-            let ids = element
-                .parent_element()
-                .unwrap()
-                .selector_all::<Element>(".step")
-                .into_iter()
-                .map(|e| e.id()[5..].parse::<i64>().unwrap())
-                .collect();
-
-            let body = ron_api::Ids { ids };
-            let _ = request::patch::<(), _>("recipe/set_steps_order", body).await;
-        });
-    });
-
-    // Step action.
-    let action: HtmlTextAreaElement = step_element.selector(".text-area-step-action");
-    action.set_value(&step.action);
-    let mut current_action = step.action.clone();
-    EventListener::new(&action.clone(), "blur", move |_event| {
-        if action.value() != current_action {
-            current_action = action.value();
-            let body = ron_api::SetStepAction {
-                step_id,
-                action: action.value(),
-            };
-            spawn_local(async move {
-                let _ = request::patch::<(), _>("recipe/set_step_action", body).await;
-            });
-        }
-    })
-    .forget();
-
-    // Delete button.
-    let step_element_cloned = step_element.clone();
-    let delete_button: HtmlInputElement = step_element.selector(".input-step-delete");
-    EventListener::new(&delete_button, "click", move |_event| {
-        // FIXME: How to avoid cloning twice?
-        let step_element_cloned = step_element_cloned.clone();
-        spawn_local(async move {
-            if modal_dialog::show_and_initialize(
-                "#hidden-templates .recipe-step-delete-confirmation",
-                async move |element| {
-                    let action = step_element_cloned
-                        .selector::<HtmlTextAreaElement>(".text-area-step-action")
-                        .value();
-                    element.set_inner_html(&substitute(&element.inner_html(), "{}", &[&action]));
-                },
-            )
-            .await
-            .is_some()
-            {
-                let body = ron_api::Id { id: step_id };
-                let _ = request::delete::<(), _>("recipe/remove_step", body).await;
-                let step_element = by_id::<Element>(&format!("step-{}", step_id));
-                step_element.next_element_sibling().unwrap().remove();
-                step_element.remove();
-            }
-        });
-    })
-    .forget();
-
-    // Add ingredient button.
-    let add_ingredient_button: HtmlInputElement = step_element.selector(".input-add-ingredient");
-    EventListener::new(&add_ingredient_button, "click", move |_event| {
-        spawn_local(async move {
-            let body = ron_api::Id { id: step_id };
-            let response: ron_api::Id = request::post("recipe/add_ingredient", body).await.unwrap();
-            create_ingredient_element(
-                &selector::<Element>(&format!("#step-{} .ingredients", step_id)),
-                &ron_api::Ingredient {
-                    id: response.id,
-                    name: "".to_string(),
-                    comment: "".to_string(),
-                    quantity_value: None,
-                    quantity_unit: "".to_string(),
-                },
-            );
-        });
-    })
-    .forget();
-
-    step_element
-}
-
-fn create_ingredient_element(step_element: &Element, ingredient: &ron_api::Ingredient) -> Element {
-    let ingredient_id = ingredient.id;
-    let ingredient_element: Element = selector_and_clone("#hidden-templates .ingredient");
-    ingredient_element.set_id(&format!("ingredient-{}", ingredient.id));
-    step_element.append_child(&ingredient_element).unwrap();
-
-    set_draggable(&ingredient_element, "ingredient", |element| {
-        let element = element.clone();
-        spawn_local(async move {
-            let ids = element
-                .parent_element()
-                .unwrap()
-                .selector_all::<Element>(".ingredient")
-                .into_iter()
-                .map(|e| e.id()[11..].parse::<i64>().unwrap())
-                .collect();
-
-            let body = ron_api::Ids { ids };
-            let _ = request::patch::<(), _>("recipe/set_ingredients_order", body).await;
-        });
-    });
-
-    // Ingredient name.
-    let name: HtmlInputElement = ingredient_element.selector(".input-ingredient-name");
-    name.set_value(&ingredient.name);
-    let mut current_name = ingredient.name.clone();
-    EventListener::new(&name.clone(), "blur", move |_event| {
-        if name.value() != current_name {
-            current_name = name.value();
-            let body = ron_api::SetIngredientName {
-                ingredient_id,
-                name: name.value(),
-            };
-            spawn_local(async move {
-                let _ = request::patch::<(), _>("recipe/set_ingredient_name", body).await;
-            });
-        }
-    })
-    .forget();
-
-    // Ingredient comment.
-    let comment: HtmlInputElement = ingredient_element.selector(".input-ingredient-comment");
-    comment.set_value(&ingredient.comment);
-    let mut current_comment = ingredient.comment.clone();
-    EventListener::new(&comment.clone(), "blur", move |_event| {
-        if comment.value() != current_comment {
-            current_comment = comment.value();
-            let body = ron_api::SetIngredientComment {
-                ingredient_id,
-                comment: comment.value(),
-            };
-            spawn_local(async move {
-                let _ = request::patch::<(), _>("recipe/set_ingredient_comment", body).await;
-            });
-        }
-    })
-    .forget();
-
-    // Ingredient quantity.
-    let quantity: HtmlInputElement = ingredient_element.selector(".input-ingredient-quantity");
-    quantity.set_value(
-        &ingredient
-            .quantity_value
-            .map_or("".to_string(), |q| q.to_string()),
-    );
-    let mut current_quantity = quantity.value_as_number();
-    EventListener::new(&quantity.clone(), "input", move |_event| {
-        let n = quantity.value_as_number();
-        if n.is_nan() {
-            quantity.set_value("");
-        }
-        if n != current_quantity {
-            let q = if n.is_nan() { None } else { Some(n) };
-            current_quantity = n;
-            let body = ron_api::SetIngredientQuantity {
-                ingredient_id,
-                quantity: q,
-            };
-            spawn_local(async move {
-                let _ = request::patch::<(), _>("recipe/set_ingredient_quantity", body).await;
-            });
-        }
-    })
-    .forget();
-
-    // Ingredient unit.
-    let unit: HtmlInputElement = ingredient_element.selector(".input-ingredient-unit");
-    unit.set_value(&ingredient.quantity_unit);
-    let mut current_unit = ingredient.quantity_unit.clone();
-    EventListener::new(&unit.clone(), "blur", move |_event| {
-        if unit.value() != current_unit {
-            current_unit = unit.value();
-            let body = ron_api::SetIngredientUnit {
-                ingredient_id,
-                unit: unit.value(),
-            };
-            spawn_local(async move {
-                let _ = request::patch::<(), _>("recipe/set_ingredient_unit", body).await;
-            });
-        }
-    })
-    .forget();
-
-    // Delete button.
-    let ingredient_element_cloned = ingredient_element.clone();
-    let delete_button: HtmlInputElement = ingredient_element.selector(".input-ingredient-delete");
-    EventListener::new(&delete_button, "click", move |_event| {
-        // FIXME: How to avoid cloning twice?
-        let ingredient_element_cloned = ingredient_element_cloned.clone();
-        spawn_local(async move {
-            if modal_dialog::show_and_initialize(
-                "#hidden-templates .recipe-ingredient-delete-confirmation",
-                async move |element| {
-                    let name = ingredient_element_cloned
-                        .selector::<HtmlInputElement>(".input-ingredient-name")
-                        .value();
-                    element.set_inner_html(&substitute(&element.inner_html(), "{}", &[&name]));
-                },
-            )
-            .await
-            .is_some()
-            {
-                let body = ron_api::Id { id: ingredient_id };
-                let _ = request::delete::<(), _>("recipe/remove_ingredient", body).await;
-                let ingredient_element = by_id::<Element>(&format!("ingredient-{}", ingredient_id));
-                ingredient_element.next_element_sibling().unwrap().remove();
-                ingredient_element.remove();
-            }
-        });
-    })
-    .forget();
-
-    ingredient_element
-}
-
-async fn reload_recipes_list(current_recipe_id: i64) {
-    match Request::get("/fragments/recipes_list")
-        .query([("current_recipe_id", current_recipe_id.to_string())])
-        .send()
-        .await
-    {
-        Err(error) => {
-            toast::show_message(Level::Info, &format!("Internal server error: {}", error));
-        }
-        Ok(response) => {
-            let list = document().get_element_by_id("recipes-list").unwrap();
-            list.set_outer_html(&response.text().await.unwrap());
-        }
-    }
-}
-
-enum CursorPosition {
-    UpperPart,
-    LowerPart,
-}
-
-fn get_cursor_position(mouse_y: f64, element: &Element) -> CursorPosition {
-    let element_y = element.get_bounding_client_rect().y();
-    // Between 0 (top) and 1 (bottom).
-    let y_relative_pos = (mouse_y - element_y) / element.get_bounding_client_rect().height();
-    if y_relative_pos < 0.5 {
-        CursorPosition::UpperPart
-    } else {
-        CursorPosition::LowerPart
-    }
-}
-
-fn get_parent_with_id_starting_with(mut element: Element, prefix: &str) -> Element {
-    while !element.id().starts_with(prefix) {
-        element = element.parent_element().unwrap();
-    }
-    element
-}
-
-// It replaces 'event.data_transfer().unwrap().get_data()/set_data()' because
-// Chrome prevent to read this during draghover event which is the correct behavior
-// according the specifications:
-// * https://html.spec.whatwg.org/multipage/dnd.html#the-drag-data-store
-static DATA_DRAGGED: Mutex<RefCell<String>> = Mutex::new(RefCell::new(String::new()));
-
-/// Set an element as draggable and add an element before and after
-/// cloned from "#hidden-templates .dropzone".
-/// All elements set as draggable in a given container can be dragged
-/// After or before another element.
-/// 'element' must have a sub-element with the class '.drag-handle' which
-/// will be used to drag the element.
-fn set_draggable<T>(element: &Element, prefix: &str, dropped: T)
-where
-    T: Fn(&Element) + 'static,
-{
-    let dropped = rc::Rc::new(dropped);
-
-    // Add a drop zone before the given element if there is none.
-    if element.previous_element_sibling().is_none() {
-        let dropzone = selector_and_clone::<Element>("#hidden-templates .dropzone");
-        element.before_with_node_1(&dropzone).unwrap();
-        setup_dragzone_events(&dropzone, prefix, dropped.clone());
-    }
-
-    let dropzone = selector_and_clone::<Element>("#hidden-templates .dropzone");
-    element.after_with_node_1(&dropzone).unwrap();
-    setup_dragzone_events(&dropzone, prefix, dropped.clone());
-
-    // DRAGOVER.
-    let prefix_copied = prefix.to_string();
-    EventListener::new_with_options(
-        element,
-        "dragover",
-        EventListenerOptions::enable_prevent_default(),
-        move |event| {
-            let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
-
-            let drag_data_lock = DATA_DRAGGED.lock().unwrap();
-            let drag_data = drag_data_lock.borrow();
-
-            if drag_data.starts_with(&prefix_copied) {
-                let element: Element = by_id(&drag_data);
-                let element_target = event
-                    .current_target()
-                    .unwrap()
-                    .dyn_into::<Element>()
-                    .unwrap();
-
-                if element.parent_element() != element_target.parent_element() {
-                    return;
-                }
-
-                event.prevent_default();
-
-                let cursor_position = get_cursor_position(event.client_y() as f64, &element_target);
-
-                element_target
-                    .previous_element_sibling()
-                    .unwrap()
-                    .set_class_name(match cursor_position {
-                        CursorPosition::UpperPart => "dropzone hover",
-                        CursorPosition::LowerPart => "dropzone active",
-                    });
-
-                element_target
-                    .next_element_sibling()
-                    .unwrap()
-                    .set_class_name(match cursor_position {
-                        CursorPosition::UpperPart => "dropzone active",
-                        CursorPosition::LowerPart => "dropzone hover",
-                    });
-            }
-        },
-    )
-    .forget();
-
-    // DRAGLEAVE.
-    let prefix_copied = prefix.to_string();
-    EventListener::new(element, "dragleave", move |event| {
-        let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
-
-        let drag_data_lock = DATA_DRAGGED.lock().unwrap();
-        let drag_data = drag_data_lock.borrow();
-
-        if drag_data.starts_with(&prefix_copied) {
-            let element: Element = by_id(&drag_data);
-            let element_target = event
-                .current_target()
-                .unwrap()
-                .dyn_into::<Element>()
-                .unwrap();
-
-            if element.parent_element() != element_target.parent_element() {
-                return;
-            }
-
-            element_target
-                .previous_element_sibling()
-                .unwrap()
-                .set_class_name("dropzone active");
-
-            element_target
-                .next_element_sibling()
-                .unwrap()
-                .set_class_name("dropzone active");
-        }
-    })
-    .forget();
-
-    // DROP.
-    let prefix_copied = prefix.to_string();
-    EventListener::new(element, "drop", move |event| {
-        let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
-
-        let drag_data_lock = DATA_DRAGGED.lock().unwrap();
-        let drag_data = drag_data_lock.borrow();
-
-        if drag_data.starts_with(&prefix_copied) {
-            let target: Element = event.current_target().unwrap().dyn_into().unwrap();
-            let element: Element = by_id(&drag_data);
-            let dropzone: Element = element.next_element_sibling().unwrap();
-
-            if element.parent_element() != target.parent_element() {
-                return;
-            }
-
-            match get_cursor_position(event.client_y() as f64, &element) {
-                CursorPosition::UpperPart => {
-                    target
-                        .previous_element_sibling()
-                        .unwrap()
-                        .after_with_node_1(&element)
-                        .unwrap();
-                    element.after_with_node_1(&dropzone).unwrap();
-                }
-                CursorPosition::LowerPart => {
-                    target
-                        .next_element_sibling()
-                        .unwrap()
-                        .after_with_node_1(&element)
-                        .unwrap();
-                    element.after_with_node_1(&dropzone).unwrap();
-                }
-            }
-
-            dropped(&element);
-        }
-    })
-    .forget();
-
-    // MOUSEDOWN.
-    let drag_handle: Element = element.selector(".drag-handle");
-    EventListener::new(&drag_handle, "mousedown", |event| {
-        event
-            .current_target()
-            .unwrap()
-            .dyn_into::<Element>()
-            .unwrap()
-            .parent_element()
-            .unwrap()
-            .set_attribute("draggable", "true")
-            .unwrap();
-    })
-    .forget();
-
-    // MOUSEUP.
-    EventListener::new(&drag_handle, "mouseup", |event| {
-        event
-            .current_target()
-            .unwrap()
-            .dyn_into::<Element>()
-            .unwrap()
-            .parent_element()
-            .unwrap()
-            .set_attribute("draggable", "false")
-            .unwrap();
-    })
-    .forget();
-
-    // DRAGSTART.
-    let prefix_copied = prefix.to_string();
-    EventListener::new(element, "dragstart", move |event| {
-        let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
-        let target_element: Element = event.current_target().unwrap().dyn_into().unwrap();
-
-        if target_element.id().starts_with(&prefix_copied) {
-            event.stop_propagation();
-
-            // Highlight where the group can be droppped.
-            for dp in target_element
-                .parent_element()
-                .unwrap()
-                .selector_all::<HtmlDivElement>(".dropzone")
-            {
-                if dp.parent_element() == target_element.parent_element() {
-                    dp.set_class_name("dropzone active");
-                }
-            }
-
-            let drag_data_lock = DATA_DRAGGED.lock().unwrap();
-            drag_data_lock.replace(target_element.id());
-
-            event.data_transfer().unwrap().set_effect_allowed("move");
-        }
-    })
-    .forget();
-
-    // DRAGEND.
-    let prefix_copied = prefix.to_string();
-    EventListener::new(element, "dragend", move |event| {
-        let target_element: Element = event.current_target().unwrap().dyn_into().unwrap();
-        target_element.set_attribute("draggable", "false").unwrap();
-        if target_element.id().starts_with(&prefix_copied) {
-            for dp in target_element
-                .parent_element()
-                .unwrap()
-                .selector_all::<HtmlDivElement>(".dropzone")
-            {
-                dp.set_class_name("dropzone");
-            }
-        }
-    })
-    .forget();
-
-    fn setup_dragzone_events<T>(dropzone: &Element, prefix: &str, dropped: rc::Rc<T>)
-    where
-        T: Fn(&Element) + 'static,
-    {
-        // DRAGOVER (dropzone).
-        let prefix_copied = prefix.to_string();
-        EventListener::new_with_options(
-            dropzone,
-            "dragover",
-            EventListenerOptions::enable_prevent_default(),
-            move |event| {
-                let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
-
-                let drag_data_lock = DATA_DRAGGED.lock().unwrap();
-                let drag_data = drag_data_lock.borrow();
-
-                if drag_data.starts_with(&prefix_copied) {
-                    let element: Element = by_id(&drag_data);
-                    let element_target = event
-                        .current_target()
-                        .unwrap()
-                        .dyn_into::<Element>()
-                        .unwrap();
-
-                    if element.parent_element() != element_target.parent_element() {
-                        return;
-                    }
-
-                    event.prevent_default();
-
-                    event
-                        .current_target()
-                        .unwrap()
-                        .dyn_into::<Element>()
-                        .unwrap()
-                        .set_class_name("dropzone hover");
-                }
-            },
-        )
-        .forget();
-
-        // DRAGLEAVE (dropzone).
-        let prefix_copied = prefix.to_string();
-        EventListener::new(dropzone, "dragleave", move |event| {
-            let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
-
-            let drag_data_lock = DATA_DRAGGED.lock().unwrap();
-            let drag_data = drag_data_lock.borrow();
-
-            if drag_data.starts_with(&prefix_copied) {
-                let element: Element = by_id(&drag_data);
-                let element_target = event
-                    .current_target()
-                    .unwrap()
-                    .dyn_into::<Element>()
-                    .unwrap();
-
-                if element.parent_element() != element_target.parent_element() {
-                    return;
-                }
-
-                event
-                    .current_target()
-                    .unwrap()
-                    .dyn_into::<Element>()
-                    .unwrap()
-                    .set_class_name("dropzone active");
-            }
-        })
-        .forget();
-
-        // DROP (dropzone).
-        let prefix_copied = prefix.to_string();
-        EventListener::new(dropzone, "drop", move |event| {
-            let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
-
-            let drag_data_lock = DATA_DRAGGED.lock().unwrap();
-            let drag_data = drag_data_lock.borrow();
-
-            if drag_data.starts_with(&prefix_copied) {
-                let target: Element = event.current_target().unwrap().dyn_into().unwrap();
-                let element: Element = by_id(&drag_data);
-                let dropzone: Element = element.next_element_sibling().unwrap();
-
-                if element.parent_element() != target.parent_element() {
-                    return;
-                }
-
-                target.after_with_node_1(&element).unwrap();
-                element.after_with_node_1(&dropzone).unwrap();
-
-                dropped(&element);
-            }
-        })
-        .forget();
-    }
-}
index f9789fd..c089afb 100644 (file)
@@ -1,7 +1,7 @@
 use chrono::{Datelike, Days, Months, NaiveDate};
 use common::ron_api;
 use gloo::storage::{LocalStorage, Storage};
-use ron::ser::{to_string_pretty, PrettyConfig};
+use ron::ser::{PrettyConfig, to_string_pretty};
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
diff --git a/frontend/src/recipe_view.rs b/frontend/src/recipe_view.rs
deleted file mode 100644 (file)
index 899f924..0000000
+++ /dev/null
@@ -1,92 +0,0 @@
-use common::utils::substitute_with_names;
-use gloo::events::EventListener;
-use wasm_bindgen_futures::spawn_local;
-use web_sys::{Element, HtmlInputElement};
-
-use crate::{
-    calendar, modal_dialog,
-    recipe_scheduler::{RecipeScheduler, ScheduleRecipeResult},
-    toast::{self, Level},
-    utils::{SelectorExt, get_locale, selector},
-};
-
-pub fn setup_page(recipe_id: i64, is_user_logged: bool) {
-    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, add_ingredients_to_shopping_list)) =
-                modal_dialog::show_and_initialize_with_ok(
-                    "#hidden-templates .date-and-servings",
-                    async |element| {
-                        calendar::setup(
-                            element.selector(".calendar"),
-                            calendar::CalendarOptions {
-                                can_select_date: true,
-                                with_link_and_remove: false,
-                            },
-                            recipe_scheduler,
-                        )
-                    },
-                    |element, calendar_state| {
-                        let servings_element: HtmlInputElement =
-                            element.selector("#input-servings");
-
-                        let add_ingredients_element: HtmlInputElement =
-                            element.selector("#input-add-ingredients-to-shopping-list");
-
-                        (
-                            calendar_state.get_selected_date(),
-                            servings_element.value_as_number() as u32,
-                            add_ingredients_element.checked(),
-                        )
-                    },
-                )
-                .await
-            {
-                if let Ok(result) = recipe_scheduler
-                    .shedule_recipe(recipe_id, date, servings, add_ingredients_to_shopping_list)
-                    .await
-                {
-                    toast::show_element_and_initialize(
-                        match result {
-                            ScheduleRecipeResult::Ok => Level::Success,
-                            ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => {
-                                Level::Warning
-                            }
-                        },
-                        match result {
-                            ScheduleRecipeResult::Ok => {
-                                "#hidden-templates .calendar-add-to-planner-success"
-                            }
-                            ScheduleRecipeResult::RecipeAlreadyScheduledAtThisDate => {
-                                "#hidden-templates .calendar-add-to-planner-already-exists"
-                            }
-                        },
-                        |element| {
-                            let title =
-                                selector::<Element>("#recipe-view .recipe-title").inner_html();
-                            let date_format =
-                                selector::<Element>("#hidden-templates .calendar-date-format")
-                                    .inner_html();
-
-                            element.set_inner_html(&substitute_with_names(
-                                &element.inner_html(),
-                                &["{title}", "{date}"],
-                                &[
-                                    &title,
-                                    &date
-                                        .format_localized(&date_format, get_locale())
-                                        .to_string(),
-                                ],
-                            ));
-                        },
-                    );
-                }
-            }
-        });
-    })
-    .forget();
-}
index ac347a2..3856111 100644 (file)
@@ -99,6 +99,16 @@ where
         .unwrap()
 }
 
+pub fn get_current_lang() -> String {
+    selector::<Element>("html")
+        .get_attribute("lang")
+        .unwrap()
+        .split("-")
+        .next()
+        .unwrap()
+        .to_string()
+}
+
 pub fn get_locale() -> Locale {
     let lang_and_territory = selector::<Element>("html")
         .get_attribute("lang")