Translation support + french.
authorGreg Burri <greg.burri@gmail.com>
Mon, 6 Jan 2025 15:04:48 +0000 (16:04 +0100)
committerGreg Burri <greg.burri@gmail.com>
Mon, 6 Jan 2025 15:04:48 +0000 (16:04 +0100)
16 files changed:
TODO.md
backend/src/consts.rs
backend/src/data/db/user.rs
backend/src/html_templates.rs
backend/src/main.rs
backend/src/services.rs
backend/src/services/recipe.rs
backend/src/services/ron.rs
backend/src/services/user.rs
backend/src/translation.rs
backend/templates/ask_reset_password.html
backend/templates/base_with_header.html
backend/templates/profile.html
backend/translation.ron
common/src/ron_api.rs
frontend/src/lib.rs

diff --git a/TODO.md b/TODO.md
index 972051a..a8fc10d 100644 (file)
--- a/TODO.md
+++ b/TODO.md
@@ -1,21 +1,29 @@
-* Finish updating profile
-    * check password and message error
-    * user can change email: add a field + revalidation of new email
 * Check position of message error in profile/sign in/sign up with flex grid layout
-* Review the recipe model (SQL)
-* Describe the use cases in details.
-    * Define the UI (mockups).
-        * Two CSS: one for desktop and one for mobile
-        * Use CSS flex/grid to define a good design/layout
-    * Define the logic behind each page and action.
-* Implement:
+* Define the UI (mockups).
+    * Two CSS: one for desktop and one for mobile
+    * Use CSS flex/grid to define a good design/layout
+* Drag and drop of steps and groups to define their order
+* Make a search page
+* Use of markdown for some field (how to add markdown as rinja filter?)
+* Quick search left panel by tags ?
+* Make the home page: Define what to display to the user
+* Show existing tags when editing a recipe
+
+[ok] Add support to translations.
+    * Make a Text database (a bit like d-lan.net) and think about translation.
+    * The language is stored in cookie or in user profile if the user is connected
+    * A combobox in the header shows all languages
+[ok] Set a lang cookie (when not connected)
+[ok] User can choose language
+[ok] Implement:
     .service(services::edit_recipe)
     .service(services::new_recipe)
     .service(services::webapi::set_recipe_title)
     .service(services::webapi::set_recipe_description)
-* Add support to translations into db model.
-* Make a Text database (a bit like d-lan.net) and think about translation.
-
+[ok] Review the recipe model (SQL)
+[ok] Finish updating profile
+    [ok] check password and message error
+    [ok] user can change email: add a field + revalidation of new email
 [ok] Try using WASM for all the client logic (test on editing/creating a recipe)
 [ok] How to log error to journalctl or elsewhere + debug log?
 [ok] Clean the old code + commit
index 76986db..222499a 100644 (file)
@@ -6,7 +6,9 @@ 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 = 60 * 60; // [s]. (1 jour).
+
 pub const COOKIE_AUTH_TOKEN_NAME: &str = "auth_token";
+pub const COOKIE_LANG_NAME: &str = "lang";
 
 pub const VALIDATION_PASSWORD_RESET_TOKEN_DURATION: i64 = 60 * 60; // [s]. (1 jour).
 
@@ -22,6 +24,7 @@ pub const REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; // Set by the reverse
 
 pub const MAX_DB_CONNECTION: u32 = 1; // To avoid database lock.
 
+// TODO: remove, should be replaced by the translation module.
 pub static LANGUAGES: LazyLock<[(&str, &str); 2]> = LazyLock::new(|| {
     let mut langs = [("Français", "fr"), ("English", "en")];
     langs.sort();
index a95feb4..53a62c1 100644 (file)
@@ -7,6 +7,7 @@ use crate::{
     consts,
     data::model,
     hash::{hash, verify_password},
+    services::user,
 };
 
 #[derive(Debug)]
@@ -162,6 +163,16 @@ WHERE [id] = $1
         })
     }
 
