Recipe edit (WIP): forms to edit groups, steps and ingredients

This commit is contained in:
Greg Burri 2024-12-26 01:39:07 +01:00
parent dd05a673d9
commit 07b7ff425e
25 changed files with 881 additions and 203 deletions

7
Cargo.lock generated
View file

@ -793,6 +793,9 @@ dependencies = [
"common", "common",
"console_error_panic_hook", "console_error_panic_hook",
"gloo", "gloo",
"ron",
"serde",
"thiserror 2.0.9",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
@ -2976,9 +2979,9 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]] [[package]]
name = "unicase" name = "unicase"
version = "2.8.0" version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"

View file

@ -35,7 +35,7 @@ fn main() {
} }
let output = if exists_in_path("sass.bat") { let output = if exists_in_path("sass.bat") {
run_sass(Command::new("cmd").args(&["/C", "sass.bat"])) run_sass(Command::new("cmd").args(["/C", "sass.bat"]))
} else { } else {
run_sass(&mut Command::new("sass")) run_sass(&mut Command::new("sass"))
}; };

View file

@ -93,6 +93,22 @@ body {
h1 { h1 {
text-align: center; text-align: center;
} }
.group {
border: 0.1em solid white;
}
.step {
border: 0.1em solid white;
}
.ingredient {
border: 0.1em solid white;
}
#hidden-templates {
display: none;
}
} }
form { form {

View file

@ -18,14 +18,61 @@ VALUES (
NULL NULL
); );
INSERT INTO [Recipe] ([user_id], [title], [is_published]) INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
VALUES (1, 'Croissant au jambon', true); VALUES (1, 1, 'Croissant au jambon', true);
INSERT INTO [Recipe] ([user_id], [title], [is_published]) INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
VALUES (1, 'Gratin de thon aux olives', true); VALUES (2, 1, 'Gratin de thon aux olives', true);
INSERT INTO [Recipe] ([user_id], [title], [is_published]) INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
VALUES (1, 'Saumon en croute', true); VALUES (3, 1, 'Saumon en croute', true);
INSERT INTO [Recipe] ([user_id], [title], [is_published]) INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
VALUES (2, 'Ouiche lorraine', true); VALUES (4, 2, 'Ouiche lorraine', true);
-- Groups, steps and ingredients for 'Gratin de thon'.
INSERT INTO [Group] ([id], [order], [recipe_id], [name], [comment])
VALUES (1, 1, 2, "Fond du gratin", "");
INSERT INTO [Group] ([id], [order], [recipe_id], [name], [comment])
VALUES (2, 2, 2, "Sauce", "");
INSERT INTO [Step] ([id], [order], [group_id], [action])
VALUES (1, 1, 1, "Égoutter et émietter dans un plat à gratting graissé");
INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (1, 1, "Thon en boîte", "", 240, "g");
INSERT INTO [Step] ([id], [order], [group_id], [action])
VALUES (2, 2, 1, "Saupoudrer");
INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (2, 2, "Sel à l'origan", "", 1, "c-à-c");
INSERT INTO [Step] ([id], [order], [group_id], [action])
VALUES (3, 3, 2, "Mélanger au fouet et verser sur le thon dans le plat");
INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (3, 3, "Concentré de tomate", "", 4, "c-à-s");
INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (4, 3, "Poivre", "", 0.25, "c-à-c");
INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (5, 3, "Herbe de Provence", "", 0.5, "c-à-c");
INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (6, 3, "Crème à café ou demi-crème", "", 2, "dl");
INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (7, 3, "Olives farcies coupées en deuxs", "", 50, "g");
INSERT INTO [Group] ([id], [order], [recipe_id], [name], [comment])
VALUES (3, 3, 2,
"15 à 20 minutes de cuisson au four à 220 °C",
"Servir avec du riz ou des patates robe des champs");

View file

@ -89,21 +89,12 @@ CREATE TABLE [Tag] (
CREATE UNIQUE INDEX [Tag_name_lang_index] ON [Tag] ([name], [lang]); CREATE UNIQUE INDEX [Tag_name_lang_index] ON [Tag] ([name], [lang]);
CREATE TABLE [Ingredient] (
[id] INTEGER PRIMARY KEY,
[name] TEXT NOT NULL,
[comment] TEXT NOT NULL DEFAULT '',
[quantity_value] INTEGER,
[quantity_unit] TEXT NOT NULL DEFAULT '',
[input_step_id] INTEGER NOT NULL,
FOREIGN KEY([input_step_id]) REFERENCES [Step]([id]) ON DELETE CASCADE
) STRICT;
CREATE TABLE [Group] ( CREATE TABLE [Group] (
[id] INTEGER PRIMARY KEY, [id] INTEGER PRIMARY KEY,
[order] INTEGER NOT NULL DEFAULT 0, [order] INTEGER NOT NULL DEFAULT 0,
[recipe_id] INTEGER NOT NULL, [recipe_id] INTEGER NOT NULL,
[name] TEXT NOT NULL DEFAULT '', [name] TEXT NOT NULL DEFAULT '',
[comment] TEXT NOT NULL DEFAULT '', [comment] TEXT NOT NULL DEFAULT '',
@ -114,15 +105,30 @@ CREATE INDEX [Group_order_index] ON [Group]([order]);
CREATE TABLE [Step] ( CREATE TABLE [Step] (
[id] INTEGER PRIMARY KEY, [id] INTEGER PRIMARY KEY,
[order] INTEGER NOT NULL DEFAULT 0, [order] INTEGER NOT NULL DEFAULT 0,
[action] TEXT NOT NULL DEFAULT '',
[group_id] INTEGER NOT NULL, [group_id] INTEGER NOT NULL,
[action] TEXT NOT NULL DEFAULT '',
FOREIGN KEY(group_id) REFERENCES [Group](id) ON DELETE CASCADE FOREIGN KEY(group_id) REFERENCES [Group](id) ON DELETE CASCADE
) STRICT; ) STRICT;
CREATE INDEX [Step_order_index] ON [Group]([order]); CREATE INDEX [Step_order_index] ON [Group]([order]);
CREATE TABLE [Ingredient] (
[id] INTEGER PRIMARY KEY,
[step_id] INTEGER NOT NULL,
[name] TEXT NOT NULL,
[comment] TEXT NOT NULL DEFAULT '',
[quantity_value] REAL,
[quantity_unit] TEXT NOT NULL DEFAULT '',
FOREIGN KEY([step_id]) REFERENCES [Step]([id]) ON DELETE CASCADE
) STRICT;
-- CREATE TABLE [IntermediateSubstance] ( -- CREATE TABLE [IntermediateSubstance] (
-- [id] INTEGER PRIMARY KEY, -- [id] INTEGER PRIMARY KEY,
-- [name] TEXT NOT NULL DEFAULT '', -- [name] TEXT NOT NULL DEFAULT '',

View file

@ -41,22 +41,31 @@ impl fmt::Debug for Config {
pub fn load() -> Config { pub fn load() -> Config {
match File::open(consts::FILE_CONF) { match File::open(consts::FILE_CONF) {
Ok(file) => from_reader(file).expect(&format!( Ok(file) => from_reader(file).unwrap_or_else(|error| {
"Failed to open configuration file {}", panic!(
consts::FILE_CONF "Failed to open configuration file {}: {}",
)), consts::FILE_CONF,
error
)
}),
Err(_) => { Err(_) => {
let file = File::create(consts::FILE_CONF).expect(&format!( let file = File::create(consts::FILE_CONF).unwrap_or_else(|error| {
"Failed to create default configuration file {}", panic!(
consts::FILE_CONF "Failed to create default configuration file {}: {}",
)); consts::FILE_CONF,
error
)
});
let default_config = Config::default(); let default_config = Config::default();
to_writer_pretty(file, &default_config, PrettyConfig::new()).expect(&format!( to_writer_pretty(file, &default_config, PrettyConfig::new()).unwrap_or_else(|error| {
"Failed to write default configuration file {}", panic!(
consts::FILE_CONF "Failed to write default configuration file {}: {}",
)); consts::FILE_CONF,
error
)
});
default_config default_config
} }

View file

@ -4,10 +4,10 @@ pub const FILE_CONF: &str = "conf.ron";
pub const DB_DIRECTORY: &str = "data"; pub const DB_DIRECTORY: &str = "data";
pub const DB_FILENAME: &str = "recipes.sqlite"; pub const DB_FILENAME: &str = "recipes.sqlite";
pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql"; pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql";
pub const VALIDATION_TOKEN_DURATION: i64 = 1 * 60 * 60; // [s]. (1 jour). pub const VALIDATION_TOKEN_DURATION: i64 = 60 * 60; // [s]. (1 jour).
pub const COOKIE_AUTH_TOKEN_NAME: &str = "auth_token"; pub const COOKIE_AUTH_TOKEN_NAME: &str = "auth_token";
pub const VALIDATION_PASSWORD_RESET_TOKEN_DURATION: i64 = 1 * 60 * 60; // [s]. (1 jour). pub const VALIDATION_PASSWORD_RESET_TOKEN_DURATION: i64 = 60 * 60; // [s]. (1 jour).
// Number of alphanumeric characters for tokens // Number of alphanumeric characters for tokens
// (cookie authentication, password reset, validation token). // (cookie authentication, password reset, validation token).
@ -21,4 +21,4 @@ pub const REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; // Set by the reverse
pub const MAX_DB_CONNECTION: u32 = 1024; pub const MAX_DB_CONNECTION: u32 = 1024;
pub const LANGUAGES: [(&'static str, &'static str); 2] = [("Français", "fr"), ("English", "en")]; pub const LANGUAGES: [(&str, &str); 2] = [("Français", "fr"), ("English", "en")];

View file

@ -196,26 +196,10 @@ WHERE [type] = 'table' AND [name] = 'Version'
} }
fn load_sql_file<P: AsRef<Path> + fmt::Display>(sql_file: P) -> Result<String> { fn load_sql_file<P: AsRef<Path> + fmt::Display>(sql_file: P) -> Result<String> {
let mut file = File::open(&sql_file).map_err(|err| { let mut file = File::open(&sql_file)
DBError::Other(format!( .map_err(|err| DBError::Other(format!("Cannot open SQL file ({}): {}", &sql_file, err)))?;
"Cannot open SQL file ({}): {}",
&sql_file,
err.to_string()
))
})?;
let mut sql = String::new(); let mut sql = String::new();
file.read_to_string(&mut sql).map_err(|err| { file.read_to_string(&mut sql)
DBError::Other(format!( .map_err(|err| DBError::Other(format!("Cannot read SQL file ({}) : {}", &sql_file, err)))?;
"Cannot read SQL file ({}) : {}",
&sql_file,
err.to_string()
))
})?;
Ok(sql) Ok(sql)
} }
// #[cfg(test)]
// mod tests {
// use super::*;
// }

View file

@ -45,6 +45,21 @@ ORDER BY [title]
.map_err(DBError::from) .map_err(DBError::from)
} }
pub async fn can_edit_recipe_group(&self, user_id: i64, group_id: i64) -> Result<bool> {
sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM [Recipe] INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
WHERE [Group].[id] = $1 AND [user_id] = $2
"#,
)
.bind(group_id)
.bind(user_id)
.fetch_one(&self.pool)
.await
.map_err(DBError::from)
}
pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> { pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
sqlx::query_as( sqlx::query_as(
r#" r#"
@ -166,6 +181,88 @@ WHERE [Recipe].[user_id] = $1
.map(|_| ()) .map(|_| ())
.map_err(DBError::from) .map_err(DBError::from)
} }
pub async fn get_groups(&self, recipe_id: i64) -> Result<Vec<model::Group>> {
let mut tx = self.tx().await?;
let mut groups: Vec<model::Group> = sqlx::query_as(
r#"
SELECT [id], [name], [comment]
FROM [Group]
WHERE [recipe_id] = $1
ORDER BY [order]
"#,
)
.bind(recipe_id)
.fetch_all(&mut *tx)
.await?;
for group in groups.iter_mut() {
group.steps = sqlx::query_as(
r#"
SELECT [id], [action]
FROM [Step]
WHERE [group_id] = $1
ORDER BY [order]
"#,
)
.bind(group.id)
.fetch_all(&mut *tx)
.await?;
for step in group.steps.iter_mut() {
step.ingredients = sqlx::query_as(
r#"
SELECT [id], [name], [comment], [quantity_value], [quantity_unit]
FROM [Ingredient]
WHERE [step_id] = $1
ORDER BY [name]
"#,
)
.bind(step.id)
.fetch_all(&mut *tx)
.await?;
}
}
Ok(groups)
}
pub async fn add_recipe_group(&self, recipe_id: i64) -> Result<i64> {
let db_result = sqlx::query("INSERT INTO [Group] ([recipe_id]) VALUES ($1)")
.bind(recipe_id)
.execute(&self.pool)
.await?;
Ok(db_result.last_insert_rowid())
}
pub async fn rm_recipe_group(&self, group_id: i64) -> Result<()> {
sqlx::query("DELETE FROM [Group] WHERE [id] = $1")
.bind(group_id)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn set_group_name(&self, group_id: i64, name: &str) -> Result<()> {
sqlx::query("UPDATE [Group] SET [name] = $2 WHERE [id] = $1")
.bind(group_id)
.bind(name)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn set_group_comment(&self, group_id: i64, comment: &str) -> Result<()> {
sqlx::query("UPDATE [Group] SET [comment] = $2 WHERE [id] = $1")
.bind(group_id)
.bind(comment)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
} }
#[cfg(test)] #[cfg(test)]
@ -214,7 +311,7 @@ mod tests {
assert_eq!(recipe.estimated_time, Some(420)); assert_eq!(recipe.estimated_time, Some(420));
assert_eq!(recipe.difficulty, Difficulty::Medium); assert_eq!(recipe.difficulty, Difficulty::Medium);
assert_eq!(recipe.lang, "fr"); assert_eq!(recipe.lang, "fr");
assert_eq!(recipe.is_published, true); assert!(recipe.is_published);
Ok(()) Ok(())
} }

View file

@ -190,7 +190,7 @@ FROM [User] WHERE [email] = $1
return Ok(SignUpResult::UserAlreadyExists); return Ok(SignUpResult::UserAlreadyExists);
} }
let token = generate_token(); let token = generate_token();
let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?; let hashed_password = hash(password).map_err(DBError::from_dyn_error)?;
sqlx::query( sqlx::query(
r#" r#"
UPDATE [User] UPDATE [User]
@ -208,7 +208,7 @@ WHERE [id] = $1
} }
None => { None => {
let token = generate_token(); let token = generate_token();
let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?; let hashed_password = hash(password).map_err(DBError::from_dyn_error)?;
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO [User] INSERT INTO [User]
@ -336,20 +336,19 @@ WHERE [id] = $1
pub async fn sign_out(&self, token: &str) -> Result<()> { pub async fn sign_out(&self, token: &str) -> Result<()> {
let mut tx = self.tx().await?; let mut tx = self.tx().await?;
match sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1")
if let Some(login_id) =
sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1")
.bind(token) .bind(token)
.fetch_optional(&mut *tx) .fetch_optional(&mut *tx)
.await? .await?
{ {
Some(login_id) => {
sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1") sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1")
.bind(login_id) .bind(login_id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
tx.commit().await?; tx.commit().await?;
} }
None => (),
}
Ok(()) Ok(())
} }
@ -429,7 +428,7 @@ WHERE [password_reset_token] = $1
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
let hashed_new_password = hash(new_password).map_err(|e| DBError::from_dyn_error(e))?; let hashed_new_password = hash(new_password).map_err(DBError::from_dyn_error)?;
sqlx::query( sqlx::query(
r#" r#"
@ -853,7 +852,7 @@ VALUES (
}; };
connection connection
.reset_password(&new_password, &token, Duration::hours(1)) .reset_password(new_password, &token, Duration::hours(1))
.await?; .await?;
// Sign in. // Sign in.

View file

@ -34,20 +34,30 @@ pub struct Recipe {
// pub groups: Vec<Group>, // pub groups: Vec<Group>,
} }
#[derive(FromRow)]
pub struct Group { pub struct Group {
pub id: i64,
pub name: String, pub name: String,
pub comment: String, pub comment: String,
#[sqlx(skip)]
pub steps: Vec<Step>, pub steps: Vec<Step>,
} }
#[derive(FromRow)]
pub struct Step { pub struct Step {
pub id: i64,
pub action: String, pub action: String,
#[sqlx(skip)]
pub ingredients: Vec<Ingredient>, pub ingredients: Vec<Ingredient>,
} }
#[derive(FromRow)]
pub struct Ingredient { pub struct Ingredient {
pub id: i64,
pub name: String, pub name: String,
pub comment: String, pub comment: String,
pub quantity: i32, pub quantity_value: f64,
pub quantity_unit: String, pub quantity_unit: String,
} }

View file

@ -9,20 +9,20 @@ use crate::consts;
#[derive(Debug, Display)] #[derive(Debug, Display)]
pub enum Error { pub enum Error {
ParseError(lettre::address::AddressError), Parse(lettre::address::AddressError),
SmtpError(lettre::transport::smtp::Error), Smtp(lettre::transport::smtp::Error),
Email(lettre::error::Error), Email(lettre::error::Error),
} }
impl From<lettre::address::AddressError> for Error { impl From<lettre::address::AddressError> for Error {
fn from(error: lettre::address::AddressError) -> Self { fn from(error: lettre::address::AddressError) -> Self {
Error::ParseError(error) Error::Parse(error)
} }
} }
impl From<lettre::transport::smtp::Error> for Error { impl From<lettre::transport::smtp::Error> for Error {
fn from(error: lettre::transport::smtp::Error) -> Self { fn from(error: lettre::transport::smtp::Error) -> Self {
Error::SmtpError(error) Error::Smtp(error)
} }
} }

View file

@ -5,7 +5,7 @@ use axum::{
http::StatusCode, http::StatusCode,
middleware::{self, Next}, middleware::{self, Next},
response::{Response, Result}, response::{Response, Result},
routing::{get, put}, routing::{delete, get, post, put},
Router, Router,
}; };
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
@ -101,6 +101,14 @@ async fn main() {
"/recipe/set_is_published", "/recipe/set_is_published",
put(services::ron::set_is_published), put(services::ron::set_is_published),
) )
.route("/recipe/get_groups", get(services::ron::get_groups))
.route("/recipe/add_group", post(services::ron::add_group))
.route("/recipe/remove_group", delete(services::ron::rm_group))
.route("/recipe/set_group_name", put(services::ron::set_group_name))
.route(
"/recipe/set_group_comment",
put(services::ron::set_group_comment),
)
.fallback(services::ron::not_found); .fallback(services::ron::not_found);
let fragments_routes = Router::new().route( let fragments_routes = Router::new().route(
@ -183,7 +191,7 @@ async fn get_current_user(
) -> Option<model::User> { ) -> Option<model::User> {
match jar.get(consts::COOKIE_AUTH_TOKEN_NAME) { match jar.get(consts::COOKIE_AUTH_TOKEN_NAME) {
Some(token_cookie) => match connection Some(token_cookie) => match connection
.authentication(token_cookie.value(), &client_ip, &client_user_agent) .authentication(token_cookie.value(), client_ip, client_user_agent)
.await .await
{ {
Ok(db::user::AuthenticationResult::NotValidToken) => None, Ok(db::user::AuthenticationResult::NotValidToken) => None,
@ -234,12 +242,15 @@ async fn process_args() -> bool {
} }
}) })
.unwrap(); .unwrap();
std::fs::copy(&db_path, &db_path_bckup).expect(&format!( std::fs::copy(&db_path, &db_path_bckup).unwrap_or_else(|error| {
"Unable to make backup of {:?} to {:?}", panic!(
&db_path, &db_path_bckup "Unable to make backup of {:?} to {:?}: {}",
)); &db_path, &db_path_bckup, error
std::fs::remove_file(&db_path) )
.expect(&format!("Unable to remove db file: {:?}", &db_path)); });
std::fs::remove_file(&db_path).unwrap_or_else(|error| {
panic!("Unable to remove db file {:?}: {}", &db_path, error)
});
} }
match db::Connection::new().await { match db::Connection::new().await {

View file

@ -60,10 +60,8 @@ where
{ {
match from_bytes::<T>(&body) { match from_bytes::<T>(&body) {
Ok(ron) => Ok(ron), Ok(ron) => Ok(ron),
Err(error) => { Err(error) => Err(RonError {
return Err(RonError {
error: format!("Ron parsing error: {}", error), error: format!("Ron parsing error: {}", error),
}); }),
}
} }
} }

View file

@ -1,7 +1,7 @@
use axum::{ use axum::{
body, debug_handler, body, debug_handler,
extract::{Extension, Request, State}, extract::{Extension, Request, State},
http::header, http::{header, StatusCode},
middleware::Next, middleware::Next,
response::{IntoResponse, Response, Result}, response::{IntoResponse, Response, Result},
}; };
@ -66,5 +66,8 @@ pub async fn home_page(
#[debug_handler] #[debug_handler]
pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse { pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
MessageTemplate::new_with_user("404: Not found", user) (
StatusCode::NOT_FOUND,
MessageTemplate::new_with_user("404: Not found", user),
)
} }

View file

@ -1,8 +1,9 @@
use axum::{ use axum::{
debug_handler, debug_handler,
extract::{Extension, State}, extract::{Extension, Query, State},
response::{IntoResponse, Result}, response::{IntoResponse, Result},
}; };
use serde::Deserialize;
// use tracing::{event, Level}; // use tracing::{event, Level};
use crate::{ use crate::{
@ -10,9 +11,15 @@ use crate::{
html_templates::*, html_templates::*,
}; };
#[derive(Deserialize)]
pub struct CurrentRecipeId {
current_recipe_id: Option<i64>,
}
#[debug_handler] #[debug_handler]
pub async fn recipes_list_fragments( pub async fn recipes_list_fragments(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
current_recipe: Query<CurrentRecipeId>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
let recipes = Recipes { let recipes = Recipes {
@ -24,8 +31,7 @@ pub async fn recipes_list_fragments(
} else { } else {
vec![] vec![]
}, },
current_id: None, current_id: current_recipe.current_recipe_id,
}; };
Ok(RecipesListFragmentTemplate { user, recipes }) Ok(RecipesListFragmentTemplate { user, recipes })
} }

View file

@ -48,13 +48,19 @@
use axum::{ use axum::{
debug_handler, debug_handler,
extract::{Extension, State}, extract::{Extension, Query, State},
http::StatusCode, http::StatusCode,
response::{ErrorResponse, IntoResponse, Result}, response::{ErrorResponse, IntoResponse, Result},
}; };
use serde::Deserialize;
// use tracing::{event, Level}; // use tracing::{event, Level};
use crate::{data::db, model, ron_extractor::ExtractRon, ron_utils::ron_error}; use crate::{
data::db,
model,
ron_extractor::ExtractRon,
ron_utils::{ron_error, ron_response},
};
#[allow(dead_code)] #[allow(dead_code)]
#[debug_handler] #[debug_handler]
@ -81,7 +87,7 @@ pub async fn update_user(
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
async fn check_user_rights( async fn check_user_rights_recipe(
connection: &db::Connection, connection: &db::Connection,
user: &Option<model::User>, user: &Option<model::User>,
recipe_id: i64, recipe_id: i64,
@ -100,13 +106,32 @@ async fn check_user_rights(
} }
} }
async fn check_user_rights_recipe_group(
connection: &db::Connection,
user: &Option<model::User>,
group_id: i64,
) -> Result<()> {
if user.is_none()
|| !connection
.can_edit_recipe_group(user.as_ref().unwrap().id, group_id)
.await?
{
Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED,
"Action not authorized",
)))
} else {
Ok(())
}
}
#[debug_handler] #[debug_handler]
pub async fn set_recipe_title( pub async fn set_recipe_title(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeTitle>, ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeTitle>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?; check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection connection
.set_recipe_title(ron.recipe_id, &ron.title) .set_recipe_title(ron.recipe_id, &ron.title)
.await?; .await?;
@ -119,7 +144,7 @@ pub async fn set_recipe_description(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeDescription>, ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeDescription>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?; check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection connection
.set_recipe_description(ron.recipe_id, &ron.description) .set_recipe_description(ron.recipe_id, &ron.description)
.await?; .await?;
@ -132,7 +157,7 @@ pub async fn set_estimated_time(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeEstimatedTime>, ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeEstimatedTime>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?; check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection connection
.set_recipe_estimated_time(ron.recipe_id, ron.estimated_time) .set_recipe_estimated_time(ron.recipe_id, ron.estimated_time)
.await?; .await?;
@ -145,7 +170,7 @@ pub async fn set_difficulty(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeDifficulty>, ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeDifficulty>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?; check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection connection
.set_recipe_difficulty(ron.recipe_id, ron.difficulty) .set_recipe_difficulty(ron.recipe_id, ron.difficulty)
.await?; .await?;
@ -158,7 +183,7 @@ pub async fn set_language(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeLanguage>, ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeLanguage>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?; check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection connection
.set_recipe_language(ron.recipe_id, &ron.lang) .set_recipe_language(ron.recipe_id, &ron.lang)
.await?; .await?;
@ -171,13 +196,128 @@ pub async fn set_is_published(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetIsPublished>, ExtractRon(ron): ExtractRon<common::ron_api::SetIsPublished>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?; check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection connection
.set_recipe_is_published(ron.recipe_id, ron.is_published) .set_recipe_is_published(ron.recipe_id, ron.is_published)
.await?; .await?;
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
impl From<model::Group> for common::ron_api::Group {
fn from(group: model::Group) -> Self {
Self {
id: group.id,
name: group.name,
comment: group.comment,
steps: group
.steps
.into_iter()
.map(common::ron_api::Step::from)
.collect(),
}
}
}
impl From<model::Step> for common::ron_api::Step {
fn from(step: model::Step) -> Self {
Self {
id: step.id,
action: step.action,
ingredients: step
.ingredients
.into_iter()
.map(common::ron_api::Ingredient::from)
.collect(),
}
}
}
impl From<model::Ingredient> for common::ron_api::Ingredient {
fn from(ingredient: model::Ingredient) -> Self {
Self {
id: ingredient.id,
name: ingredient.name,
comment: ingredient.comment,
quantity_value: ingredient.quantity_value,
quantity_unit: ingredient.quantity_unit,
}
}
}
#[derive(Deserialize)]
pub struct RecipeId {
#[serde(rename = "recipe_id")]
id: i64,
}
#[debug_handler]
pub async fn get_groups(
State(connection): State<db::Connection>,
recipe_id: Query<RecipeId>,
) -> Result<impl IntoResponse> {
println!("PROUT");
// Here we don't check user rights on purpose.
Ok(ron_response(
StatusCode::OK,
connection
.get_groups(recipe_id.id)
.await?
.into_iter()
.map(common::ron_api::Group::from)
.collect::<Vec<_>>(),
))
}
#[debug_handler]
pub async fn add_group(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::AddRecipeGroup>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
let group_id = connection.add_recipe_group(ron.recipe_id).await?;
Ok(ron_response(
StatusCode::OK,
common::ron_api::AddRecipeGroupResult { group_id },
))
}
#[debug_handler]
pub async fn rm_group(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::RemoveRecipeGroup>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe_group(&connection, &user, ron.group_id).await?;
connection.rm_recipe_group(ron.group_id).await?;
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn set_group_name(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetGroupName>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe_group(&connection, &user, ron.group_id).await?;
connection.set_group_name(ron.group_id, &ron.name).await?;
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn set_group_comment(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetGroupComment>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe_group(&connection, &user, ron.group_id).await?;
connection
.set_group_comment(ron.group_id, &ron.comment)
.await?;
Ok(StatusCode::OK)
}
///// 404 ///// ///// 404 /////
#[debug_handler] #[debug_handler]
pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse { pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {

View file

@ -22,7 +22,7 @@ use crate::{
utils, AppState, utils, AppState,
}; };
//// SIGN UP ///// /// SIGN UP ///
#[debug_handler] #[debug_handler]
pub async fn sign_up_get( pub async fn sign_up_get(
@ -207,7 +207,7 @@ pub async fn sign_up_validation(
} }
} }
///// SIGN IN ///// /// SIGN IN ///
#[debug_handler] #[debug_handler]
pub async fn sign_in_get( pub async fn sign_in_get(
@ -271,7 +271,7 @@ pub async fn sign_in_post(
} }
} }
///// SIGN OUT ///// /// SIGN OUT ///
#[debug_handler] #[debug_handler]
pub async fn sign_out( pub async fn sign_out(
@ -287,7 +287,7 @@ pub async fn sign_out(
Ok((jar, Redirect::to("/"))) Ok((jar, Redirect::to("/")))
} }
///// RESET PASSWORD ///// /// RESET PASSWORD ///
#[debug_handler] #[debug_handler]
pub async fn ask_reset_password_get( pub async fn ask_reset_password_get(
@ -510,7 +510,7 @@ pub async fn reset_password_post(
} }
} }
///// EDIT PROFILE ///// /// EDIT PROFILE ///
#[debug_handler] #[debug_handler]
pub async fn edit_user_get(Extension(user): Extension<Option<model::User>>) -> Response { pub async fn edit_user_get(Extension(user): Extension<Option<model::User>>) -> Response {
@ -614,7 +614,7 @@ pub async fn edit_user_post(
match connection match connection
.update_user( .update_user(
user.id, user.id,
Some(&email_trimmed), Some(email_trimmed),
Some(&form_data.name), Some(&form_data.name),
new_password, new_password,
) )

View file

@ -13,35 +13,27 @@
<input <input
id="input-title" id="input-title"
type="text" type="text"
name="title"
value="{{ recipe.title }}" value="{{ recipe.title }}"
autocomplete="title"
autofocus="true" /> autofocus="true" />
<label for="input-description">Description</label> <label for="text-area-description">Description</label>
<input <textarea
id="input-description" id="text-area-description">{{ recipe.description }}</textarea>
type="text"
name="description"
value="{{ recipe.description }}"
autocomplete="title" />
<label for="input-description">Estimated time</label> <label for="input-estimated-time">Estimated time</label>
<input <input
id="input-estimated-time" id="input-estimated-time"
type="number" type="number"
name="estimated-time"
value=" value="
{% match recipe.estimated_time %} {% match recipe.estimated_time %}
{% when Some with (t) %} {% when Some with (t) %}
{{ t }} {{ t }}
{% when None %} {% when None %}
0 0
{% endmatch %}" {% endmatch %}"/>
autocomplete="title" />
<label for="select-difficulty">Difficulty</label> <label for="select-difficulty">Difficulty</label>
<select id="select-difficulty" name="difficulty"> <select id="select-difficulty">
<option value="0" {%+ call is_difficulty(common::ron_api::Difficulty::Unknown) %}> - </option> <option value="0" {%+ call is_difficulty(common::ron_api::Difficulty::Unknown) %}> - </option>
<option value="1" {%+ call is_difficulty(common::ron_api::Difficulty::Easy) %}>Easy</option> <option value="1" {%+ call is_difficulty(common::ron_api::Difficulty::Easy) %}>Easy</option>
<option value="2" {%+ call is_difficulty(common::ron_api::Difficulty::Medium) %}>Medium</option> <option value="2" {%+ call is_difficulty(common::ron_api::Difficulty::Medium) %}>Medium</option>
@ -49,16 +41,19 @@
</select> </select>
<label for="select-language">Language</label> <label for="select-language">Language</label>
<select id="select-language" name="language"> <select id="select-language">
{% for lang in languages %} {% for lang in languages %}
<option value="{{ lang.1 }}">{{ lang.0 }}</option> <option value="{{ lang.1 }}"
{%+ if recipe.lang == lang.1 %}
selected
{% endif %}
>{{ lang.0 }}</option>
{% endfor %} {% endfor %}
</select> </select>
<input <input
id="input-is-published" id="input-is-published"
type="checkbox" type="checkbox"
name="is-published"
{%+ if recipe.is_published %} {%+ if recipe.is_published %}
checked checked
{% endif %} {% endif %}
@ -66,6 +61,42 @@
<label for="input-is-published">Is published</label> <label for="input-is-published">Is published</label>
<div id="groups-container"> <div id="groups-container">
</div>
<input id="button-add-group" type="button" value="Add a group"/>
<div id="hidden-templates">
<div class="group">
<label for="input-group-name">Name</label>
<input class="input-group-name" type="text" />
<label for="input-group-comment">Comment</label>
<input class="input-group-comment" type="text" />
<div class="steps"></div>
<input class="button-add-step" type="button" value="Add a step"/>
</div>
<div class="step">
<label for="text-area-step-action">Action</label>
<textarea class="text-area-step-action"></textarea>
<div class="ingredients"></div>
<input class="button-add-ingedient" type="button" value="Add an ingredient"/>
</div>
<div class="ingredient">
<label for="input-ingredient-quantity">Quantity</label>
<input class="input-ingredient-quantity" type="number" />
<label for="input-ingredient-unit">Unity</label>
<input class="input-ingredient-unit" type="text" />
<label for="input-ingredient-name">Name</label>
<input class="input-ingredient-name" type="text" />
</div>
</div> </div>
</div> </div>

View file

@ -65,6 +65,57 @@ pub struct SetIsPublished {
pub is_published: bool, pub is_published: bool,
} }
#[derive(Serialize, Deserialize, Clone)]
pub struct AddRecipeGroup {
pub recipe_id: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct AddRecipeGroupResult {
pub group_id: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RemoveRecipeGroup {
pub group_id: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct SetGroupName {
pub group_id: i64,
pub name: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct SetGroupComment {
pub group_id: i64,
pub comment: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Group {
pub id: i64,
pub name: String,
pub comment: String,
pub steps: Vec<Step>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Step {
pub id: i64,
pub action: String,
pub ingredients: Vec<Ingredient>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Ingredient {
pub id: i64,
pub name: String,
pub comment: String,
pub quantity_value: f64,
pub quantity_unit: String,
}
// #[derive(Serialize, Deserialize, Clone)] // #[derive(Serialize, Deserialize, Clone)]
// pub struct AddRecipeImage { // pub struct AddRecipeImage {
// pub recipe_id: i64, // pub recipe_id: i64,

View file

@ -13,6 +13,10 @@ default = ["console_error_panic_hook"]
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
ron = "0.8"
serde = { version = "1.0", features = ["derive"] }
thiserror = "2"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [ web-sys = { version = "0.3", features = [
@ -26,6 +30,7 @@ web-sys = { version = "0.3", features = [
"EventTarget", "EventTarget",
"HtmlLabelElement", "HtmlLabelElement",
"HtmlInputElement", "HtmlInputElement",
"HtmlTextAreaElement",
"HtmlSelectElement", "HtmlSelectElement",
] } ] }

View file

@ -1,24 +1,21 @@
use gloo::{console::log, events::EventListener, net::http::Request, utils::document}; use gloo::{console::log, events::EventListener, net::http::Request, utils::document};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use web_sys::{Document, HtmlInputElement, HtmlSelectElement}; use web_sys::{Element, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
use crate::toast::{self, Level}; use common::ron_api;
async fn api_request(body: String, api_name: &str) { use crate::{
if let Err(error) = Request::put(&format!("/ron-api/recipe/{}", api_name)) request,
.header("Content-Type", "application/ron") toast::{self, Level},
.body(body) };
.unwrap()
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() .send()
.await .await
{ {
toast::show(Level::Info, &format!("Internal server error: {}", error));
}
}
async fn reload_recipes_list() {
match Request::get("/fragments/recipes_list").send().await {
Err(error) => { Err(error) => {
toast::show(Level::Info, &format!("Internal server error: {}", error)); toast::show(Level::Info, &format!("Internal server error: {}", error));
} }
@ -35,17 +32,20 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
let input_title = document().get_element_by_id("input-title").unwrap(); let input_title = document().get_element_by_id("input-title").unwrap();
let mut current_title = input_title.dyn_ref::<HtmlInputElement>().unwrap().value(); let mut current_title = input_title.dyn_ref::<HtmlInputElement>().unwrap().value();
let on_input_title_blur = EventListener::new(&input_title, "blur", move |_event| { let on_input_title_blur = EventListener::new(&input_title, "blur", move |_event| {
let input_title = document().get_element_by_id("input-title").unwrap(); let title = document()
let title = input_title.dyn_ref::<HtmlInputElement>().unwrap(); .get_element_by_id("input-title")
.unwrap()
.dyn_into::<HtmlInputElement>()
.unwrap();
if title.value() != current_title { if title.value() != current_title {
current_title = title.value(); current_title = title.value();
let body = common::ron_api::to_string(common::ron_api::SetRecipeTitle { let body = ron_api::SetRecipeTitle {
recipe_id, recipe_id,
title: title.value(), title: title.value(),
}); };
spawn_local(async move { spawn_local(async move {
api_request(body, "set_title").await; let _ = request::put::<(), _>("recipe/set_title", body).await;
reload_recipes_list().await; reload_recipes_list(recipe_id).await;
}); });
} }
}); });
@ -54,23 +54,28 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
// Description. // Description.
{ {
let input_description = document().get_element_by_id("input-description").unwrap(); let text_area_description = document()
let mut current_description = input_description .get_element_by_id("text-area-description")
.dyn_ref::<HtmlInputElement>() .unwrap();
let mut current_description = text_area_description
.dyn_ref::<HtmlTextAreaElement>()
.unwrap() .unwrap()
.value(); .value();
let on_input_description_blur = let on_input_description_blur =
EventListener::new(&input_description, "blur", move |_event| { EventListener::new(&text_area_description, "blur", move |_event| {
let input_description = document().get_element_by_id("input-description").unwrap(); let description = document()
let description = input_description.dyn_ref::<HtmlInputElement>().unwrap(); .get_element_by_id("text-area-description")
.unwrap()
.dyn_into::<HtmlTextAreaElement>()
.unwrap();
if description.value() != current_description { if description.value() != current_description {
current_description = description.value(); current_description = description.value();
let body = common::ron_api::to_string(common::ron_api::SetRecipeDescription { let body = ron_api::SetRecipeDescription {
recipe_id, recipe_id,
description: description.value(), description: description.value(),
}); };
spawn_local(async move { spawn_local(async move {
api_request(body, "set_description").await; let _ = request::put::<(), _>("recipe/set_description", body).await;
}); });
} }
}); });
@ -88,30 +93,28 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
.value(); .value();
let on_input_estimated_time_blur = let on_input_estimated_time_blur =
EventListener::new(&input_estimated_time, "blur", move |_event| { EventListener::new(&input_estimated_time, "blur", move |_event| {
let input_estimated_time = document() let estimated_time = document()
.get_element_by_id("input-estimated-time") .get_element_by_id("input-estimated-time")
.unwrap()
.dyn_into::<HtmlInputElement>()
.unwrap(); .unwrap();
let estimated_time = input_estimated_time.dyn_ref::<HtmlInputElement>().unwrap();
if estimated_time.value() != current_time { if estimated_time.value() != current_time {
let time = if estimated_time.value().is_empty() { let time = if estimated_time.value().is_empty() {
None None
} else { } else if let Ok(t) = estimated_time.value().parse::<u32>() {
if let Ok(t) = estimated_time.value().parse::<u32>() {
Some(t) Some(t)
} else { } else {
estimated_time.set_value(&current_time); estimated_time.set_value(&current_time);
return; return;
}
}; };
current_time = estimated_time.value(); current_time = estimated_time.value();
let body = let body = ron_api::SetRecipeEstimatedTime {
common::ron_api::to_string(common::ron_api::SetRecipeEstimatedTime {
recipe_id, recipe_id,
estimated_time: time, estimated_time: time,
}); };
spawn_local(async move { spawn_local(async move {
api_request(body, "set_estimated_time").await; let _ = request::put::<(), _>("recipe/set_estimated_time", body).await;
}); });
} }
}); });
@ -127,20 +130,23 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
.value(); .value();
let on_select_difficulty_blur = let on_select_difficulty_blur =
EventListener::new(&select_difficulty, "blur", move |_event| { EventListener::new(&select_difficulty, "blur", move |_event| {
let select_difficulty = document().get_element_by_id("select-difficulty").unwrap(); let difficulty = document()
let difficulty = select_difficulty.dyn_ref::<HtmlSelectElement>().unwrap(); .get_element_by_id("select-difficulty")
.unwrap()
.dyn_into::<HtmlSelectElement>()
.unwrap();
if difficulty.value() != current_difficulty { if difficulty.value() != current_difficulty {
current_difficulty = difficulty.value(); current_difficulty = difficulty.value();
let body = common::ron_api::to_string(common::ron_api::SetRecipeDifficulty { let body = ron_api::SetRecipeDifficulty {
recipe_id, recipe_id,
difficulty: common::ron_api::Difficulty::try_from( difficulty: ron_api::Difficulty::try_from(
current_difficulty.parse::<u32>().unwrap(), current_difficulty.parse::<u32>().unwrap(),
) )
.unwrap(), .unwrap(),
}); };
spawn_local(async move { spawn_local(async move {
api_request(body, "set_difficulty").await; let _ = request::put::<(), _>("recipe/set_difficulty", body).await;
}); });
} }
}); });
@ -155,17 +161,20 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
.unwrap() .unwrap()
.value(); .value();
let on_select_language_blur = EventListener::new(&select_language, "blur", move |_event| { let on_select_language_blur = EventListener::new(&select_language, "blur", move |_event| {
let select_language = document().get_element_by_id("select-language").unwrap(); let language = document()
let difficulty = select_language.dyn_ref::<HtmlSelectElement>().unwrap(); .get_element_by_id("select-language")
if difficulty.value() != current_language { .unwrap()
current_language = difficulty.value(); .dyn_into::<HtmlSelectElement>()
.unwrap();
if language.value() != current_language {
current_language = language.value();
let body = common::ron_api::to_string(common::ron_api::SetRecipeLanguage { let body = ron_api::SetRecipeLanguage {
recipe_id, recipe_id,
lang: difficulty.value(), lang: language.value(),
}); };
spawn_local(async move { spawn_local(async move {
api_request(body, "set_language").await; let _ = request::put::<(), _>("recipe/set_language", body).await;
}); });
} }
}); });
@ -177,22 +186,147 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
let input_is_published = document().get_element_by_id("input-is-published").unwrap(); let input_is_published = document().get_element_by_id("input-is-published").unwrap();
let on_input_is_published_blur = let on_input_is_published_blur =
EventListener::new(&input_is_published, "input", move |_event| { EventListener::new(&input_is_published, "input", move |_event| {
let input_is_published = let is_published = document()
document().get_element_by_id("input-is-published").unwrap(); .get_element_by_id("input-is-published")
let is_published = input_is_published.dyn_ref::<HtmlInputElement>().unwrap(); .unwrap()
.dyn_into::<HtmlInputElement>()
.unwrap();
let body = common::ron_api::to_string(common::ron_api::SetIsPublished { let body = ron_api::SetIsPublished {
recipe_id, recipe_id,
is_published: is_published.checked(), is_published: is_published.checked(),
}); };
spawn_local(async move { spawn_local(async move {
api_request(body, "set_is_published").await; let _ = request::put::<(), _>("recipe/set_is_published", body).await;
reload_recipes_list().await; reload_recipes_list(recipe_id).await;
}); });
}); });
on_input_is_published_blur.forget(); on_input_is_published_blur.forget();
} }
// let groups_container = document().get_element_by_id("groups-container").unwrap();
// if !groups_container.has_child_nodes() {
// }
fn create_group_element(group_id: i64) -> Element {
let group_html = document()
.query_selector("#hidden-templates .group")
.unwrap()
.unwrap()
.clone_node_with_deep(true)
.unwrap()
.dyn_into::<Element>()
.unwrap();
group_html
.set_attribute("id", &format!("group-{}", group_id))
.unwrap();
let groups_container = document().get_element_by_id("groups-container").unwrap();
groups_container.append_child(&group_html).unwrap();
group_html
}
fn create_step_element(group_element: &Element, step_id: i64) -> Element {
let step_html = document()
.query_selector("#hidden-templates .step")
.unwrap()
.unwrap()
.clone_node_with_deep(true)
.unwrap()
.dyn_into::<Element>()
.unwrap();
step_html
.set_attribute("id", &format!("step-{}", step_id))
.unwrap();
group_element.append_child(&step_html).unwrap();
step_html
}
fn create_ingredient_element(step_element: &Element, ingredient_id: i64) -> Element {
let ingredient_html = document()
.query_selector("#hidden-templates .ingredient")
.unwrap()
.unwrap()
.clone_node_with_deep(true)
.unwrap()
.dyn_into::<Element>()
.unwrap();
ingredient_html
.set_attribute("id", &format!("step-{}", ingredient_id))
.unwrap();
step_element.append_child(&ingredient_html).unwrap();
ingredient_html
}
// 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.id);
let input_name = group_element
.query_selector(".input-group-name")
.unwrap()
.unwrap()
.dyn_into::<HtmlInputElement>()
.unwrap();
input_name.set_value(&group.name);
// document().get_element_by_id(&format!("group-{}", group_id))
for step in group.steps {
let step_element = create_step_element(&group_element, step.id);
let text_area_action = step_element
.query_selector(".text-area-step-action")
.unwrap()
.unwrap()
.dyn_into::<HtmlTextAreaElement>()
.unwrap();
text_area_action.set_value(&step.action);
for ingredient in step.ingredients {
let ingredient_element =
create_ingredient_element(&step_element, ingredient.id);
let input_name = ingredient_element
.query_selector(".input-ingredient-name")
.unwrap()
.unwrap()
.dyn_into::<HtmlInputElement>()
.unwrap();
input_name.set_value(&ingredient.name);
}
}
}
// log!(format!("{:?}", groups));
});
}
// Add a new group.
{
let button_add_group = document().get_element_by_id("button-add-group").unwrap();
let on_click_add_group = EventListener::new(&button_add_group, "click", move |_event| {
log!("Click!");
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(response.group_id);
// group_html.set_attribute("id", "test").unwrap();
});
});
on_click_add_group.forget();
}
Ok(()) Ok(())
} }

View file

@ -1,10 +1,10 @@
mod handles; mod handles;
mod request;
mod toast; mod toast;
mod utils; mod utils;
use gloo::{console::log, events::EventListener, utils::window}; use gloo::utils::window;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::console;
// #[wasm_bindgen] // #[wasm_bindgen]
// extern "C" { // extern "C" {
@ -27,17 +27,14 @@ pub fn main() -> Result<(), JsValue> {
let location = window().location().pathname()?; let location = window().location().pathname()?;
let path: Vec<&str> = location.split('/').skip(1).collect(); let path: Vec<&str> = location.split('/').skip(1).collect();
match path[..] { if let ["recipe", "edit", id] = path[..] {
["recipe", "edit", id] => {
let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap. let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
handles::recipe_edit(id)?; handles::recipe_edit(id)?;
}
// Disable: user editing data are now submitted as classic form data. // Disable: user editing data are now submitted as classic form data.
// ["user", "edit"] => { // ["user", "edit"] => {
// handles::user_edit(document)?; // handles::user_edit(document)?;
// } // }
_ => (),
} }
Ok(()) Ok(())

132
frontend/src/request.rs Normal file
View file

@ -0,0 +1,132 @@
use gloo::net::http::{Request, RequestBuilder};
use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error;
use common::ron_api;
use crate::toast::{self, Level};
#[derive(Error, Debug)]
pub enum Error {
#[error("Gloo error: {0}")]
Gloo(#[from] gloo::net::Error),
#[error("RON Spanned error: {0}")]
Ron(#[from] ron::error::SpannedError),
#[error("HTTP error: {0}")]
Http(String),
#[error("Unknown error: {0}")]
Other(String),
}
type Result<T> = std::result::Result<T, Error>;
const CONTENT_TYPE: &str = "Content-Type";
const CONTENT_TYPE_RON: &str = "application/ron";
async fn req_with_body<T, U>(
api_name: &str,
body: U,
method_fn: fn(&str) -> RequestBuilder,
) -> Result<T>
where
T: DeserializeOwned,
U: Serialize,
{
let url = format!("/ron-api/{}", api_name);
let request_builder = method_fn(&url).header(CONTENT_TYPE, CONTENT_TYPE_RON);
send_req(request_builder.body(ron_api::to_string(body))?).await
}
async fn req_with_params<'a, T, U, V>(
api_name: &str,
params: U,
method_fn: fn(&str) -> RequestBuilder,
) -> Result<T>
where
T: DeserializeOwned,
U: IntoIterator<Item = (&'a str, V)>,
V: AsRef<str>,
{
let url = format!("/ron-api/{}", api_name);
let request_builder = method_fn(&url)
.header(CONTENT_TYPE, CONTENT_TYPE_RON)
.query(params);
send_req(request_builder.build()?).await
}
async fn send_req<T>(request: Request) -> Result<T>
where
T: DeserializeOwned,
{
match request.send().await {
Err(error) => {
toast::show(Level::Info, &format!("Internal server error: {}", error));
Err(Error::Gloo(error))
}
Ok(response) => {
if !response.ok() {
toast::show(
Level::Info,
&format!("HTTP error: {}", response.status_text()),
);
Err(Error::Http(response.status_text()))
} else {
// Ok(())
Ok(ron::de::from_bytes::<T>(&response.binary().await?)?)
}
}
}
}
pub async fn put<T, U>(api_name: &str, body: U) -> Result<T>
where
T: DeserializeOwned,
U: Serialize,
{
req_with_body(api_name, body, Request::put).await
}
pub async fn post<T, U>(api_name: &str, body: U) -> Result<T>
where
T: DeserializeOwned,
U: Serialize,
{
req_with_body(api_name, body, Request::post).await
}
pub async fn delete<T, U>(api_name: &str, body: U) -> Result<T>
where
T: DeserializeOwned,
U: Serialize,
{
req_with_body(api_name, body, Request::delete).await
}
pub async fn get<'a, T, U, V>(api_name: &str, params: U) -> Result<T>
where
T: DeserializeOwned,
U: IntoIterator<Item = (&'a str, V)>,
V: AsRef<str>,
{
req_with_params(api_name, params, Request::get).await
}
// pub async fn api_request_get<T>(api_name: &str, params: QueryParams) -> Result<T, String>
// where
// T: DeserializeOwned,
// {
// match Request::get(&format!("/ron-api/recipe/{}?{}", api_name, params))
// .header("Content-Type", "application/ron")
// .send()
// .await
// {
// Err(error) => {
// toast::show(Level::Info, &format!("Internal server error: {}", error));
// Err(error.to_string())
// }
// Ok(response) => Ok(ron::de::from_bytes::<T>(&response.binary().await.unwrap()).unwrap()),
// }
// }

View file

@ -1,5 +1,4 @@
use gloo::{console::log, timers::callback::Timeout, utils::document}; use gloo::{timers::callback::Timeout, utils::document};
use web_sys::{console, Document, HtmlInputElement};
pub enum Level { pub enum Level {
Success, Success,