Update to Axum 0.8
authorGreg Burri <greg.burri@gmail.com>
Tue, 14 Jan 2025 14:57:02 +0000 (15:57 +0100)
committerGreg Burri <greg.burri@gmail.com>
Tue, 14 Jan 2025 14:57:02 +0000 (15:57 +0100)
20 files changed:
Cargo.lock
backend/Cargo.toml
backend/scss/style.scss
backend/src/data/db/mod.rs
backend/src/data/db/recipe.rs
backend/src/main.rs
backend/src/ron_extractor.rs
backend/src/services/fragments.rs
backend/src/services/mod.rs
backend/src/services/recipe.rs
backend/src/services/ron.rs
backend/src/services/user.rs
backend/src/translation.rs
backend/templates/recipe_edit.html
backend/translation.ron
common/src/ron_api.rs
frontend/Cargo.toml
frontend/src/handles.rs [deleted file]
frontend/src/lib.rs
frontend/src/recipe_edit.rs [new file with mode: 0644]

index cd7e1d0..9905452 100644 (file)
@@ -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",
index 0030c25..cf5db85 100644 (file)
@@ -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"] }
index bcfe72d..ddd3d8c 100644 (file)
@@ -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 {
index bf58047..7f55fd6 100644 (file)
@@ -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),
index 364c83a..f8628e8 100644 (file)
@@ -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<bool> {
+        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<i64> {
-        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<i64> {
         let db_result = sqlx::query("INSERT INTO [Ingredient] ([step_id]) VALUES ($1)")
             .bind(step_id)
index 98297fd..38b7f55 100644 (file)
@@ -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<T> = std::result::Result<T, AppError>;
+
+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",
index 8062c89..c3e1ddd 100644 (file)
@@ -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<T: DeserializeOwned>(pub T);
 
-#[async_trait]
 impl<S, T> FromRequest<S> for ExtractRon<T>
 where
     S: Send + Sync,
index 1a50a5f..d264645 100644 (file)
@@ -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()?))
 }
index 05aed48..9b44b23 100644 (file)
@@ -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<Option<model::User>>,
     Extension(tr): Extension<translation::Tr>,
-) -> impl IntoResponse {
-    (
+) -> Result<impl IntoResponse> {
+    Ok((
         StatusCode::NOT_FOUND,
-        MessageTemplate::new_with_user("404: Not found", tr, user),
-    )
+        Html(MessageTemplate::new_with_user("404: Not found", tr, user).render()?),
+    ))
 }
index 39d5778..160b6d1 100644 (file)
@@ -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()),
     }
 }
index 0bbeb63..2a90013 100644 (file)
@@ -142,6 +142,25 @@ async fn check_user_rights_recipe_step(
     }
 }
 
+async fn check_user_rights_recipe_steps(
+    connection: &db::Connection,
+    user: &Option<model::User>,
+    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<model::User>,
@@ -463,6 +482,17 @@ pub async fn set_step_action(
     Ok(StatusCode::OK)
 }
 
+#[debug_handler]
+pub async fn set_step_orders(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetStepOrders>,
+) -> Result<impl IntoResponse> {
+    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<db::Connection>,
index 59dfdc0..6695676 100644 (file)
@@ -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<Option<model::User>>,
     Extension(tr): Extension<translation::Tr>,
 ) -> Result<impl IntoResponse> {
-    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<Option<model::User>>,
     Extension(tr): Extension<translation::Tr>,
 ) -> Result<impl IntoResponse> {
-    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<translation::Tr>,
 ) -> Result<Response> {
     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<model::User>,
         tr: translation::Tr,
     ) -> Result<Response> {
-        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<Option<model::User>>,
     Extension(tr): Extension<translation::Tr>,
-) -> Response {
-    if let Some(user) = user {
-        ProfileTemplate {
-            username: &user.name,
-            email: &user.email,
-            message: "",
-            message_email: "",
-            message_password: "",
-            user: Some(user.clone()),
-            tr,
-        }
+) -> Result<Response> {
+    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()?,
+            ),
         )),
     }
 }
