[[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",
]
[[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",
"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",
[[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",
[[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",
]
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"
[[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",
[[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"
[[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"
[[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",
]
"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"
[[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",
]
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",
]
[[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",
[[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",
[[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",
[[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",
[[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",
[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"] }
.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 {
.content {
flex-grow: 1;
+ margin-left: 0px;
+
background-color: $color-2;
border: 0.1em solid $color-3;
border-radius: 1em;
}
#recipe-edit {
-
.drag-handle {
cursor: move;
}
.group {
border: 0.1em solid lighten($color-3, 30%);
+ margin-top: 0px;
+ margin-bottom: 0px;
}
.step {
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 {
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
Pool, Sqlite, Transaction,
};
-use thiserror::Error;
use tracing::{event, Level};
use crate::consts;
const CURRENT_DB_VERSION: u32 = 1;
-#[derive(Error, Debug)]
+#[derive(Debug, thiserror::Error)]
pub enum DBError {
#[error("Sqlx error: {0}")]
Sqlx(#[from] sqlx::Error),
.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,
}
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())
}
.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)
extract::{ConnectInfo, Extension, FromRef, Request, State},
http::StatusCode,
middleware::{self, Next},
- response::{Response, Result},
+ response::Response,
routing::{delete, get, post, put},
Router,
};
}
}
+#[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;
)
// 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",
use axum::{
- async_trait,
body::Bytes,
extract::{FromRequest, Request},
http::{header, StatusCode},
pub struct ExtractRon<T: DeserializeOwned>(pub T);
-#[async_trait]
impl<S, T> FromRequest<S> for ExtractRon<T>
where
S: Send + Sync,
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)]
},
current_id: current_recipe.current_recipe_id,
};
- Ok(RecipesListFragmentTemplate { tr, recipes })
+ Ok(Html(RecipesListFragmentTemplate { tr, recipes }.render()?))
}
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;
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());
}
}
current_id: None,
};
- Ok(HomeTemplate { user, recipes, tr })
+ Ok(Html(HomeTemplate { user, recipes, tr }.render()?))
}
///// 404 /////
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()?),
+ ))
}
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]
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())
}
}
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())
}
}
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());
}
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()),
}
}
}
}
+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>,
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>,
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};
email,
html_templates::*,
translation::{self, Sentence},
- utils, AppState,
+ utils, AppState, Result,
};
/// SIGN UP ///
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)]
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())
}
)
.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)
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);
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()?,
+ ),
)),
}
}
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)]
{
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) => {
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())
}
}
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())
}
)
.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(
)
.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())
}
}
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())
}
)
.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)
}
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)]
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())
}
// 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())
}
}
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);
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()?,
+ ),
)),
}
}
NotLoggedIn,
DatabaseError,
+ TemplateError,
// Sign in page.
SignInMenu,
<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">
<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>
(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"),
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,
# 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"
+++ /dev/null
-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(())
-}
-mod handles;
mod modal_dialog;
mod on_click;
+mod recipe_edit;
mod request;
mod toast;
mod utils;
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);
}
--- /dev/null
+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();
+}