Groups can now be ordered (via drag and drop)
This commit is contained in:
parent
16c484c2d1
commit
975d1ceee2
14 changed files with 461 additions and 54 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue