Recipe edit (WIP): add API to set some recipe values

This commit is contained in:
Greg Burri 2024-12-23 01:37:01 +01:00
parent c6dfff065c
commit dd05a673d9
20 changed files with 690 additions and 2189 deletions

4
Cargo.lock generated
View file

@ -2622,9 +2622,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.90" version = "2.0.91"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

1951
backend/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -79,7 +79,7 @@ body {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
// .recipes-list { // #recipes-list {
// text-align: left; // text-align: left;
// } // }

View file

@ -18,14 +18,14 @@ VALUES (
NULL NULL
); );
INSERT INTO [Recipe] ([user_id], [title]) INSERT INTO [Recipe] ([user_id], [title], [is_published])
VALUES (1, 'Croissant au jambon'); VALUES (1, 'Croissant au jambon', true);
INSERT INTO [Recipe] ([user_id], [title]) INSERT INTO [Recipe] ([user_id], [title], [is_published])
VALUES (1, 'Gratin de thon aux olives'); VALUES (1, 'Gratin de thon aux olives', true);
INSERT INTO [Recipe] ([user_id], [title]) INSERT INTO [Recipe] ([user_id], [title], [is_published])
VALUES (1, 'Saumon en croute'); VALUES (1, 'Saumon en croute', true);
INSERT INTO [Recipe] ([user_id], [title]) INSERT INTO [Recipe] ([user_id], [title], [is_published])
VALUES (2, 'Ouiche lorraine'); VALUES (2, 'Ouiche lorraine', true);

View file

@ -1,17 +1,50 @@
use super::{Connection, DBError, Result}; use super::{Connection, DBError, Result};
use crate::{ use crate::{consts, data::model};
consts,
data::model::{self, Difficulty}, use common::ron_api::Difficulty;
};
impl Connection { impl Connection {
pub async fn get_all_recipe_titles(&self) -> Result<Vec<(i64, String)>> { pub async fn get_all_published_recipe_titles(&self) -> Result<Vec<(i64, String)>> {
sqlx::query_as("SELECT [id], [title] FROM [Recipe] ORDER BY [title]") sqlx::query_as(
r#"
SELECT [id], [title]
FROM [Recipe]
WHERE [is_published] = true
ORDER BY [title]
"#,
)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(DBError::from) .map_err(DBError::from)
} }
pub async fn get_all_unpublished_recipe_titles(
&self,
owned_by: i64,
) -> Result<Vec<(i64, String)>> {
sqlx::query_as(
r#"
SELECT [id], [title]
FROM [Recipe]
WHERE [is_published] = false AND [user_id] = $1
ORDER BY [title]
"#,
)
.bind(owned_by)
.fetch_all(&self.pool)
.await
.map_err(DBError::from)
}
pub async fn can_edit_recipe(&self, user_id: i64, recipe_id: i64) -> Result<bool> {
sqlx::query_scalar(r#"SELECT COUNT(*) FROM [Recipe] WHERE [id] = $1 AND [user_id] = $2"#)
.bind(recipe_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#"

View file

@ -1,4 +1,5 @@
use chrono::prelude::*; use chrono::prelude::*;
use common::ron_api::Difficulty;
use sqlx::{self, FromRow}; use sqlx::{self, FromRow};
#[derive(Debug, Clone, FromRow)] #[derive(Debug, Clone, FromRow)]
@ -50,29 +51,3 @@ pub struct Ingredient {
pub quantity: i32, pub quantity: i32,
pub quantity_unit: String, pub quantity_unit: String,
} }
#[derive(PartialEq, Debug)]
pub enum Difficulty {
Unknown = 0,
Easy = 1,
Medium = 2,
Hard = 3,
}
impl TryFrom<u32> for Difficulty {
type Error = &'static str;
fn try_from(value: u32) -> Result<Self, Self::Error> {
Ok(match value {
1 => Self::Easy,
2 => Self::Medium,
3 => Self::Hard,
_ => Self::Unknown,
})
}
}
impl From<Difficulty> for u32 {
fn from(value: Difficulty) -> Self {
value as u32
}
}

View file

@ -3,10 +3,17 @@ use askama::Template;
use crate::data::model; use crate::data::model;
pub struct Recipes { pub struct Recipes {
pub list: Vec<(i64, String)>, pub published: Vec<(i64, String)>,
pub unpublished: Vec<(i64, String)>,
pub current_id: Option<i64>, pub current_id: Option<i64>,
} }
impl Recipes {
pub fn is_current(&self, id: &&i64) -> bool {
self.current_id == Some(**id)
}
}
#[derive(Template)] #[derive(Template)]
#[template(path = "home.html")] #[template(path = "home.html")]
pub struct HomeTemplate { pub struct HomeTemplate {
@ -111,3 +118,10 @@ pub struct RecipeEditTemplate {
pub recipe: model::Recipe, pub recipe: model::Recipe,
pub languages: [(&'static str, &'static str); 2], pub languages: [(&'static str, &'static str); 2],
} }
#[derive(Template)]
#[template(path = "recipes_list_fragment.html")]
pub struct RecipesListFragmentTemplate {
pub user: Option<model::User>,
pub recipes: Recipes,
}

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, routing::{get, put},
Router, Router,
}; };
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
@ -86,8 +86,28 @@ async fn main() {
let ron_api_routes = Router::new() let ron_api_routes = Router::new()
// Disabled: update user profile is now made with a post data ('edit_user_post'). // Disabled: update user profile is now made with a post data ('edit_user_post').
// .route("/user/update", put(services::ron::update_user)) // .route("/user/update", put(services::ron::update_user))
.route("/recipe/set_title", put(services::ron::set_recipe_title))
.route(
"/recipe/set_description",
put(services::ron::set_recipe_description),
)
.route(
"/recipe/set_estimated_time",
put(services::ron::set_estimated_time),
)
.route("/recipe/set_difficulty", put(services::ron::set_difficulty))
.route("/recipe/set_language", put(services::ron::set_language))
.route(
"/recipe/set_is_published",
put(services::ron::set_is_published),
)
.fallback(services::ron::not_found); .fallback(services::ron::not_found);
let fragments_routes = Router::new().route(
"/recipes_list",
get(services::fragments::recipes_list_fragments),
);
let html_routes = Router::new() let html_routes = Router::new()
.route("/", get(services::home_page)) .route("/", get(services::home_page))
.route( .route(
@ -123,6 +143,7 @@ async fn main() {
let app = Router::new() let app = Router::new()
.merge(html_routes) .merge(html_routes)
.nest("/fragments", fragments_routes)
.nest("/ron-api", ron_api_routes) .nest("/ron-api", ron_api_routes)
.fallback(services::not_found) .fallback(services::not_found)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())

View file

@ -13,6 +13,7 @@ use crate::{
ron_utils, ron_utils,
}; };
pub mod fragments;
pub mod recipe; pub mod recipe;
pub mod ron; pub mod ron;
pub mod user; pub mod user;
@ -46,15 +47,19 @@ pub async fn home_page(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
let recipes = connection.get_all_recipe_titles().await?; let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?,
Ok(HomeTemplate { unpublished: if let Some(user) = user.as_ref() {
user, connection
recipes: Recipes { .get_all_unpublished_recipe_titles(user.id)
list: recipes, .await?
current_id: None, } else {
vec![]
}, },
}) current_id: None,
};
Ok(HomeTemplate { user, recipes })
} }
///// 404 ///// ///// 404 /////

View file

@ -0,0 +1,31 @@
use axum::{
debug_handler,
extract::{Extension, State},
response::{IntoResponse, Result},
};
// use tracing::{event, Level};
use crate::{
data::{db, model},
html_templates::*,
};
#[debug_handler]
pub async fn recipes_list_fragments(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
) -> Result<impl IntoResponse> {
let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?,
unpublished: if let Some(user) = user.as_ref() {
connection
.get_all_unpublished_recipe_titles(user.id)
.await?
} else {
vec![]
},
current_id: None,
};
Ok(RecipesListFragmentTemplate { user, recipes })
}

View file

@ -35,18 +35,23 @@ pub async fn edit_recipe(
if let Some(user) = user { if let Some(user) = user {
let recipe = connection.get_recipe(recipe_id).await?.unwrap(); let recipe = connection.get_recipe(recipe_id).await?.unwrap();
if recipe.user_id == user.id { if recipe.user_id == user.id {
let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?,
unpublished: connection
.get_all_unpublished_recipe_titles(user.id)
.await?,
current_id: Some(recipe_id),
};
Ok(RecipeEditTemplate { Ok(RecipeEditTemplate {
user: Some(user), user: Some(user),
recipes: Recipes { recipes,
list: connection.get_all_recipe_titles().await?,
current_id: Some(recipe_id),
},
recipe, recipe,
languages: consts::LANGUAGES, languages: consts::LANGUAGES,
} }
.into_response()) .into_response())
} else { } else {
Ok(MessageTemplate::new("Unable to edit this recipe").into_response()) Ok(MessageTemplate::new("Not allowed to edit this recipe").into_response())
} }
} else { } else {
Ok(MessageTemplate::new("Not logged in").into_response()) Ok(MessageTemplate::new("Not logged in").into_response())
@ -59,17 +64,37 @@ pub async fn view(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Path(recipe_id): Path<i64>, Path(recipe_id): Path<i64>,
) -> Result<Response> { ) -> Result<Response> {
let recipes = connection.get_all_recipe_titles().await?;
match connection.get_recipe(recipe_id).await? { match connection.get_recipe(recipe_id).await? {
Some(recipe) => Ok(RecipeViewTemplate { Some(recipe) => {
if !recipe.is_published
&& (user.is_none() || recipe.user_id != user.as_ref().unwrap().id)
{
return Ok(MessageTemplate::new_with_user(
&format!("Not allowed the view the recipe {}", recipe_id),
user, user,
recipes: Recipes { )
list: recipes, .into_response());
current_id: Some(recipe.id), }
let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?,
unpublished: if let Some(user) = user.as_ref() {
connection
.get_all_unpublished_recipe_titles(user.id)
.await?
} else {
vec![]
}, },
current_id: Some(recipe_id),
};
Ok(RecipeViewTemplate {
user,
recipes,
recipe, recipe,
} }
.into_response()), .into_response())
}
None => Ok(MessageTemplate::new_with_user( None => Ok(MessageTemplate::new_with_user(
&format!("Cannot find the recipe {}", recipe_id), &format!("Cannot find the recipe {}", recipe_id),
user, user,

View file

@ -81,6 +81,103 @@ pub async fn update_user(
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
async fn check_user_rights(
connection: &db::Connection,
user: &Option<model::User>,
recipe_id: i64,
) -> Result<()> {
if user.is_none()
|| !connection
.can_edit_recipe(user.as_ref().unwrap().id, recipe_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?;
connection
.set_recipe_title(ron.recipe_id, &ron.title)
.await?;
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn set_recipe_description(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeDescription>,
) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_description(ron.recipe_id, &ron.description)
.await?;
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn set_estimated_time(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeEstimatedTime>,
) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_estimated_time(ron.recipe_id, ron.estimated_time)
.await?;
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn set_difficulty(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeDifficulty>,
) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_difficulty(ron.recipe_id, ron.difficulty)
.await?;
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn set_language(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeLanguage>,
) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_language(ron.recipe_id, &ron.lang)
.await?;
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn set_is_published(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetIsPublished>,
) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_is_published(ron.recipe_id, ron.is_published)
.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

@ -1,38 +1,6 @@
{% extends "base_with_header.html" %} {% extends "base_with_header.html" %}
{% macro recipe_item(id, title, class) %}
<a href="/recipe/view/{{ id }}" class="{{ class }}">
{% if title == "" %}
{# TODO: Translation #}
No title defined
{% else %}
{{ title }}
{% endif %}
</a>
{% endmacro %}
{% block main_container %} {% block main_container %}
<nav class="recipes-list"> {% include "recipes_list_fragment.html" %}
<ul>
{% for (id, title) in recipes.list %}
<li>
{% match recipes.current_id %}
{# Don't know how to avoid
repetition: comparing (using '==' or .eq()) recipes.current_recipe_id.unwrap() and id doesn't work.
Guards for match don't exist.
See: https://github.com/djc/askama/issues/752 #}
{% when Some(current_id) %}
{% if current_id == id %}
{% call recipe_item(id, title, "recipe-item-current") %}
{% else %}
{% call recipe_item(id, title, "recipe-item") %}
{% endif %}
{% when None %}
{% call recipe_item(id, title, "recipe-item") %}
{% endmatch %}
</li>
{% endfor %}
</ul>
</nav>
{% block content %}{% endblock %} {% block content %}{% endblock %}
{% endblock %} {% endblock %}

View file

@ -42,10 +42,10 @@
<label for="select-difficulty">Difficulty</label> <label for="select-difficulty">Difficulty</label>
<select id="select-difficulty" name="difficulty"> <select id="select-difficulty" name="difficulty">
<option value="0" {%+ call is_difficulty(crate::data::model::Difficulty::Unknown) %}> - </option> <option value="0" {%+ call is_difficulty(common::ron_api::Difficulty::Unknown) %}> - </option>
<option value="1" {%+ call is_difficulty(crate::data::model::Difficulty::Easy) %}>Easy</option> <option value="1" {%+ call is_difficulty(common::ron_api::Difficulty::Easy) %}>Easy</option>
<option value="2" {%+ call is_difficulty(crate::data::model::Difficulty::Medium) %}>Medium</option> <option value="2" {%+ call is_difficulty(common::ron_api::Difficulty::Medium) %}>Medium</option>
<option value="3" {%+ call is_difficulty(crate::data::model::Difficulty::Hard) %}>Hard</option> <option value="3" {%+ call is_difficulty(common::ron_api::Difficulty::Hard) %}>Hard</option>
</select> </select>
<label for="select-language">Language</label> <label for="select-language">Language</label>
@ -59,7 +59,10 @@
id="input-is-published" id="input-is-published"
type="checkbox" type="checkbox"
name="is-published" name="is-published"
value="{{ recipe.is_published }}" /> {%+ if recipe.is_published %}
checked
{% endif %}
>
<label for="input-is-published">Is published</label> <label for="input-is-published">Is published</label>
<div id="groups-container"> <div id="groups-container">

View file

@ -0,0 +1,50 @@
{% macro recipe_item(id, title, class) %}
<a href="/recipe/view/{{ id }}" class="{{ class }}" id="recipe-{{ id }}">
{% if title == "" %}
{# TODO: Translation #}
Untitled recipe
{% else %}
{{ title }}
{% endif %}
</a>
{% endmacro %}
<div id="recipes-list">
{% if !recipes.unpublished.is_empty() %}
Unpublished recipes
{% endif %}
<nav class="recipes-list-unpublished">
<ul>
{% for (id, title) in recipes.unpublished %}
<li>
{% if recipes.is_current(id) %}
{% call recipe_item(id, title, "recipe-item-current") %}
{% else %}
{% call recipe_item(id, title, "recipe-item") %}
{% endif %}
</li>
{% endfor %}
</ul>
</nav>
{% if !recipes.unpublished.is_empty() %}
<hr />
{% endif %}
<nav class="recipes-list-published">
<ul>
{% for (id, title) in recipes.published %}
<li>
{% if recipes.is_current(id) %}
{% call recipe_item(id, title, "recipe-item-current") %}
{% else %}
{% call recipe_item(id, title, "recipe-item") %}
{% endif %}
</li>
{% endfor %}
</ul>
</nav>
</div>

View file

@ -16,84 +16,134 @@ pub struct SetRecipeDescription {
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct AddRecipeImage { pub struct SetRecipeEstimatedTime {
pub recipe_id: i64, pub recipe_id: i64,
pub image: Vec<u8>, pub estimated_time: Option<u32>,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum Difficulty {
Unknown = 0,
Easy = 1,
Medium = 2,
Hard = 3,
}
impl TryFrom<u32> for Difficulty {
type Error = &'static str;
fn try_from(value: u32) -> Result<Self, Self::Error> {
Ok(match value {
1 => Self::Easy,
2 => Self::Medium,
3 => Self::Hard,
_ => Self::Unknown,
})
}
}
impl From<Difficulty> for u32 {
fn from(value: Difficulty) -> Self {
value as u32
}
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct AddRecipeImageReply { pub struct SetRecipeDifficulty {
pub image_id: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RemoveRecipeImage {
pub image_id: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct AddRecipeIngredient {
pub group_id: i64,
pub name: String,
pub quantity_value: Option<f64>,
pub quantity_unit: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct AddRecipeIngredientReply {
pub ingredient_id: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RemoveRecipeIngredient {
pub group_id: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct SetRecipeIngredientsOrder {
pub group_id: i64,
pub ingredient_ids: Vec<i64>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct AddRecipeGroup {
pub recipe_id: i64, pub recipe_id: i64,
pub name: String, pub difficulty: Difficulty,
pub quantity_value: Option<f64>,
pub quantity_unit: String,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct AddRecipeGroupReply { pub struct SetRecipeLanguage {
pub group_id: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RemoveRecipeGroupReply {
pub group_id: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct SetRecipeGroupsOrder {
pub recipe_id: i64, pub recipe_id: i64,
pub group_ids: Vec<i64>, pub lang: String,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct AddRecipeStep { pub struct SetIsPublished {
pub group_id: i64, pub recipe_id: i64,
pub name: String, pub is_published: bool,
} }
#[derive(Serialize, Deserialize, Clone)] // #[derive(Serialize, Deserialize, Clone)]
pub struct AddRecipeStepReply { // pub struct AddRecipeImage {
pub step_id: i64, // pub recipe_id: i64,
} // pub image: Vec<u8>,
// }
#[derive(Serialize, Deserialize, Clone)] // #[derive(Serialize, Deserialize, Clone)]
pub struct RemoveRecipeStep { // pub struct AddRecipeImageReply {
pub step_id: i64, // pub image_id: i64,
} // }
// #[derive(Serialize, Deserialize, Clone)]
// pub struct RemoveRecipeImage {
// pub image_id: i64,
// }
// #[derive(Serialize, Deserialize, Clone)]
// pub struct AddRecipeIngredient {
// pub group_id: i64,
// pub name: String,
// pub quantity_value: Option<f64>,
// pub quantity_unit: String,
// }
// #[derive(Serialize, Deserialize, Clone)]
// pub struct AddRecipeIngredientReply {
// pub ingredient_id: i64,
// }
// #[derive(Serialize, Deserialize, Clone)]
// pub struct RemoveRecipeIngredient {
// pub group_id: i64,
// }
// #[derive(Serialize, Deserialize, Clone)]
// pub struct SetRecipeIngredientsOrder {
// pub group_id: i64,
// pub ingredient_ids: Vec<i64>,
// }
// #[derive(Serialize, Deserialize, Clone)]
// pub struct AddRecipeGroup {
// pub recipe_id: i64,
// pub name: String,
// pub quantity_value: Option<f64>,
// pub quantity_unit: String,
// }
// #[derive(Serialize, Deserialize, Clone)]
// pub struct AddRecipeGroupReply {
// pub group_id: i64,
// }
// #[derive(Serialize, Deserialize, Clone)]
// pub struct RemoveRecipeGroupReply {
// pub group_id: i64,
// }
// #[derive(Serialize, Deserialize, Clone)]
// pub struct SetRecipeGroupsOrder {
// pub recipe_id: i64,
// pub group_ids: Vec<i64>,
// }
// #[derive(Serialize, Deserialize, Clone)]
// pub struct AddRecipeStep {
// pub group_id: i64,
// pub name: String,
// }
// #[derive(Serialize, Deserialize, Clone)]
// pub struct AddRecipeStepReply {
// pub step_id: i64,
// }
// #[derive(Serialize, Deserialize, Clone)]
// pub struct RemoveRecipeStep {
// pub step_id: i64,
// }
///// PROFILE ///// ///// PROFILE /////

View file

@ -26,6 +26,7 @@ web-sys = { version = "0.3", features = [
"EventTarget", "EventTarget",
"HtmlLabelElement", "HtmlLabelElement",
"HtmlInputElement", "HtmlInputElement",
"HtmlSelectElement",
] } ] }
gloo = "0.11" gloo = "0.11"

View file

@ -1,73 +1,254 @@
use gloo::{console::log, events::EventListener, net::http::Request}; 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}; use web_sys::{Document, HtmlInputElement, HtmlSelectElement};
use crate::toast::{self, Level}; use crate::toast::{self, Level};
pub fn recipe_edit(doc: Document) -> Result<(), JsValue> { async fn api_request(body: String, api_name: &str) {
let title_input = doc.get_element_by_id("title_field").unwrap(); if let Err(error) = Request::put(&format!("/ron-api/recipe/{}", api_name))
Ok(())
}
pub fn user_edit(doc: Document) -> Result<(), JsValue> {
log!("user_edit");
let button = doc
.query_selector("#user-edit input[type='button']")?
.unwrap();
let on_click_submit = EventListener::new(&button, "click", move |_event| {
log!("Click!");
let input_name = doc.get_element_by_id("input-name").unwrap();
let name = input_name.dyn_ref::<HtmlInputElement>().unwrap().value();
let update_data = common::ron_api::UpdateProfile {
name: Some(name),
email: None,
password: None,
};
let body = common::ron_api::to_string(update_data);
let doc = doc.clone();
spawn_local(async move {
match Request::put("/ron-api/user/update")
.header("Content-Type", "application/ron") .header("Content-Type", "application/ron")
.body(body) .body(body)
.unwrap() .unwrap()
.send() .send()
.await .await
{ {
Ok(resp) => { toast::show(Level::Info, &format!("Internal server error: {}", error));
log!("Status code: {}", resp.status());
if resp.status() == 200 {
toast::show(Level::Info, "Profile saved", doc);
} else {
toast::show(
Level::Error,
&format!(
"Status code: {} {}",
resp.status(),
resp.text().await.unwrap()
),
doc,
);
} }
} }
Err(error) => {
toast::show(
Level::Info,
&format!("Internal server error: {}", error),
doc,
);
}
}
});
});
on_click_submit.forget(); async fn reload_recipes_list() {
match Request::get("/fragments/recipes_list").send().await {
Err(error) => {
toast::show(Level::Info, &format!("Internal server error: {}", error));
}
Ok(response) => {
let list = document().get_element_by_id("recipes-list").unwrap();
list.set_outer_html(&response.text().await.unwrap());
}
}
}
pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
// Title.
{
let 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();
if title.value() != current_title {
current_title = title.value();
let body = common::ron_api::to_string(common::ron_api::SetRecipeTitle {
recipe_id,
title: title.value(),
});
spawn_local(async move {
api_request(body, "set_title").await;
reload_recipes_list().await;
});
}
});
on_input_title_blur.forget();
}
// Description.
{
let input_description = document().get_element_by_id("input-description").unwrap();
let mut current_description = input_description
.dyn_ref::<HtmlInputElement>()
.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();
if description.value() != current_description {
current_description = description.value();
let body = common::ron_api::to_string(common::ron_api::SetRecipeDescription {
recipe_id,
description: description.value(),
});
spawn_local(async move {
api_request(body, "set_description").await;
});
}
});
on_input_description_blur.forget();
}
// Estimated time.
{
let input_estimated_time = document()
.get_element_by_id("input-estimated-time")
.unwrap();
let mut current_time = input_estimated_time
.dyn_ref::<HtmlInputElement>()
.unwrap()
.value();
let on_input_estimated_time_blur =
EventListener::new(&input_estimated_time, "blur", move |_event| {
let input_estimated_time = document()
.get_element_by_id("input-estimated-time")
.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 {
estimated_time.set_value(&current_time);
return;
}
};
current_time = estimated_time.value();
let body =
common::ron_api::to_string(common::ron_api::SetRecipeEstimatedTime {
recipe_id,
estimated_time: time,
});
spawn_local(async move {
api_request(body, "set_estimated_time").await;
});
}
});
on_input_estimated_time_blur.forget();
}
// Difficulty.
{
let select_difficulty = document().get_element_by_id("select-difficulty").unwrap();
let mut current_difficulty = select_difficulty
.dyn_ref::<HtmlSelectElement>()
.unwrap()
.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();
if difficulty.value() != current_difficulty {
current_difficulty = difficulty.value();
let body = common::ron_api::to_string(common::ron_api::SetRecipeDifficulty {
recipe_id,
difficulty: common::ron_api::Difficulty::try_from(
current_difficulty.parse::<u32>().unwrap(),
)
.unwrap(),
});
spawn_local(async move {
api_request(body, "set_difficulty").await;
});
}
});
on_select_difficulty_blur.forget();
}
// Language.
{
let select_language = document().get_element_by_id("select-language").unwrap();
let mut current_language = select_language
.dyn_ref::<HtmlSelectElement>()
.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 body = common::ron_api::to_string(common::ron_api::SetRecipeLanguage {
recipe_id,
lang: difficulty.value(),
});
spawn_local(async move {
api_request(body, "set_language").await;
});
}
});
on_select_language_blur.forget();
}
// Is published.
{
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 body = common::ron_api::to_string(common::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;
});
});
on_input_is_published_blur.forget();
}
Ok(()) Ok(())
} }
// pub fn user_edit(doc: Document) -> Result<(), JsValue> {
// log!("user_edit");
// let button = doc
// .query_selector("#user-edit input[type='button']")?
// .unwrap();
// let on_click_submit = EventListener::new(&button, "click", move |_event| {
// log!("Click!");
// let input_name = doc.get_element_by_id("input-name").unwrap();
// let name = input_name.dyn_ref::<HtmlInputElement>().unwrap().value();
// let update_data = common::ron_api::UpdateProfile {
// name: Some(name),
// email: None,
// password: None,
// };
// let body = common::ron_api::to_string(update_data);
// let doc = doc.clone();
// spawn_local(async move {
// match Request::put("/ron-api/user/update")
// .header("Content-Type", "application/ron")
// .body(body)
// .unwrap()
// .send()
// .await
// {
// Ok(resp) => {
// log!("Status code: {}", resp.status());
// if resp.status() == 200 {
// toast::show(Level::Info, "Profile saved");
// } else {
// toast::show(
// Level::Error,
// &format!(
// "Status code: {} {}",
// resp.status(),
// resp.text().await.unwrap()
// ),
// );
// }
// }
// Err(error) => {
// toast::show(Level::Info, &format!("Internal server error: {}", error));
// }
// }
// });
// });
// on_click_submit.forget();
// Ok(())
// }

View file

@ -2,7 +2,7 @@ mod handles;
mod toast; mod toast;
mod utils; mod utils;
use gloo::{console::log, events::EventListener}; use gloo::{console::log, events::EventListener, utils::window};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::console; use web_sys::console;
@ -21,18 +21,16 @@ use web_sys::console;
pub fn main() -> Result<(), JsValue> { pub fn main() -> Result<(), JsValue> {
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
let window = web_sys::window().expect("no global `window` exists"); // let window = web_sys::window().expect("no global `window` exists");
let document = window.document().expect("should have a document on window"); // let document = window.document().expect("should have a document on window");
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[..] { match path[..] {
["recipe", "edit", id] => { ["recipe", "edit", id] => {
let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap. let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
log!("recipe edit ID: {}", id); handles::recipe_edit(id)?;
handles::recipe_edit(document)?;
} }
// Disable: user editing data are now submitted as classic form data. // Disable: user editing data are now submitted as classic form data.

View file

@ -1,4 +1,4 @@
use gloo::{console::log, timers::callback::Timeout}; use gloo::{console::log, timers::callback::Timeout, utils::document};
use web_sys::{console, Document, HtmlInputElement}; use web_sys::{console, Document, HtmlInputElement};
pub enum Level { pub enum Level {
@ -8,8 +8,8 @@ pub enum Level {
Warning, Warning,
} }
pub fn show(level: Level, message: &str, doc: Document) { pub fn show(level: Level, message: &str) {
let toast_element = doc.get_element_by_id("toast").unwrap(); let toast_element = document().get_element_by_id("toast").unwrap();
toast_element.set_inner_html(message); toast_element.set_inner_html(message);
toast_element.set_class_name("show"); toast_element.set_class_name("show");