Update to Axum 0.8

This commit is contained in:
Greg Burri 2025-01-14 15:57:02 +01:00
parent 975d1ceee2
commit e355800f98
20 changed files with 1377 additions and 1199 deletions

136
Cargo.lock generated
View file

@ -100,11 +100,12 @@ dependencies = [
[[package]]
name = "anstyle-wincon"
version = "3.0.6"
version = "3.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125"
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
dependencies = [
"anstyle",
"once_cell",
"windows-sys 0.59.0",
]
@ -148,14 +149,14 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "axum"
version = "0.7.9"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8"
dependencies = [
"async-trait",
"axum-core",
"axum-core 0.5.0",
"axum-macros",
"bytes",
"form_urlencoded",
"futures-util",
"http 1.2.0",
"http-body",
@ -199,26 +200,43 @@ dependencies = [
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.9.6"
name = "axum-core"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733"
dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"fastrand",
"futures-util",
"http 1.2.0",
"http-body",
"http-body-util",
"mime",
"multer",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b"
dependencies = [
"axum",
"axum-core 0.5.0",
"bytes",
"cookie",
"futures-util",
"http 1.2.0",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"serde",
"tower",
@ -228,9 +246,9 @@ dependencies = [
[[package]]
name = "axum-macros"
version = "0.4.2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
@ -335,9 +353,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
[[package]]
name = "cc"
version = "1.2.7"
version = "1.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7"
checksum = "c8293772165d9345bdaaa39b45b2109591e63fe5e6fbc23c6ff930a048aa310b"
dependencies = [
"shlex",
]
@ -623,15 +641,6 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.1"
@ -1416,9 +1425,9 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
[[package]]
name = "js-sys"
version = "0.3.76"
version = "0.3.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7"
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
dependencies = [
"once_cell",
"wasm-bindgen",
@ -1511,9 +1520,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.22"
version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f"
[[package]]
name = "matchers"
@ -1526,9 +1535,9 @@ dependencies = [
[[package]]
name = "matchit"
version = "0.7.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "md-5"
@ -1570,9 +1579,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394"
checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924"
dependencies = [
"adler2",
]
@ -1588,23 +1597,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http 1.2.0",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "nom"
version = "7.1.3"
@ -1851,9 +1843,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.92"
version = "1.0.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99"
dependencies = [
"unicode-ident",
]
@ -2028,7 +2020,7 @@ version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc64d77bb950f6498d0fc64b028d168fcb4e56ac31b66a8ae05f64d3b0c218b6"
dependencies = [
"axum-core",
"axum-core 0.4.5",
"http 1.2.0",
"rinja",
]
@ -3059,20 +3051,21 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.99"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396"
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.99"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79"
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
dependencies = [
"bumpalo",
"log",
@ -3084,9 +3077,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.49"
version = "0.4.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2"
checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
dependencies = [
"cfg-if",
"js-sys",
@ -3097,9 +3090,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.99"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe"
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@ -3107,9 +3100,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.99"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2"
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
@ -3120,15 +3113,18 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.99"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.76"
version = "0.3.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc"
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
dependencies = [
"js-sys",
"wasm-bindgen",

View file

@ -7,8 +7,8 @@ edition = "2021"
[dependencies]
common = { path = "../common" }
axum = { version = "0.7", features = ["macros"] }
axum-extra = { version = "0.9", features = ["cookie"] }
axum = { version = "0.8", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie"] }
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["fs", "trace"] }

View file

@ -96,6 +96,8 @@ body {
.recipe-item {
padding: 4px;
// Transparent border: to keep same size than '.recipe-item-current'.
border: 0.1em solid rgba(0, 0, 0, 0);
}
.recipe-item-current {
@ -111,6 +113,8 @@ body {
.content {
flex-grow: 1;
margin-left: 0px;
background-color: $color-2;
border: 0.1em solid $color-3;
border-radius: 1em;
@ -122,13 +126,14 @@ body {
}
#recipe-edit {
.drag-handle {
cursor: move;
}
.group {
border: 0.1em solid lighten($color-3, 30%);
margin-top: 0px;
margin-bottom: 0px;
}
.step {
@ -139,9 +144,11 @@ body {
border: 0.1em solid lighten($color-3, 30%);
}
.dropzone-group,
.dropzone-step {
.dropzone {
height: 10px;
margin-top: 0px;
margin-bottom: 0px;
background-color: white;
&.active {

View file

@ -11,7 +11,6 @@ use sqlx::{
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
Pool, Sqlite, Transaction,
};
use thiserror::Error;
use tracing::{event, Level};
use crate::consts;
@ -21,7 +20,7 @@ pub mod user;
const CURRENT_DB_VERSION: u32 = 1;
#[derive(Error, Debug)]
#[derive(Debug, thiserror::Error)]
pub enum DBError {
#[error("Sqlx error: {0}")]
Sqlx(#[from] sqlx::Error),

View file

@ -128,6 +128,28 @@ WHERE [Step].[id] = $1 AND [user_id] = $2
.map_err(DBError::from)
}
pub async fn can_edit_recipe_all_steps(&self, user_id: i64, steps_ids: &[i64]) -> Result<bool> {
let params = (0..steps_ids.len())
.map(|n| format!("${}", n + 2))
.join(", ");
let query_str = format!(
r#"
SELECT COUNT(*)
FROM [Recipe]
INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
INNER JOIN [Step] ON [Step].[group_id] = [Group].[id]
WHERE [Step].[id] IN ({}) AND [user_id] = $1
"#,
params
);
let mut query = sqlx::query_scalar::<_, u64>(&query_str).bind(user_id);
for id in steps_ids {
query = query.bind(id);
}
Ok(query.fetch_one(&self.pool).await? == steps_ids.len() as u64)
}
pub async fn can_edit_recipe_ingredient(
&self,
user_id: i64,
@ -475,10 +497,22 @@ ORDER BY [name]
}
pub async fn add_recipe_group(&self, recipe_id: i64) -> Result<i64> {
let db_result = sqlx::query("INSERT INTO [Group] ([recipe_id]) VALUES ($1)")
let mut tx = self.tx().await?;
let last_order = sqlx::query_scalar(
"SELECT [order] FROM [Group] WHERE [recipe_id] = $1 ORDER BY [order] DESC LIMIT 1",
)
.bind(recipe_id)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(-1);
let db_result = sqlx::query("INSERT INTO [Group] ([recipe_id, [order]) VALUES ($1, $2)")
.bind(recipe_id)
.execute(&self.pool)
.bind(last_order + 1)
.execute(&mut *tx)
.await?;
Ok(db_result.last_insert_rowid())
}
@ -554,6 +588,22 @@ ORDER BY [name]
.map_err(DBError::from)
}
pub async fn set_steps_order(&self, step_ids: &[i64]) -> Result<()> {
let mut tx = self.tx().await?;
for (order, id) in step_ids.iter().enumerate() {
sqlx::query("UPDATE [Step] SET [order] = $2 WHERE [id] = $1")
.bind(id)
.bind(order as i64)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn add_recipe_ingredient(&self, step_id: i64) -> Result<i64> {
let db_result = sqlx::query("INSERT INTO [Ingredient] ([step_id]) VALUES ($1)")
.bind(step_id)

View file

@ -4,7 +4,7 @@ use axum::{
extract::{ConnectInfo, Extension, FromRef, Request, State},
http::StatusCode,
middleware::{self, Next},
response::{Response, Result},
response::Response,
routing::{delete, get, post, put},
Router,
};
@ -55,6 +55,23 @@ impl axum::response::IntoResponse for db::DBError {
}
}
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("Database error: {0}")]
Database(#[from] db::DBError),
#[error("Template error: {0}")]
Render(#[from] rinja::Error),
}
type Result<T> = std::result::Result<T, AppError>;
impl axum::response::IntoResponse for AppError {
fn into_response(self) -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
#[cfg(debug_assertions)]
const TRACING_LEVEL: tracing::Level = tracing::Level::DEBUG;
@ -183,8 +200,8 @@ async fn main() {
)
// Recipes.
.route("/recipe/new", get(services::recipe::create))
.route("/recipe/edit/:id", get(services::recipe::edit_recipe))
.route("/recipe/view/:id", get(services::recipe::view))
.route("/recipe/edit/{id}", get(services::recipe::edit_recipe))
.route("/recipe/view/{id}", get(services::recipe::view))
// User.
.route(
"/user/edit",

View file

@ -1,5 +1,4 @@
use axum::{
async_trait,
body::Bytes,
extract::{FromRequest, Request},
http::{header, StatusCode},
@ -11,7 +10,6 @@ use crate::ron_utils;
pub struct ExtractRon<T: DeserializeOwned>(pub T);
#[async_trait]
impl<S, T> FromRequest<S> for ExtractRon<T>
where
S: Send + Sync,

View file

@ -1,15 +1,16 @@
use axum::{
debug_handler,
extract::{Extension, Query, State},
response::{IntoResponse, Result},
response::{Html, IntoResponse},
};
use rinja::Template;
use serde::Deserialize;
// use tracing::{event, Level};
use crate::{
data::{db, model},
html_templates::*,
translation,
translation, Result,
};
#[derive(Deserialize)]
@ -37,5 +38,5 @@ pub async fn recipes_list_fragments(
},
current_id: current_recipe.current_recipe_id,
};
Ok(RecipesListFragmentTemplate { tr, recipes })
Ok(Html(RecipesListFragmentTemplate { tr, recipes }.render()?))
}

View file

@ -3,13 +3,14 @@ use axum::{
extract::{Extension, Request, State},
http::{header, StatusCode},
middleware::Next,
response::{IntoResponse, Response, Result},
response::{Html, IntoResponse, Response},
};
use rinja::Template;
use crate::{
data::{db, model},
html_templates::*,
ron_utils, translation,
ron_utils, translation, Result,
};
pub mod fragments;
@ -31,12 +32,15 @@ pub async fn ron_error_to_html(
Ok(bytes) => String::from_utf8(bytes.to_vec()).unwrap_or_default(),
Err(error) => error.to_string(),
};
return Ok(MessageTemplate {
user: None,
message: &message,
as_code: true,
tr,
}
return Ok(Html(
MessageTemplate {
user: None,
message: &message,
as_code: true,
tr,
}
.render()?,
)
.into_response());
}
}
@ -66,7 +70,7 @@ pub async fn home_page(
current_id: None,
};
Ok(HomeTemplate { user, recipes, tr })
Ok(Html(HomeTemplate { user, recipes, tr }.render()?))
}
///// 404 /////
@ -75,9 +79,9 @@ pub async fn home_page(
pub async fn not_found(
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> impl IntoResponse {
(
) -> Result<impl IntoResponse> {
Ok((
StatusCode::NOT_FOUND,
MessageTemplate::new_with_user("404: Not found", tr, user),
)
Html(MessageTemplate::new_with_user("404: Not found", tr, user).render()?),
))
}

View file

@ -1,14 +1,16 @@
use axum::{
debug_handler,
extract::{Extension, Path, State},
response::{IntoResponse, Redirect, Response, Result},
response::{Html, IntoResponse, Redirect, Response},
};
use rinja::Template;
// use tracing::{event, Level};
use crate::{
data::{db, model},
html_templates::*,
translation::{self, Sentence},
Result,
};
#[debug_handler]
@ -21,7 +23,7 @@ pub async fn create(
let recipe_id = connection.create_recipe(user.id).await?;
Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response())
} else {
Ok(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response())
Ok(Html(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).render()?).into_response())
}
}
@ -45,24 +47,33 @@ pub async fn edit_recipe(
current_id: Some(recipe_id),
};
Ok(RecipeEditTemplate {
user: Some(user),
tr,
recipes,
recipe,
}
Ok(Html(
RecipeEditTemplate {
user: Some(user),
tr,
recipes,
recipe,
}
.render()?,
)
.into_response())
} else {
Ok(
MessageTemplate::new(tr.t(Sentence::RecipeNotAllowedToEdit), tr)
.into_response(),
Html(
MessageTemplate::new(tr.t(Sentence::RecipeNotAllowedToEdit), tr)
.render()?,
)
.into_response(),
)
}
} else {
Ok(MessageTemplate::new(tr.t(Sentence::RecipeNotFound), tr).into_response())
Ok(
Html(MessageTemplate::new(tr.t(Sentence::RecipeNotFound), tr).render()?)
.into_response(),
)
}
} else {
Ok(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response())
Ok(Html(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).render()?).into_response())
}
}
@ -78,10 +89,13 @@ pub async fn view(
if !recipe.is_published
&& (user.is_none() || recipe.user_id != user.as_ref().unwrap().id)
{
return Ok(MessageTemplate::new_with_user(
&tr.tp(Sentence::RecipeNotAllowedToView, &[Box::new(recipe_id)]),
tr,
user,
return Ok(Html(
MessageTemplate::new_with_user(
&tr.tp(Sentence::RecipeNotAllowedToView, &[Box::new(recipe_id)]),
tr,
user,
)
.render()?,
)
.into_response());
}
@ -103,17 +117,20 @@ pub async fn view(
current_id: Some(recipe_id),
};
Ok(RecipeViewTemplate {
user,
tr,
recipes,
recipe,
}
Ok(Html(
RecipeViewTemplate {
user,
tr,
recipes,
recipe,
}
.render()?,
)
.into_response())
}
None => Ok(
MessageTemplate::new_with_user(tr.t(Sentence::RecipeNotFound), tr, user)
.into_response(),
),
None => Ok(Html(
MessageTemplate::new_with_user(tr.t(Sentence::RecipeNotFound), tr, user).render()?,
)
.into_response()),
}
}

View file

@ -142,6 +142,25 @@ async fn check_user_rights_recipe_step(
}
}
async fn check_user_rights_recipe_steps(
connection: &db::Connection,
user: &Option<model::User>,
step_ids: &[i64],
) -> Result<()> {
if user.is_none()
|| !connection
.can_edit_recipe_all_steps(user.as_ref().unwrap().id, step_ids)
.await?
{
Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED,
NOT_AUTHORIZED_MESSAGE,
)))
} else {
Ok(())
}
}
async fn check_user_rights_recipe_ingredient(
connection: &db::Connection,
user: &Option<model::User>,
@ -463,6 +482,17 @@ pub async fn set_step_action(
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn set_step_orders(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetStepOrders>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe_steps(&connection, &user, &ron.step_ids).await?;
connection.set_steps_order(&ron.step_ids).await?;
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn add_ingredient(
State(connection): State<db::Connection>,

View file

@ -3,13 +3,17 @@ use std::{collections::HashMap, net::SocketAddr};
use axum::{
body::Body,
debug_handler,
extract::{ConnectInfo, Extension, Host, Query, Request, State},
extract::{ConnectInfo, Extension, Query, Request, State},
http::HeaderMap,
response::{IntoResponse, Redirect, Response, Result},
response::{Html, IntoResponse, Redirect, Response},
Form,
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use axum_extra::extract::{
cookie::{Cookie, CookieJar},
Host,
};
use chrono::Duration;
use rinja::Template;
use serde::Deserialize;
use tracing::{event, Level};
@ -20,7 +24,7 @@ use crate::{
email,
html_templates::*,
translation::{self, Sentence},
utils, AppState,
utils, AppState, Result,
};
/// SIGN UP ///
@ -30,14 +34,17 @@ pub async fn sign_up_get(
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> {
Ok(SignUpFormTemplate {
user,
tr,
email: String::new(),
message: "",
message_email: "",
message_password: "",
})
Ok(Html(
SignUpFormTemplate {
user,
tr,
email: String::new(),
message: "",
message_email: "",
message_password: "",
}
.render()?,
))
}
#[derive(Deserialize, Debug)]
@ -75,26 +82,29 @@ pub async fn sign_up_post(
Sentence::InvalidPassword,
&[Box::new(common::consts::MIN_PASSWORD_SIZE)],
);
Ok(SignUpFormTemplate {
user,
email: form_data.email.clone(),
message_email: match error {
SignUpError::InvalidEmail => tr.t(Sentence::InvalidEmail),
_ => "",
},
message_password: match error {
SignUpError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
SignUpError::InvalidPassword => invalid_password_mess,
_ => "",
},
message: match error {
SignUpError::UserAlreadyExists => tr.t(Sentence::EmailAlreadyTaken),
SignUpError::DatabaseError => tr.t(Sentence::DatabaseError),
SignUpError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail),
_ => "",
},
tr,
}
Ok(Html(
SignUpFormTemplate {
user,
email: form_data.email.clone(),
message_email: match error {
SignUpError::InvalidEmail => tr.t(Sentence::InvalidEmail),
_ => "",
},
message_password: match error {
SignUpError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
SignUpError::InvalidPassword => invalid_password_mess,
_ => "",
},
message: match error {
SignUpError::UserAlreadyExists => tr.t(Sentence::EmailAlreadyTaken),
SignUpError::DatabaseError => tr.t(Sentence::DatabaseError),
SignUpError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail),
_ => "",
},
tr,
}
.render()?,
)
.into_response())
}
@ -140,12 +150,11 @@ pub async fn sign_up_post(
)
.await
{
Ok(()) => {
Ok(
MessageTemplate::new_with_user(tr.t(Sentence::SignUpEmailSent), tr, user)
.into_response(),
)
}
Ok(()) => Ok(Html(
MessageTemplate::new_with_user(tr.t(Sentence::SignUpEmailSent), tr, user)
.render()?,
)
.into_response()),
Err(_) => {
// error!("Email validation error: {}", error); // TODO: log
error_response(SignUpError::UnableSendEmail, &form_data, user, tr)
@ -172,7 +181,14 @@ pub async fn sign_up_validation(
if user.is_some() {
return Ok((
jar,
MessageTemplate::new_with_user(tr.t(Sentence::ValidationUserAlreadyExists), tr, user),
Html(
MessageTemplate::new_with_user(
tr.t(Sentence::ValidationUserAlreadyExists),
tr,
user,
)
.render()?,
),
));
}
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@ -194,34 +210,46 @@ pub async fn sign_up_validation(
let user = connection.load_user(user_id).await?;
Ok((
jar,
MessageTemplate::new_with_user(
tr.t(Sentence::SignUpEmailValidationSuccess),
tr,
user,
Html(
MessageTemplate::new_with_user(
tr.t(Sentence::SignUpEmailValidationSuccess),
tr,
user,
)
.render()?,
),
))
}
db::user::ValidationResult::ValidationExpired => Ok((
jar,
MessageTemplate::new_with_user(
tr.t(Sentence::SignUpValidationExpired),
tr,
user,
Html(
MessageTemplate::new_with_user(
tr.t(Sentence::SignUpValidationExpired),
tr,
user,
)
.render()?,
),
)),
db::user::ValidationResult::UnknownUser => Ok((
jar,
MessageTemplate::new_with_user(
tr.t(Sentence::SignUpValidationErrorTryAgain),
tr,
user,
Html(
MessageTemplate::new_with_user(
tr.t(Sentence::SignUpValidationErrorTryAgain),
tr,
user,
)
.render()?,
),
)),
}
}
None => Ok((
jar,
MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user),
Html(
MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user)
.render()?,
),
)),
}
}
@ -233,12 +261,15 @@ pub async fn sign_in_get(
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> {
Ok(SignInFormTemplate {
user,
tr,
email: "",
message: "",
})
Ok(Html(
SignInFormTemplate {
user,
tr,
email: "",
message: "",
}
.render()?,
))
}
#[derive(Deserialize, Debug)]
@ -270,22 +301,28 @@ pub async fn sign_in_post(
{
db::user::SignInResult::AccountNotValidated => Ok((
jar,
SignInFormTemplate {
user,
email: &form_data.email,
message: tr.t(Sentence::AccountMustBeValidatedFirst),
tr,
}
Html(
SignInFormTemplate {
user,
email: &form_data.email,
message: tr.t(Sentence::AccountMustBeValidatedFirst),
tr,
}
.render()?,
)
.into_response(),
)),
db::user::SignInResult::UserNotFound | db::user::SignInResult::WrongPassword => Ok((
jar,
SignInFormTemplate {
user,
email: &form_data.email,
message: tr.t(Sentence::WrongEmailOrPassword),
tr,
}
Html(
SignInFormTemplate {
user,
email: &form_data.email,
message: tr.t(Sentence::WrongEmailOrPassword),
tr,
}
.render()?,
)
.into_response(),
)),
db::user::SignInResult::Ok(token, _user_id) => {
@ -319,18 +356,22 @@ pub async fn ask_reset_password_get(
Extension(tr): Extension<translation::Tr>,
) -> Result<Response> {
if user.is_some() {
Ok(
Ok(Html(
MessageTemplate::new_with_user(tr.t(Sentence::AskResetAlreadyLoggedInError), tr, user)
.into_response(),
.render()?,
)
.into_response())
} else {
Ok(AskResetPasswordTemplate {
user,
tr,
email: "",
message: "",
message_email: "",
}
Ok(Html(
AskResetPasswordTemplate {
user,
tr,
email: "",
message: "",
message_email: "",
}
.render()?,
)
.into_response())
}
}
@ -363,24 +404,29 @@ pub async fn ask_reset_password_post(
user: Option<model::User>,
tr: translation::Tr,
) -> Result<Response> {
Ok(AskResetPasswordTemplate {
user,
email,
message_email: match error {
AskResetPasswordError::InvalidEmail => tr.t(Sentence::InvalidEmail),
_ => "",
},
message: match error {
AskResetPasswordError::EmailAlreadyReset => {
tr.t(Sentence::AskResetEmailAlreadyResetError)
}
AskResetPasswordError::EmailUnknown => tr.t(Sentence::EmailUnknown),
AskResetPasswordError::UnableSendEmail => tr.t(Sentence::UnableToSendResetEmail),
AskResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError),
_ => "",
},
tr,
}
Ok(Html(
AskResetPasswordTemplate {
user,
email,
message_email: match error {
AskResetPasswordError::InvalidEmail => tr.t(Sentence::InvalidEmail),
_ => "",
},
message: match error {
AskResetPasswordError::EmailAlreadyReset => {
tr.t(Sentence::AskResetEmailAlreadyResetError)
}
AskResetPasswordError::EmailUnknown => tr.t(Sentence::EmailUnknown),
AskResetPasswordError::UnableSendEmail => {
tr.t(Sentence::UnableToSendResetEmail)
}
AskResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError),
_ => "",
},
tr,
}
.render()?,
)
.into_response())
}
@ -432,12 +478,11 @@ pub async fn ask_reset_password_post(
)
.await
{
Ok(()) => {
Ok(
MessageTemplate::new_with_user(tr.t(Sentence::AskResetEmailSent), tr, user)
.into_response(),
)
}
Ok(()) => Ok(Html(
MessageTemplate::new_with_user(tr.t(Sentence::AskResetEmailSent), tr, user)
.render()?,
)
.into_response()),
Err(_) => {
// error!("Email validation error: {}", error); // TODO: log
error_response(
@ -477,25 +522,30 @@ pub async fn reset_password_get(
)
.await?
{
Ok(ResetPasswordTemplate {
user,
tr,
reset_token,
message: "",
message_password: "",
}
Ok(Html(
ResetPasswordTemplate {
user,
tr,
reset_token,
message: "",
message_password: "",
}
.render()?,
)
.into_response())
} else {
Ok(
Ok(Html(
MessageTemplate::new_with_user(tr.t(Sentence::AskResetTokenMissing), tr, user)
.into_response(),
.render()?,
)
.into_response())
}
} else {
Ok(
Ok(Html(
MessageTemplate::new_with_user(tr.t(Sentence::AskResetTokenMissing), tr, user)
.into_response(),
.render()?,
)
.into_response())
}
}
@ -530,21 +580,24 @@ pub async fn reset_password_post(
Sentence::InvalidPassword,
&[Box::new(common::consts::MIN_PASSWORD_SIZE)],
);
Ok(ResetPasswordTemplate {
user,
reset_token: &form_data.reset_token,
message_password: match error {
ResetPasswordError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
ResetPasswordError::InvalidPassword => reset_password_mess,
_ => "",
},
message: match error {
ResetPasswordError::TokenExpired => tr.t(Sentence::AskResetTokenExpired),
ResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError),
_ => "",
},
tr,
}
Ok(Html(
ResetPasswordTemplate {
user,
reset_token: &form_data.reset_token,
message_password: match error {
ResetPasswordError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
ResetPasswordError::InvalidPassword => reset_password_mess,
_ => "",
},
message: match error {
ResetPasswordError::TokenExpired => tr.t(Sentence::AskResetTokenExpired),
ResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError),
_ => "",
},
tr,
}
.render()?,
)
.into_response())
}
@ -566,12 +619,10 @@ pub async fn reset_password_post(
)
.await
{
Ok(db::user::ResetPasswordResult::Ok) => {
Ok(
MessageTemplate::new_with_user(tr.t(Sentence::PasswordReset), tr, user)
.into_response(),
)
}
Ok(db::user::ResetPasswordResult::Ok) => Ok(Html(
MessageTemplate::new_with_user(tr.t(Sentence::PasswordReset), tr, user).render()?,
)
.into_response()),
Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
error_response(ResetPasswordError::TokenExpired, &form_data, user, tr)
}
@ -585,21 +636,24 @@ pub async fn reset_password_post(
pub async fn edit_user_get(
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Response {
if let Some(user) = user {
ProfileTemplate {
username: &user.name,
email: &user.email,
message: "",
message_email: "",
message_password: "",
user: Some(user.clone()),
tr,
}
) -> Result<Response> {
Ok(if let Some(user) = user {
Html(
ProfileTemplate {
username: &user.name,
email: &user.email,
message: "",
message_email: "",
message_password: "",
user: Some(user.clone()),
tr,
}
.render()?,
)
.into_response()
} else {
MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response()
}
Html(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).render()?).into_response()
})
}
#[derive(Deserialize, Debug)]
@ -640,27 +694,30 @@ pub async fn edit_user_post(
Sentence::InvalidPassword,
&[Box::new(common::consts::MIN_PASSWORD_SIZE)],
);
Ok(ProfileTemplate {
user: Some(user),
username: &form_data.name,
email: &form_data.email,
message_email: match error {
ProfileUpdateError::InvalidEmail => tr.t(Sentence::InvalidEmail),
ProfileUpdateError::EmailAlreadyTaken => tr.t(Sentence::EmailAlreadyTaken),
_ => "",
},
message_password: match error {
ProfileUpdateError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
ProfileUpdateError::InvalidPassword => invalid_password_mess,
_ => "",
},
message: match error {
ProfileUpdateError::DatabaseError => tr.t(Sentence::DatabaseError),
ProfileUpdateError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail),
_ => "",
},
tr,
}
Ok(Html(
ProfileTemplate {
user: Some(user),
username: &form_data.name,
email: &form_data.email,
message_email: match error {
ProfileUpdateError::InvalidEmail => tr.t(Sentence::InvalidEmail),
ProfileUpdateError::EmailAlreadyTaken => tr.t(Sentence::EmailAlreadyTaken),
_ => "",
},
message_password: match error {
ProfileUpdateError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
ProfileUpdateError::InvalidPassword => invalid_password_mess,
_ => "",
},
message: match error {
ProfileUpdateError::DatabaseError => tr.t(Sentence::DatabaseError),
ProfileUpdateError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail),
_ => "",
},
tr,
}
.render()?,
)
.into_response())
}
@ -742,18 +799,21 @@ pub async fn edit_user_post(
// Reload after update.
let user = connection.load_user(user.id).await?;
Ok(ProfileTemplate {
user,
username: &form_data.name,
email: &form_data.email,
message,
message_email: "",
message_password: "",
tr,
}
Ok(Html(
ProfileTemplate {
user,
username: &form_data.name,
email: &form_data.email,
message,
message_email: "",
message_password: "",
tr,
}
.render()?,
)
.into_response())
} else {
Ok(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response())
Ok(Html(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).render()?).into_response())
}
}
@ -770,7 +830,14 @@ pub async fn email_revalidation(
if user.is_some() {
return Ok((
jar,
MessageTemplate::new_with_user(tr.t(Sentence::ValidationUserAlreadyExists), tr, user),
Html(
MessageTemplate::new_with_user(
tr.t(Sentence::ValidationUserAlreadyExists),
tr,
user,
)
.render()?,
),
));
}
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@ -792,30 +859,42 @@ pub async fn email_revalidation(
let user = connection.load_user(user_id).await?;
Ok((
jar,
MessageTemplate::new_with_user(
tr.t(Sentence::ValidationSuccessful),
tr,
user,
Html(
MessageTemplate::new_with_user(
tr.t(Sentence::ValidationSuccessful),
tr,
user,
)
.render()?,
),
))
}
db::user::ValidationResult::ValidationExpired => Ok((
jar,
MessageTemplate::new_with_user(tr.t(Sentence::ValidationExpired), tr, user),
Html(
MessageTemplate::new_with_user(tr.t(Sentence::ValidationExpired), tr, user)
.render()?,
),
)),
db::user::ValidationResult::UnknownUser => Ok((
jar,
MessageTemplate::new_with_user(
tr.t(Sentence::ValidationErrorTryToSignUpAgain),
tr,
user,
Html(
MessageTemplate::new_with_user(
tr.t(Sentence::ValidationErrorTryToSignUpAgain),
tr,
user,
)
.render()?,
),
)),
}
}
None => Ok((
jar,
MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user),
Html(
MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user)
.render()?,
),
)),
}
}

View file

@ -24,6 +24,7 @@ pub enum Sentence {
NotLoggedIn,
DatabaseError,
TemplateError,
// Sign in page.
SignInMenu,

View file

@ -80,8 +80,8 @@
<input id="input-delete" type="button" value="{{ tr.t(Sentence::RecipeDelete) }}" />
<div id="groups-container">
<div class="dropzone-group"></div>
</div>
<input id="input-add-group" type="button" value="{{ tr.t(Sentence::RecipeAddAGroup) }}" />
<div id="hidden-templates">
@ -97,7 +97,6 @@
<input class="input-group-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveGroup) }}" />
<div class="steps">
<div class="dropzone-step"></div>
</div>
<input class="input-add-step" type="button" value="{{ tr.t(Sentence::RecipeAddAStep) }}" />
@ -131,6 +130,8 @@
<input class="input-ingredient-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveIngredient) }}" />
</div>
<div class="dropzone"></div>
</div>
</div>

View file

@ -17,6 +17,7 @@
(NotLoggedIn, "No logged in"),
(DatabaseError, "Database error"),
(TemplateError, "Template error"),
(SignInMenu, "Sign in"),
(SignInTitle, "Sign in"),
@ -112,7 +113,8 @@
(Save, "Sauvegarder"),
(NotLoggedIn, "Pas connecté"),
(DatabaseError, "Erreur de la base de données"),
(DatabaseError, "Erreur de la base de données (Database error)"),
(TemplateError, "Erreur du moteur de modèles (Template error)"),
(SignInMenu, "Se connecter"),
(SignInTitle, "Se connecter"),

View file

@ -134,6 +134,11 @@ pub struct SetStepAction {
pub action: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct SetStepOrders {
pub step_ids: Vec<i64>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct AddRecipeIngredient {
pub step_id: i64,

View file

@ -50,9 +50,6 @@ gloo = "0.11"
# code size when deploying.
console_error_panic_hook = { version = "0.1", optional = true }
# [dev-dependencies]
# wasm-bindgen-test = "0.3"
[profile.release]
# Tell `rustc` to optimize for small code size.
opt-level = "s"

View file

@ -1,875 +0,0 @@
use gloo::{
console::log,
events::{EventListener, EventListenerOptions},
net::http::Request,
utils::{document, window},
};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::{
DragEvent, Element, HtmlDivElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement,
KeyboardEvent,
};
use common::ron_api;
use crate::{
modal_dialog, request,
toast::{self, Level},
utils::{by_id, selector, selector_all, selector_and_clone, SelectorExt},
};
async fn reload_recipes_list(current_recipe_id: i64) {
match Request::get("/fragments/recipes_list")
.query([("current_recipe_id", current_recipe_id.to_string())])
.send()
.await
{
Err(error) => {
toast::show(Level::Info, &format!("Internal server error: {}", error));
}
Ok(response) => {
let list = document().get_element_by_id("recipes-list").unwrap();
list.set_outer_html(&response.text().await.unwrap());
}
}
}
pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
// Title.
{
let Some(title) = document().get_element_by_id("input-title") else {
return Err(JsValue::from_str("Unable to find 'input-title' element"));
};
let title: HtmlInputElement = title.dyn_into().unwrap();
// Check if the recipe has been loaded.
let mut current_title = title.value();
EventListener::new(&title.clone(), "blur", move |_event| {
if title.value() != current_title {
current_title = title.value();
let body = ron_api::SetRecipeTitle {
recipe_id,
title: title.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_title", body).await;
reload_recipes_list(recipe_id).await;
});
}
})
.forget();
}
// Description.
{
let description: HtmlTextAreaElement = by_id("text-area-description");
let mut current_description = description.value();
EventListener::new(&description.clone(), "blur", move |_event| {
if description.value() != current_description {
current_description = description.value();
let body = ron_api::SetRecipeDescription {
recipe_id,
description: description.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_description", body).await;
});
}
})
.forget();
}
// Servings.
{
let servings: HtmlInputElement = by_id("input-servings");
let mut current_servings = servings.value_as_number();
EventListener::new(&servings.clone(), "input", move |_event| {
let n = servings.value_as_number();
if n.is_nan() {
servings.set_value("");
}
if n != current_servings {
let servings = if n.is_nan() {
None
} else {
// TODO: Find a better way to validate integer numbers.
let n = n as u32;
servings.set_value_as_number(n as f64);
Some(n)
};
current_servings = n;
let body = ron_api::SetRecipeServings {
recipe_id,
servings,
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_servings", body).await;
});
}
})
.forget();
}
// Estimated time.
{
let estimated_time: HtmlInputElement = by_id("input-estimated-time");
let mut current_time = estimated_time.value_as_number();
EventListener::new(&estimated_time.clone(), "input", move |_event| {
let n = estimated_time.value_as_number();
if n.is_nan() {
estimated_time.set_value("");
}
if n != current_time {
let time = if n.is_nan() {
None
} else {
// TODO: Find a better way to validate integer numbers.
let n = n as u32;
estimated_time.set_value_as_number(n as f64);
Some(n)
};
current_time = n;
let body = ron_api::SetRecipeEstimatedTime {
recipe_id,
estimated_time: time,
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_estimated_time", body).await;
});
}
})
.forget();
}
// Difficulty.
{
let difficulty: HtmlSelectElement = by_id("select-difficulty");
let mut current_difficulty = difficulty.value();
EventListener::new(&difficulty.clone(), "blur", move |_event| {
if difficulty.value() != current_difficulty {
current_difficulty = difficulty.value();
let body = ron_api::SetRecipeDifficulty {
recipe_id,
difficulty: ron_api::Difficulty::try_from(
current_difficulty.parse::<u32>().unwrap(),
)
.unwrap(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_difficulty", body).await;
});
}
})
.forget();
}
// Tags.
{
spawn_local(async move {
let tags: ron_api::Tags =
request::get("recipe/get_tags", [("recipe_id", &recipe_id.to_string())])
.await
.unwrap();
create_tag_elements(recipe_id, &tags.tags);
});
fn add_tags(recipe_id: i64, tags: String) {
spawn_local(async move {
let tag_list: Vec<String> = tags.split_whitespace().map(String::from).collect();
if !tag_list.is_empty() {
let body = ron_api::Tags {
recipe_id,
tags: tag_list.clone(),
};
let _ = request::post::<(), _>("recipe/add_tags", body).await;
create_tag_elements(recipe_id, &tag_list);
}
by_id::<HtmlInputElement>("input-tags").set_value("");
});
}
let input_tags: HtmlInputElement = by_id("input-tags");
EventListener::new(&input_tags.clone(), "input", move |_event| {
let tags = input_tags.value();
if tags.ends_with(' ') {
add_tags(recipe_id, tags);
}
})
.forget();
let input_tags: HtmlInputElement = by_id("input-tags");
EventListener::new(&input_tags.clone(), "keypress", move |event| {
if let Some(keyboard_event) = event.dyn_ref::<KeyboardEvent>() {
if keyboard_event.key_code() == 13 {
let tags = input_tags.value();
add_tags(recipe_id, tags);
}
}
})
.forget();
let input_tags: HtmlInputElement = by_id("input-tags");
EventListener::new(&input_tags.clone(), "blur", move |_event| {
let tags = input_tags.value();
add_tags(recipe_id, tags);
})
.forget();
}
// Language.
{
let language: HtmlSelectElement = by_id("select-language");
let mut current_language = language.value();
EventListener::new(&language.clone(), "blur", move |_event| {
if language.value() != current_language {
current_language = language.value();
let body = ron_api::SetRecipeLanguage {
recipe_id,
lang: language.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_language", body).await;
});
}
})
.forget();
}
// Is published.
{
let is_published: HtmlInputElement = by_id("input-is-published");
EventListener::new(&is_published.clone(), "input", move |_event| {
let body = ron_api::SetIsPublished {
recipe_id,
is_published: is_published.checked(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_is_published", body).await;
reload_recipes_list(recipe_id).await;
});
})
.forget();
}
// Delete recipe button.
let delete_button: HtmlInputElement = by_id("input-delete");
EventListener::new(&delete_button, "click", move |_event| {
let title: HtmlInputElement = by_id("input-title");
spawn_local(async move {
if modal_dialog::show(&format!(
"Are you sure to delete the recipe '{}'",
title.value()
))
.await
{
let body = ron_api::Remove { recipe_id };
let _ = request::delete::<(), _>("recipe/remove", body).await;
window().location().set_href("/").unwrap();
// by_id::<Element>(&format!("group-{}", group_id)).remove();
}
});
})
.forget();
let group_dropzone: Element = selector(".dropzone-group");
setup_dragzone_events(&group_dropzone);
fn setup_dragzone_events(dropzone: &Element) {
EventListener::new_with_options(
dropzone,
"dragover",
EventListenerOptions::enable_prevent_default(),
|event| {
let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
let drag_data = event
.data_transfer()
.unwrap()
.get_data("text/plain")
.unwrap();
if drag_data.starts_with("group") {
event.prevent_default();
// event.data_transfer().unwrap().set_effect_allowed("move");
log!("drag over");
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.set_class_name("dropzone-group hover");
}
},
)
.forget();
EventListener::new(dropzone, "dragleave", |event| {
let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
let drag_data = event
.data_transfer()
.unwrap()
.get_data("text/plain")
.unwrap();
if drag_data.starts_with("group") {
log!("drag leave");
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.set_class_name("dropzone-group active");
}
})
.forget();
EventListener::new(dropzone, "drop", |event| {
let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
let drag_data = event
.data_transfer()
.unwrap()
.get_data("text/plain")
.unwrap();
if drag_data.starts_with("group") {
let id: i64 = drag_data[6..].parse().unwrap();
let target: Element = event.target().unwrap().dyn_into().unwrap();
let group: Element = by_id(&format!("group-{}", id));
let group_dropzone: Element = by_id(&format!("dropzone-group-{}", id));
target.after_with_node_1(&group).unwrap();
group.after_with_node_1(&group_dropzone).unwrap();
send_groups_order();
}
})
.forget();
}
fn send_groups_order() {
spawn_local(async move {
let group_ids = by_id::<Element>("groups-container")
.selector_all::<Element>(".group")
.into_iter()
.map(|e| e.get_attribute("id").unwrap()[6..].parse::<i64>().unwrap())
.collect();
let body = ron_api::SetGroupOrders { group_ids };
let _ = request::put::<(), _>("recipe/set_groups_order", body).await;
});
}
fn create_tag_elements<T>(recipe_id: i64, tags: &[T])
where
T: AsRef<str>,
{
let tags_span: Element = selector("#container-tags .tags");
// Collect current tags to avoid re-adding an existing tag.
let mut current_tags: Vec<String> = vec![];
let mut current_tag_element = tags_span.first_child();
while let Some(element) = current_tag_element {
current_tags.push(
element
.dyn_ref::<Element>()
.unwrap()
.text_content()
.unwrap(),
);
current_tag_element = element.next_sibling();
}
for tag in tags {
let tag = tag.as_ref().to_string();
if current_tags.contains(&tag) {
continue;
}
let tag_span = document().create_element("span").unwrap();
tag_span.set_inner_html(&tag);
let delete_tag_button: HtmlInputElement = document()
.create_element("input")
.unwrap()
.dyn_into()
.unwrap();
delete_tag_button.set_attribute("type", "button").unwrap();
delete_tag_button.set_attribute("value", "X").unwrap();
tag_span.append_child(&delete_tag_button).unwrap();
tags_span.append_child(&tag_span).unwrap();
EventListener::new(&delete_tag_button, "click", move |_event| {
let tag_span = tag_span.clone();
let tag = tag.clone();
spawn_local(async move {
let body = ron_api::Tags {
recipe_id,
tags: vec![tag],
};
let _ = request::delete::<(), _>("recipe/rm_tags", body).await;
tag_span.remove();
});
})
.forget();
}
}
fn create_group_element(group: &ron_api::Group) -> Element {
let group_id = group.id;
let group_element: Element = selector_and_clone("#hidden-templates .group");
group_element
.set_attribute("id", &format!("group-{}", group.id))
.unwrap();
let groups_container: Element = by_id("groups-container");
groups_container.append_child(&group_element).unwrap();
let dropzone_group: Element = selector_and_clone(".dropzone-group");
dropzone_group
.set_attribute("id", &format!("dropzone-group-{}", group.id))
.unwrap();
groups_container.append_child(&dropzone_group).unwrap();
setup_dragzone_events(&dropzone_group);
let drag_handle: Element = group_element.selector(".drag-handle");
EventListener::new(&drag_handle, "mousedown", |event| {
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.parent_element()
.unwrap()
.set_attribute("draggable", "true")
.unwrap();
})
.forget();
EventListener::new(&drag_handle, "mouseup", |event| {
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.parent_element()
.unwrap()
.set_attribute("draggable", "false")
.unwrap();
})
.forget();
EventListener::new(&group_element, "dragstart", |event| {
let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
let target_element: Element = event.target().unwrap().dyn_into().unwrap();
if target_element.get_attribute("class").unwrap() == "group" {
// Highlight where the group can be droppped.
for dp in selector_all::<HtmlDivElement>(".dropzone-group") {
dp.set_class_name("dropzone-group active");
}
event
.data_transfer()
.unwrap()
.set_data("text/plain", &target_element.get_attribute("id").unwrap())
.unwrap();
event.data_transfer().unwrap().set_effect_allowed("move");
}
})
.forget();
EventListener::new(&group_element, "dragend", |event| {
// let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.set_attribute("draggable", "false")
.unwrap();
let target_element: Element = event.target().unwrap().dyn_into().unwrap();
if target_element.get_attribute("class").unwrap() == "group" {
for dp in selector_all::<HtmlDivElement>(".dropzone-group") {
dp.set_class_name("dropzone-group");
}
}
})
.forget();
// Group name.
let name = group_element.selector::<HtmlInputElement>(".input-group-name");
name.set_value(&group.name);
let mut current_name = group.name.clone();
EventListener::new(&name.clone(), "blur", move |_event| {
if name.value() != current_name {
current_name = name.value();
let body = ron_api::SetGroupName {
group_id,
name: name.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_group_name", body).await;
})
}
})
.forget();
// Group comment.
let comment: HtmlInputElement = group_element.selector(".input-group-comment");
comment.set_value(&group.comment);
let mut current_comment = group.comment.clone();
EventListener::new(&comment.clone(), "blur", move |_event| {
if comment.value() != current_comment {
current_comment = comment.value();
let body = ron_api::SetGroupComment {
group_id,
comment: comment.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_group_comment", body).await;
});
}
})
.forget();
// Delete button.
let group_element_cloned = group_element.clone();
let delete_button: HtmlInputElement = group_element.selector(".input-group-delete");
EventListener::new(&delete_button, "click", move |_event| {
let name = group_element_cloned
.selector::<HtmlInputElement>(".input-group-name")
.value();
spawn_local(async move {
if modal_dialog::show(&format!("Are you sure to delete the group '{}'", name)).await
{
let body = ron_api::RemoveRecipeGroup { group_id };
let _ = request::delete::<(), _>("recipe/remove_group", body).await;
by_id::<Element>(&format!("group-{}", group_id)).remove();
by_id::<Element>(&format!("dropzone-group-{}", group_id)).remove();
}
});
})
.forget();
// Add step button.
let add_step_button: HtmlInputElement = group_element.selector(".input-add-step");
EventListener::new(&add_step_button, "click", move |_event| {
spawn_local(async move {
let body = ron_api::AddRecipeStep { group_id };
let response: ron_api::AddRecipeStepResult =
request::post("recipe/add_step", body).await.unwrap();
create_step_element(
&selector::<Element>(&format!("#group-{} .steps", group_id)),
&ron_api::Step {
id: response.step_id,
action: "".to_string(),
ingredients: vec![],
},
);
});
})
.forget();
group_element
}
fn create_step_element(group_element: &Element, step: &ron_api::Step) -> Element {
let step_id = step.id;
let step_element: Element = selector_and_clone("#hidden-templates .step");
step_element
.set_attribute("id", &format!("step-{}", step.id))
.unwrap();
group_element.append_child(&step_element).unwrap();
let dropzone_step: Element = selector_and_clone(".dropzone-step");
dropzone_step
.set_attribute("id", &format!("dropzone-step-{}", step.id))
.unwrap();
group_element.append_child(&dropzone_step).unwrap();
let drag_handle: Element = step_element.selector(".drag-handle");
EventListener::new(&drag_handle, "mousedown", |event| {
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.parent_element()
.unwrap()
.set_attribute("draggable", "true")
.unwrap();
})
.forget();
EventListener::new(&drag_handle, "mouseup", |event| {
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.parent_element()
.unwrap()
.set_attribute("draggable", "false")
.unwrap();
})
.forget();
EventListener::new(&step_element, "dragstart", |event| {
let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
// let target_element: Element = event.target().unwrap().dyn_into().unwrap();
// if target_element.get_attribute("class").unwrap() == "step" {
// Highlight where the step can be droppped.
log!("START DRAG STEP");
// log!(event);
// }
})
.forget();
EventListener::new(&step_element, "dragend", |event| {
let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
// let target_element: Element = event.target().unwrap().dyn_into().unwrap();
// if target_element.get_attribute("class").unwrap() == "step" {
// Highlight where the step can be droppped.
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.set_attribute("draggable", "false")
.unwrap();
log!("STOP DRAG STEP");
// log!(event);
// }
})
.forget();
// Step action.
let action: HtmlTextAreaElement = step_element.selector(".text-area-step-action");
action.set_value(&step.action);
let mut current_action = step.action.clone();
EventListener::new(&action.clone(), "blur", move |_event| {
if action.value() != current_action {
current_action = action.value();
let body = ron_api::SetStepAction {
step_id,
action: action.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_step_action", body).await;
});
}
})
.forget();
// Delete button.
let step_element_cloned = step_element.clone();
let delete_button: HtmlInputElement = step_element.selector(".input-step-delete");
EventListener::new(&delete_button, "click", move |_event| {
let action = step_element_cloned
.selector::<HtmlTextAreaElement>(".text-area-step-action")
.value();
spawn_local(async move {
if modal_dialog::show(&format!("Are you sure to delete the step '{}'", action))
.await
{
let body = ron_api::RemoveRecipeStep { step_id };
let _ = request::delete::<(), _>("recipe/remove_step", body).await;
by_id::<Element>(&format!("step-{}", step_id)).remove();
by_id::<Element>(&format!("dropzone-step-{}", step_id)).remove();
}
});
})
.forget();
// Add ingredient button.
let add_ingredient_button: HtmlInputElement =
step_element.selector(".input-add-ingredient");
EventListener::new(&add_ingredient_button, "click", move |_event| {
spawn_local(async move {
let body = ron_api::AddRecipeIngredient { step_id };
let response: ron_api::AddRecipeIngredientResult =
request::post("recipe/add_ingredient", body).await.unwrap();
create_ingredient_element(
&selector::<Element>(&format!("#step-{} .ingredients", step_id)),
&ron_api::Ingredient {
id: response.ingredient_id,
name: "".to_string(),
comment: "".to_string(),
quantity_value: None,
quantity_unit: "".to_string(),
},
);
});
})
.forget();
step_element
}
fn create_ingredient_element(
step_element: &Element,
ingredient: &ron_api::Ingredient,
) -> Element {
let ingredient_id = ingredient.id;
let ingredient_element: Element = selector_and_clone("#hidden-templates .ingredient");
ingredient_element
.set_attribute("id", &format!("ingredient-{}", ingredient.id))
.unwrap();
step_element.append_child(&ingredient_element).unwrap();
// Ingredient name.
let name: HtmlInputElement = ingredient_element.selector(".input-ingredient-name");
name.set_value(&ingredient.name);
let mut current_name = ingredient.name.clone();
EventListener::new(&name.clone(), "blur", move |_event| {
if name.value() != current_name {
current_name = name.value();
let body = ron_api::SetIngredientName {
ingredient_id,
name: name.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_ingredient_name", body).await;
});
}
})
.forget();
// Ingredient comment.
let comment: HtmlInputElement = ingredient_element.selector(".input-ingredient-comment");
comment.set_value(&ingredient.comment);
let mut current_comment = ingredient.comment.clone();
EventListener::new(&comment.clone(), "blur", move |_event| {
if comment.value() != current_comment {
current_comment = comment.value();
let body = ron_api::SetIngredientComment {
ingredient_id,
comment: comment.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_ingredient_comment", body).await;
});
}
})
.forget();
// Ingredient quantity.
let quantity: HtmlInputElement = ingredient_element.selector(".input-ingredient-quantity");
quantity.set_value(
&ingredient
.quantity_value
.map_or("".to_string(), |q| q.to_string()),
);
let mut current_quantity = quantity.value_as_number();
EventListener::new(&quantity.clone(), "input", move |_event| {
let n = quantity.value_as_number();
if n.is_nan() {
quantity.set_value("");
}
if n != current_quantity {
let q = if n.is_nan() { None } else { Some(n) };
current_quantity = n;
let body = ron_api::SetIngredientQuantity {
ingredient_id,
quantity: q,
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_ingredient_quantity", body).await;
});
}
})
.forget();
// Ingredient unit.
let unit: HtmlInputElement = ingredient_element.selector(".input-ingredient-unit");
unit.set_value(&ingredient.quantity_unit);
let mut current_unit = ingredient.quantity_unit.clone();
EventListener::new(&unit.clone(), "blur", move |_event| {
if unit.value() != current_unit {
current_unit = unit.value();
let body = ron_api::SetIngredientUnit {
ingredient_id,
unit: unit.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_ingredient_unit", body).await;
});
}
})
.forget();
// Delete button.
let ingredient_element_cloned = ingredient_element.clone();
let delete_button: HtmlInputElement =
ingredient_element.selector(".input-ingredient-delete");
EventListener::new(&delete_button, "click", move |_event| {
let name = ingredient_element_cloned
.selector::<HtmlInputElement>(".input-ingredient-name")
.value();
spawn_local(async move {
if modal_dialog::show(&format!("Are you sure to delete the ingredient '{}'", name))
.await
{
let body = ron_api::RemoveRecipeIngredient { ingredient_id };
let _ = request::delete::<(), _>("recipe/remove_ingredient", body).await;
by_id::<Element>(&format!("ingredient-{}", ingredient_id)).remove();
}
});
})
.forget();
ingredient_element
}
// Load initial groups, steps and ingredients.
{
spawn_local(async move {
let groups: Vec<common::ron_api::Group> =
request::get("recipe/get_groups", [("recipe_id", &recipe_id.to_string())])
.await
.unwrap();
for group in groups {
let group_element = create_group_element(&group);
for step in group.steps {
let step_element =
create_step_element(&group_element.selector(".steps"), &step);
for ingredient in step.ingredients {
create_ingredient_element(
&step_element.selector(".ingredients"),
&ingredient,
);
}
}
}
});
}
// Add a new group.
{
let button_add_group: HtmlInputElement = by_id("input-add-group");
let on_click_add_group = EventListener::new(&button_add_group, "click", move |_event| {
let body = ron_api::AddRecipeGroup { recipe_id };
spawn_local(async move {
let response: ron_api::AddRecipeGroupResult =
request::post("recipe/add_group", body).await.unwrap();
create_group_element(&ron_api::Group {
id: response.group_id,
name: "".to_string(),
comment: "".to_string(),
steps: vec![],
});
});
});
on_click_add_group.forget();
}
Ok(())
}

View file

@ -1,6 +1,6 @@
mod handles;
mod modal_dialog;
mod on_click;
mod recipe_edit;
mod request;
mod toast;
mod utils;
@ -22,7 +22,7 @@ pub fn main() -> Result<(), JsValue> {
if let ["recipe", "edit", id] = path[..] {
let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
if let Err(error) = handles::recipe_edit(id) {
if let Err(error) = recipe_edit::setup_page(id) {
log!(error);
}

849
frontend/src/recipe_edit.rs Normal file
View file

@ -0,0 +1,849 @@
use std::rc;
use gloo::{
console::log,
events::{EventListener, EventListenerOptions},
net::http::Request,
utils::{document, window},
};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::{
DragEvent, Element, HtmlDivElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement,
KeyboardEvent,
};
use common::ron_api;
use crate::{
modal_dialog, request,
toast::{self, Level},
utils::{by_id, selector, selector_all, selector_and_clone, SelectorExt},
};
async fn reload_recipes_list(current_recipe_id: i64) {
match Request::get("/fragments/recipes_list")
.query([("current_recipe_id", current_recipe_id.to_string())])
.send()
.await
{
Err(error) => {
toast::show(Level::Info, &format!("Internal server error: {}", error));
}
Ok(response) => {
let list = document().get_element_by_id("recipes-list").unwrap();
list.set_outer_html(&response.text().await.unwrap());
}
}
}
pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
// Title.
{
let Some(title) = document().get_element_by_id("input-title") else {
return Err(JsValue::from_str("Unable to find 'input-title' element"));
};
let title: HtmlInputElement = title.dyn_into().unwrap();
// Check if the recipe has been loaded.
let mut current_title = title.value();
EventListener::new(&title.clone(), "blur", move |_event| {
if title.value() != current_title {
current_title = title.value();
let body = ron_api::SetRecipeTitle {
recipe_id,
title: title.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_title", body).await;
reload_recipes_list(recipe_id).await;
});
}
})
.forget();
}
// Description.
{
let description: HtmlTextAreaElement = by_id("text-area-description");
let mut current_description = description.value();
EventListener::new(&description.clone(), "blur", move |_event| {
if description.value() != current_description {
current_description = description.value();
let body = ron_api::SetRecipeDescription {
recipe_id,
description: description.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_description", body).await;
});
}
})
.forget();
}
// Servings.
{
let servings: HtmlInputElement = by_id("input-servings");
let mut current_servings = servings.value_as_number();
EventListener::new(&servings.clone(), "input", move |_event| {
let n = servings.value_as_number();
if n.is_nan() {
servings.set_value("");
}
if n != current_servings {
let servings = if n.is_nan() {
None
} else {
// TODO: Find a better way to validate integer numbers.
let n = n as u32;
servings.set_value_as_number(n as f64);
Some(n)
};
current_servings = n;
let body = ron_api::SetRecipeServings {
recipe_id,
servings,
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_servings", body).await;
});
}
})
.forget();
}
// Estimated time.
{
let estimated_time: HtmlInputElement = by_id("input-estimated-time");
let mut current_time = estimated_time.value_as_number();
EventListener::new(&estimated_time.clone(), "input", move |_event| {
let n = estimated_time.value_as_number();
if n.is_nan() {
estimated_time.set_value("");
}
if n != current_time {
let time = if n.is_nan() {
None
} else {
// TODO: Find a better way to validate integer numbers.
let n = n as u32;
estimated_time.set_value_as_number(n as f64);
Some(n)
};
current_time = n;
let body = ron_api::SetRecipeEstimatedTime {
recipe_id,
estimated_time: time,
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_estimated_time", body).await;
});
}
})
.forget();
}
// Difficulty.
{
let difficulty: HtmlSelectElement = by_id("select-difficulty");
let mut current_difficulty = difficulty.value();
EventListener::new(&difficulty.clone(), "blur", move |_event| {
if difficulty.value() != current_difficulty {
current_difficulty = difficulty.value();
let body = ron_api::SetRecipeDifficulty {
recipe_id,
difficulty: ron_api::Difficulty::try_from(
current_difficulty.parse::<u32>().unwrap(),
)
.unwrap(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_difficulty", body).await;
});
}
})
.forget();
}
// Tags.
{
spawn_local(async move {
let tags: ron_api::Tags =
request::get("recipe/get_tags", [("recipe_id", &recipe_id.to_string())])
.await
.unwrap();
create_tag_elements(recipe_id, &tags.tags);
});
fn add_tags(recipe_id: i64, tags: String) {
spawn_local(async move {
let tag_list: Vec<String> = tags.split_whitespace().map(String::from).collect();
if !tag_list.is_empty() {
let body = ron_api::Tags {
recipe_id,
tags: tag_list.clone(),
};
let _ = request::post::<(), _>("recipe/add_tags", body).await;
create_tag_elements(recipe_id, &tag_list);
}
by_id::<HtmlInputElement>("input-tags").set_value("");
});
}
let input_tags: HtmlInputElement = by_id("input-tags");
EventListener::new(&input_tags.clone(), "input", move |_event| {
let tags = input_tags.value();
if tags.ends_with(' ') {
add_tags(recipe_id, tags);
}
})
.forget();
let input_tags: HtmlInputElement = by_id("input-tags");
EventListener::new(&input_tags.clone(), "keypress", move |event| {
if let Some(keyboard_event) = event.dyn_ref::<KeyboardEvent>() {
if keyboard_event.key_code() == 13 {
let tags = input_tags.value();
add_tags(recipe_id, tags);
}
}
})
.forget();
let input_tags: HtmlInputElement = by_id("input-tags");
EventListener::new(&input_tags.clone(), "blur", move |_event| {
let tags = input_tags.value();
add_tags(recipe_id, tags);
})
.forget();
}
// Language.
{
let language: HtmlSelectElement = by_id("select-language");
let mut current_language = language.value();
EventListener::new(&language.clone(), "blur", move |_event| {
if language.value() != current_language {
current_language = language.value();
let body = ron_api::SetRecipeLanguage {
recipe_id,
lang: language.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_language", body).await;
});
}
})
.forget();
}
// Is published.
{
let is_published: HtmlInputElement = by_id("input-is-published");
EventListener::new(&is_published.clone(), "input", move |_event| {
let body = ron_api::SetIsPublished {
recipe_id,
is_published: is_published.checked(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_is_published", body).await;
reload_recipes_list(recipe_id).await;
});
})
.forget();
}
// Delete recipe button.
let delete_button: HtmlInputElement = by_id("input-delete");
EventListener::new(&delete_button, "click", move |_event| {
let title: HtmlInputElement = by_id("input-title");
spawn_local(async move {
if modal_dialog::show(&format!(
"Are you sure to delete the recipe '{}'",
title.value()
))
.await
{
let body = ron_api::Remove { recipe_id };
let _ = request::delete::<(), _>("recipe/remove", body).await;
window().location().set_href("/").unwrap();
// by_id::<Element>(&format!("group-{}", group_id)).remove();
}
});
})
.forget();
// let group_dropzone: Element = selector(".dropzone-group");
// setup_dragzone_events(&group_dropzone);
// Load initial groups, steps and ingredients.
{
spawn_local(async move {
let groups: Vec<common::ron_api::Group> =
request::get("recipe/get_groups", [("recipe_id", &recipe_id.to_string())])
.await
.unwrap();
for group in groups {
let group_element = create_group_element(&group);
for step in group.steps {
let step_element =
create_step_element(&group_element.selector(".steps"), &step);
for ingredient in step.ingredients {
create_ingredient_element(
&step_element.selector(".ingredients"),
&ingredient,
);
}
}
}
});
}
// Add a new group.
{
let button_add_group: HtmlInputElement = by_id("input-add-group");
let on_click_add_group = EventListener::new(&button_add_group, "click", move |_event| {
let body = ron_api::AddRecipeGroup { recipe_id };
spawn_local(async move {
let response: ron_api::AddRecipeGroupResult =
request::post("recipe/add_group", body).await.unwrap();
create_group_element(&ron_api::Group {
id: response.group_id,
name: "".to_string(),
comment: "".to_string(),
steps: vec![],
});
});
});
on_click_add_group.forget();
}
Ok(())
}
fn create_group_element(group: &ron_api::Group) -> Element {
let group_id = group.id;
let group_element: Element = selector_and_clone("#hidden-templates .group");
group_element
.set_attribute("id", &format!("group-{}", group.id))
.unwrap();
let groups_container: Element = by_id("groups-container");
groups_container.append_child(&group_element).unwrap();
set_draggable(&group_element, "group", |_element| {
spawn_local(async move {
let group_ids = by_id::<Element>("groups-container")
.selector_all::<Element>(".group")
.into_iter()
.map(|e| e.get_attribute("id").unwrap()[6..].parse::<i64>().unwrap())
.collect();
let body = ron_api::SetGroupOrders { group_ids };
let _ = request::put::<(), _>("recipe/set_groups_order", body).await;
});
});
// Group name.
let name = group_element.selector::<HtmlInputElement>(".input-group-name");
name.set_value(&group.name);
let mut current_name = group.name.clone();
EventListener::new(&name.clone(), "blur", move |_event| {
if name.value() != current_name {
current_name = name.value();
let body = ron_api::SetGroupName {
group_id,
name: name.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_group_name", body).await;
})
}
})
.forget();
// Group comment.
let comment: HtmlInputElement = group_element.selector(".input-group-comment");
comment.set_value(&group.comment);
let mut current_comment = group.comment.clone();
EventListener::new(&comment.clone(), "blur", move |_event| {
if comment.value() != current_comment {
current_comment = comment.value();
let body = ron_api::SetGroupComment {
group_id,
comment: comment.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_group_comment", body).await;
});
}
})
.forget();
// Delete button.
let group_element_cloned = group_element.clone();
let delete_button: HtmlInputElement = group_element.selector(".input-group-delete");
EventListener::new(&delete_button, "click", move |_event| {
let name = group_element_cloned
.selector::<HtmlInputElement>(".input-group-name")
.value();
spawn_local(async move {
if modal_dialog::show(&format!("Are you sure to delete the group '{}'", name)).await {
let body = ron_api::RemoveRecipeGroup { group_id };
let _ = request::delete::<(), _>("recipe/remove_group", body).await;
by_id::<Element>(&format!("group-{}", group_id)).remove();
by_id::<Element>(&format!("dropzone-group-{}", group_id)).remove();
}
});
})
.forget();
// Add step button.
let add_step_button: HtmlInputElement = group_element.selector(".input-add-step");
EventListener::new(&add_step_button, "click", move |_event| {
spawn_local(async move {
let body = ron_api::AddRecipeStep { group_id };
let response: ron_api::AddRecipeStepResult =
request::post("recipe/add_step", body).await.unwrap();
create_step_element(
&selector::<Element>(&format!("#group-{} .steps", group_id)),
&ron_api::Step {
id: response.step_id,
action: "".to_string(),
ingredients: vec![],
},
);
});
})
.forget();
group_element
}
fn create_tag_elements<T>(recipe_id: i64, tags: &[T])
where
T: AsRef<str>,
{
let tags_span: Element = selector("#container-tags .tags");
// Collect current tags to avoid re-adding an existing tag.
let mut current_tags: Vec<String> = vec![];
let mut current_tag_element = tags_span.first_child();
while let Some(element) = current_tag_element {
current_tags.push(
element
.dyn_ref::<Element>()
.unwrap()
.text_content()
.unwrap(),
);
current_tag_element = element.next_sibling();
}
for tag in tags {
let tag = tag.as_ref().to_string();
if current_tags.contains(&tag) {
continue;
}
let tag_span = document().create_element("span").unwrap();
tag_span.set_inner_html(&tag);
let delete_tag_button: HtmlInputElement = document()
.create_element("input")
.unwrap()
.dyn_into()
.unwrap();
delete_tag_button.set_attribute("type", "button").unwrap();
delete_tag_button.set_attribute("value", "X").unwrap();
tag_span.append_child(&delete_tag_button).unwrap();
tags_span.append_child(&tag_span).unwrap();
EventListener::new(&delete_tag_button, "click", move |_event| {
let tag_span = tag_span.clone();
let tag = tag.clone();
spawn_local(async move {
let body = ron_api::Tags {
recipe_id,
tags: vec![tag],
};
let _ = request::delete::<(), _>("recipe/rm_tags", body).await;
tag_span.remove();
});
})
.forget();
}
}
fn create_step_element(group_element: &Element, step: &ron_api::Step) -> Element {
let step_id = step.id;
let step_element: Element = selector_and_clone("#hidden-templates .step");
step_element
.set_attribute("id", &format!("step-{}", step.id))
.unwrap();
group_element.append_child(&step_element).unwrap();
// Step action.
let action: HtmlTextAreaElement = step_element.selector(".text-area-step-action");
action.set_value(&step.action);
let mut current_action = step.action.clone();
EventListener::new(&action.clone(), "blur", move |_event| {
if action.value() != current_action {
current_action = action.value();
let body = ron_api::SetStepAction {
step_id,
action: action.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_step_action", body).await;
});
}
})
.forget();
// Delete button.
let step_element_cloned = step_element.clone();
let delete_button: HtmlInputElement = step_element.selector(".input-step-delete");
EventListener::new(&delete_button, "click", move |_event| {
let action = step_element_cloned
.selector::<HtmlTextAreaElement>(".text-area-step-action")
.value();
spawn_local(async move {
if modal_dialog::show(&format!("Are you sure to delete the step '{}'", action)).await {
let body = ron_api::RemoveRecipeStep { step_id };
let _ = request::delete::<(), _>("recipe/remove_step", body).await;
by_id::<Element>(&format!("step-{}", step_id)).remove();
by_id::<Element>(&format!("dropzone-step-{}", step_id)).remove();
}
});
})
.forget();
// Add ingredient button.
let add_ingredient_button: HtmlInputElement = step_element.selector(".input-add-ingredient");
EventListener::new(&add_ingredient_button, "click", move |_event| {
spawn_local(async move {
let body = ron_api::AddRecipeIngredient { step_id };
let response: ron_api::AddRecipeIngredientResult =
request::post("recipe/add_ingredient", body).await.unwrap();
create_ingredient_element(
&selector::<Element>(&format!("#step-{} .ingredients", step_id)),
&ron_api::Ingredient {
id: response.ingredient_id,
name: "".to_string(),
comment: "".to_string(),
quantity_value: None,
quantity_unit: "".to_string(),
},
);
});
})
.forget();
step_element
}
fn create_ingredient_element(step_element: &Element, ingredient: &ron_api::Ingredient) -> Element {
let ingredient_id = ingredient.id;
let ingredient_element: Element = selector_and_clone("#hidden-templates .ingredient");
ingredient_element
.set_attribute("id", &format!("ingredient-{}", ingredient.id))
.unwrap();
step_element.append_child(&ingredient_element).unwrap();
// Ingredient name.
let name: HtmlInputElement = ingredient_element.selector(".input-ingredient-name");
name.set_value(&ingredient.name);
let mut current_name = ingredient.name.clone();
EventListener::new(&name.clone(), "blur", move |_event| {
if name.value() != current_name {
current_name = name.value();
let body = ron_api::SetIngredientName {
ingredient_id,
name: name.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_ingredient_name", body).await;
});
}
})
.forget();
// Ingredient comment.
let comment: HtmlInputElement = ingredient_element.selector(".input-ingredient-comment");
comment.set_value(&ingredient.comment);
let mut current_comment = ingredient.comment.clone();
EventListener::new(&comment.clone(), "blur", move |_event| {
if comment.value() != current_comment {
current_comment = comment.value();
let body = ron_api::SetIngredientComment {
ingredient_id,
comment: comment.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_ingredient_comment", body).await;
});
}
})
.forget();
// Ingredient quantity.
let quantity: HtmlInputElement = ingredient_element.selector(".input-ingredient-quantity");
quantity.set_value(
&ingredient
.quantity_value
.map_or("".to_string(), |q| q.to_string()),
);
let mut current_quantity = quantity.value_as_number();
EventListener::new(&quantity.clone(), "input", move |_event| {
let n = quantity.value_as_number();
if n.is_nan() {
quantity.set_value("");
}
if n != current_quantity {
let q = if n.is_nan() { None } else { Some(n) };
current_quantity = n;
let body = ron_api::SetIngredientQuantity {
ingredient_id,
quantity: q,
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_ingredient_quantity", body).await;
});
}
})
.forget();
// Ingredient unit.
let unit: HtmlInputElement = ingredient_element.selector(".input-ingredient-unit");
unit.set_value(&ingredient.quantity_unit);
let mut current_unit = ingredient.quantity_unit.clone();
EventListener::new(&unit.clone(), "blur", move |_event| {
if unit.value() != current_unit {
current_unit = unit.value();
let body = ron_api::SetIngredientUnit {
ingredient_id,
unit: unit.value(),
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_ingredient_unit", body).await;
});
}
})
.forget();
// Delete button.
let ingredient_element_cloned = ingredient_element.clone();
let delete_button: HtmlInputElement = ingredient_element.selector(".input-ingredient-delete");
EventListener::new(&delete_button, "click", move |_event| {
let name = ingredient_element_cloned
.selector::<HtmlInputElement>(".input-ingredient-name")
.value();
spawn_local(async move {
if modal_dialog::show(&format!("Are you sure to delete the ingredient '{}'", name))
.await
{
let body = ron_api::RemoveRecipeIngredient { ingredient_id };
let _ = request::delete::<(), _>("recipe/remove_ingredient", body).await;
by_id::<Element>(&format!("ingredient-{}", ingredient_id)).remove();
}
});
})
.forget();
ingredient_element
}
/// Set an element as draggable and add an element before and after
/// cloned from "#hidden-templates .dropzone".
/// All elements set as draggable in a given container can be dragged
/// After or before another element.
/// 'element' must have a sub-element with the class '.drag-handle' which
/// will be used to drag the element.
fn set_draggable<T>(element: &Element, prefix: &str, dropped: T)
where
T: Fn(&Element) + 'static,
{
let dropped = rc::Rc::new(dropped);
// Add a drop zone before the given element if there is none.
if element.previous_element_sibling().is_none() {
let dropzone = selector_and_clone::<Element>("#hidden-templates .dropzone");
element.before_with_node_1(&dropzone).unwrap();
setup_dragzone_events(&dropzone, prefix, dropped.clone());
}
let dropzone = selector_and_clone::<Element>("#hidden-templates .dropzone");
element.after_with_node_1(&dropzone).unwrap();
setup_dragzone_events(&dropzone, prefix, dropped.clone());
let drag_handle: Element = element.selector(".drag-handle");
EventListener::new(&drag_handle, "mousedown", |event| {
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.parent_element()
.unwrap()
.set_attribute("draggable", "true")
.unwrap();
})
.forget();
EventListener::new(&drag_handle, "mouseup", |event| {
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.parent_element()
.unwrap()
.set_attribute("draggable", "false")
.unwrap();
})
.forget();
let prefix_copied = prefix.to_string();
EventListener::new(element, "dragstart", move |event| {
let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
let target_element: Element = event.target().unwrap().dyn_into().unwrap();
if target_element
.get_attribute("id")
.unwrap()
.starts_with(&prefix_copied)
{
// Highlight where the group can be droppped.
// TODO: only select direct children.
for dp in target_element
.parent_element()
.unwrap()
.selector_all::<HtmlDivElement>(".dropzone")
{
dp.set_class_name("dropzone active");
}
event
.data_transfer()
.unwrap()
.set_data("text/plain", &target_element.get_attribute("id").unwrap())
.unwrap();
event.data_transfer().unwrap().set_effect_allowed("move");
}
})
.forget();
let prefix_copied = prefix.to_string();
EventListener::new(element, "dragend", move |event| {
// let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.set_attribute("draggable", "false")
.unwrap();
let target_element: Element = event.target().unwrap().dyn_into().unwrap();
if target_element
.get_attribute("id")
.unwrap()
.starts_with(&prefix_copied)
{
for dp in target_element
.parent_element()
.unwrap()
.selector_all::<HtmlDivElement>(".dropzone")
{
dp.set_class_name("dropzone");
}
}
})
.forget();
}
fn setup_dragzone_events<T>(dropzone: &Element, prefix: &str, dropped: rc::Rc<T>)
where
T: Fn(&Element) + 'static,
{
let prefix_copied = prefix.to_string();
EventListener::new_with_options(
dropzone,
"dragover",
EventListenerOptions::enable_prevent_default(),
move |event| {
let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
let drag_data = event
.data_transfer()
.unwrap()
.get_data("text/plain")
.unwrap();
if drag_data.starts_with(&prefix_copied) {
event.prevent_default();
// event.data_transfer().unwrap().set_effect_allowed("move");
// log!("drag over");
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.set_class_name("dropzone hover");
}
},
)
.forget();
let prefix_copied = prefix.to_string();
EventListener::new(dropzone, "dragleave", move |event| {
let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
let drag_data = event
.data_transfer()
.unwrap()
.get_data("text/plain")
.unwrap();
if drag_data.starts_with(&prefix_copied) {
// log!("drag leave");
event
.target()
.unwrap()
.dyn_into::<Element>()
.unwrap()
.set_class_name("dropzone active");
}
})
.forget();
let prefix_copied = prefix.to_string();
EventListener::new(dropzone, "drop", move |event| {
let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
let drag_data = event
.data_transfer()
.unwrap()
.get_data("text/plain")
.unwrap();
if drag_data.starts_with(&prefix_copied) {
let id: i64 = drag_data[prefix_copied.len() + 1..].parse().unwrap();
let target: Element = event.target().unwrap().dyn_into().unwrap();
let element: Element = by_id(&format!("{}-{}", &prefix_copied, id));
let group_dropzone: Element = element.next_element_sibling().unwrap(); // = by_id(&format!("dropzone-group-{}", id));
target.after_with_node_1(&element).unwrap();
element.after_with_node_1(&group_dropzone).unwrap();
dropped(&element);
}
})
.forget();
}