"common",
"console_error_panic_hook",
"gloo",
+ "ron",
+ "serde",
+ "thiserror 2.0.9",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
[[package]]
name = "unicase"
-version = "2.8.0"
+version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df"
+checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-bidi"
}
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 {
run_sass(&mut Command::new("sass"))
};
h1 {
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 {
NULL
);
-INSERT INTO [Recipe] ([user_id], [title], [is_published])
-VALUES (1, 'Croissant au jambon', true);
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
+VALUES (1, 1, 'Croissant au jambon', true);
-INSERT INTO [Recipe] ([user_id], [title], [is_published])
-VALUES (1, 'Gratin de thon aux olives', true);
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
+VALUES (2, 1, 'Gratin de thon aux olives', true);
-INSERT INTO [Recipe] ([user_id], [title], [is_published])
-VALUES (1, 'Saumon en croute', true);
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
+VALUES (3, 1, 'Saumon en croute', true);
-INSERT INTO [Recipe] ([user_id], [title], [is_published])
-VALUES (2, 'Ouiche lorraine', true);
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
+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");
\ No newline at end of file
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] (
[id] INTEGER PRIMARY KEY,
+
[order] INTEGER NOT NULL DEFAULT 0,
[recipe_id] INTEGER NOT NULL,
+
[name] TEXT NOT NULL DEFAULT '',
[comment] TEXT NOT NULL DEFAULT '',
CREATE TABLE [Step] (
[id] INTEGER PRIMARY KEY,
+
[order] INTEGER NOT NULL DEFAULT 0,
- [action] TEXT NOT NULL DEFAULT '',
[group_id] INTEGER NOT NULL,
+ [action] TEXT NOT NULL DEFAULT '',
+
FOREIGN KEY(group_id) REFERENCES [Group](id) ON DELETE CASCADE
) STRICT;
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] (
-- [id] INTEGER PRIMARY KEY,
-- [name] TEXT NOT NULL DEFAULT '',
pub fn load() -> Config {
match File::open(consts::FILE_CONF) {
- Ok(file) => from_reader(file).expect(&format!(
- "Failed to open configuration file {}",
- consts::FILE_CONF
- )),
+ Ok(file) => from_reader(file).unwrap_or_else(|error| {
+ panic!(
+ "Failed to open configuration file {}: {}",
+ consts::FILE_CONF,
+ error
+ )
+ }),
Err(_) => {
- let file = File::create(consts::FILE_CONF).expect(&format!(
- "Failed to create default configuration file {}",
- consts::FILE_CONF
- ));
+ let file = File::create(consts::FILE_CONF).unwrap_or_else(|error| {
+ panic!(
+ "Failed to create default configuration file {}: {}",
+ consts::FILE_CONF,
+ error
+ )
+ });
let default_config = Config::default();
- to_writer_pretty(file, &default_config, PrettyConfig::new()).expect(&format!(
- "Failed to write default configuration file {}",
- consts::FILE_CONF
- ));
+ to_writer_pretty(file, &default_config, PrettyConfig::new()).unwrap_or_else(|error| {
+ panic!(
+ "Failed to write default configuration file {}: {}",
+ consts::FILE_CONF,
+ error
+ )
+ });
default_config
}
pub const DB_DIRECTORY: &str = "data";
pub const DB_FILENAME: &str = "recipes.sqlite";
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 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
// (cookie authentication, password reset, validation token).
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")];
}
fn load_sql_file<P: AsRef<Path> + fmt::Display>(sql_file: P) -> Result<String> {
- let mut file = File::open(&sql_file).map_err(|err| {
- DBError::Other(format!(
- "Cannot open SQL file ({}): {}",
- &sql_file,
- err.to_string()
- ))
- })?;
+ let mut file = File::open(&sql_file)
+ .map_err(|err| DBError::Other(format!("Cannot open SQL file ({}): {}", &sql_file, err)))?;
let mut sql = String::new();
- file.read_to_string(&mut sql).map_err(|err| {
- DBError::Other(format!(
- "Cannot read SQL file ({}) : {}",
- &sql_file,
- err.to_string()
- ))
- })?;
+ file.read_to_string(&mut sql)
+ .map_err(|err| DBError::Other(format!("Cannot read SQL file ({}) : {}", &sql_file, err)))?;
Ok(sql)
}
-
-// #[cfg(test)]
-// mod tests {
-// use super::*;
-
-// }
.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>> {
sqlx::query_as(
r#"
.map(|_| ())
.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)]
assert_eq!(recipe.estimated_time, Some(420));
assert_eq!(recipe.difficulty, Difficulty::Medium);
assert_eq!(recipe.lang, "fr");
- assert_eq!(recipe.is_published, true);
+ assert!(recipe.is_published);
Ok(())
}
return Ok(SignUpResult::UserAlreadyExists);
}
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(
r#"
UPDATE [User]
}
None => {
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(
r#"
INSERT INTO [User]
pub async fn sign_out(&self, token: &str) -> Result<()> {
let mut tx = self.tx().await?;
- match sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1")
- .bind(token)
- .fetch_optional(&mut *tx)
- .await?
+
+ if let Some(login_id) =
+ sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1")
+ .bind(token)
+ .fetch_optional(&mut *tx)
+ .await?
{
- Some(login_id) => {
- sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1")
- .bind(login_id)
- .execute(&mut *tx)
- .await?;
- tx.commit().await?;
- }
- None => (),
+ sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1")
+ .bind(login_id)
+ .execute(&mut *tx)
+ .await?;
+ tx.commit().await?;
}
Ok(())
}
.execute(&mut *tx)
.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(
r#"
};
connection
- .reset_password(&new_password, &token, Duration::hours(1))
+ .reset_password(new_password, &token, Duration::hours(1))
.await?;
// Sign in.
// pub groups: Vec<Group>,
}
+#[derive(FromRow)]
pub struct Group {
+ pub id: i64,
pub name: String,
pub comment: String,
+
+ #[sqlx(skip)]
pub steps: Vec<Step>,
}
+#[derive(FromRow)]
pub struct Step {
+ pub id: i64,
pub action: String,
+
+ #[sqlx(skip)]
pub ingredients: Vec<Ingredient>,
}
+#[derive(FromRow)]
pub struct Ingredient {
+ pub id: i64,
pub name: String,
pub comment: String,
- pub quantity: i32,
+ pub quantity_value: f64,
pub quantity_unit: String,
}
#[derive(Debug, Display)]
pub enum Error {
- ParseError(lettre::address::AddressError),
- SmtpError(lettre::transport::smtp::Error),
+ Parse(lettre::address::AddressError),
+ Smtp(lettre::transport::smtp::Error),
Email(lettre::error::Error),
}
impl From<lettre::address::AddressError> for Error {
fn from(error: lettre::address::AddressError) -> Self {
- Error::ParseError(error)
+ Error::Parse(error)
}
}
impl From<lettre::transport::smtp::Error> for Error {
fn from(error: lettre::transport::smtp::Error) -> Self {
- Error::SmtpError(error)
+ Error::Smtp(error)
}
}
http::StatusCode,
middleware::{self, Next},
response::{Response, Result},
- routing::{get, put},
+ routing::{delete, get, post, put},
Router,
};
use axum_extra::extract::cookie::CookieJar;
"/recipe/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);
let fragments_routes = Router::new().route(
) -> Option<model::User> {
match jar.get(consts::COOKIE_AUTH_TOKEN_NAME) {
Some(token_cookie) => match connection
- .authentication(token_cookie.value(), &client_ip, &client_user_agent)
+ .authentication(token_cookie.value(), client_ip, client_user_agent)
.await
{
Ok(db::user::AuthenticationResult::NotValidToken) => None,
}
})
.unwrap();
- std::fs::copy(&db_path, &db_path_bckup).expect(&format!(
- "Unable to make backup of {:?} to {:?}",
- &db_path, &db_path_bckup
- ));
- std::fs::remove_file(&db_path)
- .expect(&format!("Unable to remove db file: {:?}", &db_path));
+ std::fs::copy(&db_path, &db_path_bckup).unwrap_or_else(|error| {
+ panic!(
+ "Unable to make backup of {:?} to {:?}: {}",
+ &db_path, &db_path_bckup, error
+ )
+ });
+ 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 from_bytes::<T>(&body) {
Ok(ron) => Ok(ron),
- Err(error) => {
- return Err(RonError {
- error: format!("Ron parsing error: {}", error),
- });
- }
+ Err(error) => Err(RonError {
+ error: format!("Ron parsing error: {}", error),
+ }),
}
}
use axum::{
body, debug_handler,
extract::{Extension, Request, State},
- http::header,
+ http::{header, StatusCode},
middleware::Next,
response::{IntoResponse, Response, Result},
};
#[debug_handler]
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),
+ )
}
use axum::{
debug_handler,
- extract::{Extension, State},
+ extract::{Extension, Query, State},
response::{IntoResponse, Result},
};
+use serde::Deserialize;
// use tracing::{event, Level};
use crate::{
html_templates::*,
};
+#[derive(Deserialize)]
+pub struct CurrentRecipeId {
+ current_recipe_id: Option<i64>,
+}
+
#[debug_handler]
pub async fn recipes_list_fragments(
State(connection): State<db::Connection>,
+ current_recipe: Query<CurrentRecipeId>,
Extension(user): Extension<Option<model::User>>,
) -> Result<impl IntoResponse> {
let recipes = Recipes {
} else {
vec![]
},
- current_id: None,
+ current_id: current_recipe.current_recipe_id,
};
-
Ok(RecipesListFragmentTemplate { user, recipes })
}
use axum::{
debug_handler,
- extract::{Extension, State},
+ extract::{Extension, Query, State},
http::StatusCode,
response::{ErrorResponse, IntoResponse, Result},
};
+use serde::Deserialize;
// 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)]
#[debug_handler]
Ok(StatusCode::OK)
}
-async fn check_user_rights(
+async fn check_user_rights_recipe(
connection: &db::Connection,
user: &Option<model::User>,
recipe_id: i64,
}
}
+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]
pub async fn set_recipe_title(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeTitle>,
) -> Result<StatusCode> {
- check_user_rights(&connection, &user, ron.recipe_id).await?;
+ check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_title(ron.recipe_id, &ron.title)
.await?;
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeDescription>,
) -> Result<StatusCode> {
- check_user_rights(&connection, &user, ron.recipe_id).await?;
+ check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_description(ron.recipe_id, &ron.description)
.await?;
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeEstimatedTime>,
) -> Result<StatusCode> {
- check_user_rights(&connection, &user, ron.recipe_id).await?;
+ check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_estimated_time(ron.recipe_id, ron.estimated_time)
.await?;
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeDifficulty>,
) -> Result<StatusCode> {
- check_user_rights(&connection, &user, ron.recipe_id).await?;
+ check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_difficulty(ron.recipe_id, ron.difficulty)
.await?;
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeLanguage>,
) -> Result<StatusCode> {
- check_user_rights(&connection, &user, ron.recipe_id).await?;
+ check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_language(ron.recipe_id, &ron.lang)
.await?;
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetIsPublished>,
) -> Result<StatusCode> {
- check_user_rights(&connection, &user, ron.recipe_id).await?;
+ check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_is_published(ron.recipe_id, ron.is_published)
.await?;
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 /////
#[debug_handler]
pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {
utils, AppState,
};
-//// SIGN UP /////
+/// SIGN UP ///
#[debug_handler]
pub async fn sign_up_get(
}
}
-///// SIGN IN /////
+/// SIGN IN ///
#[debug_handler]
pub async fn sign_in_get(
}
}
-///// SIGN OUT /////
+/// SIGN OUT ///
#[debug_handler]
pub async fn sign_out(
Ok((jar, Redirect::to("/")))
}
-///// RESET PASSWORD /////
+/// RESET PASSWORD ///
#[debug_handler]
pub async fn ask_reset_password_get(
}
}
-///// EDIT PROFILE /////
+/// EDIT PROFILE ///
#[debug_handler]
pub async fn edit_user_get(Extension(user): Extension<Option<model::User>>) -> Response {
match connection
.update_user(
user.id,
- Some(&email_trimmed),
+ Some(email_trimmed),
Some(&form_data.name),
new_password,
)
<input
id="input-title"
type="text"
- name="title"
value="{{ recipe.title }}"
- autocomplete="title"
autofocus="true" />
- <label for="input-description">Description</label>
- <input
- id="input-description"
- type="text"
- name="description"
- value="{{ recipe.description }}"
- autocomplete="title" />
+ <label for="text-area-description">Description</label>
+ <textarea
+ id="text-area-description">{{ recipe.description }}</textarea>
- <label for="input-description">Estimated time</label>
+ <label for="input-estimated-time">Estimated time</label>
<input
id="input-estimated-time"
type="number"
- name="estimated-time"
value="
{% match recipe.estimated_time %}
{% when Some with (t) %}
{{ t }}
{% when None %}
0
- {% endmatch %}"
- autocomplete="title" />
+ {% endmatch %}"/>
<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="1" {%+ call is_difficulty(common::ron_api::Difficulty::Easy) %}>Easy</option>
<option value="2" {%+ call is_difficulty(common::ron_api::Difficulty::Medium) %}>Medium</option>
</select>
<label for="select-language">Language</label>
- <select id="select-language" name="language">
+ <select id="select-language">
{% 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 %}
</select>
<input
id="input-is-published"
type="checkbox"
- name="is-published"
{%+ if recipe.is_published %}
checked
{% endif %}
<label for="input-is-published">Is published</label>
<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>
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)]
// pub struct AddRecipeImage {
// pub recipe_id: i64,
[dependencies]
common = { path = "../common" }
+ron = "0.8"
+serde = { version = "1.0", features = ["derive"] }
+thiserror = "2"
+
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"EventTarget",
"HtmlLabelElement",
"HtmlInputElement",
+ "HtmlTextAreaElement",
"HtmlSelectElement",
] }
use gloo::{console::log, events::EventListener, net::http::Request, utils::document};
use wasm_bindgen::prelude::*;
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) {
- if let Err(error) = Request::put(&format!("/ron-api/recipe/{}", api_name))
- .header("Content-Type", "application/ron")
- .body(body)
- .unwrap()
+use crate::{
+ request,
+ toast::{self, Level},
+};
+
+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
{
- toast::show(Level::Info, &format!("Internal server error: {}", error));
- }
-}
-
-async fn reload_recipes_list() {
- match Request::get("/fragments/recipes_list").send().await {
Err(error) => {
toast::show(Level::Info, &format!("Internal server error: {}", error));
}
let input_title = document().get_element_by_id("input-title").unwrap();
let mut current_title = input_title.dyn_ref::<HtmlInputElement>().unwrap().value();
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 = input_title.dyn_ref::<HtmlInputElement>().unwrap();
+ let title = document()
+ .get_element_by_id("input-title")
+ .unwrap()
+ .dyn_into::<HtmlInputElement>()
+ .unwrap();
if title.value() != current_title {
current_title = title.value();
- let body = common::ron_api::to_string(common::ron_api::SetRecipeTitle {
+ let body = ron_api::SetRecipeTitle {
recipe_id,
title: title.value(),
- });
+ };
spawn_local(async move {
- api_request(body, "set_title").await;
- reload_recipes_list().await;
+ let _ = request::put::<(), _>("recipe/set_title", body).await;
+ reload_recipes_list(recipe_id).await;
});
}
});
// Description.
{
- let input_description = document().get_element_by_id("input-description").unwrap();
- let mut current_description = input_description
- .dyn_ref::<HtmlInputElement>()
+ let text_area_description = document()
+ .get_element_by_id("text-area-description")
+ .unwrap();
+ let mut current_description = text_area_description
+ .dyn_ref::<HtmlTextAreaElement>()
.unwrap()
.value();
let on_input_description_blur =
- EventListener::new(&input_description, "blur", move |_event| {
- let input_description = document().get_element_by_id("input-description").unwrap();
- let description = input_description.dyn_ref::<HtmlInputElement>().unwrap();
+ EventListener::new(&text_area_description, "blur", move |_event| {
+ let description = document()
+ .get_element_by_id("text-area-description")
+ .unwrap()
+ .dyn_into::<HtmlTextAreaElement>()
+ .unwrap();
if description.value() != current_description {
current_description = description.value();
- let body = common::ron_api::to_string(common::ron_api::SetRecipeDescription {
+ let body = ron_api::SetRecipeDescription {
recipe_id,
description: description.value(),
- });
+ };
spawn_local(async move {
- api_request(body, "set_description").await;
+ let _ = request::put::<(), _>("recipe/set_description", body).await;
});
}
});
.value();
let on_input_estimated_time_blur =
EventListener::new(&input_estimated_time, "blur", move |_event| {
- let input_estimated_time = document()
+ let estimated_time = document()
.get_element_by_id("input-estimated-time")
+ .unwrap()
+ .dyn_into::<HtmlInputElement>()
.unwrap();
- let estimated_time = input_estimated_time.dyn_ref::<HtmlInputElement>().unwrap();
if estimated_time.value() != current_time {
let time = if estimated_time.value().is_empty() {
None
+ } else if let Ok(t) = estimated_time.value().parse::<u32>() {
+ Some(t)
} else {
- if let Ok(t) = estimated_time.value().parse::<u32>() {
- Some(t)
- } else {
- estimated_time.set_value(¤t_time);
- return;
- }
+ estimated_time.set_value(¤t_time);
+ return;
};
current_time = estimated_time.value();
- let body =
- common::ron_api::to_string(common::ron_api::SetRecipeEstimatedTime {
- recipe_id,
- estimated_time: time,
- });
+ let body = ron_api::SetRecipeEstimatedTime {
+ recipe_id,
+ estimated_time: time,
+ };
spawn_local(async move {
- api_request(body, "set_estimated_time").await;
+ let _ = request::put::<(), _>("recipe/set_estimated_time", body).await;
});
}
});
.value();
let on_select_difficulty_blur =
EventListener::new(&select_difficulty, "blur", move |_event| {
- let select_difficulty = document().get_element_by_id("select-difficulty").unwrap();
- let difficulty = select_difficulty.dyn_ref::<HtmlSelectElement>().unwrap();
+ let difficulty = document()
+ .get_element_by_id("select-difficulty")
+ .unwrap()
+ .dyn_into::<HtmlSelectElement>()
+ .unwrap();
if difficulty.value() != current_difficulty {
current_difficulty = difficulty.value();
- let body = common::ron_api::to_string(common::ron_api::SetRecipeDifficulty {
+ let body = ron_api::SetRecipeDifficulty {
recipe_id,
- difficulty: common::ron_api::Difficulty::try_from(
+ difficulty: ron_api::Difficulty::try_from(
current_difficulty.parse::<u32>().unwrap(),
)
.unwrap(),
- });
+ };
spawn_local(async move {
- api_request(body, "set_difficulty").await;
+ let _ = request::put::<(), _>("recipe/set_difficulty", body).await;
});
}
});
.unwrap()
.value();
let on_select_language_blur = EventListener::new(&select_language, "blur", move |_event| {
- let select_language = document().get_element_by_id("select-language").unwrap();
- let difficulty = select_language.dyn_ref::<HtmlSelectElement>().unwrap();
- if difficulty.value() != current_language {
- current_language = difficulty.value();
+ let language = document()
+ .get_element_by_id("select-language")
+ .unwrap()
+ .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,
- lang: difficulty.value(),
- });
+ lang: language.value(),
+ };
spawn_local(async move {
- api_request(body, "set_language").await;
+ let _ = request::put::<(), _>("recipe/set_language", body).await;
});
}
});
let input_is_published = document().get_element_by_id("input-is-published").unwrap();
let on_input_is_published_blur =
EventListener::new(&input_is_published, "input", move |_event| {
- let input_is_published =
- document().get_element_by_id("input-is-published").unwrap();
- let is_published = input_is_published.dyn_ref::<HtmlInputElement>().unwrap();
+ let is_published = document()
+ .get_element_by_id("input-is-published")
+ .unwrap()
+ .dyn_into::<HtmlInputElement>()
+ .unwrap();
- let body = common::ron_api::to_string(common::ron_api::SetIsPublished {
+ let body = ron_api::SetIsPublished {
recipe_id,
is_published: is_published.checked(),
- });
+ };
spawn_local(async move {
- api_request(body, "set_is_published").await;
- reload_recipes_list().await;
+ let _ = request::put::<(), _>("recipe/set_is_published", body).await;
+ reload_recipes_list(recipe_id).await;
});
});
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(())
}
mod handles;
+mod request;
mod toast;
mod utils;
-use gloo::{console::log, events::EventListener, utils::window};
+use gloo::utils::window;
use wasm_bindgen::prelude::*;
-use web_sys::console;
// #[wasm_bindgen]
// extern "C" {
let location = window().location().pathname()?;
let path: Vec<&str> = location.split('/').skip(1).collect();
- match path[..] {
- ["recipe", "edit", id] => {
- let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
- handles::recipe_edit(id)?;
- }
+ if let ["recipe", "edit", id] = path[..] {
+ let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
+ handles::recipe_edit(id)?;
// Disable: user editing data are now submitted as classic form data.
// ["user", "edit"] => {
// handles::user_edit(document)?;
// }
- _ => (),
}
Ok(())
--- /dev/null
+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()),
+// }
+// }
-use gloo::{console::log, timers::callback::Timeout, utils::document};
-use web_sys::{console, Document, HtmlInputElement};
+use gloo::{timers::callback::Timeout, utils::document};
pub enum Level {
Success,