Groups can now be ordered (via drag and drop)

This commit is contained in:
Greg Burri 2025-01-10 22:38:34 +01:00
parent 16c484c2d1
commit 975d1ceee2
14 changed files with 461 additions and 54 deletions

View file

@ -70,6 +70,8 @@ body {
.drag-handle {
width: 20px;
height: 20px;
display: inline-block;
vertical-align: bottom;
background-color: blue;
}
@ -92,7 +94,6 @@ body {
border: 0;
}
.recipe-item {
padding: 4px;
}
@ -118,6 +119,13 @@ body {
h1 {
text-align: center;
}
}
#recipe-edit {
.drag-handle {
cursor: move;
}
.group {
border: 0.1em solid lighten($color-3, 30%);
@ -131,6 +139,21 @@ body {
border: 0.1em solid lighten($color-3, 30%);
}
.dropzone-group,
.dropzone-step {
height: 10px;
background-color: white;
&.active {
background-color: blue;
}
&.hover {
background-color: red;
}
}
#hidden-templates {
display: none;
}

View file

@ -20,17 +20,17 @@ VALUES (
NULL
);
INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
VALUES (1, 1, 'Croissant au jambon', true);
INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
VALUES (1, 1, 'Croissant au jambon', true, '2025-01-07T10:41:05.697884837+00:00');
INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
VALUES (2, 1, 'Gratin de thon aux olives', true);
INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
VALUES (2, 1, 'Gratin de thon aux olives', true, '2025-01-07T10:41:05.697884837+00:00');
INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
VALUES (3, 1, 'Saumon en croute', true);
INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
VALUES (3, 1, 'Saumon en croute', true, '2025-01-07T10:41:05.697884837+00:00');
INSERT INTO [Recipe] ([id], [user_id], [title], [is_published])
VALUES (4, 2, 'Ouiche lorraine', true);
INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
VALUES (4, 2, 'Ouiche lorraine', true, '2025-01-07T10:41:05.697884837+00:00');
-- Groups, steps and ingredients for 'Gratin de thon'.

View file

@ -28,6 +28,7 @@ CREATE TABLE [User] (
[is_admin] INTEGER NOT NULL DEFAULT FALSE
) STRICT;
CREATE INDEX [validation_token_index] ON [User]([validation_token]);
CREATE UNIQUE INDEX [User_email_index] ON [User]([email]);
CREATE TABLE [UserLoginToken] (
@ -58,6 +59,7 @@ CREATE TABLE [Recipe] (
[difficulty] INTEGER NOT NULL DEFAULT 0,
[servings] INTEGER DEFAULT 4,
[is_published] INTEGER NOT NULL DEFAULT FALSE,
[creation_datetime] TEXT NOT NULL,
FOREIGN KEY([user_id]) REFERENCES [User]([id]) ON DELETE SET NULL
) STRICT;

View file

@ -1,3 +1,6 @@
use chrono::prelude::*;
use itertools::Itertools;
use super::{Connection, DBError, Result};
use crate::data::model;
@ -17,7 +20,7 @@ impl Connection {
SELECT [id], [title]
FROM [Recipe]
WHERE [is_published] = true AND ([lang] = $1 OR [user_id] = $2)
ORDER BY [title]
ORDER BY [title] COLLATE NOCASE
"#,
)
.bind(lang)
@ -28,7 +31,7 @@ impl Connection {
SELECT [id], [title]
FROM [Recipe]
WHERE [is_published] = true AND [lang] = $1
ORDER BY [title]
ORDER BY [title] COLLATE NOCASE
"#,
)
.bind(lang)
@ -83,6 +86,31 @@ WHERE [Group].[id] = $1 AND [user_id] = $2
.map_err(DBError::from)
}
pub async fn can_edit_recipe_all_groups(
&self,
user_id: i64,
group_ids: &[i64],
) -> Result<bool> {
let params = (0..group_ids.len())
.map(|n| format!("${}", n + 2))
.join(", ");
let query_str = format!(
r#"
SELECT COUNT(*)
FROM [Recipe]
INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
WHERE [Group].[id] IN ({}) AND [user_id] = $1
"#,
params
);
let mut query = sqlx::query_scalar::<_, u64>(&query_str).bind(user_id);
for id in group_ids {
query = query.bind(id);
}
Ok(query.fetch_one(&self.pool).await? == group_ids.len() as u64)
}
pub async fn can_edit_recipe_step(&self, user_id: i64, step_id: i64) -> Result<bool> {
sqlx::query_scalar(
r#"
@ -173,10 +201,11 @@ WHERE [Recipe].[user_id] = $1
.await?;
let db_result = sqlx::query(
"INSERT INTO [Recipe] ([user_id], [lang], [title]) VALUES ($1, $2, '')",
"INSERT INTO [Recipe] ([user_id], [lang], [title], [creation_datetime]) VALUES ($1, $2, '', $3)",
)
.bind(user_id)
.bind(lang)
.bind(Utc::now())
.execute(&mut *tx)
.await?;
@ -482,6 +511,22 @@ ORDER BY [name]
.map_err(DBError::from)
}
pub async fn set_groups_order(&self, group_ids: &[i64]) -> Result<()> {
let mut tx = self.tx().await?;
for (order, id) in group_ids.iter().enumerate() {
sqlx::query("UPDATE [Group] SET [order] = $2 WHERE [id] = $1")
.bind(id)
.bind(order as i64)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn add_recipe_step(&self, group_id: i64) -> Result<i64> {
let db_result = sqlx::query("INSERT INTO [Step] ([group_id]) VALUES ($1)")
.bind(group_id)

View file

@ -410,6 +410,28 @@ WHERE [email] = $1
Ok(GetTokenResetPasswordResult::Ok(token))
}
pub async fn is_reset_password_token_valid(
&self,
token: &str,
validation_time: Duration,
) -> Result<bool> {
if let Some(Some(db_datetime)) = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(
r#"
SELECT [password_reset_datetime]
FROM [User]
WHERE [password_reset_token] = $1
"#,
)
.bind(token)
.fetch_optional(&self.pool)
.await?
{
Ok(Utc::now() - db_datetime <= validation_time)
} else {
Ok(false)
}
}
pub async fn reset_password(
&self,
new_password: &str,

View file

@ -118,6 +118,10 @@ async fn main() {
"/recipe/set_group_comment",
put(services::ron::set_group_comment),
)
.route(
"/recipe/set_groups_order",
put(services::ron::set_group_orders),
)
.route("/recipe/add_step", post(services::ron::add_step))
.route("/recipe/remove_step", delete(services::ron::rm_step))
.route(

View file

@ -104,6 +104,25 @@ async fn check_user_rights_recipe_group(
}
}
async fn check_user_rights_recipe_groups(
connection: &db::Connection,
user: &Option<model::User>,
group_ids: &[i64],
) -> Result<()> {
if user.is_none()
|| !connection
.can_edit_recipe_all_groups(user.as_ref().unwrap().id, group_ids)
.await?
{
Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED,
NOT_AUTHORIZED_MESSAGE,
)))
} else {
Ok(())
}
}
async fn check_user_rights_recipe_step(
connection: &db::Connection,
user: &Option<model::User>,
@ -396,6 +415,17 @@ pub async fn set_group_comment(
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn set_group_orders(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetGroupOrders>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe_groups(&connection, &user, &ron.group_ids).await?;
connection.set_groups_order(&ron.group_ids).await?;
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn add_step(
State(connection): State<db::Connection>,

View file

@ -463,19 +463,34 @@ pub async fn ask_reset_password_post(
#[debug_handler]
pub async fn reset_password_get(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Query(query): Query<HashMap<String, String>>,
) -> Result<Response> {
if let Some(reset_token) = query.get("reset_token") {
Ok(ResetPasswordTemplate {
user,
tr,
reset_token,
message: "",
message_password: "",
// Check if the token is valid.
if connection
.is_reset_password_token_valid(
reset_token,
Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION),
)
.await?
{
Ok(ResetPasswordTemplate {
user,
tr,
reset_token,
message: "",
message_password: "",
}
.into_response())
} else {
Ok(
MessageTemplate::new_with_user(tr.t(Sentence::AskResetTokenMissing), tr, user)
.into_response(),
)
}
.into_response())
} else {
Ok(
MessageTemplate::new_with_user(tr.t(Sentence::AskResetTokenMissing), tr, user)

View file

@ -80,13 +80,13 @@
<input id="input-delete" type="button" value="{{ tr.t(Sentence::RecipeDelete) }}" />
<div id="groups-container">
<div class="dropzone-group"></div>
</div>
<input id="input-add-group" type="button" value="{{ tr.t(Sentence::RecipeAddAGroup) }}" />
<div id="hidden-templates">
<div class="group">
<div class="drag-handle"></div>
<span class="drag-handle"></span>
<label for="input-group-name">{{ tr.t(Sentence::RecipeGroupName) }}</label>
<input class="input-group-name" type="text" />
@ -96,13 +96,15 @@
<input class="input-group-delete" type="button" value="{{ tr.t(Sentence::RecipeRemoveGroup) }}" />
<div class="steps"></div>
<div class="steps">
<div class="dropzone-step"></div>
</div>
<input class="input-add-step" type="button" value="{{ tr.t(Sentence::RecipeAddAStep) }}" />
</div>
<div class="step">
<div class="drag-handle"></div>
<span class="drag-handle"></span>
<label for="text-area-step-action">{{ tr.t(Sentence::RecipeStepAction) }}</label>
<textarea class="text-area-step-action"></textarea>