Groups can now be ordered (via drag and drop)
authorGreg Burri <greg.burri@gmail.com>
Fri, 10 Jan 2025 21:38:34 +0000 (22:38 +0100)
committerGreg Burri <greg.burri@gmail.com>
Fri, 10 Jan 2025 21:38:34 +0000 (22:38 +0100)
14 files changed:
Cargo.lock
backend/scss/style.scss
backend/sql/data_test.sql
backend/sql/version_1.sql
backend/src/data/db/recipe.rs
backend/src/data/db/user.rs
backend/src/main.rs
backend/src/services/ron.rs
backend/src/services/user.rs
backend/templates/recipe_edit.html
common/src/ron_api.rs
frontend/Cargo.toml
frontend/src/handles.rs
frontend/src/utils.rs

index e2bbbc2..cd7e1d0 100644 (file)
@@ -290,9 +290,9 @@ dependencies = [
 
 [[package]]
 name = "bitflags"
-version = "2.6.0"
+version = "2.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
+checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be"
 dependencies = [
  "serde",
 ]
@@ -374,9 +374,9 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "4.5.24"
+version = "4.5.26"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9560b07a799281c7e0958b9296854d6fafd4c5f31444a7e5bb1ad6dde5ccf1bd"
+checksum = "a8eb5e908ef3a6efbe1ed62520fb7287959888c88485abe072543190ecc66783"
 dependencies = [
  "clap_builder",
  "clap_derive",
@@ -384,9 +384,9 @@ dependencies = [
 
 [[package]]
 name = "clap_builder"
-version = "4.5.24"
+version = "4.5.26"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "874e0dd3eb68bf99058751ac9712f622e61e6f393a94f7128fa26e3f02f5c7cd"
+checksum = "96b01801b5fc6a0a232407abc821660c9c6d25a1cafc0d4f85f29fb8d9afc121"
 dependencies = [
  "anstream",
  "anstyle",
@@ -718,7 +718,7 @@ dependencies = [
  "gloo",
  "ron",
  "serde",
- "thiserror 2.0.9",
+ "thiserror 2.0.11",
  "wasm-bindgen",
  "wasm-bindgen-futures",
  "web-sys",
@@ -1934,7 +1934,7 @@ dependencies = [
  "sqlx",
  "strum",
  "strum_macros",
- "thiserror 2.0.9",
+ "thiserror 2.0.11",
  "tokio",
  "tower",
  "tower-http",
@@ -2121,9 +2121,9 @@ dependencies = [
 
 [[package]]
 name = "rustls"
-version = "0.23.20"
+version = "0.23.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b"
+checksum = "8f287924602bf649d949c63dc8ac8b235fa5387d394020705b80c4eb597ce5b8"
 dependencies = [
  "log",
  "once_cell",
@@ -2386,7 +2386,7 @@ dependencies = [
  "serde_json",
  "sha2",
  "smallvec",
- "thiserror 2.0.9",
+ "thiserror 2.0.11",
  "tokio",
  "tokio-stream",
  "tracing",
@@ -2470,7 +2470,7 @@ dependencies = [
  "smallvec",
  "sqlx-core",
  "stringprep",
- "thiserror 2.0.9",
+ "thiserror 2.0.11",
  "tracing",
  "whoami",
 ]
@@ -2508,7 +2508,7 @@ dependencies = [
  "smallvec",
  "sqlx-core",
  "stringprep",
- "thiserror 2.0.9",
+ "thiserror 2.0.11",
  "tracing",
  "whoami",
 ]
@@ -2600,9 +2600,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
 
 [[package]]
 name = "syn"
-version = "2.0.95"
+version = "2.0.96"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46f71c0377baf4ef1cc3e3402ded576dccc315800fbc62dfc7fe04b009773b4a"
+checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2651,11 +2651,11 @@ dependencies = [
 
 [[package]]
 name = "thiserror"
-version = "2.0.9"
+version = "2.0.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc"
+checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
 dependencies = [
- "thiserror-impl 2.0.9",
+ "thiserror-impl 2.0.11",
 ]
 
 [[package]]
@@ -2671,9 +2671,9 @@ dependencies = [
 
 [[package]]
 name = "thiserror-impl"
-version = "2.0.9"
+version = "2.0.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4"
+checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2748,9 +2748,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
 
 [[package]]
 name = "tokio"
-version = "1.42.0"
+version = "1.43.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551"
+checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e"
 dependencies = [
  "backtrace",
  "bytes",
@@ -2766,9 +2766,9 @@ dependencies = [
 
 [[package]]
 name = "tokio-macros"
-version = "2.4.0"
+version = "2.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
+checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
 dependencies = [
  "proc-macro2",
  "quote",
index f2db295..bcfe72d 100644 (file)
@@ -70,6 +70,8 @@ body {
     .drag-handle {
         width: 20px;
         height: 20px;
+        display: inline-block;
+        vertical-align: bottom;
         background-color: blue;
     }
 
@@ -92,7 +94,6 @@ body {
                 border: 0;
             }
 
-
             .recipe-item {
                 padding: 4px;
             }
@@ -118,6 +119,13 @@ body {
             h1 {
                 text-align: center;
             }
+        }
+
+        #recipe-edit {
+
+            .drag-handle {
+                cursor: move;
+            }
 
             .group {
                 border: 0.1em solid lighten($color-3, 30%);
@@ -131,6 +139,21 @@ body {
                 border: 0.1em solid lighten($color-3, 30%);
             }
 
+            .dropzone-group,
+            .dropzone-step {
+                height: 10px;
+                background-color: white;
+
+                &.active {
+                    background-color: blue;
+                }
+
+                &.hover {
+                    background-color: red;
+                }
+            }
+
+
             #hidden-templates {
                 display: none;
             }
index 5f37367..7e4a46b 100644 (file)
@@ -20,17 +20,17 @@ VALUES (
     NULL
 );
 
-INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
-VALUES (1, 1, 'Croissant au jambon', true);
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
+VALUES (1, 1, 'Croissant au jambon', true, '2025-01-07T10:41:05.697884837+00:00');
 
-INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
-VALUES (2, 1, 'Gratin de thon aux olives', true);
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
+VALUES (2, 1, 'Gratin de thon aux olives', true, '2025-01-07T10:41:05.697884837+00:00');
 
-INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
-VALUES (3, 1, 'Saumon en croute', true);
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
+VALUES (3, 1, 'Saumon en croute', true, '2025-01-07T10:41:05.697884837+00:00');
 
-INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
-VALUES (4, 2, 'Ouiche lorraine', true);
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
+VALUES (4, 2, 'Ouiche lorraine', true, '2025-01-07T10:41:05.697884837+00:00');
 
 
 -- Groups, steps and ingredients for 'Gratin de thon'.
index 306f195..aa9c14d 100644 (file)
@@ -28,6 +28,7 @@ CREATE TABLE [User] (
     [is_admin] INTEGER NOT NULL DEFAULT FALSE
 ) STRICT;
 
+CREATE INDEX [validation_token_index] ON [User]([validation_token]);
 CREATE UNIQUE INDEX [User_email_index] ON [User]([email]);
 
 CREATE TABLE [UserLoginToken] (
@@ -58,6 +59,7 @@ CREATE TABLE [Recipe] (
     [difficulty] INTEGER NOT NULL DEFAULT 0,
     [servings] INTEGER DEFAULT 4,
     [is_published] INTEGER NOT NULL DEFAULT FALSE,
+    [creation_datetime] TEXT NOT NULL,
 
     FOREIGN KEY([user_id]) REFERENCES [User]([id]) ON DELETE SET NULL
 ) STRICT;
index 1dc5c8f..364c83a 100644 (file)
@@ -1,3 +1,6 @@
+use chrono::prelude::*;
+use itertools::Itertools;
+
 use super::{Connection, DBError, Result};
 use crate::data::model;
 
@@ -17,7 +20,7 @@ impl Connection {
     SELECT [id], [title]
     FROM [Recipe]
     WHERE [is_published] = true AND ([lang] = $1 OR [user_id] = $2)
-    ORDER BY [title]
+    ORDER BY [title] COLLATE NOCASE
                 "#,
             )
             .bind(lang)
@@ -28,7 +31,7 @@ impl Connection {
     SELECT [id], [title]
     FROM [Recipe]
     WHERE [is_published] = true AND [lang] = $1
-    ORDER BY [title]
+    ORDER BY [title] COLLATE NOCASE
                 "#,
             )
             .bind(lang)
@@ -83,6 +86,31 @@ WHERE [Group].[id] = $1 AND [user_id] = $2
         .map_err(DBError::from)
     }
 
+    pub async fn can_edit_recipe_all_groups(
+        &self,
+        user_id: i64,
+        group_ids: &[i64],
+    ) -> Result<bool> {
+        let params = (0..group_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]
+WHERE [Group].[id] IN ({}) AND [user_id] = $1
+            "#,
+            params
+        );
+
+        let mut query = sqlx::query_scalar::<_, u64>(&query_str).bind(user_id);
+        for id in group_ids {
+            query = query.bind(id);
+        }
+        Ok(query.fetch_one(&self.pool).await? == group_ids.len() as u64)
+    }
+
     pub async fn can_edit_recipe_step(&self, user_id: i64, step_id: i64) -> Result<bool> {
         sqlx::query_scalar(
             r#"
@@ -173,10 +201,11 @@ WHERE [Recipe].[user_id] = $1
                     .await?;
 
                 let db_result = sqlx::query(
-                    "INSERT INTO [Recipe] ([user_id], [lang], [title]) VALUES ($1, $2, '')",
+                    "INSERT INTO [Recipe] ([user_id], [lang], [title], [creation_datetime]) VALUES ($1, $2, '', $3)",
                 )
                 .bind(user_id)
                 .bind(lang)
+                .bind(Utc::now())
                 .execute(&mut *tx)
                 .await?;
 
@@ -482,6 +511,22 @@ ORDER BY [name]
             .map_err(DBError::from)
     }
 
+    pub async fn set_groups_order(&self, group_ids: &[i64]) -> Result<()> {
+        let mut tx = self.tx().await?;
+
+        for (order, id) in group_ids.iter().enumerate() {
+            sqlx::query("UPDATE [Group] 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_step(&self, group_id: i64) -> Result<i64> {
         let db_result = sqlx::query("INSERT INTO [Step] ([group_id]) VALUES ($1)")
             .bind(group_id)
index 446bdf5..e23fd38 100644 (file)
@@ -410,6 +410,28 @@ WHERE [email] = $1
         Ok(GetTokenResetPasswordResult::Ok(token))
     }
 
+    pub async fn is_reset_password_token_valid(
+        &self,
+        token: &str,
+        validation_time: Duration,
+    ) -> Result<bool> {
+        if let Some(Some(db_datetime)) = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(
+            r#"
+SELECT [password_reset_datetime]
+FROM [User]
+WHERE [password_reset_token] = $1
+            "#,
+        )
+        .bind(token)
+        .fetch_optional(&self.pool)
+        .await?
+        {
+            Ok(Utc::now() - db_datetime <= validation_time)
+        } else {
+            Ok(false)
+        }
+    }
+
     pub async fn reset_password(
         &self,
         new_password: &str,
index 90b169e..98297fd 100644 (file)
@@ -118,6 +118,10 @@ async fn main() {
             "/recipe/set_group_comment",
             put(services::ron::set_group_comment),
         )
+        .route(
+            "/recipe/set_groups_order",
+            put(services::ron::set_group_orders),
+        )
         .route("/recipe/add_step", post(services::ron::add_step))
         .route("/recipe/remove_step", delete(services::ron::rm_step))
         .route(
index f93c3b1..0bbeb63 100644 (file)
@@ -104,6 +104,25 @@ async fn check_user_rights_recipe_group(
     }
 }
 
+async fn check_user_rights_recipe_groups(
+    connection: &db::Connection,
+    user: &Option<model::User>,
+    group_ids: &[i64],
+) -> Result<()> {
+    if user.is_none()
+        || !connection
+            .can_edit_recipe_all_groups(user.as_ref().unwrap().id, group_ids)
+            .await?
+    {
+        Err(ErrorResponse::from(ron_error(
+            StatusCode::UNAUTHORIZED,
+            NOT_AUTHORIZED_MESSAGE,
+        )))
+    } else {
+        Ok(())
+    }
+}
+
 async fn check_user_rights_recipe_step(
     connection: &db::Connection,
     user: &Option<model::User>,
@@ -396,6 +415,17 @@ pub async fn set_group_comment(
     Ok(StatusCode::OK)
 }
 
+#[debug_handler]
+pub async fn set_group_orders(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetGroupOrders>,
+) -> Result<impl IntoResponse> {
+    check_user_rights_recipe_groups(&connection, &user, &ron.group_ids).await?;
+    connection.set_groups_order(&ron.group_ids).await?;
+    Ok(StatusCode::OK)
+}
+
 #[debug_handler]
 pub async fn add_step(
     State(connection): State<db::Connection>,
index e54672e..59dfdc0 100644 (file)
@@ -463,19 +463,34 @@ pub async fn ask_reset_password_post(
 
 #[debug_handler]
 pub async fn reset_password_get(
+    State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
     Extension(tr): Extension<translation::Tr>,
     Query(query): Query<HashMap<String, String>>,
 ) -> Result<Response> {
     if let Some(reset_token) = query.get("reset_token") {
-        Ok(ResetPasswordTemplate {
-            user,
-            tr,
-            reset_token,
-            message: "",
-            message_password: "",
+        // Check if the token is valid.
+        if connection
+            .is_reset_password_token_valid(
+                reset_token,
+                Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION),
+            )
+            .await?
+        {
+            Ok(ResetPasswordTemplate {
+                user,
+                tr,
+                reset_token,
+                message: "",
+                message_password: "",
+            }
+            .into_response())
+        } else {
+            Ok(
+                MessageTemplate::new_with_user(tr.t(Sentence::AskResetTokenMissing), tr, user)
+                    .into_response(),
+            )
         }
-        .into_response())
     } else {
         Ok(
             MessageTemplate::new_with_user(tr.t(Sentence::AskResetTokenMissing), tr, user)
index 4b89f93..fd928f3 100644 (file)
     <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">
         <div class="group">
-            <div class="drag-handle"></div>
+            <span class="drag-handle"></span>
 
             <label for="input-group-name">{{ tr.t(Sentence::RecipeGroupName) }}</label>
             <input class="input-group-name" type="text" />
 
             <input class="input-group-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveGroup) }}" />
 
-            <div class="steps"></div>
+            <div class="steps">
+                <div class="dropzone-step"></div>
+            </div>
 
             <input class="input-add-step" type="button" value="{{ tr.t(Sentence::RecipeAddAStep) }}" />
         </div>
 
         <div class="step">
-            <div class="drag-handle"></div>
+            <span class="drag-handle"></span>
 
             <label for="text-area-step-action">{{ tr.t(Sentence::RecipeStepAction) }}</label>
             <textarea class="text-area-step-action"></textarea>
index 3f233fa..105a456 100644 (file)
@@ -108,6 +108,11 @@ pub struct SetGroupComment {
     pub comment: String,
 }
 
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetGroupOrders {
+    pub group_ids: Vec<i64>,
+}
+
 #[derive(Serialize, Deserialize, Clone)]
 pub struct AddRecipeStep {
     pub group_id: i64,
index 452c3c8..8ce594e 100644 (file)
@@ -24,18 +24,22 @@ wasm-bindgen-futures = "0.4"
 web-sys = { version = "0.3", features = [
     "console",
     "Document",
-    "Element",
-    "HtmlElement",
     "Node",
+    "NodeList",
     "Window",
     "Location",
     "EventTarget",
+    "DragEvent",
+    "DataTransfer",
+    "KeyboardEvent",
+    "Element",
+    "HtmlElement",
+    "HtmlDivElement",
     "HtmlLabelElement",
     "HtmlInputElement",
     "HtmlTextAreaElement",
     "HtmlSelectElement",
     "HtmlDialogElement",
-    "KeyboardEvent",
 ] }
 
 gloo = "0.11"
index d05fa41..0e11df8 100644 (file)
@@ -1,18 +1,22 @@
 use gloo::{
-    events::EventListener,
+    console::log,
+    events::{EventListener, EventListenerOptions},
     net::http::Request,
     utils::{document, window},
 };
 use wasm_bindgen::prelude::*;
 use wasm_bindgen_futures::spawn_local;
-use web_sys::{Element, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement, KeyboardEvent};
+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_and_clone, SelectorExt},
+    utils::{by_id, selector, selector_all, selector_and_clone, SelectorExt},
 };
 
 async fn reload_recipes_list(current_recipe_id: i64) {
@@ -276,6 +280,92 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
     })
     .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>,
@@ -339,6 +429,77 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
         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);
@@ -388,6 +549,7 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
                     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();
                 }
             });
         })
@@ -423,6 +585,69 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
             .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);
@@ -455,6 +680,7 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
                     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();
                 }
             });
         })
index 89d256b..58e7803 100644 (file)
@@ -1,4 +1,4 @@
-use gloo::utils::document;
+use gloo::{console::log, utils::document};
 use wasm_bindgen::prelude::*;
 use web_sys::Element;
 
@@ -6,6 +6,10 @@ pub trait SelectorExt {
     fn selector<T>(&self, selectors: &str) -> T
     where
         T: JsCast;
+
+    fn selector_all<T>(&self, selectors: &str) -> Vec<T>
+    where
+        T: JsCast;
 }
 
 impl SelectorExt for Element {
@@ -19,6 +23,18 @@ impl SelectorExt for Element {
             .dyn_into::<T>()
             .unwrap()
     }
+
+    fn selector_all<T>(&self, selectors: &str) -> Vec<T>
+    where
+        T: JsCast,
+    {
+        self.query_selector_all(selectors)
+            .unwrap()
+            .values()
+            .into_iter()
+            .map(|e| e.unwrap().dyn_into::<T>().unwrap())
+            .collect()
+    }
 }
 
 pub fn selector<T>(selectors: &str) -> T
@@ -33,6 +49,19 @@ where
         .unwrap()
 }
 
+pub fn selector_all<T>(selectors: &str) -> Vec<T>
+where
+    T: JsCast,
+{
+    document()
+        .query_selector_all(selectors)
+        .unwrap()
+        .values()
+        .into_iter()
+        .map(|e| e.unwrap().dyn_into::<T>().unwrap())
+        .collect()
+}
+
 pub fn selector_and_clone<T>(selectors: &str) -> T
 where
     T: JsCast,