From: Greg Burri Date: Wed, 26 Mar 2025 00:49:02 +0000 (+0100) Subject: * Support for lang in URL as /fr/recipe/view/42 X-Git-Url: https://git.euphorik.ch/?a=commitdiff_plain;h=418d31a127c0eec0605fe041621f453f0def7e23;p=recipes.git * Support for lang in URL as /fr/recipe/view/42 * Create a pages module in the frontend crate --- diff --git a/Cargo.lock b/Cargo.lock index ccf8b95..5d3c8fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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 --- 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 diff --git a/backend/src/main.rs b/backend/src/main.rs index efee562..06ff7c4 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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::(); + .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::(), + ) + .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); + +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::() { + 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, Extension(user): Extension>, mut req: Request, next: Next, ) -> Result { - // 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::() { - // 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(); diff --git a/backend/src/services/recipe.rs b/backend/src/services/recipe.rs index 799ffee..9f5c1f2 100644 --- a/backend/src/services/recipe.rs +++ b/backend/src/services/recipe.rs @@ -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] diff --git a/deploy.nu b/deploy.nu index 4fe6404..3972ce0 100644 --- 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 index 986bd9f..0000000 --- a/frontend/src/home.rs +++ /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::("#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::(".item-name") - .set_inner_html(&item.name); - - if let Some(quantity_value) = item.quantity_value { - item_element - .selector::(".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::(".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::("shopping-list-checked") - .append_child(&item_element) - .unwrap(); - } else { - by_id::("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::(".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/lib.rs b/frontend/src/lib.rs index 0323e37..11dba8d 100644 --- a/frontend/src/lib.rs +++ b/frontend/src/lib.rs @@ -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::("html") .dataset() @@ -36,14 +40,14 @@ pub fn main() -> Result<(), JsValue> { match path[..] { ["recipe", "edit", id] => { let id = id.parse::().unwrap(); // TODO: remove unwrap. - recipe_edit::setup_page(id) + pages::recipe_edit::setup_page(id) } ["recipe", "view", id] => { let id = id.parse::().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 index 0000000..986bd9f --- /dev/null +++ b/frontend/src/pages/home.rs @@ -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::("#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::(".item-name") + .set_inner_html(&item.name); + + if let Some(quantity_value) = item.quantity_value { + item_element + .selector::(".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::(".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::("shopping-list-checked") + .append_child(&item_element) + .unwrap(); + } else { + by_id::("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::(".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 index 0000000..2db264d --- /dev/null +++ b/frontend/src/pages/mod.rs @@ -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 index 0000000..9fbf10e --- /dev/null +++ b/frontend/src/pages/recipe_edit.rs @@ -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::().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 = + 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::("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::() { + 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 = + 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::("groups-container") + .selector_all::(".group") + .into_iter() + .map(|e| e.id()[6..].parse::().unwrap()) + .collect(); + + let body = ron_api::Ids { ids }; + let _ = request::patch::<(), _>("recipe/set_groups_order", body).await; + }); + }); + + // Group name. + let name = group_element.selector::(".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::(".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::(&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::(&format!("#group-{} .steps", group_id)), + &ron_api::Step { + id: response.id, + action: "".to_string(), + ingredients: vec![], + }, + ); + }); + }) + .forget(); + + group_element +} + +fn create_tag_elements(recipe_id: i64, tags: &[T]) +where + T: AsRef, +{ + let tags_span: Element = selector("#container-tags .tags"); + + // Collect current tags to avoid re-adding an existing tag. + let mut current_tags: Vec = vec![]; + let mut current_tag_element = tags_span.first_child(); + while let Some(element) = current_tag_element { + current_tags.push( + element + .dyn_ref::() + .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::(".step") + .into_iter() + .map(|e| e.id()[5..].parse::().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::(".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::(&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::(&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::(".ingredient") + .into_iter() + .map(|e| e.id()[11..].parse::().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::(".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::(&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> = 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(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::("#hidden-templates .dropzone"); + element.before_with_node_1(&dropzone).unwrap(); + setup_dragzone_events(&dropzone, prefix, dropped.clone()); + } + + let dropzone = selector_and_clone::("#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::().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::() + .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::().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::() + .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::().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::() + .unwrap() + .parent_element() + .unwrap() + .set_attribute("draggable", "true") + .unwrap(); + }) + .forget(); + + // MOUSEUP. + EventListener::new(&drag_handle, "mouseup", |event| { + event + .current_target() + .unwrap() + .dyn_into::() + .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::().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::(".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::(".dropzone") + { + dp.set_class_name("dropzone"); + } + } + }) + .forget(); + + fn setup_dragzone_events(dropzone: &Element, prefix: &str, dropped: rc::Rc) + 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::().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::() + .unwrap(); + + if element.parent_element() != element_target.parent_element() { + return; + } + + event.prevent_default(); + + event + .current_target() + .unwrap() + .dyn_into::() + .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::().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::() + .unwrap(); + + if element.parent_element() != element_target.parent_element() { + return; + } + + event + .current_target() + .unwrap() + .dyn_into::() + .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::().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 index 0000000..899f924 --- /dev/null +++ b/frontend/src/pages/recipe_view.rs @@ -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::("#recipe-view .recipe-title").inner_html(); + let date_format = + selector::("#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 index 9fbf10e..0000000 --- a/frontend/src/recipe_edit.rs +++ /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::().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 = - 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::("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::() { - 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 = - 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::("groups-container") - .selector_all::(".group") - .into_iter() - .map(|e| e.id()[6..].parse::().unwrap()) - .collect(); - - let body = ron_api::Ids { ids }; - let _ = request::patch::<(), _>("recipe/set_groups_order", body).await; - }); - }); - - // Group name. - let name = group_element.selector::(".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::(".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::(&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::(&format!("#group-{} .steps", group_id)), - &ron_api::Step { - id: response.id, - action: "".to_string(), - ingredients: vec![], - }, - ); - }); - }) - .forget(); - - group_element -} - -fn create_tag_elements(recipe_id: i64, tags: &[T]) -where - T: AsRef, -{ - let tags_span: Element = selector("#container-tags .tags"); - - // Collect current tags to avoid re-adding an existing tag. - let mut current_tags: Vec = vec![]; - let mut current_tag_element = tags_span.first_child(); - while let Some(element) = current_tag_element { - current_tags.push( - element - .dyn_ref::() - .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::(".step") - .into_iter() - .map(|e| e.id()[5..].parse::().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::(".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::(&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::(&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::(".ingredient") - .into_iter() - .map(|e| e.id()[11..].parse::().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::(".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::(&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> = 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(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::("#hidden-templates .dropzone"); - element.before_with_node_1(&dropzone).unwrap(); - setup_dragzone_events(&dropzone, prefix, dropped.clone()); - } - - let dropzone = selector_and_clone::("#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::().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::() - .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::().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::() - .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::().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::() - .unwrap() - .parent_element() - .unwrap() - .set_attribute("draggable", "true") - .unwrap(); - }) - .forget(); - - // MOUSEUP. - EventListener::new(&drag_handle, "mouseup", |event| { - event - .current_target() - .unwrap() - .dyn_into::() - .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::().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::(".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::(".dropzone") - { - dp.set_class_name("dropzone"); - } - } - }) - .forget(); - - fn setup_dragzone_events(dropzone: &Element, prefix: &str, dropped: rc::Rc) - 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::().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::() - .unwrap(); - - if element.parent_element() != element_target.parent_element() { - return; - } - - event.prevent_default(); - - event - .current_target() - .unwrap() - .dyn_into::() - .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::().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::() - .unwrap(); - - if element.parent_element() != element_target.parent_element() { - return; - } - - event - .current_target() - .unwrap() - .dyn_into::() - .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::().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/recipe_scheduler.rs b/frontend/src/recipe_scheduler.rs index f9789fd..c089afb 100644 --- a/frontend/src/recipe_scheduler.rs +++ b/frontend/src/recipe_scheduler.rs @@ -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 index 899f924..0000000 --- a/frontend/src/recipe_view.rs +++ /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::("#recipe-view .recipe-title").inner_html(); - let date_format = - selector::("#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/utils.rs b/frontend/src/utils.rs index ac347a2..3856111 100644 --- a/frontend/src/utils.rs +++ b/frontend/src/utils.rs @@ -99,6 +99,16 @@ where .unwrap() } +pub fn get_current_lang() -> String { + selector::("html") + .get_attribute("lang") + .unwrap() + .split("-") + .next() + .unwrap() + .to_string() +} + pub fn get_locale() -> Locale { let lang_and_territory = selector::("html") .get_attribute("lang")