+    pub async fn set_user_lang(&self, user_id: i64, lang: &str) -> Result<()> {
+        sqlx::query("UPDATE [User] SET [lang] = $2 WHERE [id] = $1")
+            .bind(user_id)
+            .bind(lang)
+            .execute(&self.pool)
+            .await
+            .map(|_| ())
+            .map_err(DBError::from)
+    }
+
     pub async fn sign_up(&self, email: &str, password: &str) -> Result<SignUpResult> {
         self.sign_up_with_given_time(email, password, Utc::now())
             .await
index d7d4160..25b0587 100644 (file)
@@ -37,20 +37,20 @@ pub struct MessageTemplate {
 }
 
 impl MessageTemplate {
-    pub fn new(message: &str, tr: Tr) -> MessageTemplate {
+    pub fn new(message: String, tr: Tr) -> MessageTemplate {
         MessageTemplate {
             user: None,
             tr,
-            message: message.to_string(),
+            message,
             as_code: false,
         }
     }
 
-    pub fn new_with_user(message: &str, tr: Tr, user: Option<model::User>) -> MessageTemplate {
+    pub fn new_with_user(message: String, tr: Tr, user: Option<model::User>) -> MessageTemplate {
         MessageTemplate {
             user,
             tr,
-            message: message.to_string(),
+            message,
             as_code: false,
         }
     }
index af80c90..610ea49 100644 (file)
@@ -89,6 +89,7 @@ async fn main() {
     let ron_api_routes = Router::new()
         // Disabled: update user profile is now made with a post data ('edit_user_post').
         // .route("/user/update", put(services::ron::update_user))
+        .route("/set_lang", put(services::ron::set_lang))
         .route("/recipe/set_title", put(services::ron::set_recipe_title))
         .route(
             "/recipe/set_description",
@@ -231,26 +232,26 @@ async fn translation(
         user.lang
     } else {
         let available_codes = Tr::available_codes();
-
-        // TODO: Check cookies before http headers.
-
-        let accept_language = req
-            .headers()
-            .get(axum::http::header::ACCEPT_LANGUAGE)
-            .map(|v| v.to_str().unwrap_or_default())
-            .unwrap_or_default()
-            .split(',')
-            .map(|l| l.split('-').next().unwrap_or_default())
-            .find_or_first(|l| available_codes.contains(l));
-
-        // TODO: Save to cookies.
-
-        accept_language.unwrap_or("en").to_string()
+        let jar = CookieJar::from_headers(req.headers());
+        match jar.get(consts::COOKIE_LANG_NAME) {
+            Some(lang) if available_codes.contains(&lang.value()) => lang.value().to_string(),
+            _ => {
+                let accept_language = req
+                    .headers()
+                    .get(axum::http::header::ACCEPT_LANGUAGE)
+                    .map(|v| v.to_str().unwrap_or_default())
+                    .unwrap_or_default()
+                    .split(',')
+                    .map(|l| l.split('-').next().unwrap_or_default())
+                    .find_or_first(|l| available_codes.contains(l));
+
+                accept_language.unwrap_or("en").to_string()
+            }
+        }
     };
 
     let tr = Tr::new(&language);
 
-    // let jar = CookieJar::from_headers(req.headers());
     req.extensions_mut().insert(tr);
     Ok(next.run(req).await)
 }
index 8036a14..9fcfc1a 100644 (file)
@@ -77,6 +77,6 @@ pub async fn not_found(
 ) -> impl IntoResponse {
     (
         StatusCode::NOT_FOUND,
-        MessageTemplate::new_with_user("404: Not found", tr, user),
+        MessageTemplate::new_with_user("404: Not found".to_string(), tr, user),
     )
 }
index 8c7eaa4..7367d40 100644 (file)
@@ -9,7 +9,7 @@ use crate::{
     consts,
     data::{db, model},
     html_templates::*,
-    translation,
+    translation::{self, Sentence},
 };
 
 #[debug_handler]
@@ -22,7 +22,7 @@ pub async fn create(
         let recipe_id = connection.create_recipe(user.id).await?;
         Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response())
     } else {
-        Ok(MessageTemplate::new("Not logged in", tr).into_response())
+        Ok(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response())
     }
 }
 
@@ -53,13 +53,16 @@ pub async fn edit_recipe(
                 }
                 .into_response())
             } else {
-                Ok(MessageTemplate::new("Not allowed to edit this recipe", tr).into_response())
+                Ok(
+                    MessageTemplate::new(tr.t(Sentence::RecipeNotAllowedToEdit), tr)
+                        .into_response(),
+                )
             }
         } else {
-            Ok(MessageTemplate::new("Recipe not found", tr).into_response())
+            Ok(MessageTemplate::new(tr.t(Sentence::RecipeNotFound), tr).into_response())
         }
     } else {
-        Ok(MessageTemplate::new("Not logged in", tr).into_response())
+        Ok(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response())
     }
 }
 
@@ -76,7 +79,7 @@ pub async fn view(
                 && (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),
+                    tr.tp(Sentence::RecipeNotAllowedToView, &[Box::new(recipe_id)]),
                     tr,
                     user,
                 )
@@ -103,11 +106,9 @@ pub async fn view(
             }
             .into_response())
         }
-        None => Ok(MessageTemplate::new_with_user(
-            &format!("Cannot find the recipe {}", recipe_id),
-            tr,
-            user,
-        )
-        .into_response()),
+        None => Ok(
+            MessageTemplate::new_with_user(tr.t(Sentence::RecipeNotFound), tr, user)
+                .into_response(),
+        ),
     }
 }
index 73330aa..d7c52bc 100644 (file)
@@ -1,13 +1,15 @@
 use axum::{
     debug_handler,
     extract::{Extension, Query, State},
-    http::StatusCode,
+    http::{HeaderMap, StatusCode},
     response::{ErrorResponse, IntoResponse, Result},
 };
