From b86cded45d06d7429cb9b2ad42505d211d8d6f58 Mon Sep 17 00:00:00 2001 From: Greg Burri Date: Wed, 15 Jan 2025 01:37:49 +0100 Subject: [PATCH] Steps can now be ordered (via drag and drop) --- Cargo.toml | 10 +- backend/src/data/db/recipe.rs | 22 +- backend/src/main.rs | 6 +- backend/src/services/ron.rs | 4 +- frontend/Cargo.toml | 6 +- frontend/deploy.nu | 6 +- frontend/src/recipe_edit.rs | 372 +++++++++++++++++++++++++--------- frontend/src/utils.rs | 2 +- 8 files changed, 316 insertions(+), 112 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ffc5e91..6fa07ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,8 +5,12 @@ resolver = "2" members = ["backend", "frontend", "common"] [profile.release] -strip = true codegen-units = 1 lto = true -# Tell `rustc` to optimize for small code size. -# opt-level = "s" +strip = true + +[profile.release.package.frontend] +codegen-units = 1 +strip = true +# To reduce the 'wasm' file size. +opt-level = "z" diff --git a/backend/src/data/db/recipe.rs b/backend/src/data/db/recipe.rs index f8628e8..0e41b8f 100644 --- a/backend/src/data/db/recipe.rs +++ b/backend/src/data/db/recipe.rs @@ -507,12 +507,14 @@ ORDER BY [name] .await? .unwrap_or(-1); - let db_result = sqlx::query("INSERT INTO [Group] ([recipe_id, [order]) VALUES ($1, $2)") + let db_result = sqlx::query("INSERT INTO [Group] ([recipe_id], [order]) VALUES ($1, $2)") .bind(recipe_id) .bind(last_order + 1) .execute(&mut *tx) .await?; + tx.commit().await?; + Ok(db_result.last_insert_rowid()) } @@ -562,10 +564,24 @@ ORDER BY [name] } pub async fn add_recipe_step(&self, group_id: i64) -> Result { - let db_result = sqlx::query("INSERT INTO [Step] ([group_id]) VALUES ($1)") + let mut tx = self.tx().await?; + + let last_order = sqlx::query_scalar( + "SELECT [order] FROM [Step] WHERE [group_id] = $1 ORDER BY [order] DESC LIMIT 1", + ) + .bind(group_id) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(-1); + + let db_result = sqlx::query("INSERT INTO [Step] ([group_id], [order]) VALUES ($1, $2)") .bind(group_id) - .execute(&self.pool) + .bind(last_order + 1) + .execute(&mut *tx) .await?; + + tx.commit().await?; + Ok(db_result.last_insert_rowid()) } diff --git a/backend/src/main.rs b/backend/src/main.rs index 38b7f55..ad2fd2a 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -137,7 +137,7 @@ async fn main() { ) .route( "/recipe/set_groups_order", - put(services::ron::set_group_orders), + put(services::ron::set_groups_order), ) .route("/recipe/add_step", post(services::ron::add_step)) .route("/recipe/remove_step", delete(services::ron::rm_step)) @@ -145,6 +145,10 @@ async fn main() { "/recipe/set_step_action", put(services::ron::set_step_action), ) + .route( + "/recipe/set_steps_order", + put(services::ron::set_steps_order), + ) .route( "/recipe/add_ingredient", post(services::ron::add_ingredient), diff --git a/backend/src/services/ron.rs b/backend/src/services/ron.rs index 2a90013..28e9a9b 100644 --- a/backend/src/services/ron.rs +++ b/backend/src/services/ron.rs @@ -435,7 +435,7 @@ pub async fn set_group_comment( } #[debug_handler] -pub async fn set_group_orders( +pub async fn set_groups_order( State(connection): State, Extension(user): Extension>, ExtractRon(ron): ExtractRon, @@ -483,7 +483,7 @@ pub async fn set_step_action( } #[debug_handler] -pub async fn set_step_orders( +pub async fn set_steps_order( State(connection): State, Extension(user): Extension>, ExtractRon(ron): ExtractRon, diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 7f156bf..affb787 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -31,6 +31,7 @@ web-sys = { version = "0.3", features = [ "EventTarget", "DragEvent", "DataTransfer", + "DomRect", "KeyboardEvent", "Element", "HtmlElement", @@ -50,10 +51,5 @@ gloo = "0.11" # code size when deploying. console_error_panic_hook = { version = "0.1", optional = true } -[profile.release] -# Tell `rustc` to optimize for small code size. -opt-level = "s" -lto = true - [package.metadata.wasm-pack.profile.release] wasm-opt = false diff --git a/frontend/deploy.nu b/frontend/deploy.nu index 6a69206..6b9d549 100644 --- a/frontend/deploy.nu +++ b/frontend/deploy.nu @@ -1,8 +1,10 @@ def main [release: bool = false] { - mut wasm_pack_args = [ build --target web ] + mut wasm_pack_args = [ build --target web --no-pack --no-typescript ] if $release { - $wasm_pack_args = $wasm_pack_args ++ [ --release ] + $wasm_pack_args ++= [ --release ] + } else { + $wasm_pack_args ++= [ --dev ] } wasm-pack ...$wasm_pack_args diff --git a/frontend/src/recipe_edit.rs b/frontend/src/recipe_edit.rs index 8d7032b..df491d7 100644 --- a/frontend/src/recipe_edit.rs +++ b/frontend/src/recipe_edit.rs @@ -18,7 +18,7 @@ use common::ron_api; use crate::{ modal_dialog, request, toast::{self, Level}, - utils::{by_id, selector, selector_all, selector_and_clone, SelectorExt}, + utils::{by_id, selector, selector_and_clone, SelectorExt}, }; async fn reload_recipes_list(current_recipe_id: i64) { @@ -336,9 +336,7 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> { 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(); + group_element.set_id(&format!("group-{}", group.id)); let groups_container: Element = by_id("groups-container"); groups_container.append_child(&group_element).unwrap(); @@ -348,7 +346,7 @@ fn create_group_element(group: &ron_api::Group) -> Element { let group_ids = by_id::("groups-container") .selector_all::(".group") .into_iter() - .map(|e| e.get_attribute("id").unwrap()[6..].parse::().unwrap()) + .map(|e| e.id()[6..].parse::().unwrap()) .collect(); let body = ron_api::SetGroupOrders { group_ids }; @@ -488,11 +486,25 @@ where 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(); + step_element.set_id(&format!("step-{}", step.id)); group_element.append_child(&step_element).unwrap(); + set_draggable(&step_element, "step", |element| { + let element = element.clone(); + spawn_local(async move { + let step_ids = element + .parent_element() + .unwrap() + .selector_all::(".step") + .into_iter() + .map(|e| e.id()[5..].parse::().unwrap()) + .collect(); + + let body = ron_api::SetStepOrders { step_ids }; + let _ = request::put::<(), _>("recipe/set_steps_order", body).await; + }); + }); + // Step action. let action: HtmlTextAreaElement = step_element.selector(".text-area-step-action"); action.set_value(&step.action); @@ -556,9 +568,7 @@ fn create_step_element(group_element: &Element, step: &ron_api::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(); + ingredient_element.set_id(&format!("ingredient-{}", ingredient.id)); step_element.append_child(&ingredient_element).unwrap(); // Ingredient name. @@ -664,6 +674,32 @@ fn create_ingredient_element(step_element: &Element, ingredient: &ron_api::Ingre ingredient_element } +// "text/plain" is avoided to prevent dropping to a input text box. +const DRAG_AND_DROP_MIME_TYPE: &str = "recipes/element-id"; + +enum CursorPosition { + UpperPart, + LowerPart, +} + +fn get_cursor_position(mouse_y: f64, element: &Element) -> CursorPosition { + let element_y = element.get_bounding_client_rect().y(); + // Between 0 (top) and 1 (bottom). + let y_relative_pos = (mouse_y - element_y) / element.get_bounding_client_rect().height(); + if y_relative_pos < 0.5 { + CursorPosition::UpperPart + } else { + CursorPosition::LowerPart + } +} + +fn get_parent_with_id_starting_with(mut element: Element, prefix: &str) -> Element { + while !element.id().starts_with(prefix) { + element = element.parent_element().unwrap(); + } + element +} + /// 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 @@ -687,10 +723,139 @@ where element.after_with_node_1(&dropzone).unwrap(); setup_dragzone_events(&dropzone, prefix, dropped.clone()); + // DRAGOVER. + let prefix_copied = prefix.to_string(); + EventListener::new_with_options( + element, + "dragover", + EventListenerOptions::enable_prevent_default(), + move |event| { + let event: &DragEvent = event.dyn_ref::().unwrap(); + let drag_data = event + .data_transfer() + .unwrap() + .get_data(DRAG_AND_DROP_MIME_TYPE) + .unwrap(); + + if drag_data.starts_with(&prefix_copied) { + let element: Element = by_id(&drag_data); + let element_target = event + .current_target() + .unwrap() + .dyn_into::() + .unwrap(); + + if element.parent_element() != element_target.parent_element() { + return; + } + + event.prevent_default(); + + let cursor_position = get_cursor_position(event.client_y() as f64, &element_target); + + element_target + .previous_element_sibling() + .unwrap() + .set_class_name(match cursor_position { + CursorPosition::UpperPart => "dropzone hover", + CursorPosition::LowerPart => "dropzone active", + }); + + element_target + .next_element_sibling() + .unwrap() + .set_class_name(match cursor_position { + CursorPosition::UpperPart => "dropzone active", + CursorPosition::LowerPart => "dropzone hover", + }); + } + }, + ) + .forget(); + + // DRAGLEAVE. + let prefix_copied = prefix.to_string(); + EventListener::new(element, "dragleave", move |event| { + let event: &DragEvent = event.dyn_ref::().unwrap(); + let drag_data = event + .data_transfer() + .unwrap() + .get_data(DRAG_AND_DROP_MIME_TYPE) + .unwrap(); + + if drag_data.starts_with(&prefix_copied) { + let element: Element = by_id(&drag_data); + let element_target = event + .current_target() + .unwrap() + .dyn_into::() + .unwrap(); + + if element.parent_element() != element_target.parent_element() { + return; + } + + element_target + .previous_element_sibling() + .unwrap() + .set_class_name("dropzone active"); + + element_target + .next_element_sibling() + .unwrap() + .set_class_name("dropzone active"); + } + }) + .forget(); + + // DROP. + let prefix_copied = prefix.to_string(); + EventListener::new(element, "drop", move |event| { + let event: &DragEvent = event.dyn_ref::().unwrap(); + let drag_data = event + .data_transfer() + .unwrap() + .get_data(DRAG_AND_DROP_MIME_TYPE) + .unwrap(); + + if drag_data.starts_with(&prefix_copied) { + let target: Element = event.current_target().unwrap().dyn_into().unwrap(); + let element: Element = by_id(&drag_data); + let dropzone: Element = element.next_element_sibling().unwrap(); + + if element.parent_element() != target.parent_element() { + return; + } + + match get_cursor_position(event.client_y() as f64, &element) { + CursorPosition::UpperPart => { + target + .previous_element_sibling() + .unwrap() + .after_with_node_1(&element) + .unwrap(); + element.after_with_node_1(&dropzone).unwrap(); + } + CursorPosition::LowerPart => { + target + .next_element_sibling() + .unwrap() + .after_with_node_1(&element) + .unwrap(); + element.after_with_node_1(&dropzone).unwrap(); + } + } + + dropped(&element); + } + }) + .forget(); + + // MOUSEDOWN. let drag_handle: Element = element.selector(".drag-handle"); EventListener::new(&drag_handle, "mousedown", |event| { event - .target() + .current_target() .unwrap() .dyn_into::() .unwrap() @@ -701,9 +866,10 @@ where }) .forget(); + // MOUSEUP. EventListener::new(&drag_handle, "mouseup", |event| { event - .target() + .current_target() .unwrap() .dyn_into::() .unwrap() @@ -714,52 +880,41 @@ where }) .forget(); + // DRAGSTART. let prefix_copied = prefix.to_string(); EventListener::new(element, "dragstart", move |event| { let event: &DragEvent = event.dyn_ref::().unwrap(); - let target_element: Element = event.target().unwrap().dyn_into().unwrap(); + let target_element: Element = event.current_target().unwrap().dyn_into().unwrap(); + + if target_element.id().starts_with(&prefix_copied) { + event.stop_propagation(); - if target_element - .get_attribute("id") - .unwrap() - .starts_with(&prefix_copied) - { // Highlight where the group can be droppped. - // TODO: only select direct children. for dp in target_element .parent_element() .unwrap() .selector_all::(".dropzone") { - dp.set_class_name("dropzone active"); + if dp.parent_element() == target_element.parent_element() { + dp.set_class_name("dropzone active"); + } } event .data_transfer() .unwrap() - .set_data("text/plain", &target_element.get_attribute("id").unwrap()) + .set_data(DRAG_AND_DROP_MIME_TYPE, &target_element.id()) .unwrap(); event.data_transfer().unwrap().set_effect_allowed("move"); } }) .forget(); + // DRAGEND. let prefix_copied = prefix.to_string(); EventListener::new(element, "dragend", move |event| { - // let event: &DragEvent = event.dyn_ref::().unwrap(); - event - .target() - .unwrap() - .dyn_into::() - .unwrap() - .set_attribute("draggable", "false") - .unwrap(); - - let target_element: Element = event.target().unwrap().dyn_into().unwrap(); - if target_element - .get_attribute("id") - .unwrap() - .starts_with(&prefix_copied) - { + let target_element: Element = event.current_target().unwrap().dyn_into().unwrap(); + target_element.set_attribute("draggable", "false").unwrap(); + if target_element.id().starts_with(&prefix_copied) { for dp in target_element .parent_element() .unwrap() @@ -770,80 +925,107 @@ where } }) .forget(); -} -fn setup_dragzone_events(dropzone: &Element, prefix: &str, dropped: rc::Rc) -where - T: Fn(&Element) + 'static, -{ - let prefix_copied = prefix.to_string(); - EventListener::new_with_options( - dropzone, - "dragover", - EventListenerOptions::enable_prevent_default(), - move |event| { + fn setup_dragzone_events(dropzone: &Element, prefix: &str, dropped: rc::Rc) + where + T: Fn(&Element) + 'static, + { + // DRAGOVER (dropzone). + let prefix_copied = prefix.to_string(); + EventListener::new_with_options( + dropzone, + "dragover", + EventListenerOptions::enable_prevent_default(), + move |event| { + let event: &DragEvent = event.dyn_ref::().unwrap(); + let drag_data = event + .data_transfer() + .unwrap() + .get_data(DRAG_AND_DROP_MIME_TYPE) + .unwrap(); + + if drag_data.starts_with(&prefix_copied) { + let element: Element = by_id(&drag_data); + let element_target = event + .current_target() + .unwrap() + .dyn_into::() + .unwrap(); + + if element.parent_element() != element_target.parent_element() { + return; + } + + event.prevent_default(); + + event + .current_target() + .unwrap() + .dyn_into::() + .unwrap() + .set_class_name("dropzone hover"); + } + }, + ) + .forget(); + + // DRAGLEAVE (dropzone). + let prefix_copied = prefix.to_string(); + EventListener::new(dropzone, "dragleave", move |event| { let event: &DragEvent = event.dyn_ref::().unwrap(); let drag_data = event .data_transfer() .unwrap() - .get_data("text/plain") + .get_data(DRAG_AND_DROP_MIME_TYPE) .unwrap(); if drag_data.starts_with(&prefix_copied) { - event.prevent_default(); - // event.data_transfer().unwrap().set_effect_allowed("move"); - // log!("drag over"); + let element: Element = by_id(&drag_data); + let element_target = event + .current_target() + .unwrap() + .dyn_into::() + .unwrap(); + + if element.parent_element() != element_target.parent_element() { + return; + } + event - .target() + .current_target() .unwrap() .dyn_into::() .unwrap() - .set_class_name("dropzone hover"); + .set_class_name("dropzone active"); } - }, - ) - .forget(); - - let prefix_copied = prefix.to_string(); - EventListener::new(dropzone, "dragleave", move |event| { - let event: &DragEvent = event.dyn_ref::().unwrap(); - let drag_data = event - .data_transfer() - .unwrap() - .get_data("text/plain") - .unwrap(); + }) + .forget(); - if drag_data.starts_with(&prefix_copied) { - // log!("drag leave"); - event - .target() - .unwrap() - .dyn_into::() + // DROP (dropzone). + let prefix_copied = prefix.to_string(); + EventListener::new(dropzone, "drop", move |event| { + let event: &DragEvent = event.dyn_ref::().unwrap(); + let drag_data = event + .data_transfer() .unwrap() - .set_class_name("dropzone active"); - } - }) - .forget(); + .get_data(DRAG_AND_DROP_MIME_TYPE) + .unwrap(); - let prefix_copied = prefix.to_string(); - EventListener::new(dropzone, "drop", move |event| { - let event: &DragEvent = event.dyn_ref::().unwrap(); - let drag_data = event - .data_transfer() - .unwrap() - .get_data("text/plain") - .unwrap(); + if drag_data.starts_with(&prefix_copied) { + let target: Element = event.current_target().unwrap().dyn_into().unwrap(); + let element: Element = by_id(&drag_data); + let dropzone: Element = element.next_element_sibling().unwrap(); - if 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(); + if element.parent_element() != target.parent_element() { + return; + } - dropped(&element); - } - }) - .forget(); + target.after_with_node_1(&element).unwrap(); + element.after_with_node_1(&dropzone).unwrap(); + + dropped(&element); + } + }) + .forget(); + } } diff --git a/frontend/src/utils.rs b/frontend/src/utils.rs index 58e7803..6e400e0 100644 --- a/frontend/src/utils.rs +++ b/frontend/src/utils.rs @@ -1,4 +1,4 @@ -use gloo::{console::log, utils::document}; +use gloo::utils::document; use wasm_bindgen::prelude::*; use web_sys::Element; -- 2.49.0