From e355800f981d8e60dd7a1267ce6b6e07157669ec Mon Sep 17 00:00:00 2001 From: Greg Burri Date: Tue, 14 Jan 2025 15:57:02 +0100 Subject: [PATCH] Update to Axum 0.8 --- Cargo.lock | 128 ++--- backend/Cargo.toml | 4 +- backend/scss/style.scss | 13 +- backend/src/data/db/mod.rs | 3 +- backend/src/data/db/recipe.rs | 54 +- backend/src/main.rs | 23 +- backend/src/ron_extractor.rs | 2 - backend/src/services/fragments.rs | 7 +- backend/src/services/mod.rs | 30 +- backend/src/services/recipe.rs | 69 ++- backend/src/services/ron.rs | 30 + backend/src/services/user.rs | 459 ++++++++------- backend/src/translation.rs | 1 + backend/templates/recipe_edit.html | 5 +- backend/translation.ron | 4 +- common/src/ron_api.rs | 5 + frontend/Cargo.toml | 3 - frontend/src/handles.rs | 875 ----------------------------- frontend/src/lib.rs | 4 +- frontend/src/recipe_edit.rs | 849 ++++++++++++++++++++++++++++ 20 files changed, 1373 insertions(+), 1195 deletions(-) delete mode 100644 frontend/src/handles.rs create mode 100644 frontend/src/recipe_edit.rs diff --git a/Cargo.lock b/Cargo.lock index cd7e1d0..9905452 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,11 +100,12 @@ dependencies = [ [[package]] name = "anstyle-wincon" -version = "3.0.6" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" dependencies = [ "anstyle", + "once_cell", "windows-sys 0.59.0", ] @@ -148,14 +149,14 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.7.9" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" dependencies = [ - "async-trait", - "axum-core", + "axum-core 0.5.0", "axum-macros", "bytes", + "form_urlencoded", "futures-util", "http 1.2.0", "http-body", @@ -199,26 +200,43 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" +dependencies = [ + "bytes", + "futures-util", + "http 1.2.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", "tracing", ] [[package]] name = "axum-extra" -version = "0.9.6" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b" dependencies = [ "axum", - "axum-core", + "axum-core 0.5.0", "bytes", "cookie", - "fastrand", "futures-util", "http 1.2.0", "http-body", "http-body-util", "mime", - "multer", "pin-project-lite", "serde", "tower", @@ -228,9 +246,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", @@ -335,9 +353,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.2.7" +version = "1.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7" +checksum = "c8293772165d9345bdaaa39b45b2109591e63fe5e6fbc23c6ff930a048aa310b" dependencies = [ "shlex", ] @@ -623,15 +641,6 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.1" @@ -1416,9 +1425,9 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "js-sys" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ "once_cell", "wasm-bindgen", @@ -1511,9 +1520,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" [[package]] name = "matchers" @@ -1526,9 +1535,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" @@ -1570,9 +1579,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" +checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" dependencies = [ "adler2", ] @@ -1588,23 +1597,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http 1.2.0", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] - [[package]] name = "nom" version = "7.1.3" @@ -1851,9 +1843,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.92" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" dependencies = [ "unicode-ident", ] @@ -2028,7 +2020,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc64d77bb950f6498d0fc64b028d168fcb4e56ac31b66a8ae05f64d3b0c218b6" dependencies = [ - "axum-core", + "axum-core 0.4.5", "http 1.2.0", "rinja", ] @@ -3059,20 +3051,21 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", @@ -3084,9 +3077,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.49" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ "cfg-if", "js-sys", @@ -3097,9 +3090,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3107,9 +3100,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", @@ -3120,15 +3113,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "web-sys" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0030c25..cf5db85 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -7,8 +7,8 @@ edition = "2021" [dependencies] common = { path = "../common" } -axum = { version = "0.7", features = ["macros"] } -axum-extra = { version = "0.9", features = ["cookie"] } +axum = { version = "0.8", features = ["macros"] } +axum-extra = { version = "0.10", features = ["cookie"] } tokio = { version = "1", features = ["full"] } tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["fs", "trace"] } diff --git a/backend/scss/style.scss b/backend/scss/style.scss index bcfe72d..ddd3d8c 100644 --- a/backend/scss/style.scss +++ b/backend/scss/style.scss @@ -96,6 +96,8 @@ body { .recipe-item { padding: 4px; + // Transparent border: to keep same size than '.recipe-item-current'. + border: 0.1em solid rgba(0, 0, 0, 0); } .recipe-item-current { @@ -111,6 +113,8 @@ body { .content { flex-grow: 1; + margin-left: 0px; + background-color: $color-2; border: 0.1em solid $color-3; border-radius: 1em; @@ -122,13 +126,14 @@ body { } #recipe-edit { - .drag-handle { cursor: move; } .group { border: 0.1em solid lighten($color-3, 30%); + margin-top: 0px; + margin-bottom: 0px; } .step { @@ -139,9 +144,11 @@ body { border: 0.1em solid lighten($color-3, 30%); } - .dropzone-group, - .dropzone-step { + .dropzone { height: 10px; + margin-top: 0px; + margin-bottom: 0px; + background-color: white; &.active { diff --git a/backend/src/data/db/mod.rs b/backend/src/data/db/mod.rs index bf58047..7f55fd6 100644 --- a/backend/src/data/db/mod.rs +++ b/backend/src/data/db/mod.rs @@ -11,7 +11,6 @@ use sqlx::{ sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, Pool, Sqlite, Transaction, }; -use thiserror::Error; use tracing::{event, Level}; use crate::consts; @@ -21,7 +20,7 @@ pub mod user; const CURRENT_DB_VERSION: u32 = 1; -#[derive(Error, Debug)] +#[derive(Debug, thiserror::Error)] pub enum DBError { #[error("Sqlx error: {0}")] Sqlx(#[from] sqlx::Error), diff --git a/backend/src/data/db/recipe.rs b/backend/src/data/db/recipe.rs index 364c83a..f8628e8 100644 --- a/backend/src/data/db/recipe.rs +++ b/backend/src/data/db/recipe.rs @@ -128,6 +128,28 @@ WHERE [Step].[id] = $1 AND [user_id] = $2 .map_err(DBError::from) } + pub async fn can_edit_recipe_all_steps(&self, user_id: i64, steps_ids: &[i64]) -> Result { + let params = (0..steps_ids.len()) + .map(|n| format!("${}", n + 2)) + .join(", "); + let query_str = format!( + r#" +SELECT COUNT(*) +FROM [Recipe] +INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id] +INNER JOIN [Step] ON [Step].[group_id] = [Group].[id] +WHERE [Step].[id] IN ({}) AND [user_id] = $1 + "#, + params + ); + + let mut query = sqlx::query_scalar::<_, u64>(&query_str).bind(user_id); + for id in steps_ids { + query = query.bind(id); + } + Ok(query.fetch_one(&self.pool).await? == steps_ids.len() as u64) + } + pub async fn can_edit_recipe_ingredient( &self, user_id: i64, @@ -475,10 +497,22 @@ ORDER BY [name] } pub async fn add_recipe_group(&self, recipe_id: i64) -> Result { - let db_result = sqlx::query("INSERT INTO [Group] ([recipe_id]) VALUES ($1)") + let mut tx = self.tx().await?; + + let last_order = sqlx::query_scalar( + "SELECT [order] FROM [Group] WHERE [recipe_id] = $1 ORDER BY [order] DESC LIMIT 1", + ) + .bind(recipe_id) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(-1); + + let db_result = sqlx::query("INSERT INTO [Group] ([recipe_id, [order]) VALUES ($1, $2)") .bind(recipe_id) - .execute(&self.pool) + .bind(last_order + 1) + .execute(&mut *tx) .await?; + Ok(db_result.last_insert_rowid()) } @@ -554,6 +588,22 @@ ORDER BY [name] .map_err(DBError::from) } + pub async fn set_steps_order(&self, step_ids: &[i64]) -> Result<()> { + let mut tx = self.tx().await?; + + for (order, id) in step_ids.iter().enumerate() { + sqlx::query("UPDATE [Step] SET [order] = $2 WHERE [id] = $1") + .bind(id) + .bind(order as i64) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) + } + pub async fn add_recipe_ingredient(&self, step_id: i64) -> Result { let db_result = sqlx::query("INSERT INTO [Ingredient] ([step_id]) VALUES ($1)") .bind(step_id) diff --git a/backend/src/main.rs b/backend/src/main.rs index 98297fd..38b7f55 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -4,7 +4,7 @@ use axum::{ extract::{ConnectInfo, Extension, FromRef, Request, State}, http::StatusCode, middleware::{self, Next}, - response::{Response, Result}, + response::Response, routing::{delete, get, post, put}, Router, }; @@ -55,6 +55,23 @@ impl axum::response::IntoResponse for db::DBError { } } +#[derive(Debug, thiserror::Error)] +enum AppError { + #[error("Database error: {0}")] + Database(#[from] db::DBError), + + #[error("Template error: {0}")] + Render(#[from] rinja::Error), +} + +type Result = std::result::Result; + +impl axum::response::IntoResponse for AppError { + fn into_response(self) -> Response { + (StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response() + } +} + #[cfg(debug_assertions)] const TRACING_LEVEL: tracing::Level = tracing::Level::DEBUG; @@ -183,8 +200,8 @@ async fn main() { ) // Recipes. .route("/recipe/new", get(services::recipe::create)) - .route("/recipe/edit/:id", get(services::recipe::edit_recipe)) - .route("/recipe/view/:id", get(services::recipe::view)) + .route("/recipe/edit/{id}", get(services::recipe::edit_recipe)) + .route("/recipe/view/{id}", get(services::recipe::view)) // User. .route( "/user/edit", diff --git a/backend/src/ron_extractor.rs b/backend/src/ron_extractor.rs index 8062c89..c3e1ddd 100644 --- a/backend/src/ron_extractor.rs +++ b/backend/src/ron_extractor.rs @@ -1,5 +1,4 @@ use axum::{ - async_trait, body::Bytes, extract::{FromRequest, Request}, http::{header, StatusCode}, @@ -11,7 +10,6 @@ use crate::ron_utils; pub struct ExtractRon(pub T); -#[async_trait] impl FromRequest for ExtractRon where S: Send + Sync, diff --git a/backend/src/services/fragments.rs b/backend/src/services/fragments.rs index 1a50a5f..d264645 100644 --- a/backend/src/services/fragments.rs +++ b/backend/src/services/fragments.rs @@ -1,15 +1,16 @@ use axum::{ debug_handler, extract::{Extension, Query, State}, - response::{IntoResponse, Result}, + response::{Html, IntoResponse}, }; +use rinja::Template; use serde::Deserialize; // use tracing::{event, Level}; use crate::{ data::{db, model}, html_templates::*, - translation, + translation, Result, }; #[derive(Deserialize)] @@ -37,5 +38,5 @@ pub async fn recipes_list_fragments( }, current_id: current_recipe.current_recipe_id, }; - Ok(RecipesListFragmentTemplate { tr, recipes }) + Ok(Html(RecipesListFragmentTemplate { tr, recipes }.render()?)) } diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 05aed48..9b44b23 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -3,13 +3,14 @@ use axum::{ extract::{Extension, Request, State}, http::{header, StatusCode}, middleware::Next, - response::{IntoResponse, Response, Result}, + response::{Html, IntoResponse, Response}, }; +use rinja::Template; use crate::{ data::{db, model}, html_templates::*, - ron_utils, translation, + ron_utils, translation, Result, }; pub mod fragments; @@ -31,12 +32,15 @@ pub async fn ron_error_to_html( Ok(bytes) => String::from_utf8(bytes.to_vec()).unwrap_or_default(), Err(error) => error.to_string(), }; - return Ok(MessageTemplate { - user: None, - message: &message, - as_code: true, - tr, - } + return Ok(Html( + MessageTemplate { + user: None, + message: &message, + as_code: true, + tr, + } + .render()?, + ) .into_response()); } } @@ -66,7 +70,7 @@ pub async fn home_page( current_id: None, }; - Ok(HomeTemplate { user, recipes, tr }) + Ok(Html(HomeTemplate { user, recipes, tr }.render()?)) } ///// 404 ///// @@ -75,9 +79,9 @@ pub async fn home_page( pub async fn not_found( Extension(user): Extension>, Extension(tr): Extension, -) -> impl IntoResponse { - ( +) -> Result { + Ok(( StatusCode::NOT_FOUND, - MessageTemplate::new_with_user("404: Not found", tr, user), - ) + Html(MessageTemplate::new_with_user("404: Not found", tr, user).render()?), + )) } diff --git a/backend/src/services/recipe.rs b/backend/src/services/recipe.rs index 39d5778..160b6d1 100644 --- a/backend/src/services/recipe.rs +++ b/backend/src/services/recipe.rs @@ -1,14 +1,16 @@ use axum::{ debug_handler, extract::{Extension, Path, State}, - response::{IntoResponse, Redirect, Response, Result}, + response::{Html, IntoResponse, Redirect, Response}, }; +use rinja::Template; // use tracing::{event, Level}; use crate::{ data::{db, model}, html_templates::*, translation::{self, Sentence}, + Result, }; #[debug_handler] @@ -21,7 +23,7 @@ pub async fn create( let recipe_id = connection.create_recipe(user.id).await?; Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response()) } else { - Ok(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response()) + Ok(Html(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).render()?).into_response()) } } @@ -45,24 +47,33 @@ pub async fn edit_recipe( current_id: Some(recipe_id), }; - Ok(RecipeEditTemplate { - user: Some(user), - tr, - recipes, - recipe, - } + Ok(Html( + RecipeEditTemplate { + user: Some(user), + tr, + recipes, + recipe, + } + .render()?, + ) .into_response()) } else { Ok( - MessageTemplate::new(tr.t(Sentence::RecipeNotAllowedToEdit), tr) - .into_response(), + Html( + MessageTemplate::new(tr.t(Sentence::RecipeNotAllowedToEdit), tr) + .render()?, + ) + .into_response(), ) } } else { - Ok(MessageTemplate::new(tr.t(Sentence::RecipeNotFound), tr).into_response()) + Ok( + Html(MessageTemplate::new(tr.t(Sentence::RecipeNotFound), tr).render()?) + .into_response(), + ) } } else { - Ok(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response()) + Ok(Html(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).render()?).into_response()) } } @@ -78,10 +89,13 @@ pub async fn view( if !recipe.is_published && (user.is_none() || recipe.user_id != user.as_ref().unwrap().id) { - return Ok(MessageTemplate::new_with_user( - &tr.tp(Sentence::RecipeNotAllowedToView, &[Box::new(recipe_id)]), - tr, - user, + return Ok(Html( + MessageTemplate::new_with_user( + &tr.tp(Sentence::RecipeNotAllowedToView, &[Box::new(recipe_id)]), + tr, + user, + ) + .render()?, ) .into_response()); } @@ -103,17 +117,20 @@ pub async fn view( current_id: Some(recipe_id), }; - Ok(RecipeViewTemplate { - user, - tr, - recipes, - recipe, - } + Ok(Html( + RecipeViewTemplate { + user, + tr, + recipes, + recipe, + } + .render()?, + ) .into_response()) } - None => Ok( - MessageTemplate::new_with_user(tr.t(Sentence::RecipeNotFound), tr, user) - .into_response(), - ), + None => Ok(Html( + MessageTemplate::new_with_user(tr.t(Sentence::RecipeNotFound), tr, user).render()?, + ) + .into_response()), } } diff --git a/backend/src/services/ron.rs b/backend/src/services/ron.rs index 0bbeb63..2a90013 100644 --- a/backend/src/services/ron.rs +++ b/backend/src/services/ron.rs @@ -142,6 +142,25 @@ async fn check_user_rights_recipe_step( } } +async fn check_user_rights_recipe_steps( + connection: &db::Connection, + user: &Option, + step_ids: &[i64], +) -> Result<()> { + if user.is_none() + || !connection + .can_edit_recipe_all_steps(user.as_ref().unwrap().id, step_ids) + .await? + { + Err(ErrorResponse::from(ron_error( + StatusCode::UNAUTHORIZED, + NOT_AUTHORIZED_MESSAGE, + ))) + } else { + Ok(()) + } +} + async fn check_user_rights_recipe_ingredient( connection: &db::Connection, user: &Option, @@ -463,6 +482,17 @@ pub async fn set_step_action( Ok(StatusCode::OK) } +#[debug_handler] +pub async fn set_step_orders( + State(connection): State, + Extension(user): Extension>, + ExtractRon(ron): ExtractRon, +) -> Result { + check_user_rights_recipe_steps(&connection, &user, &ron.step_ids).await?; + connection.set_steps_order(&ron.step_ids).await?; + Ok(StatusCode::OK) +} + #[debug_handler] pub async fn add_ingredient( State(connection): State, diff --git a/backend/src/services/user.rs b/backend/src/services/user.rs index 59dfdc0..6695676 100644 --- a/backend/src/services/user.rs +++ b/backend/src/services/user.rs @@ -3,13 +3,17 @@ use std::{collections::HashMap, net::SocketAddr}; use axum::{ body::Body, debug_handler, - extract::{ConnectInfo, Extension, Host, Query, Request, State}, + extract::{ConnectInfo, Extension, Query, Request, State}, http::HeaderMap, - response::{IntoResponse, Redirect, Response, Result}, + response::{Html, IntoResponse, Redirect, Response}, Form, }; -use axum_extra::extract::cookie::{Cookie, CookieJar}; +use axum_extra::extract::{ + cookie::{Cookie, CookieJar}, + Host, +}; use chrono::Duration; +use rinja::Template; use serde::Deserialize; use tracing::{event, Level}; @@ -20,7 +24,7 @@ use crate::{ email, html_templates::*, translation::{self, Sentence}, - utils, AppState, + utils, AppState, Result, }; /// SIGN UP /// @@ -30,14 +34,17 @@ pub async fn sign_up_get( Extension(user): Extension>, Extension(tr): Extension, ) -> Result { - Ok(SignUpFormTemplate { - user, - tr, - email: String::new(), - message: "", - message_email: "", - message_password: "", - }) + Ok(Html( + SignUpFormTemplate { + user, + tr, + email: String::new(), + message: "", + message_email: "", + message_password: "", + } + .render()?, + )) } #[derive(Deserialize, Debug)] @@ -75,26 +82,29 @@ pub async fn sign_up_post( Sentence::InvalidPassword, &[Box::new(common::consts::MIN_PASSWORD_SIZE)], ); - Ok(SignUpFormTemplate { - user, - email: form_data.email.clone(), - message_email: match error { - SignUpError::InvalidEmail => tr.t(Sentence::InvalidEmail), - _ => "", - }, - message_password: match error { - SignUpError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch), - SignUpError::InvalidPassword => invalid_password_mess, - _ => "", - }, - message: match error { - SignUpError::UserAlreadyExists => tr.t(Sentence::EmailAlreadyTaken), - SignUpError::DatabaseError => tr.t(Sentence::DatabaseError), - SignUpError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail), - _ => "", - }, - tr, - } + Ok(Html( + SignUpFormTemplate { + user, + email: form_data.email.clone(), + message_email: match error { + SignUpError::InvalidEmail => tr.t(Sentence::InvalidEmail), + _ => "", + }, + message_password: match error { + SignUpError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch), + SignUpError::InvalidPassword => invalid_password_mess, + _ => "", + }, + message: match error { + SignUpError::UserAlreadyExists => tr.t(Sentence::EmailAlreadyTaken), + SignUpError::DatabaseError => tr.t(Sentence::DatabaseError), + SignUpError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail), + _ => "", + }, + tr, + } + .render()?, + ) .into_response()) } @@ -140,12 +150,11 @@ pub async fn sign_up_post( ) .await { - Ok(()) => { - Ok( - MessageTemplate::new_with_user(tr.t(Sentence::SignUpEmailSent), tr, user) - .into_response(), - ) - } + Ok(()) => Ok(Html( + MessageTemplate::new_with_user(tr.t(Sentence::SignUpEmailSent), tr, user) + .render()?, + ) + .into_response()), Err(_) => { // error!("Email validation error: {}", error); // TODO: log error_response(SignUpError::UnableSendEmail, &form_data, user, tr) @@ -172,7 +181,14 @@ pub async fn sign_up_validation( if user.is_some() { return Ok(( jar, - MessageTemplate::new_with_user(tr.t(Sentence::ValidationUserAlreadyExists), tr, user), + Html( + MessageTemplate::new_with_user( + tr.t(Sentence::ValidationUserAlreadyExists), + tr, + user, + ) + .render()?, + ), )); } let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr); @@ -194,34 +210,46 @@ pub async fn sign_up_validation( let user = connection.load_user(user_id).await?; Ok(( jar, - MessageTemplate::new_with_user( - tr.t(Sentence::SignUpEmailValidationSuccess), - tr, - user, + Html( + MessageTemplate::new_with_user( + tr.t(Sentence::SignUpEmailValidationSuccess), + tr, + user, + ) + .render()?, ), )) } db::user::ValidationResult::ValidationExpired => Ok(( jar, - MessageTemplate::new_with_user( - tr.t(Sentence::SignUpValidationExpired), - tr, - user, + Html( + MessageTemplate::new_with_user( + tr.t(Sentence::SignUpValidationExpired), + tr, + user, + ) + .render()?, ), )), db::user::ValidationResult::UnknownUser => Ok(( jar, - MessageTemplate::new_with_user( - tr.t(Sentence::SignUpValidationErrorTryAgain), - tr, - user, + Html( + MessageTemplate::new_with_user( + tr.t(Sentence::SignUpValidationErrorTryAgain), + tr, + user, + ) + .render()?, ), )), } } None => Ok(( jar, - MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user), + Html( + MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user) + .render()?, + ), )), } } @@ -233,12 +261,15 @@ pub async fn sign_in_get( Extension(user): Extension>, Extension(tr): Extension, ) -> Result { - Ok(SignInFormTemplate { - user, - tr, - email: "", - message: "", - }) + Ok(Html( + SignInFormTemplate { + user, + tr, + email: "", + message: "", + } + .render()?, + )) } #[derive(Deserialize, Debug)] @@ -270,22 +301,28 @@ pub async fn sign_in_post( { db::user::SignInResult::AccountNotValidated => Ok(( jar, - SignInFormTemplate { - user, - email: &form_data.email, - message: tr.t(Sentence::AccountMustBeValidatedFirst), - tr, - } + Html( + SignInFormTemplate { + user, + email: &form_data.email, + message: tr.t(Sentence::AccountMustBeValidatedFirst), + tr, + } + .render()?, + ) .into_response(), )), db::user::SignInResult::UserNotFound | db::user::SignInResult::WrongPassword => Ok(( jar, - SignInFormTemplate { - user, - email: &form_data.email, - message: tr.t(Sentence::WrongEmailOrPassword), - tr, - } + Html( + SignInFormTemplate { + user, + email: &form_data.email, + message: tr.t(Sentence::WrongEmailOrPassword), + tr, + } + .render()?, + ) .into_response(), )), db::user::SignInResult::Ok(token, _user_id) => { @@ -319,18 +356,22 @@ pub async fn ask_reset_password_get( Extension(tr): Extension, ) -> Result { if user.is_some() { - Ok( + Ok(Html( MessageTemplate::new_with_user(tr.t(Sentence::AskResetAlreadyLoggedInError), tr, user) - .into_response(), + .render()?, ) + .into_response()) } else { - Ok(AskResetPasswordTemplate { - user, - tr, - email: "", - message: "", - message_email: "", - } + Ok(Html( + AskResetPasswordTemplate { + user, + tr, + email: "", + message: "", + message_email: "", + } + .render()?, + ) .into_response()) } } @@ -363,24 +404,29 @@ pub async fn ask_reset_password_post( user: Option, tr: translation::Tr, ) -> Result { - Ok(AskResetPasswordTemplate { - user, - email, - message_email: match error { - AskResetPasswordError::InvalidEmail => tr.t(Sentence::InvalidEmail), - _ => "", - }, - message: match error { - AskResetPasswordError::EmailAlreadyReset => { - tr.t(Sentence::AskResetEmailAlreadyResetError) - } - AskResetPasswordError::EmailUnknown => tr.t(Sentence::EmailUnknown), - AskResetPasswordError::UnableSendEmail => tr.t(Sentence::UnableToSendResetEmail), - AskResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError), - _ => "", - }, - tr, - } + Ok(Html( + AskResetPasswordTemplate { + user, + email, + message_email: match error { + AskResetPasswordError::InvalidEmail => tr.t(Sentence::InvalidEmail), + _ => "", + }, + message: match error { + AskResetPasswordError::EmailAlreadyReset => { + tr.t(Sentence::AskResetEmailAlreadyResetError) + } + AskResetPasswordError::EmailUnknown => tr.t(Sentence::EmailUnknown), + AskResetPasswordError::UnableSendEmail => { + tr.t(Sentence::UnableToSendResetEmail) + } + AskResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError), + _ => "", + }, + tr, + } + .render()?, + ) .into_response()) } @@ -432,12 +478,11 @@ pub async fn ask_reset_password_post( ) .await { - Ok(()) => { - Ok( - MessageTemplate::new_with_user(tr.t(Sentence::AskResetEmailSent), tr, user) - .into_response(), - ) - } + Ok(()) => Ok(Html( + MessageTemplate::new_with_user(tr.t(Sentence::AskResetEmailSent), tr, user) + .render()?, + ) + .into_response()), Err(_) => { // error!("Email validation error: {}", error); // TODO: log error_response( @@ -477,25 +522,30 @@ pub async fn reset_password_get( ) .await? { - Ok(ResetPasswordTemplate { - user, - tr, - reset_token, - message: "", - message_password: "", - } + Ok(Html( + ResetPasswordTemplate { + user, + tr, + reset_token, + message: "", + message_password: "", + } + .render()?, + ) .into_response()) } else { - Ok( + Ok(Html( MessageTemplate::new_with_user(tr.t(Sentence::AskResetTokenMissing), tr, user) - .into_response(), + .render()?, ) + .into_response()) } } else { - Ok( + Ok(Html( MessageTemplate::new_with_user(tr.t(Sentence::AskResetTokenMissing), tr, user) - .into_response(), + .render()?, ) + .into_response()) } } @@ -530,21 +580,24 @@ pub async fn reset_password_post( Sentence::InvalidPassword, &[Box::new(common::consts::MIN_PASSWORD_SIZE)], ); - Ok(ResetPasswordTemplate { - user, - reset_token: &form_data.reset_token, - message_password: match error { - ResetPasswordError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch), - ResetPasswordError::InvalidPassword => reset_password_mess, - _ => "", - }, - message: match error { - ResetPasswordError::TokenExpired => tr.t(Sentence::AskResetTokenExpired), - ResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError), - _ => "", - }, - tr, - } + Ok(Html( + ResetPasswordTemplate { + user, + reset_token: &form_data.reset_token, + message_password: match error { + ResetPasswordError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch), + ResetPasswordError::InvalidPassword => reset_password_mess, + _ => "", + }, + message: match error { + ResetPasswordError::TokenExpired => tr.t(Sentence::AskResetTokenExpired), + ResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError), + _ => "", + }, + tr, + } + .render()?, + ) .into_response()) } @@ -566,12 +619,10 @@ pub async fn reset_password_post( ) .await { - Ok(db::user::ResetPasswordResult::Ok) => { - Ok( - MessageTemplate::new_with_user(tr.t(Sentence::PasswordReset), tr, user) - .into_response(), - ) - } + Ok(db::user::ResetPasswordResult::Ok) => Ok(Html( + MessageTemplate::new_with_user(tr.t(Sentence::PasswordReset), tr, user).render()?, + ) + .into_response()), Ok(db::user::ResetPasswordResult::ResetTokenExpired) => { error_response(ResetPasswordError::TokenExpired, &form_data, user, tr) } @@ -585,21 +636,24 @@ pub async fn reset_password_post( pub async fn edit_user_get( Extension(user): Extension>, Extension(tr): Extension, -) -> Response { - if let Some(user) = user { - ProfileTemplate { - username: &user.name, - email: &user.email, - message: "", - message_email: "", - message_password: "", - user: Some(user.clone()), - tr, - } +) -> Result { + Ok(if let Some(user) = user { + Html( + ProfileTemplate { + username: &user.name, + email: &user.email, + message: "", + message_email: "", + message_password: "", + user: Some(user.clone()), + tr, + } + .render()?, + ) .into_response() } else { - MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response() - } + Html(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).render()?).into_response() + }) } #[derive(Deserialize, Debug)] @@ -640,27 +694,30 @@ pub async fn edit_user_post( Sentence::InvalidPassword, &[Box::new(common::consts::MIN_PASSWORD_SIZE)], ); - Ok(ProfileTemplate { - user: Some(user), - username: &form_data.name, - email: &form_data.email, - message_email: match error { - ProfileUpdateError::InvalidEmail => tr.t(Sentence::InvalidEmail), - ProfileUpdateError::EmailAlreadyTaken => tr.t(Sentence::EmailAlreadyTaken), - _ => "", - }, - message_password: match error { - ProfileUpdateError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch), - ProfileUpdateError::InvalidPassword => invalid_password_mess, - _ => "", - }, - message: match error { - ProfileUpdateError::DatabaseError => tr.t(Sentence::DatabaseError), - ProfileUpdateError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail), - _ => "", - }, - tr, - } + Ok(Html( + ProfileTemplate { + user: Some(user), + username: &form_data.name, + email: &form_data.email, + message_email: match error { + ProfileUpdateError::InvalidEmail => tr.t(Sentence::InvalidEmail), + ProfileUpdateError::EmailAlreadyTaken => tr.t(Sentence::EmailAlreadyTaken), + _ => "", + }, + message_password: match error { + ProfileUpdateError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch), + ProfileUpdateError::InvalidPassword => invalid_password_mess, + _ => "", + }, + message: match error { + ProfileUpdateError::DatabaseError => tr.t(Sentence::DatabaseError), + ProfileUpdateError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail), + _ => "", + }, + tr, + } + .render()?, + ) .into_response()) } @@ -742,18 +799,21 @@ pub async fn edit_user_post( // Reload after update. let user = connection.load_user(user.id).await?; - Ok(ProfileTemplate { - user, - username: &form_data.name, - email: &form_data.email, - message, - message_email: "", - message_password: "", - tr, - } + Ok(Html( + ProfileTemplate { + user, + username: &form_data.name, + email: &form_data.email, + message, + message_email: "", + message_password: "", + tr, + } + .render()?, + ) .into_response()) } else { - Ok(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response()) + Ok(Html(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).render()?).into_response()) } } @@ -770,7 +830,14 @@ pub async fn email_revalidation( if user.is_some() { return Ok(( jar, - MessageTemplate::new_with_user(tr.t(Sentence::ValidationUserAlreadyExists), tr, user), + Html( + MessageTemplate::new_with_user( + tr.t(Sentence::ValidationUserAlreadyExists), + tr, + user, + ) + .render()?, + ), )); } let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr); @@ -792,30 +859,42 @@ pub async fn email_revalidation( let user = connection.load_user(user_id).await?; Ok(( jar, - MessageTemplate::new_with_user( - tr.t(Sentence::ValidationSuccessful), - tr, - user, + Html( + MessageTemplate::new_with_user( + tr.t(Sentence::ValidationSuccessful), + tr, + user, + ) + .render()?, ), )) } db::user::ValidationResult::ValidationExpired => Ok(( jar, - MessageTemplate::new_with_user(tr.t(Sentence::ValidationExpired), tr, user), + Html( + MessageTemplate::new_with_user(tr.t(Sentence::ValidationExpired), tr, user) + .render()?, + ), )), db::user::ValidationResult::UnknownUser => Ok(( jar, - MessageTemplate::new_with_user( - tr.t(Sentence::ValidationErrorTryToSignUpAgain), - tr, - user, + Html( + MessageTemplate::new_with_user( + tr.t(Sentence::ValidationErrorTryToSignUpAgain), + tr, + user, + ) + .render()?, ), )), } } None => Ok(( jar, - MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user), + Html( + MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user) + .render()?, + ), )), } } diff --git a/backend/src/translation.rs b/backend/src/translation.rs index 8cf4489..7e85874 100644 --- a/backend/src/translation.rs +++ b/backend/src/translation.rs @@ -24,6 +24,7 @@ pub enum Sentence { NotLoggedIn, DatabaseError, + TemplateError, // Sign in page. SignInMenu, diff --git a/backend/templates/recipe_edit.html b/backend/templates/recipe_edit.html index fd928f3..b830569 100644 --- a/backend/templates/recipe_edit.html +++ b/backend/templates/recipe_edit.html @@ -80,8 +80,8 @@
-
+
@@ -97,7 +97,6 @@
-
@@ -131,6 +130,8 @@
+ +
diff --git a/backend/translation.ron b/backend/translation.ron index 4ad2a03..8638f71 100644 --- a/backend/translation.ron +++ b/backend/translation.ron @@ -17,6 +17,7 @@ (NotLoggedIn, "No logged in"), (DatabaseError, "Database error"), + (TemplateError, "Template error"), (SignInMenu, "Sign in"), (SignInTitle, "Sign in"), @@ -112,7 +113,8 @@ (Save, "Sauvegarder"), (NotLoggedIn, "Pas connecté"), - (DatabaseError, "Erreur de la base de données"), + (DatabaseError, "Erreur de la base de données (Database error)"), + (TemplateError, "Erreur du moteur de modèles (Template error)"), (SignInMenu, "Se connecter"), (SignInTitle, "Se connecter"), diff --git a/common/src/ron_api.rs b/common/src/ron_api.rs index 105a456..d533243 100644 --- a/common/src/ron_api.rs +++ b/common/src/ron_api.rs @@ -134,6 +134,11 @@ pub struct SetStepAction { pub action: String, } +#[derive(Serialize, Deserialize, Clone)] +pub struct SetStepOrders { + pub step_ids: Vec, +} + #[derive(Serialize, Deserialize, Clone)] pub struct AddRecipeIngredient { pub step_id: i64, diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 8ce594e..7f156bf 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -50,9 +50,6 @@ gloo = "0.11" # code size when deploying. console_error_panic_hook = { version = "0.1", optional = true } -# [dev-dependencies] -# wasm-bindgen-test = "0.3" - [profile.release] # Tell `rustc` to optimize for small code size. opt-level = "s" diff --git a/frontend/src/handles.rs b/frontend/src/handles.rs deleted file mode 100644 index 0e11df8..0000000 --- a/frontend/src/handles.rs +++ /dev/null @@ -1,875 +0,0 @@ -use gloo::{ - console::log, - 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 common::ron_api; - -use crate::{ - modal_dialog, request, - toast::{self, Level}, - utils::{by_id, selector, selector_all, selector_and_clone, SelectorExt}, -}; - -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(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()); - } - } -} - -pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> { - // Title. - { - let Some(title) = document().get_element_by_id("input-title") else { - return Err(JsValue::from_str("Unable to find 'input-title' element")); - }; - - let title: HtmlInputElement = title.dyn_into().unwrap(); - - // 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::put::<(), _>("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::put::<(), _>("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::put::<(), _>("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::put::<(), _>("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::put::<(), _>("recipe/set_difficulty", body).await; - }); - } - }) - .forget(); - } - - // Tags. - { - spawn_local(async move { - let tags: ron_api::Tags = - request::get("recipe/get_tags", [("recipe_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(String::from).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::put::<(), _>("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::put::<(), _>("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| { - let title: HtmlInputElement = by_id("input-title"); - spawn_local(async move { - if modal_dialog::show(&format!( - "Are you sure to delete the recipe '{}'", - title.value() - )) - .await - { - let body = ron_api::Remove { recipe_id }; - let _ = request::delete::<(), _>("recipe/remove", body).await; - window().location().set_href("/").unwrap(); - - // by_id::(&format!("group-{}", group_id)).remove(); - } - }); - }) - .forget(); - - let group_dropzone: Element = selector(".dropzone-group"); - setup_dragzone_events(&group_dropzone); - - fn setup_dragzone_events(dropzone: &Element) { - EventListener::new_with_options( - dropzone, - "dragover", - EventListenerOptions::enable_prevent_default(), - |event| { - let event: &DragEvent = event.dyn_ref::().unwrap(); - let drag_data = event - .data_transfer() - .unwrap() - .get_data("text/plain") - .unwrap(); - - if drag_data.starts_with("group") { - event.prevent_default(); - // event.data_transfer().unwrap().set_effect_allowed("move"); - log!("drag over"); - event - .target() - .unwrap() - .dyn_into::() - .unwrap() - .set_class_name("dropzone-group hover"); - } - }, - ) - .forget(); - - EventListener::new(dropzone, "dragleave", |event| { - let event: &DragEvent = event.dyn_ref::().unwrap(); - let drag_data = event - .data_transfer() - .unwrap() - .get_data("text/plain") - .unwrap(); - - if drag_data.starts_with("group") { - log!("drag leave"); - event - .target() - .unwrap() - .dyn_into::() - .unwrap() - .set_class_name("dropzone-group active"); - } - }) - .forget(); - - EventListener::new(dropzone, "drop", |event| { - let event: &DragEvent = event.dyn_ref::().unwrap(); - let drag_data = event - .data_transfer() - .unwrap() - .get_data("text/plain") - .unwrap(); - - if drag_data.starts_with("group") { - let id: i64 = drag_data[6..].parse().unwrap(); - let target: Element = event.target().unwrap().dyn_into().unwrap(); - let group: Element = by_id(&format!("group-{}", id)); - let group_dropzone: Element = by_id(&format!("dropzone-group-{}", id)); - target.after_with_node_1(&group).unwrap(); - group.after_with_node_1(&group_dropzone).unwrap(); - - send_groups_order(); - } - }) - .forget(); - } - - fn send_groups_order() { - spawn_local(async move { - let group_ids = by_id::("groups-container") - .selector_all::(".group") - .into_iter() - .map(|e| e.get_attribute("id").unwrap()[6..].parse::().unwrap()) - .collect(); - - let body = ron_api::SetGroupOrders { group_ids }; - let _ = request::put::<(), _>("recipe/set_groups_order", body).await; - }); - } - - 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_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_attribute("id", &format!("group-{}", group.id)) - .unwrap(); - - let groups_container: Element = by_id("groups-container"); - groups_container.append_child(&group_element).unwrap(); - - let dropzone_group: Element = selector_and_clone(".dropzone-group"); - dropzone_group - .set_attribute("id", &format!("dropzone-group-{}", group.id)) - .unwrap(); - groups_container.append_child(&dropzone_group).unwrap(); - setup_dragzone_events(&dropzone_group); - - let drag_handle: Element = group_element.selector(".drag-handle"); - EventListener::new(&drag_handle, "mousedown", |event| { - event - .target() - .unwrap() - .dyn_into::() - .unwrap() - .parent_element() - .unwrap() - .set_attribute("draggable", "true") - .unwrap(); - }) - .forget(); - - EventListener::new(&drag_handle, "mouseup", |event| { - event - .target() - .unwrap() - .dyn_into::() - .unwrap() - .parent_element() - .unwrap() - .set_attribute("draggable", "false") - .unwrap(); - }) - .forget(); - - EventListener::new(&group_element, "dragstart", |event| { - let event: &DragEvent = event.dyn_ref::().unwrap(); - let target_element: Element = event.target().unwrap().dyn_into().unwrap(); - if target_element.get_attribute("class").unwrap() == "group" { - // Highlight where the group can be droppped. - for dp in selector_all::(".dropzone-group") { - dp.set_class_name("dropzone-group active"); - } - event - .data_transfer() - .unwrap() - .set_data("text/plain", &target_element.get_attribute("id").unwrap()) - .unwrap(); - event.data_transfer().unwrap().set_effect_allowed("move"); - } - }) - .forget(); - - EventListener::new(&group_element, "dragend", |event| { - // let event: &DragEvent = event.dyn_ref::().unwrap(); - event - .target() - .unwrap() - .dyn_into::() - .unwrap() - .set_attribute("draggable", "false") - .unwrap(); - - let target_element: Element = event.target().unwrap().dyn_into().unwrap(); - if target_element.get_attribute("class").unwrap() == "group" { - for dp in selector_all::(".dropzone-group") { - dp.set_class_name("dropzone-group"); - } - } - }) - .forget(); - - // 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::put::<(), _>("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::put::<(), _>("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| { - let name = group_element_cloned - .selector::(".input-group-name") - .value(); - spawn_local(async move { - if modal_dialog::show(&format!("Are you sure to delete the group '{}'", name)).await - { - let body = ron_api::RemoveRecipeGroup { group_id }; - let _ = request::delete::<(), _>("recipe/remove_group", body).await; - by_id::(&format!("group-{}", group_id)).remove(); - by_id::(&format!("dropzone-group-{}", group_id)).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::AddRecipeStep { group_id }; - let response: ron_api::AddRecipeStepResult = - request::post("recipe/add_step", body).await.unwrap(); - create_step_element( - &selector::(&format!("#group-{} .steps", group_id)), - &ron_api::Step { - id: response.step_id, - action: "".to_string(), - ingredients: vec![], - }, - ); - }); - }) - .forget(); - - group_element - } - - 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_attribute("id", &format!("step-{}", step.id)) - .unwrap(); - group_element.append_child(&step_element).unwrap(); - - let dropzone_step: Element = selector_and_clone(".dropzone-step"); - dropzone_step - .set_attribute("id", &format!("dropzone-step-{}", step.id)) - .unwrap(); - group_element.append_child(&dropzone_step).unwrap(); - - let drag_handle: Element = step_element.selector(".drag-handle"); - - EventListener::new(&drag_handle, "mousedown", |event| { - event - .target() - .unwrap() - .dyn_into::() - .unwrap() - .parent_element() - .unwrap() - .set_attribute("draggable", "true") - .unwrap(); - }) - .forget(); - - EventListener::new(&drag_handle, "mouseup", |event| { - event - .target() - .unwrap() - .dyn_into::() - .unwrap() - .parent_element() - .unwrap() - .set_attribute("draggable", "false") - .unwrap(); - }) - .forget(); - - EventListener::new(&step_element, "dragstart", |event| { - let event: &DragEvent = event.dyn_ref::().unwrap(); - // let target_element: Element = event.target().unwrap().dyn_into().unwrap(); - // if target_element.get_attribute("class").unwrap() == "step" { - // Highlight where the step can be droppped. - log!("START DRAG STEP"); - // log!(event); - // } - }) - .forget(); - EventListener::new(&step_element, "dragend", |event| { - let event: &DragEvent = event.dyn_ref::().unwrap(); - // let target_element: Element = event.target().unwrap().dyn_into().unwrap(); - // if target_element.get_attribute("class").unwrap() == "step" { - // Highlight where the step can be droppped. - event - .target() - .unwrap() - .dyn_into::() - .unwrap() - .set_attribute("draggable", "false") - .unwrap(); - - log!("STOP DRAG STEP"); - // log!(event); - // } - }) - .forget(); - - // 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::put::<(), _>("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| { - let action = step_element_cloned - .selector::(".text-area-step-action") - .value(); - spawn_local(async move { - if modal_dialog::show(&format!("Are you sure to delete the step '{}'", action)) - .await - { - let body = ron_api::RemoveRecipeStep { step_id }; - let _ = request::delete::<(), _>("recipe/remove_step", body).await; - by_id::(&format!("step-{}", step_id)).remove(); - by_id::(&format!("dropzone-step-{}", step_id)).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::AddRecipeIngredient { step_id }; - let response: ron_api::AddRecipeIngredientResult = - request::post("recipe/add_ingredient", body).await.unwrap(); - create_ingredient_element( - &selector::(&format!("#step-{} .ingredients", step_id)), - &ron_api::Ingredient { - id: response.ingredient_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_attribute("id", &format!("ingredient-{}", ingredient.id)) - .unwrap(); - step_element.append_child(&ingredient_element).unwrap(); - - // 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::put::<(), _>("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::put::<(), _>("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::put::<(), _>("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::put::<(), _>("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| { - let name = ingredient_element_cloned - .selector::(".input-ingredient-name") - .value(); - spawn_local(async move { - if modal_dialog::show(&format!("Are you sure to delete the ingredient '{}'", name)) - .await - { - let body = ron_api::RemoveRecipeIngredient { ingredient_id }; - let _ = request::delete::<(), _>("recipe/remove_ingredient", body).await; - by_id::(&format!("ingredient-{}", ingredient_id)).remove(); - } - }); - }) - .forget(); - - ingredient_element - } - - // Load initial groups, steps and ingredients. - { - spawn_local(async move { - let groups: Vec = - request::get("recipe/get_groups", [("recipe_id", &recipe_id.to_string())]) - .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"); - let on_click_add_group = EventListener::new(&button_add_group, "click", move |_event| { - let body = ron_api::AddRecipeGroup { recipe_id }; - spawn_local(async move { - let response: ron_api::AddRecipeGroupResult = - request::post("recipe/add_group", body).await.unwrap(); - create_group_element(&ron_api::Group { - id: response.group_id, - name: "".to_string(), - comment: "".to_string(), - steps: vec![], - }); - }); - }); - on_click_add_group.forget(); - } - - Ok(()) -} diff --git a/frontend/src/lib.rs b/frontend/src/lib.rs index eced213..d114c56 100644 --- a/frontend/src/lib.rs +++ b/frontend/src/lib.rs @@ -1,6 +1,6 @@ -mod handles; mod modal_dialog; mod on_click; +mod recipe_edit; mod request; mod toast; mod utils; @@ -22,7 +22,7 @@ pub fn main() -> Result<(), JsValue> { if let ["recipe", "edit", id] = path[..] { let id = id.parse::().unwrap(); // TODO: remove unwrap. - if let Err(error) = handles::recipe_edit(id) { + if let Err(error) = recipe_edit::setup_page(id) { log!(error); } diff --git a/frontend/src/recipe_edit.rs b/frontend/src/recipe_edit.rs new file mode 100644 index 0000000..8d7032b --- /dev/null +++ b/frontend/src/recipe_edit.rs @@ -0,0 +1,849 @@ +use std::rc; + +use gloo::{ + console::log, + 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 common::ron_api; + +use crate::{ + modal_dialog, request, + toast::{self, Level}, + utils::{by_id, selector, selector_all, selector_and_clone, SelectorExt}, +}; + +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(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()); + } + } +} + +pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> { + // Title. + { + let Some(title) = document().get_element_by_id("input-title") else { + return Err(JsValue::from_str("Unable to find 'input-title' element")); + }; + + let title: HtmlInputElement = title.dyn_into().unwrap(); + + // 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::put::<(), _>("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::put::<(), _>("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::put::<(), _>("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::put::<(), _>("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::put::<(), _>("recipe/set_difficulty", body).await; + }); + } + }) + .forget(); + } + + // Tags. + { + spawn_local(async move { + let tags: ron_api::Tags = + request::get("recipe/get_tags", [("recipe_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(String::from).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::put::<(), _>("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::put::<(), _>("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| { + let title: HtmlInputElement = by_id("input-title"); + spawn_local(async move { + if modal_dialog::show(&format!( + "Are you sure to delete the recipe '{}'", + title.value() + )) + .await + { + let body = ron_api::Remove { recipe_id }; + let _ = request::delete::<(), _>("recipe/remove", body).await; + window().location().set_href("/").unwrap(); + + // by_id::(&format!("group-{}", group_id)).remove(); + } + }); + }) + .forget(); + + // let group_dropzone: Element = selector(".dropzone-group"); + // setup_dragzone_events(&group_dropzone); + + // Load initial groups, steps and ingredients. + { + spawn_local(async move { + let groups: Vec = + request::get("recipe/get_groups", [("recipe_id", &recipe_id.to_string())]) + .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"); + let on_click_add_group = EventListener::new(&button_add_group, "click", move |_event| { + let body = ron_api::AddRecipeGroup { recipe_id }; + spawn_local(async move { + let response: ron_api::AddRecipeGroupResult = + request::post("recipe/add_group", body).await.unwrap(); + create_group_element(&ron_api::Group { + id: response.group_id, + name: "".to_string(), + comment: "".to_string(), + steps: vec![], + }); + }); + }); + on_click_add_group.forget(); + } + + Ok(()) +} + +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_attribute("id", &format!("group-{}", group.id)) + .unwrap(); + + 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 group_ids = by_id::("groups-container") + .selector_all::(".group") + .into_iter() + .map(|e| e.get_attribute("id").unwrap()[6..].parse::().unwrap()) + .collect(); + + let body = ron_api::SetGroupOrders { group_ids }; + let _ = request::put::<(), _>("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::put::<(), _>("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::put::<(), _>("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| { + let name = group_element_cloned + .selector::(".input-group-name") + .value(); + spawn_local(async move { + if modal_dialog::show(&format!("Are you sure to delete the group '{}'", name)).await { + let body = ron_api::RemoveRecipeGroup { group_id }; + let _ = request::delete::<(), _>("recipe/remove_group", body).await; + by_id::(&format!("group-{}", group_id)).remove(); + by_id::(&format!("dropzone-group-{}", group_id)).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::AddRecipeStep { group_id }; + let response: ron_api::AddRecipeStepResult = + request::post("recipe/add_step", body).await.unwrap(); + create_step_element( + &selector::(&format!("#group-{} .steps", group_id)), + &ron_api::Step { + id: response.step_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_attribute("id", &format!("step-{}", step.id)) + .unwrap(); + group_element.append_child(&step_element).unwrap(); + + // 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::put::<(), _>("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| { + let action = step_element_cloned + .selector::(".text-area-step-action") + .value(); + spawn_local(async move { + if modal_dialog::show(&format!("Are you sure to delete the step '{}'", action)).await { + let body = ron_api::RemoveRecipeStep { step_id }; + let _ = request::delete::<(), _>("recipe/remove_step", body).await; + by_id::(&format!("step-{}", step_id)).remove(); + by_id::(&format!("dropzone-step-{}", step_id)).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::AddRecipeIngredient { step_id }; + let response: ron_api::AddRecipeIngredientResult = + request::post("recipe/add_ingredient", body).await.unwrap(); + create_ingredient_element( + &selector::(&format!("#step-{} .ingredients", step_id)), + &ron_api::Ingredient { + id: response.ingredient_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_attribute("id", &format!("ingredient-{}", ingredient.id)) + .unwrap(); + step_element.append_child(&ingredient_element).unwrap(); + + // 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::put::<(), _>("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::put::<(), _>("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::put::<(), _>("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::put::<(), _>("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| { + let name = ingredient_element_cloned + .selector::(".input-ingredient-name") + .value(); + spawn_local(async move { + if modal_dialog::show(&format!("Are you sure to delete the ingredient '{}'", name)) + .await + { + let body = ron_api::RemoveRecipeIngredient { ingredient_id }; + let _ = request::delete::<(), _>("recipe/remove_ingredient", body).await; + by_id::(&format!("ingredient-{}", ingredient_id)).remove(); + } + }); + }) + .forget(); + + ingredient_element +} + +/// 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()); + + let drag_handle: Element = element.selector(".drag-handle"); + EventListener::new(&drag_handle, "mousedown", |event| { + event + .target() + .unwrap() + .dyn_into::() + .unwrap() + .parent_element() + .unwrap() + .set_attribute("draggable", "true") + .unwrap(); + }) + .forget(); + + EventListener::new(&drag_handle, "mouseup", |event| { + event + .target() + .unwrap() + .dyn_into::() + .unwrap() + .parent_element() + .unwrap() + .set_attribute("draggable", "false") + .unwrap(); + }) + .forget(); + + let prefix_copied = prefix.to_string(); + EventListener::new(element, "dragstart", move |event| { + let event: &DragEvent = event.dyn_ref::().unwrap(); + let target_element: Element = event.target().unwrap().dyn_into().unwrap(); + + if target_element + .get_attribute("id") + .unwrap() + .starts_with(&prefix_copied) + { + // Highlight where the group can be droppped. + // TODO: only select direct children. + for dp in target_element + .parent_element() + .unwrap() + .selector_all::(".dropzone") + { + dp.set_class_name("dropzone active"); + } + event + .data_transfer() + .unwrap() + .set_data("text/plain", &target_element.get_attribute("id").unwrap()) + .unwrap(); + event.data_transfer().unwrap().set_effect_allowed("move"); + } + }) + .forget(); + + let prefix_copied = prefix.to_string(); + EventListener::new(element, "dragend", move |event| { + // let event: &DragEvent = event.dyn_ref::().unwrap(); + event + .target() + .unwrap() + .dyn_into::() + .unwrap() + .set_attribute("draggable", "false") + .unwrap(); + + let target_element: Element = event.target().unwrap().dyn_into().unwrap(); + if target_element + .get_attribute("id") + .unwrap() + .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, +{ + 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 = event + .data_transfer() + .unwrap() + .get_data("text/plain") + .unwrap(); + + if drag_data.starts_with(&prefix_copied) { + event.prevent_default(); + // event.data_transfer().unwrap().set_effect_allowed("move"); + // log!("drag over"); + event + .target() + .unwrap() + .dyn_into::() + .unwrap() + .set_class_name("dropzone hover"); + } + }, + ) + .forget(); + + let prefix_copied = prefix.to_string(); + EventListener::new(dropzone, "dragleave", move |event| { + let event: &DragEvent = event.dyn_ref::().unwrap(); + let drag_data = event + .data_transfer() + .unwrap() + .get_data("text/plain") + .unwrap(); + + if drag_data.starts_with(&prefix_copied) { + // log!("drag leave"); + event + .target() + .unwrap() + .dyn_into::() + .unwrap() + .set_class_name("dropzone active"); + } + }) + .forget(); + + let prefix_copied = prefix.to_string(); + EventListener::new(dropzone, "drop", move |event| { + let event: &DragEvent = event.dyn_ref::().unwrap(); + let drag_data = event + .data_transfer() + .unwrap() + .get_data("text/plain") + .unwrap(); + + if drag_data.starts_with(&prefix_copied) { + let id: i64 = drag_data[prefix_copied.len() + 1..].parse().unwrap(); + let target: Element = event.target().unwrap().dyn_into().unwrap(); + let element: Element = by_id(&format!("{}-{}", &prefix_copied, id)); + let group_dropzone: Element = element.next_element_sibling().unwrap(); // = by_id(&format!("dropzone-group-{}", id)); + target.after_with_node_1(&element).unwrap(); + element.after_with_node_1(&group_dropzone).unwrap(); + + dropped(&element); + } + }) + .forget(); +} -- 2.49.0