[[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",
]
[[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",
]
[[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",
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
- "zerocopy 0.8.23",
+ "zerocopy 0.8.24",
]
[[package]]
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
- "zerocopy 0.8.23",
+ "zerocopy 0.8.24",
]
[[package]]
[[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",
[[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",
[[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",
[[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",
[[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",
[[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]]
[[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",
* 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
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},
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},
.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(
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();
// use tracing::{event, Level};
use crate::{
+ Result,
data::{db, model},
html_templates::*,
translation::{self, Sentence},
- Result,
};
#[debug_handler]
}
cd frontend
- # source frontend/deploy.nu
- # main true
trunk build --release
cd ..
+++ /dev/null
-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();
- }
- }
- });
-}
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;
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()
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),
}
--- /dev/null
+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();
+ }
+ }
+ });
+}
--- /dev/null
+pub mod home;
+pub mod recipe_edit;
+pub mod recipe_view;
--- /dev/null
+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();
+ }
+}
--- /dev/null
+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();
+}
+++ /dev/null
-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();
- }
-}
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;
+++ /dev/null
-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();
-}
.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")