index 8cf4489..7e85874 100644 (file)
@@ -24,6 +24,7 @@ pub enum Sentence {
     NotLoggedIn,
 
     DatabaseError,
+    TemplateError,
 
     // Sign in page.
     SignInMenu,
index fd928f3..b830569 100644 (file)
@@ -80,8 +80,8 @@
     <input id="input-delete" type="button" value="{{ tr.t(Sentence::RecipeDelete) }}" />
 
     <div id="groups-container">
-        <div class="dropzone-group"></div>
     </div>
+
     <input id="input-add-group" type="button" value="{{ tr.t(Sentence::RecipeAddAGroup) }}" />
 
     <div id="hidden-templates">
@@ -97,7 +97,6 @@
             <input class="input-group-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveGroup) }}" />
 
             <div class="steps">
-                <div class="dropzone-step"></div>
             </div>
 
             <input class="input-add-step" type="button" value="{{ tr.t(Sentence::RecipeAddAStep) }}" />
 
             <input class="input-ingredient-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveIngredient) }}" />
         </div>
+
+        <div class="dropzone"></div>
     </div>
 </div>
 
index 4ad2a03..8638f71 100644 (file)
@@ -17,6 +17,7 @@
             (NotLoggedIn, "No logged in"),
 
             (DatabaseError, "Database error"),
+            (TemplateError, "Template error"),
 
             (SignInMenu, "Sign in"),
             (SignInTitle, "Sign in"),
             (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"),
index 105a456..d533243 100644 (file)
@@ -134,6 +134,11 @@ pub struct SetStepAction {
     pub action: String,
 }
 
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetStepOrders {
+    pub step_ids: Vec<i64>,
+}
+
 #[derive(Serialize, Deserialize, Clone)]
 pub struct AddRecipeIngredient {
     pub step_id: i64,
index 8ce594e..7f156bf 100644 (file)
@@ -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 (file)
index 0e11df8..0000000
+++ /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::<u32>().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<String> = 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::<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::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::<Element>(&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::<DragEvent>().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::<Element>()
-                        .unwrap()
-                        .set_class_name("dropzone-group hover");
-                }
-            },
-        )
-        .forget();
-
-        EventListener::new(dropzone, "dragleave", |event| {
-            let event: &DragEvent = event.dyn_ref::<DragEvent>().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::<Element>()
-                    .unwrap()
-                    .set_class_name("dropzone-group active");
-            }
-        })
-        .forget();
-
-        EventListener::new(dropzone, "drop", |event| {
-            let event: &DragEvent = event.dyn_ref::<DragEvent>().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::<Element>("groups-container")
-                .selector_all::<Element>(".group")
-                .into_iter()
-                .map(|e| e.get_attribute("id").unwrap()[6..].parse::<i64>().unwrap())
-                .collect();
-
-            let body = ron_api::SetGroupOrders { group_ids };
-            let _ = request::put::<(), _>("recipe/set_groups_order", body).await;
-        });
-    }
-
-    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_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::<Element>()
-                .unwrap()
-                .parent_element()
-                .unwrap()
-                .set_attribute("draggable", "true")
-                .unwrap();
-        })
-        .forget();
-
-        EventListener::new(&drag_handle, "mouseup", |event| {
-            event
-                .target()
-                .unwrap()
-                .dyn_into::<Element>()
-                .unwrap()
-                .parent_element()
-                .unwrap()
-                .set_attribute("draggable", "false")
-                .unwrap();
-        })
-        .forget();
-
-        EventListener::new(&group_element, "dragstart", |event| {
-            let event: &DragEvent = event.dyn_ref::<DragEvent>().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::<HtmlDivElement>(".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::<DragEvent>().unwrap();
-            event
-                .target()
-                .unwrap()
-                .dyn_into::<Element>()
-                .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::<HtmlDivElement>(".dropzone-group") {
-                    dp.set_class_name("dropzone-group");
-                }
-            }
-        })
-        .forget();
-
-        // 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::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::<HtmlInputElement>(".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::<Element>(&format!("group-{}", group_id)).remove();
-                    by_id::<Element>(&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::<Element>(&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::<Element>()
-                .unwrap()
-                .parent_element()
-                .unwrap()
-                .set_attribute("draggable", "true")
-                .unwrap();
-        })
-        .forget();
-
-        EventListener::new(&drag_handle, "mouseup", |event| {
-            event
-                .target()
-                .unwrap()
-                .dyn_into::<Element>()
-                .unwrap()
-                .parent_element()
-                .unwrap()
-                .set_attribute("draggable", "false")
-                .unwrap();
-        })
-        .forget();
-
-        EventListener::new(&step_element, "dragstart", |event| {
-            let event: &DragEvent = event.dyn_ref::<DragEvent>().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::<DragEvent>().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::<Element>()
-                .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::<HtmlTextAreaElement>(".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::<Element>(&format!("step-{}", step_id)).remove();
-                    by_id::<Element>(&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::<Element>(&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::<HtmlInputElement>(".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::<Element>(&format!("ingredient-{}", ingredient_id)).remove();
-                }
-            });
-        })
-        .forget();
-
-        ingredient_element
-    }
-
-    // Load initial groups, steps and ingredients.
-    {
-        spawn_local(async move {
-            let groups: Vec<common::ron_api::Group> =
-                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(())
-}
index eced213..d114c56 100644 (file)
@@ -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::<i64>().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 (file)
index 0000000..8d7032b
--- /dev/null
@@ -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::<u32>().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<String> = 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::<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::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::<Element>(&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<common::ron_api::Group> =
+                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::<Element>("groups-container")
+                .selector_all::<Element>(".group")
+                .into_iter()
+                .map(|e| e.get_attribute("id").unwrap()[6..].parse::<i64>().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::<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::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::<HtmlInputElement>(".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::<Element>(&format!("group-{}", group_id)).remove();
+                by_id::<Element>(&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::<Element>(&format!("#group-{} .steps", group_id)),
+                &ron_api::Step {
+                    id: response.step_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_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::<HtmlTextAreaElement>(".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::<Element>(&format!("step-{}", step_id)).remove();
+                by_id::<Element>(&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::<Element>(&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::<HtmlInputElement>(".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::<Element>(&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<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());
+
+    let drag_handle: Element = element.selector(".drag-handle");
+    EventListener::new(&drag_handle, "mousedown", |event| {
+        event
+            .target()
+            .unwrap()
+            .dyn_into::<Element>()
+            .unwrap()
+            .parent_element()
+            .unwrap()
+            .set_attribute("draggable", "true")
+            .unwrap();
+    })
+    .forget();
+
+    EventListener::new(&drag_handle, "mouseup", |event| {
+        event
+            .target()
+            .unwrap()
+            .dyn_into::<Element>()
+            .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::<DragEvent>().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::<HtmlDivElement>(".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::<DragEvent>().unwrap();
+        event
+            .target()
+            .unwrap()
+            .dyn_into::<Element>()
+            .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::<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,
+{
+    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 = 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::<Element>()
+                    .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::<DragEvent>().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::<Element>()
+                .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::<DragEvent>().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();
+}