+use axum_extra::extract::cookie::{Cookie, CookieJar};
 use serde::Deserialize;
 // use tracing::{event, Level};
 
 use crate::{
+    consts,
     data::db,
     model,
     ron_extractor::ExtractRon,
@@ -22,29 +24,46 @@ pub struct RecipeId {
     id: i64,
 }
 
-#[allow(dead_code)]
+// #[allow(dead_code)]
+// #[debug_handler]
+// pub async fn update_user(
+//     State(connection): State<db::Connection>,
+//     Extension(user): Extension<Option<model::User>>,
+//     ExtractRon(ron): ExtractRon<common::ron_api::UpdateProfile>,
+// ) -> Result<StatusCode> {
+//     if let Some(user) = user {
+//         connection
+//             .update_user(
+//                 user.id,
+//                 ron.email.as_deref().map(str::trim),
+//                 ron.name.as_deref(),
+//                 ron.password.as_deref(),
+//             )
+//             .await?;
+//     } else {
+//         return Err(ErrorResponse::from(ron_error(
+//             StatusCode::UNAUTHORIZED,
+//             NOT_AUTHORIZED_MESSAGE,
+//         )));
+//     }
+//     Ok(StatusCode::OK)
+// }
+
 #[debug_handler]
-pub async fn update_user(
+pub async fn set_lang(
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
-    ExtractRon(ron): ExtractRon<common::ron_api::UpdateProfile>,
-) -> Result<StatusCode> {
+    headers: HeaderMap,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetLang>,
+) -> Result<(CookieJar, StatusCode)> {
+    let mut jar = CookieJar::from_headers(&headers);
     if let Some(user) = user {
-        connection
-            .update_user(
-                user.id,
-                ron.email.as_deref().map(str::trim),
-                ron.name.as_deref(),
-                ron.password.as_deref(),
-            )
-            .await?;
+        connection.set_user_lang(user.id, &ron.lang).await?;
     } else {
-        return Err(ErrorResponse::from(ron_error(
-            StatusCode::UNAUTHORIZED,
-            NOT_AUTHORIZED_MESSAGE,
-        )));
+        let cookie = Cookie::build((consts::COOKIE_LANG_NAME, ron.lang)).path("/");
+        jar = jar.add(cookie);
     }
-    Ok(StatusCode::OK)
+    Ok((jar, StatusCode::OK))
 }
 
 async fn check_user_rights_recipe(
index 76b7676..ae2b138 100644 (file)
@@ -126,9 +126,12 @@ pub async fn sign_up_post(
             let email = form_data.email.clone();
             match email::send_email(
                 &email,
-                &format!(
-                    "Follow this link to confirm your inscription: {}/validation?validation_token={}",
-                    url, token
+                &tr.tp(
+                    Sentence::SignUpFollowEmailLink,
+                    &[Box::new(format!(
+                        "{}/validation?validation_token={}",
+                        url, token
+                    ))],
                 ),
                 &config.smtp_relay_address,
                 &config.smtp_login,
@@ -136,10 +139,12 @@ pub async fn sign_up_post(
             )
             .await
             {
-                Ok(()) => Ok(
-                    MessageTemplate::new_with_user(
-                        "An email has been sent, follow the link to validate your account",
-                    tr, user).into_response()),
+                Ok(()) => {
+                    Ok(
+                        MessageTemplate::new_with_user(tr.t(Sentence::SignUpEmailSent), tr, user)
+                            .into_response(),
+                    )
+                }
                 Err(_) => {
                     // error!("Email validation error: {}", error); // TODO: log
                     error_response(SignUpError::UnableSendEmail, &form_data, user, tr)
@@ -166,7 +171,7 @@ pub async fn sign_up_validation(
     if user.is_some() {
         return Ok((
             jar,
-            MessageTemplate::new_with_user("User already exists", tr, user),
+            MessageTemplate::new_with_user(tr.t(Sentence::ValidationUserAlreadyExists), tr, user),
         ));
     }
     let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@@ -189,7 +194,7 @@ pub async fn sign_up_validation(
                     Ok((
                         jar,
                         MessageTemplate::new_with_user(
-                            "Email validation successful, your account has been created",
+                            tr.t(Sentence::SignUpEmailValidationSuccess),
                             tr,
                             user,
                         ),
@@ -198,7 +203,7 @@ pub async fn sign_up_validation(
                 db::user::ValidationResult::ValidationExpired => Ok((
                     jar,
                     MessageTemplate::new_with_user(
-                        "The validation has expired. Try to sign up again",
+                        tr.t(Sentence::SignUpValidationExpired),
                         tr,
                         user,
                     ),
@@ -206,7 +211,7 @@ pub async fn sign_up_validation(
                 db::user::ValidationResult::UnknownUser => Ok((
                     jar,
                     MessageTemplate::new_with_user(
-                        "Validation error. Try to sign up again",
+                        tr.t(Sentence::SignUpValidationErrorTryAgain),
                         tr,
                         user,
                     ),
@@ -215,7 +220,7 @@ pub async fn sign_up_validation(
         }
         None => Ok((
             jar,
-            MessageTemplate::new_with_user("Validation error", tr, user),
+            MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user),
         )),
     }
 }
@@ -313,12 +318,10 @@ pub async fn ask_reset_password_get(
     Extension(tr): Extension<translation::Tr>,
 ) -> Result<Response> {
     if user.is_some() {
-        Ok(MessageTemplate::new_with_user(
-            "Can't ask to reset password when already logged in",
-            tr,
-            user,
+        Ok(
+            MessageTemplate::new_with_user(tr.t(Sentence::AskResetAlreadyLoggedInError), tr, user)
+                .into_response(),
         )
-        .into_response())
     } else {
         Ok(AskResetPasswordTemplate {
             user,
@@ -361,23 +364,21 @@ pub async fn ask_reset_password_post(
     ) -> Result<Response> {
         Ok(AskResetPasswordTemplate {
             user,
-            tr,
             email: email.to_string(),
             message_email: match error {
-                AskResetPasswordError::InvalidEmail => "Invalid email",
-                _ => "",
-            }
-            .to_string(),
+                AskResetPasswordError::InvalidEmail => tr.t(Sentence::InvalidEmail),
+                _ => String::new(),
+            },
             message: match error {
                 AskResetPasswordError::EmailAlreadyReset => {
-                    "The password has already been reset for this email"
+                    tr.t(Sentence::AskResetEmailAlreadyResetError)
                 }
-                AskResetPasswordError::EmailUnknown => "Email unknown",
-                AskResetPasswordError::UnableSendEmail => "Unable to send the reset password email",
-                AskResetPasswordError::DatabaseError => "Database error",
-                _ => "",
-            }
-            .to_string(),
+                AskResetPasswordError::EmailUnknown => tr.t(Sentence::EmailUnknown),
+                AskResetPasswordError::UnableSendEmail => tr.t(Sentence::UnableToSendResetEmail),
+                AskResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError),
+                _ => String::new(),
+            },
+            tr,
         }
         .into_response())
     }
@@ -417,9 +418,12 @@ pub async fn ask_reset_password_post(
             let url = utils::get_url_from_host(&host);
             match email::send_email(
                 &form_data.email,
-                &format!(
-                    "Follow this link to reset your password: {}/reset_password?reset_token={}",
-                    url, token
+                &tr.tp(
+                    Sentence::AskResetFollowEmailLink,
+                    &[Box::new(format!(
+                        "{}/reset_password?reset_token={}",
+                        url, token
+                    ))],
                 ),
                 &config.smtp_relay_address,
                 &config.smtp_login,
@@ -427,12 +431,12 @@ pub async fn ask_reset_password_post(
             )
             .await
             {
-                Ok(()) => Ok(MessageTemplate::new_with_user(
-                    "An email has been sent, follow the link to reset your password.",
-                    tr,
-                    user,
-                )
-                .into_response()),
+                Ok(()) => {
+                    Ok(
+                        MessageTemplate::new_with_user(tr.t(Sentence::AskResetEmailSent), tr, user)
+                            .into_response(),
+                    )
+                }
                 Err(_) => {
                     // error!("Email validation error: {}", error); // TODO: log
                     error_response(
@@ -472,7 +476,10 @@ pub async fn reset_password_get(
         }
         .into_response())
     } else {
-        Ok(MessageTemplate::new_with_user("Reset token missing", tr, user).into_response())
+        Ok(
+            MessageTemplate::new_with_user(tr.t(Sentence::AskResetTokenMissing), tr, user)
+                .into_response(),
+        )
     }
 }
 
@@ -505,22 +512,21 @@ pub async fn reset_password_post(
     ) -> Result<Response> {
         Ok(ResetPasswordTemplate {
             user,
-            tr,
             reset_token: form_data.reset_token.clone(),
             message_password: match error {
-                ResetPasswordError::PasswordsNotEqual => "Passwords don't match",
-                ResetPasswordError::InvalidPassword => {
-                    "Password must have at least eight characters"
-                }
-                _ => "",
-            }
-            .to_string(),
+                ResetPasswordError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
+                ResetPasswordError::InvalidPassword => tr.tp(
+                    Sentence::InvalidPassword,
+                    &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
+                ),
+                _ => String::new(),
+            },
             message: match error {
-                ResetPasswordError::TokenExpired => "Token expired, try to reset password again",
-                ResetPasswordError::DatabaseError => "Database error",
-                _ => "",
-            }
-            .to_string(),
+                ResetPasswordError::TokenExpired => tr.t(Sentence::AskResetTokenExpired),
+                ResetPasswordError::DatabaseError => tr.t(Sentence::DatabaseError),
+                _ => String::new(),
+            },
+            tr,
         }
         .into_response())
     }
@@ -545,7 +551,7 @@ pub async fn reset_password_post(
     {
         Ok(db::user::ResetPasswordResult::Ok) => {
             Ok(
-                MessageTemplate::new_with_user("Your password has been reset", tr, user)
+                MessageTemplate::new_with_user(tr.t(Sentence::PasswordReset), tr, user)
                     .into_response(),
             )
         }
@@ -575,7 +581,7 @@ pub async fn edit_user_get(
         }
         .into_response()
     } else {
-        MessageTemplate::new("Not logged in", tr).into_response()
+        MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response()
     }
 }
 
@@ -617,25 +623,23 @@ pub async fn edit_user_post(
                 username: form_data.name.clone(),
                 email: form_data.email.clone(),
                 message_email: match error {
-                    ProfileUpdateError::InvalidEmail => "Invalid email",
-                    ProfileUpdateError::EmailAlreadyTaken => "Email already taken",
-                    _ => "",
-                }
-                .to_string(),
+                    ProfileUpdateError::InvalidEmail => tr.t(Sentence::InvalidEmail),
+                    ProfileUpdateError::EmailAlreadyTaken => tr.t(Sentence::EmailAlreadyTaken),
+                    _ => String::new(),
+                },
                 message_password: match error {
-                    ProfileUpdateError::PasswordsNotEqual => "Passwords don't match",
-                    ProfileUpdateError::InvalidPassword => {
-                        "Password must have at least eight characters"
-                    }
-                    _ => "",
-                }
-                .to_string(),
+                    ProfileUpdateError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
+                    ProfileUpdateError::InvalidPassword => tr.tp(
+                        Sentence::InvalidPassword,
+                        &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
+                    ),
+                    _ => String::new(),
+                },
                 message: match error {
-                    ProfileUpdateError::DatabaseError => "Database error",
-                    ProfileUpdateError::UnableSendEmail => "Unable to send the validation email",
-                    _ => "",
-                }
-                .to_string(),
+                    ProfileUpdateError::DatabaseError => tr.t(Sentence::DatabaseError),
+                    ProfileUpdateError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail),
+                    _ => String::new(),
+                },
                 tr,
             }
             .into_response())
@@ -662,7 +666,7 @@ pub async fn edit_user_post(
         };
 
         let email_trimmed = form_data.email.trim();
-        let message: &str;
+        let message: String;
 
         match connection
             .update_user(
@@ -681,9 +685,12 @@ pub async fn edit_user_post(
                 let email = form_data.email.clone();
                 match email::send_email(
                     &email,
-                    &format!(
-                        "Follow this link to validate this email address: {}/revalidation?validation_token={}",
-                        url, token
+                    &tr.tp(
+                        Sentence::ProfileFollowEmailLink,
+                        &[Box::new(format!(
+                            "{}/revalidation?validation_token={}",
+                            url, token
+                        ))],
                     ),
                     &config.smtp_relay_address,
                     &config.smtp_login,
@@ -692,18 +699,21 @@ pub async fn edit_user_post(
                 .await
                 {
                     Ok(()) => {
-                        message =
-                            "An email has been sent, follow the link to validate your new email";
+                        message = tr.t(Sentence::ProfileEmailSent);
                     }
                     Err(_) => {
                         // error!("Email validation error: {}", error); // TODO: log
                         return error_response(
-                            ProfileUpdateError::UnableSendEmail, &form_data, user, tr);
+                            ProfileUpdateError::UnableSendEmail,
+                            &form_data,
+                            user,
+                            tr,
+                        );
                     }
                 }
             }
             Ok(db::user::UpdateUserResult::Ok) => {
-                message = "Profile saved";
+                message = tr.t(Sentence::ProfileSaved);
             }
             Err(_) => {
                 return error_response(ProfileUpdateError::DatabaseError, &form_data, user, tr)
@@ -717,14 +727,14 @@ pub async fn edit_user_post(
             user,
             username: form_data.name,
             email: form_data.email,
-            message: message.to_string(),
+            message,
             message_email: String::new(),
             message_password: String::new(),
             tr,
         }
         .into_response())
     } else {
-        Ok(MessageTemplate::new("Not logged in", tr).into_response())
+        Ok(MessageTemplate::new(tr.t(Sentence::NotLoggedIn), tr).into_response())
     }
 }
 
@@ -741,7 +751,7 @@ pub async fn email_revalidation(
     if user.is_some() {
         return Ok((
             jar,
-            MessageTemplate::new_with_user("User already exists", tr, user),
+            MessageTemplate::new_with_user(tr.t(Sentence::ValidationUserAlreadyExists), tr, user),
         ));
     }
     let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@@ -763,21 +773,21 @@ pub async fn email_revalidation(
                     let user = connection.load_user(user_id).await?;
                     Ok((
                         jar,
-                        MessageTemplate::new_with_user("Email validation successful", tr, user),
+                        MessageTemplate::new_with_user(
+                            tr.t(Sentence::ValidationSuccessful),
+                            tr,
+                            user,
+                        ),
                     ))
                 }
                 db::user::ValidationResult::ValidationExpired => Ok((
                     jar,
-                    MessageTemplate::new_with_user(
-                        "The validation has expired. Try to sign up again with the same email",
-                        tr,
-                        user,
-                    ),
+                    MessageTemplate::new_with_user(tr.t(Sentence::ValidationExpired), tr, user),
                 )),
                 db::user::ValidationResult::UnknownUser => Ok((
                     jar,
                     MessageTemplate::new_with_user(
-                        "Validation error. Try to sign up again with the same email",
+                        tr.t(Sentence::ValidationErrorTryToSignUpAgain),
                         tr,
                         user,
                     ),
@@ -786,7 +796,7 @@ pub async fn email_revalidation(
         }
         None => Ok((
             jar,
-            MessageTemplate::new_with_user("Validation error", tr, user),
+            MessageTemplate::new_with_user(tr.t(Sentence::ValidationError), tr, user),
         )),
     }
 }
index 97740cb..ccf22c8 100644 (file)
@@ -9,15 +9,21 @@ use crate::consts;
 
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Clone)]
 pub enum Sentence {
-    ProfileTitle,
     MainTitle,
     CreateNewRecipe,
     UnpublishedRecipes,
     UntitledRecipe,
 
+    Name,
     EmailAddress,
     Password,
 
+    SignOut,
+    Save,
+    NotLoggedIn,
+
+    DatabaseError,
+
     // Sign in page.
     SignInMenu,
     SignInTitle,
@@ -28,6 +34,11 @@ pub enum Sentence {
     SignUpMenu,
     SignUpTitle,
     SignUpButton,
+    SignUpEmailSent,
+    SignUpFollowEmailLink,
+    SignUpEmailValidationSuccess,
+    SignUpValidationExpired,
+    SignUpValidationErrorTryAgain,
     ChooseAPassword,
     ReEnterPassword,
     AccountMustBeValidatedFirst,
@@ -37,9 +48,38 @@ pub enum Sentence {
     EmailAlreadyTaken,
     UnableToSendEmail,
 
+    // Validation.
+    ValidationSuccessful,
+    ValidationExpired,
+    ValidationErrorTryToSignUpAgain,
+    ValidationError,
+    ValidationUserAlreadyExists,
+
     // Reset password page.
     LostPassword,
     AskResetButton,
+    AskResetAlreadyLoggedInError,
+    AskResetEmailAlreadyResetError,
+    AskResetFollowEmailLink,
+    AskResetEmailSent,
+    AskResetTokenMissing,
+    AskResetTokenExpired,
+    PasswordReset,
+    EmailUnknown,
+    UnableToSendResetEmail,
+
+    // Profile
+    ProfileTitle,
+    ProfileEmail,
+    ProfileNewPassword,
+    ProfileFollowEmailLink,
+    ProfileEmailSent,
+    ProfileSaved,
+
+    // Recipe.
+    RecipeNotAllowedToEdit,
+    RecipeNotAllowedToView,
+    RecipeNotFound,
 }
 
 #[derive(Clone)]
@@ -74,7 +114,7 @@ impl Tr {
         }
     }
 
-    pub fn tp(&self, sentence: Sentence, params: &[Box<dyn ToString>]) -> String {
+    pub fn tp(&self, sentence: Sentence, params: &[Box<dyn ToString + Send>]) -> String {
         match self.lang.translation.get(&sentence) {
             Some(str) => {
                 let mut result = str.clone();
@@ -90,6 +130,10 @@ impl Tr {
         }
     }
 
+    pub fn current_lang_code(&self) -> &str {
+        &self.lang.code
+    }
+
     pub fn available_languages() -> Vec<(&'static str, &'static str)> {
         TRANSLATIONS
             .iter()
index e670cee..a9655ff 100644 (file)
@@ -2,13 +2,15 @@
 
 {% block main_container %}
     <div class="content">
-        <h1></h1>
+        <h1>{{ tr.t(Sentence::LostPassword) }}</h1>
         <form action="/ask_reset_password" method="post">
-            <label for="email_field">Your email address</label>
-            <input id="email_field" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
+            <label for="email_field">{{ tr.t(Sentence::EmailAddress) }}</label>
+            <input id="email_field" type="email"
+                name="email" value="{{ email }}"
+                autocapitalize="none" autocomplete="email" autofocus="autofocus" />
             {{ message_email }}
 
-            <input type="submit" name="commit" value="Ask reset" />
+            <input type="submit" name="commit" value="{{ tr.t(Sentence::AskResetButton) }}" />
         </form>
         {{ message }}
     </div>
index e2de70c..dfca040 100644 (file)
                 {% else %}
                     {{ user.name }}
                 {% endif %}
-            </a> / <a href="/signout" />Sign out</a></span>
+            </a> / <a href="/signout" />{{ tr.t(Sentence::SignOut) }}</a></span>
         {% when None %}
             <span>
                 <a href="/signin" >{{ tr.t(Sentence::SignInMenu) }}</a>/<a href="/signup">{{ tr.t(Sentence::SignUpMenu) }}</a>/<a href="/ask_reset_password">{{ tr.t(Sentence::LostPassword) }}</a>
             </span>
         {% endmatch %}
 
+        <select id="select-website-language">
+        {% for lang in Tr::available_languages() %}
+            <option value="{{ lang.0 }}"
+            {%+ if tr.current_lang_code() == lang.0 %}
+                selected
+            {% endif %}
+            >{{ lang.1 }}</option>
+        {% endfor %}
+        </select>
     </div>
+
     <div class="main-container">
         {% block main_container %}{% endblock %}
     </div>
+
 {% endblock %}
\ No newline at end of file
index 72b042b..1ec5955 100644 (file)
@@ -10,7 +10,7 @@
 
     <form action="/user/edit" method="post">
 
-        <label for="input-name">Name</label>
+        <label for="input-name">{{ tr.t(Sentence::Name) }}</label>
         <input
             id="input-name"
             type="text"
             autocomplete="title"
             autofocus="autofocus" />
 
-        <label for="input-email">Email (need to be revalidated if changed)</label>
+        <label for="input-email">{{ tr.t(Sentence::ProfileEmail) }}</label>
         <input id="input-email" type="email"
             name="email" value="{{ email }}"
             autocapitalize="none" autocomplete="email" autofocus="autofocus" />
 
         {{ message_email }}
 
-        <label for="input-password-1">New password (minimum 8 characters)</label>
+        <label for="input-password-1">{{ tr.tp(Sentence::ProfileNewPassword, [Box::new(common::consts::MIN_PASSWORD_SIZE)]) }}</label>
         <input id="input-password-1" type="password" name="password_1" autocomplete="new-password" />
 
-        <label for="input-password-2">Re-enter password</label>
+        <label for="input-password-2">{{ tr.t(Sentence::ReEnterPassword) }}</label>
         <input id="input-password-2" type="password" name="password_2" autocomplete="new-password" />
 
         {{ message_password }}
 
-        <input type="submit" name="commit" value="Save" />
+        <input type="submit" name="commit" value="{{ tr.t(Sentence::Save) }}" />
     </form>
     {{ message }}
 </div>
index 4daa75b..4ed85ca 100644 (file)
@@ -3,15 +3,21 @@
         code: "en",
         name: "English",
         translation: {
-            ProfileTitle: "Profile",
             MainTitle: "Cooking Recipes",
             CreateNewRecipe: "Create a new recipe",
             UnpublishedRecipes: "Unpublished recipes",
             UntitledRecipe: "Untitled recipe",
 
+            Name: "Name",
             EmailAddress: "Email address",
             Password: "Password",
 
+            SignOut: "Sign out",
+            Save: "Save",
+            NotLoggedIn: "No logged in",
+
+            DatabaseError: "Database error",
+
             SignInMenu: "Sign in",
             SignInTitle: "Sign in",
             SignInButton: "Sign in",
             EmailAlreadyTaken: "This email is not available",
             UnableToSendEmail: "Unable to send the validation email",
 
+            ValidationSuccessful: "Email validation successful",
+            ValidationExpired: "The validation has expired. Try to sign up again with the same email",
+            ValidationErrorTryToSignUpAgain: "Validation error. Try to sign up again with the same email",
+            ValidationError: "Validation error",
+            ValidationUserAlreadyExists: "User already exists",
+
             SignUpMenu: "Sign up",
             SignUpTitle: "Sign up",
             SignUpButton: "Sign up",
+            SignUpEmailSent: "An email has been sent, follow the link to validate your account",
+            SignUpFollowEmailLink: "Follow this link to confirm your inscription: {}",
+            SignUpEmailValidationSuccess: "Email validation successful, your account has been created",
+            SignUpValidationExpired: "The validation has expired. Try to sign up again",
+            SignUpValidationErrorTryAgain: "Validation error. Try to sign up again",
             ChooseAPassword: "Choose a password (minimum {} characters)",
             ReEnterPassword: "Re-enter password",
 
             LostPassword: "Lost password",
             AskResetButton: "Ask reset",
+            AskResetAlreadyLoggedInError: "Can't ask to reset password when already logged in",
+            AskResetEmailAlreadyResetError: "The password has already been reset for this email",
+            AskResetFollowEmailLink: "Follow this link to reset your password: {}",
+            AskResetEmailSent: "An email has been sent, follow the link to reset your password",
+            AskResetTokenMissing: "Reset token missing",
+            AskResetTokenExpired: "Token expired, try to reset password again",
+            PasswordReset: "Your password has been reset",
+            EmailUnknown: "Email unknown",
+            UnableToSendResetEmail: "Unable to send the reset password email",
+
+            ProfileTitle: "Profile",
+            ProfileEmail: "Email (need to be revalidated if changed)",
+            ProfileNewPassword: "New password (minimum {} characters)",
+            ProfileFollowEmailLink: "Follow this link to validate this email address: {}",
+            ProfileEmailSent: "An email has been sent, follow the link to validate your new email",
+            ProfileSaved: "Profile saved",
+
+            RecipeNotAllowedToEdit: "Not allowed to edit this recipe",
+            RecipeNotAllowedToView: "Not allowed the view the recipe {}",
+            RecipeNotFound: "Recipe not found",
         }
     ),
     (
         code: "fr",
         name: "Français",
         translation: {
-            ProfileTitle: "Profile",
-            MainTitle: "Recette de Cuisine",
+            MainTitle: "Recettes de Cuisine",
             CreateNewRecipe: "Créer une nouvelle recette",
             UnpublishedRecipes: "Recettes non-publiés",
             UntitledRecipe: "Recette sans nom",
 
+            Name: "Nom",
             EmailAddress: "Adresse email",
             Password: "Mot de passe",
 
+            SignOut: "Se déconnecter",
+            Save: "Sauvegarder",
+            NotLoggedIn: "Pas connecté",
+
+            DatabaseError: "Erreur de la base de données",
+
             SignInMenu: "Se connecter",
             SignInTitle: "Se connecter",
             SignInButton: "Se connecter",
             EmailAlreadyTaken: "Cette adresse email n'est pas disponible",
             UnableToSendEmail: "L'email de validation n'a pas pu être envoyé",
 
+            ValidationSuccessful: "Email validé avec succès",
+            ValidationExpired: "La validation a expiré. Essayez de vous inscrire à nouveau avec la même adresse email",
+            ValidationErrorTryToSignUpAgain: "Erreur de validation. Essayez de vous inscrire à nouveau avec la même adresse email",
+            ValidationError: "Erreur de validation",
+            ValidationUserAlreadyExists: "Utilisateur déjà existant",
+
             SignUpMenu: "S'inscrire",
             SignUpTitle: "Inscription",
             SignUpButton: "Valider",
+            SignUpEmailSent: "Un email a été envoyé, suivez le lien pour valider votre compte",
+            SignUpFollowEmailLink: "Suivez ce lien pour valider votre inscription: {}",
+            SignUpEmailValidationSuccess: "La validation de votre email s'est déroulée avec succès, votre compte a été créé",
+            SignUpValidationExpired: "La validation a expiré. Essayez de vous inscrire à nouveau",
+            SignUpValidationErrorTryAgain: "Erreur de validation. Essayez de vous inscrire à nouveau",
             ChooseAPassword: "Choisir un mot de passe (minimum {} caractères)",
             ReEnterPassword: "Entrez à nouveau le mot de passe",
 
             LostPassword: "Mot de passe perdu",
             AskResetButton: "Demander la réinitialisation",
+            AskResetAlreadyLoggedInError: "Impossible de demander une réinitialisation du mot de passe lorsque déjà connecté",
+            AskResetEmailAlreadyResetError: "Le mot de passe a déjà été réinitialisé pour cette adresse email",
+            AskResetFollowEmailLink: "Suivez ce lien pour réinitialiser votre mot de passe: {}",
+            AskResetEmailSent: "Un email a été envoyé, suivez le lien pour réinitialiser votre mot de passe",
+            AskResetTokenMissing: "Jeton de réinitialisation manquant",
+            AskResetTokenExpired: "Jeton expiré, essayez de réinitialiser votre mot de passe à nouveau",
+            PasswordReset: "Votre mot de passe a été réinitialisé",
+            EmailUnknown: "Email inconnu",
+            UnableToSendResetEmail: "Impossible d'envoyer l'email pour la réinitialisation du mot de passe",
+
+            ProfileTitle: "Profile",
+            ProfileEmail: "Email (doit être revalidé si changé)",
+            ProfileNewPassword: "Nouveau mot de passe (minimum {} caractères)",
+            ProfileFollowEmailLink: "Suivez ce lien pour valider l'adresse email: {}",
+            ProfileEmailSent: "Un email a été envoyé, suivez le lien pour valider la nouvelle adresse email",
+            ProfileSaved: "Profile sauvegardé",
+
+            RecipeNotAllowedToEdit: "Vous n'êtes pas autorisé à éditer cette recette",
+            RecipeNotAllowedToView: "Vous n'êtes pas autorisé à voir la recette {}",
+            RecipeNotFound: "Recette non-trouvée",
         }
     )
 ]
\ No newline at end of file
index 9525dd6..3f233fa 100644 (file)
@@ -1,6 +1,11 @@
 use ron::ser::{to_string_pretty, PrettyConfig};
 use serde::{Deserialize, Serialize};
 
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetLang {
+    pub lang: String,
+}
+
 ///// RECIPE /////
 
 #[derive(Serialize, Deserialize, Clone)]
index 1a2bde4..06a8bf7 100644 (file)
@@ -5,8 +5,17 @@ mod request;
 mod toast;
 mod utils;
 
-use gloo::utils::window;
+use gloo::{
+    console::log,
+    events::EventListener,
+    utils::{document, window},
+};
+use utils::by_id;
 use wasm_bindgen::prelude::*;
+use wasm_bindgen_futures::spawn_local;
+use web_sys::HtmlSelectElement;
+
+use common::ron_api;
 
 // #[wasm_bindgen]
 // extern "C" {
@@ -39,5 +48,18 @@ pub fn main() -> Result<(), JsValue> {
         // }
     }
 
+    let select_language: HtmlSelectElement = by_id("select-website-language");
+    EventListener::new(&select_language.clone(), "input", move |_event| {
+        let lang = select_language.value();
+        let body = ron_api::SetLang { lang };
+        spawn_local(async move {
+            let _ = request::put::<(), _>("set_lang", body).await;
+            let _ = window().location().reload();
+        });
+
+        // log!(lang);
+    })
+    .forget();
+
     Ok(())
 }