"password-hash",
]
-[[package]]
-name = "askama"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28"
-dependencies = [
- "askama_derive",
- "askama_escape",
- "comrak",
- "humansize",
- "num-traits",
- "percent-encoding",
-]
-
-[[package]]
-name = "askama_axum"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a41603f7cdbf5ac4af60760f17253eb6adf6ec5b6f14a7ed830cf687d375f163"
-dependencies = [
- "askama",
- "axum-core",
- "http 1.2.0",
-]
-
-[[package]]
-name = "askama_derive"
-version = "0.12.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83"
-dependencies = [
- "askama_parser",
- "basic-toml",
- "mime",
- "mime_guess",
- "proc-macro2",
- "quote",
- "serde",
- "syn",
-]
-
-[[package]]
-name = "askama_escape"
-version = "0.10.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
-
-[[package]]
-name = "askama_parser"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
-dependencies = [
- "nom",
-]
-
[[package]]
name = "async-trait"
-version = "0.1.83"
+version = "0.1.84"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd"
+checksum = "1b1244b10dcd56c92219da4e14caa97e312079e185f04ba3eea25061561dc0a0"
dependencies = [
"proc-macro2",
"quote",
[[package]]
name = "cc"
-version = "1.2.6"
+version = "1.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d6dbb628b8f8555f86d0323c2eb39e3ec81901f4b83e091db8a6a76d316a333"
+checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7"
dependencies = [
"shlex",
]
"serde",
]
-[[package]]
-name = "comrak"
-version = "0.18.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "482aa5695bca086022be453c700a40c02893f1ba7098a2c88351de55341ae894"
-dependencies = [
- "entities",
- "memchr",
- "once_cell",
- "regex",
- "slug",
- "typed-arena",
- "unicode_categories",
-]
-
[[package]]
name = "concurrent-queue"
version = "2.5.0"
"unicode-xid",
]
-[[package]]
-name = "deunicode"
-version = "1.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00"
-
[[package]]
name = "digest"
version = "0.10.7"
"cfg-if",
]
-[[package]]
-name = "entities"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
-
[[package]]
name = "equivalent"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "foldhash"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
+
[[package]]
name = "form_urlencoded"
version = "1.2.1"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
[[package]]
name = "hashlink"
-version = "0.9.1"
+version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
- "hashbrown 0.14.5",
+ "hashbrown 0.15.2",
]
[[package]]
[[package]]
name = "itertools"
-version = "0.13.0"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
"subtle",
]
-[[package]]
-name = "paste"
-version = "1.0.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
-
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
version = "1.0.0"
dependencies = [
"argon2",
- "askama",
- "askama_axum",
"axum",
"axum-extra",
"chrono",
"lettre",
"rand",
"rand_core",
+ "rinja",
+ "rinja_axum",
"ron",
+ "rustc-hash",
"serde",
"sqlx",
"thiserror 2.0.9",
"windows-sys 0.52.0",
]
+[[package]]
+name = "rinja"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3dc4940d00595430b3d7d5a01f6222b5e5b51395d1120bdb28d854bb8abb17a5"
+dependencies = [
+ "humansize",
+ "itoa",
+ "percent-encoding",
+ "rinja_derive",
+]
+
+[[package]]
+name = "rinja_axum"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc64d77bb950f6498d0fc64b028d168fcb4e56ac31b66a8ae05f64d3b0c218b6"
+dependencies = [
+ "axum-core",
+ "http 1.2.0",
+ "rinja",
+]
+
+[[package]]
+name = "rinja_derive"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d9ed0146aef6e2825f1b1515f074510549efba38d71f4554eec32eb36ba18b"
+dependencies = [
+ "basic-toml",
+ "memchr",
+ "mime",
+ "mime_guess",
+ "proc-macro2",
+ "quote",
+ "rinja_parser",
+ "rustc-hash",
+ "serde",
+ "syn",
+]
+
+[[package]]
+name = "rinja_parser"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93f9a866e2e00a7a1fb27e46e9e324a6f7c0e7edc4543cae1d38f4e4a100c610"
+dependencies = [
+ "memchr",
+ "nom",
+ "serde",
+]
+
[[package]]
name = "ron"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
+[[package]]
+name = "rustc-hash"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497"
+
[[package]]
name = "rustix"
version = "0.38.42"
"autocfg",
]
-[[package]]
-name = "slug"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
-dependencies = [
- "deunicode",
- "wasm-bindgen",
-]
-
[[package]]
name = "smallvec"
version = "1.13.2"
"der",
]
-[[package]]
-name = "sqlformat"
-version = "0.2.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790"
-dependencies = [
- "nom",
- "unicode_categories",
-]
-
[[package]]
name = "sqlx"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93334716a037193fac19df402f8571269c84a00852f6a7066b5d2616dcd64d3e"
+checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f"
dependencies = [
"sqlx-core",
"sqlx-macros",
[[package]]
name = "sqlx-core"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4d8060b456358185f7d50c55d9b5066ad956956fddec42ee2e8567134a8936e"
+checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0"
dependencies = [
- "atoi",
- "byteorder",
"bytes",
"chrono",
"crc",
"crossbeam-queue",
"either",
"event-listener",
- "futures-channel",
"futures-core",
"futures-intrusive",
"futures-io",
"futures-util",
- "hashbrown 0.14.5",
+ "hashbrown 0.15.2",
"hashlink",
- "hex",
"indexmap",
"log",
"memchr",
"once_cell",
- "paste",
"percent-encoding",
"serde",
"serde_json",
"sha2",
"smallvec",
- "sqlformat",
- "thiserror 1.0.69",
+ "thiserror 2.0.9",
"tokio",
"tokio-stream",
"tracing",
[[package]]
name = "sqlx-macros"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cac0692bcc9de3b073e8d747391827297e075c7710ff6276d9f7a1f3d58c6657"
+checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310"
dependencies = [
"proc-macro2",
"quote",
[[package]]
name = "sqlx-macros-core"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1804e8a7c7865599c9c79be146dc8a9fd8cc86935fa641d3ea58e5f0688abaa5"
+checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad"
dependencies = [
"dotenvy",
"either",
[[package]]
name = "sqlx-mysql"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "64bb4714269afa44aef2755150a0fc19d756fb580a67db8885608cf02f47d06a"
+checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233"
dependencies = [
"atoi",
"base64 0.22.1",
"smallvec",
"sqlx-core",
"stringprep",
- "thiserror 1.0.69",
+ "thiserror 2.0.9",
"tracing",
"whoami",
]
[[package]]
name = "sqlx-postgres"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6fa91a732d854c5d7726349bb4bb879bb9478993ceb764247660aee25f67c2f8"
+checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613"
dependencies = [
"atoi",
"base64 0.22.1",
"etcetera",
"futures-channel",
"futures-core",
- "futures-io",
"futures-util",
"hex",
"hkdf",
"smallvec",
"sqlx-core",
"stringprep",
- "thiserror 1.0.69",
+ "thiserror 2.0.9",
"tracing",
"whoami",
]
[[package]]
name = "sqlx-sqlite"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680"
+checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540"
dependencies = [
"atoi",
"chrono",
[[package]]
name = "syn"
-version = "2.0.93"
+version = "2.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058"
+checksum = "46f71c0377baf4ef1cc3e3402ded576dccc315800fbc62dfc7fe04b009773b4a"
dependencies = [
"proc-macro2",
"quote",
[[package]]
name = "tempfile"
-version = "3.14.0"
+version = "3.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c"
+checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
dependencies = [
"cfg-if",
"fastrand",
+ "getrandom",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"tracing-log",
]
-[[package]]
-name = "typed-arena"
-version = "2.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
-
[[package]]
name = "typenum"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
-[[package]]
-name = "unicode_categories"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
-
[[package]]
name = "untrusted"
version = "0.9.0"
ron = "0.8"
serde = { version = "1.0", features = ["derive"] }
-itertools = "0.13"
+itertools = "0.14"
+rustc-hash = "2.1"
clap = { version = "4", features = ["derive"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] }
-askama = { version = "0.12", features = [
- "with-axum",
- "mime",
- "mime_guess",
- "markdown",
-] }
-askama_axum = "0.4"
+rinja = { version = "0.3", features = ["with-axum"] }
+rinja_axum = "0.3"
argon2 = { version = "0.5", features = ["default", "std"] }
rand_core = { version = "0.6", features = ["std"] }
[email] TEXT NOT NULL,
[name] TEXT NOT NULL DEFAULT '',
[default_servings] INTEGER DEFAULT 4,
+ [lang] TEXT NOT NULL DEFAULT 'en',
[password] TEXT NOT NULL, -- argon2(password_plain, salt).
use std::{sync::LazyLock, time::Duration};
pub const FILE_CONF: &str = "conf.ron";
+pub const TRANSLATION_FILE: &str = "translation.ron";
pub const DB_DIRECTORY: &str = "data";
pub const DB_FILENAME: &str = "recipes.sqlite";
pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql";
.map_err(DBError::from)
}
- pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
- sqlx::query_as(
+ pub async fn get_recipe(&self, id: i64, with_groups: bool) -> Result<Option<model::Recipe>> {
+ match sqlx::query_as::<_, model::Recipe>(
r#"
SELECT
[id], [user_id], [title], [lang],
)
.bind(id)
.fetch_optional(&self.pool)
- .await
- .map_err(DBError::from)
+ .await?
+ {
+ Some(mut recipe) if with_groups => {
+ recipe.groups = self.get_groups(id).await?;
+ Ok(Some(recipe))
+ }
+ recipe => Ok(recipe),
+ }
}
pub async fn create_recipe(&self, user_id: i64) -> Result<i64> {
#[cfg(test)]
mod tests {
- use axum::routing::connect;
-
use super::*;
#[tokio::test]
let recipe_id = connection.create_recipe(user_id).await?;
connection.set_recipe_title(recipe_id, "Crêpe").await?;
- let recipe = connection.get_recipe(recipe_id).await?.unwrap();
+ let recipe = connection.get_recipe(recipe_id, false).await?.unwrap();
assert_eq!(recipe.title, "Crêpe".to_string());
Ok(())
connection.set_recipe_language(recipe_id, "fr").await?;
connection.set_recipe_is_published(recipe_id, true).await?;
- let recipe = connection.get_recipe(recipe_id).await?.unwrap();
+ let recipe = connection.get_recipe(recipe_id, false).await?.unwrap();
assert_eq!(recipe.id, recipe_id);
assert_eq!(recipe.title, "Ouiche");
}
pub async fn load_user(&self, user_id: i64) -> Result<Option<model::User>> {
- sqlx::query_as("SELECT [id], [email], [name] FROM [User] WHERE [id] = $1")
+ sqlx::query_as("SELECT [id], [email], [name], [lang] FROM [User] WHERE [id] = $1")
.bind(user_id)
.fetch_optional(&self.pool)
.await
.fetch_one(&mut *tx)
.await?;
+ let new_email = new_email.map(str::trim);
let email_changed = new_email.is_some_and(|new_email| new_email != email);
// Check if email not already taken.
let validation_token = if email_changed {
- if sqlx::query_scalar::<_, i64>(
+ if sqlx::query_scalar(
r#"
-SELECT COUNT(*)
+SELECT COUNT(*) > 0
FROM [User]
WHERE [email] = $1
"#,
.bind(new_email.unwrap())
.fetch_one(&mut *tx)
.await?
- > 0
{
return Ok(UpdateUserResult::EmailAlreadyTaken);
}
)
.bind(user_id)
.bind(new_email.unwrap_or(&email))
- .bind(new_name.unwrap_or(&name))
+ .bind(new_name.map(str::trim).unwrap_or(&name))
.bind(hashed_new_password.unwrap_or(hashed_password))
.execute(&mut *tx)
.await?;
pub id: i64,
pub name: String,
pub email: String,
+ pub lang: String,
}
#[derive(FromRow)]
pub servings: Option<u32>,
pub is_published: bool,
- // pub tags: Vec<String>,
- // pub groups: Vec<Group>,
+
+ #[sqlx(skip)]
+ pub groups: Vec<Group>,
}
#[derive(FromRow)]
-use askama::Template;
+use rinja_axum::Template;
-use crate::data::model;
+use crate::{
+ data::model,
+ translation::{Sentence, Tr},
+};
pub struct Recipes {
pub published: Vec<(i64, String)>,
#[template(path = "home.html")]
pub struct HomeTemplate {
pub user: Option<model::User>,
+ pub tr: Tr,
+
pub recipes: Recipes,
}
#[template(path = "message.html")]
pub struct MessageTemplate {
pub user: Option<model::User>,
+ pub tr: Tr,
pub message: String,
pub as_code: bool, // Display the message in <pre> markup.
}
impl MessageTemplate {
- pub fn new(message: &str) -> MessageTemplate {
+ pub fn new(message: &str, tr: Tr) -> MessageTemplate {
MessageTemplate {
user: None,
+ tr,
message: message.to_string(),
as_code: false,
}
}
- pub fn new_with_user(message: &str, user: Option<model::User>) -> MessageTemplate {
+ pub fn new_with_user(message: &str, tr: Tr, user: Option<model::User>) -> MessageTemplate {
MessageTemplate {
user,
+ tr,
message: message.to_string(),
as_code: false,
}
#[template(path = "sign_up_form.html")]
pub struct SignUpFormTemplate {
pub user: Option<model::User>,
+ pub tr: Tr,
pub email: String,
pub message: String,
#[template(path = "sign_in_form.html")]
pub struct SignInFormTemplate {
pub user: Option<model::User>,
+ pub tr: Tr,
pub email: String,
pub message: String,
#[template(path = "ask_reset_password.html")]
pub struct AskResetPasswordTemplate {
pub user: Option<model::User>,
+ pub tr: Tr,
pub email: String,
pub message: String,
#[template(path = "reset_password.html")]
pub struct ResetPasswordTemplate {
pub user: Option<model::User>,
+ pub tr: Tr,
pub reset_token: String,
pub message: String,
#[template(path = "profile.html")]
pub struct ProfileTemplate {
pub user: Option<model::User>,
+ pub tr: Tr,
pub username: String,
pub email: String,
#[template(path = "recipe_view.html")]
pub struct RecipeViewTemplate {
pub user: Option<model::User>,
+ pub tr: Tr,
+
pub recipes: Recipes,
pub recipe: model::Recipe,
#[template(path = "recipe_edit.html")]
pub struct RecipeEditTemplate {
pub user: Option<model::User>,
+ pub tr: Tr,
+
pub recipes: Recipes,
pub recipe: model::Recipe,
#[template(path = "recipes_list_fragment.html")]
pub struct RecipesListFragmentTemplate {
pub user: Option<model::User>,
+ pub tr: Tr,
+
pub recipes: Recipes,
}
use std::{net::SocketAddr, path::Path};
use axum::{
- extract::{ConnectInfo, FromRef, Request, State},
+ extract::{ConnectInfo, Extension, FromRef, Request, State},
http::StatusCode,
middleware::{self, Next},
response::{Response, Result},
use chrono::prelude::*;
use clap::Parser;
use config::Config;
+use itertools::Itertools;
use tower_http::{services::ServeDir, trace::TraceLayer};
use tracing::{event, Level};
use data::{db, model};
+use translation::Tr;
mod config;
mod consts;
mod ron_extractor;
mod ron_utils;
mod services;
+mod translation;
mod utils;
#[derive(Clone)]
.fallback(services::not_found)
.layer(TraceLayer::new_for_http())
// FIXME: Should be 'route_layer' but it doesn't work for 'fallback(..)'.
+ .layer(middleware::from_fn(translation))
.layer(middleware::from_fn_with_state(
state.clone(),
user_authentication,
Ok(next.run(req).await)
}
+async fn translation(
+ Extension(user): Extension<Option<model::User>>,
+ mut req: Request,
+ next: Next,
+) -> Result<Response> {
+ let language = if let Some(user) = user {
+ 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 tr = Tr::new(&language);
+
+ // let jar = CookieJar::from_headers(req.headers());
+ req.extensions_mut().insert(tr);
+ Ok(next.run(req).await)
+}
+
async fn get_current_user(
connection: db::Connection,
jar: &CookieJar,
use crate::{
data::{db, model},
html_templates::*,
- ron_utils,
+ ron_utils, translation,
};
pub mod fragments;
pub mod user;
// Will embed RON error in HTML page.
-pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
+pub async fn ron_error_to_html(
+ Extension(tr): Extension<translation::Tr>,
+ req: Request,
+ next: Next,
+) -> Result<Response> {
let response = next.run(req).await;
if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) {
user: None,
message,
as_code: true,
+ tr,
}
.into_response());
}
pub async fn home_page(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> {
let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?,
current_id: None,
};
- Ok(HomeTemplate { user, recipes })
+ Ok(HomeTemplate { user, recipes, tr })
}
///// 404 /////
#[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>>,
+ Extension(tr): Extension<translation::Tr>,
+) -> impl IntoResponse {
(
StatusCode::NOT_FOUND,
- MessageTemplate::new_with_user("404: Not found", user),
+ MessageTemplate::new_with_user("404: Not found", tr, user),
)
}
use crate::{
data::{db, model},
html_templates::*,
+ translation,
};
#[derive(Deserialize)]
State(connection): State<db::Connection>,
current_recipe: Query<CurrentRecipeId>,
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> {
let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?,
},
current_id: current_recipe.current_recipe_id,
};
- Ok(RecipesListFragmentTemplate { user, recipes })
+ Ok(RecipesListFragmentTemplate { user, tr, recipes })
}
consts,
data::{db, model},
html_templates::*,
+ translation,
};
-///// RECIPE /////
-
#[debug_handler]
pub async fn create(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
) -> Result<Response> {
if let Some(user) = user {
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").into_response())
+ Ok(MessageTemplate::new("Not logged in", tr).into_response())
}
}
pub async fn edit_recipe(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
Path(recipe_id): Path<i64>,
) -> Result<Response> {
if let Some(user) = user {
- if let Some(recipe) = connection.get_recipe(recipe_id).await? {
+ if let Some(recipe) = connection.get_recipe(recipe_id, false).await? {
if recipe.user_id == user.id {
let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?,
Ok(RecipeEditTemplate {
user: Some(user),
+ tr,
recipes,
recipe,
languages: *consts::LANGUAGES,
}
.into_response())
} else {
- Ok(MessageTemplate::new("Not allowed to edit this recipe").into_response())
+ Ok(MessageTemplate::new("Not allowed to edit this recipe", tr).into_response())
}
} else {
- Ok(MessageTemplate::new("Recipe not found").into_response())
+ Ok(MessageTemplate::new("Recipe not found", tr).into_response())
}
} else {
- Ok(MessageTemplate::new("Not logged in").into_response())
+ Ok(MessageTemplate::new("Not logged in", tr).into_response())
}
}
pub async fn view(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
Path(recipe_id): Path<i64>,
) -> Result<Response> {
- match connection.get_recipe(recipe_id).await? {
+ match connection.get_recipe(recipe_id, true).await? {
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),
+ tr,
user,
)
.into_response());
Ok(RecipeViewTemplate {
user,
+ tr,
recipes,
recipe,
}
}
None => Ok(MessageTemplate::new_with_user(
&format!("Cannot find the recipe {}", recipe_id),
+ tr,
user,
)
.into_response()),
data::{db, model},
email,
html_templates::*,
+ translation::{self, Sentence},
utils, AppState,
};
#[debug_handler]
pub async fn sign_up_get(
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> {
Ok(SignUpFormTemplate {
user,
+ tr,
email: String::new(),
message: String::new(),
message_email: String::new(),
State(connection): State<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
Form(form_data): Form<SignUpFormData>,
) -> Result<Response> {
fn error_response(
error: SignUpError,
form_data: &SignUpFormData,
user: Option<model::User>,
+ tr: translation::Tr,
) -> Result<Response> {
Ok(SignUpFormTemplate {
user,
email: form_data.email.clone(),
message_email: match error {
- SignUpError::InvalidEmail => "Invalid email",
- _ => "",
- }
- .to_string(),
+ SignUpError::InvalidEmail => tr.t(Sentence::InvalidEmail),
+ _ => String::new(),
+ },
message_password: match error {
- SignUpError::PasswordsNotEqual => "Passwords don't match",
- SignUpError::InvalidPassword => "Password must have at least eight characters",
- _ => "",
- }
- .to_string(),
+ SignUpError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
+ SignUpError::InvalidPassword => tr.tp(
+ Sentence::InvalidPassword,
+ &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
+ ),
+ _ => String::new(),
+ },
message: match error {
- SignUpError::UserAlreadyExists => "This email is not available",
- SignUpError::DatabaseError => "Database error",
- SignUpError::UnableSendEmail => "Unable to send the validation email",
- _ => "",
- }
- .to_string(),
+ SignUpError::UserAlreadyExists => tr.t(Sentence::EmailAlreadyTaken),
+ SignUpError::DatabaseError => "Database error".to_string(),
+ SignUpError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail),
+ _ => String::new(),
+ },
+ tr,
}
.into_response())
}
if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email)
{
- return error_response(SignUpError::InvalidEmail, &form_data, user);
+ return error_response(SignUpError::InvalidEmail, &form_data, user, tr);
}
if form_data.password_1 != form_data.password_2 {
- return error_response(SignUpError::PasswordsNotEqual, &form_data, user);
+ return error_response(SignUpError::PasswordsNotEqual, &form_data, user, tr);
}
if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1)
{
- return error_response(SignUpError::InvalidPassword, &form_data, user);
+ return error_response(SignUpError::InvalidPassword, &form_data, user, tr);
}
match connection
.await
{
Ok(db::user::SignUpResult::UserAlreadyExists) => {
- error_response(SignUpError::UserAlreadyExists, &form_data, user)
+ error_response(SignUpError::UserAlreadyExists, &form_data, user, tr)
}
Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => {
let url = utils::get_url_from_host(&host);
Ok(()) => Ok(
MessageTemplate::new_with_user(
"An email has been sent, follow the link to validate your account",
- user).into_response()),
+ tr, user).into_response()),
Err(_) => {
// error!("Email validation error: {}", error); // TODO: log
- error_response(SignUpError::UnableSendEmail, &form_data, user)
+ error_response(SignUpError::UnableSendEmail, &form_data, user, tr)
}
}
}
Err(_) => {
// error!("Signup database error: {}", error); // TODO: log
- error_response(SignUpError::DatabaseError, &form_data, user)
+ error_response(SignUpError::DatabaseError, &form_data, user, tr)
}
}
}
pub async fn sign_up_validation(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>,
headers: HeaderMap,
if user.is_some() {
return Ok((
jar,
- MessageTemplate::new_with_user("User already exists", user),
+ MessageTemplate::new_with_user("User already exists", tr, user),
));
}
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
jar,
MessageTemplate::new_with_user(
"Email validation successful, your account has been created",
+ tr,
user,
),
))
jar,
MessageTemplate::new_with_user(
"The validation has expired. Try to sign up again",
+ tr,
user,
),
)),
db::user::ValidationResult::UnknownUser => Ok((
jar,
- MessageTemplate::new_with_user("Validation error. Try to sign up again", user),
+ MessageTemplate::new_with_user(
+ "Validation error. Try to sign up again",
+ tr,
+ user,
+ ),
)),
}
}
None => Ok((
jar,
- MessageTemplate::new_with_user("Validation error", user),
+ MessageTemplate::new_with_user("Validation error", tr, user),
)),
}
}
#[debug_handler]
pub async fn sign_in_get(
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> {
Ok(SignInFormTemplate {
user,
+ tr,
email: String::new(),
message: String::new(),
})
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
headers: HeaderMap,
Form(form_data): Form<SignInFormData>,
) -> Result<(CookieJar, Response)> {
SignInFormTemplate {
user,
email: form_data.email,
- message: "This account must be validated first".to_string(),
+ message: tr.t(Sentence::AccountMustBeValidatedFirst),
+ tr,
}
.into_response(),
)),
SignInFormTemplate {
user,
email: form_data.email,
- message: "Wrong email or password".to_string(),
+ message: tr.t(Sentence::WrongEmailOrPassword),
+ tr,
}
.into_response(),
)),
#[debug_handler]
pub async fn ask_reset_password_get(
Extension(user): Extension<Option<model::User>>,
+ 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,
)
.into_response())
} else {
Ok(AskResetPasswordTemplate {
user,
+ tr,
email: String::new(),
message: String::new(),
message_email: String::new(),
State(connection): State<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
Form(form_data): Form<AskResetPasswordForm>,
) -> Result<Response> {
fn error_response(
error: AskResetPasswordError,
email: &str,
user: Option<model::User>,
+ tr: translation::Tr,
) -> Result<Response> {
Ok(AskResetPasswordTemplate {
user,
+ tr,
email: email.to_string(),
message_email: match error {
AskResetPasswordError::InvalidEmail => "Invalid email",
if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email)
{
- return error_response(AskResetPasswordError::InvalidEmail, &form_data.email, user);
+ return error_response(
+ AskResetPasswordError::InvalidEmail,
+ &form_data.email,
+ user,
+ tr,
+ );
}
match connection
AskResetPasswordError::EmailAlreadyReset,
&form_data.email,
user,
+ tr,
+ ),
+ Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => error_response(
+ AskResetPasswordError::EmailUnknown,
+ &form_data.email,
+ user,
+ tr,
),
- Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => {
- error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user)
- }
Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => {
let url = utils::get_url_from_host(&host);
match email::send_email(
{
Ok(()) => Ok(MessageTemplate::new_with_user(
"An email has been sent, follow the link to reset your password.",
+ tr,
user,
)
.into_response()),
AskResetPasswordError::UnableSendEmail,
&form_data.email,
user,
+ tr,
)
}
}
}
Err(error) => {
event!(Level::ERROR, "{}", error);
- error_response(AskResetPasswordError::DatabaseError, &form_data.email, user)
+ error_response(
+ AskResetPasswordError::DatabaseError,
+ &form_data.email,
+ user,
+ tr,
+ )
}
}
}
#[debug_handler]
pub async fn reset_password_get(
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: reset_token.to_string(),
message: String::new(),
message_password: String::new(),
}
.into_response())
} else {
- Ok(MessageTemplate::new_with_user("Reset token missing", user).into_response())
+ Ok(MessageTemplate::new_with_user("Reset token missing", tr, user).into_response())
}
}
pub async fn reset_password_post(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
Form(form_data): Form<ResetPasswordForm>,
) -> Result<Response> {
fn error_response(
error: ResetPasswordError,
form_data: &ResetPasswordForm,
user: Option<model::User>,
+ tr: translation::Tr,
) -> Result<Response> {
Ok(ResetPasswordTemplate {
user,
+ tr,
reset_token: form_data.reset_token.clone(),
message_password: match error {
ResetPasswordError::PasswordsNotEqual => "Passwords don't match",
}
if form_data.password_1 != form_data.password_2 {
- return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user);
+ return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user, tr);
}
if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1)
{
- return error_response(ResetPasswordError::InvalidPassword, &form_data, user);
+ return error_response(ResetPasswordError::InvalidPassword, &form_data, user, tr);
}
match connection
)
.await
{
- Ok(db::user::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user(
- "Your password has been reset",
- user,
- )
- .into_response()),
+ Ok(db::user::ResetPasswordResult::Ok) => {
+ Ok(
+ MessageTemplate::new_with_user("Your password has been reset", tr, user)
+ .into_response(),
+ )
+ }
Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
- error_response(ResetPasswordError::TokenExpired, &form_data, user)
+ error_response(ResetPasswordError::TokenExpired, &form_data, user, tr)
}
- Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user),
+ Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user, tr),
}
}
/// EDIT PROFILE ///
#[debug_handler]
-pub async fn edit_user_get(Extension(user): Extension<Option<model::User>>) -> Response {
+pub async fn edit_user_get(
+ Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
+) -> Response {
if let Some(user) = user {
ProfileTemplate {
username: user.name.clone(),
email: user.email.clone(),
- user: Some(user),
message: String::new(),
message_email: String::new(),
message_password: String::new(),
+ user: Some(user),
+ tr,
}
.into_response()
} else {
- MessageTemplate::new("Not logged in").into_response()
+ MessageTemplate::new("Not logged in", tr).into_response()
}
}
State(connection): State<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
Form(form_data): Form<EditUserForm>,
) -> Result<Response> {
if let Some(user) = user {
error: ProfileUpdateError,
form_data: &EditUserForm,
user: model::User,
+ tr: translation::Tr,
) -> Result<Response> {
Ok(ProfileTemplate {
user: Some(user),
_ => "",
}
.to_string(),
+ tr,
}
.into_response())
}
if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email)
{
- return error_response(ProfileUpdateError::InvalidEmail, &form_data, user);
+ return error_response(ProfileUpdateError::InvalidEmail, &form_data, user, tr);
}
let new_password = if !form_data.password_1.is_empty() || !form_data.password_2.is_empty() {
if form_data.password_1 != form_data.password_2 {
- return error_response(ProfileUpdateError::PasswordsNotEqual, &form_data, user);
+ return error_response(ProfileUpdateError::PasswordsNotEqual, &form_data, user, tr);
}
if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1)
{
- return error_response(ProfileUpdateError::InvalidPassword, &form_data, user);
+ return error_response(ProfileUpdateError::InvalidPassword, &form_data, user, tr);
}
Some(form_data.password_1.as_ref())
} else {
.await
{
Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => {
- return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user);
+ return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user, tr);
}
Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
let url = utils::get_url_from_host(&host);
}
Err(_) => {
// error!("Email validation error: {}", error); // TODO: log
- return error_response(ProfileUpdateError::UnableSendEmail, &form_data, user);
+ return error_response(
+ ProfileUpdateError::UnableSendEmail, &form_data, user, tr);
}
}
}
Ok(db::user::UpdateUserResult::Ok) => {
message = "Profile saved";
}
- Err(_) => return error_response(ProfileUpdateError::DatabaseError, &form_data, user),
+ Err(_) => {
+ return error_response(ProfileUpdateError::DatabaseError, &form_data, user, tr)
+ }
}
// Reload after update.
message: message.to_string(),
message_email: String::new(),
message_password: String::new(),
+ tr,
}
.into_response())
} else {
- Ok(MessageTemplate::new("Not logged in").into_response())
+ Ok(MessageTemplate::new("Not logged in", tr).into_response())
}
}
pub async fn email_revalidation(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
+ Extension(tr): Extension<translation::Tr>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>,
headers: HeaderMap,
if user.is_some() {
return Ok((
jar,
- MessageTemplate::new_with_user("User already exists", user),
+ MessageTemplate::new_with_user("User already exists", tr, user),
));
}
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
let user = connection.load_user(user_id).await?;
Ok((
jar,
- MessageTemplate::new_with_user("Email validation successful", user),
+ MessageTemplate::new_with_user("Email validation successful", 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,
),
)),
jar,
MessageTemplate::new_with_user(
"Validation error. Try to sign up again with the same email",
+ tr,
user,
),
)),
}
None => Ok((
jar,
- MessageTemplate::new_with_user("Validation error", user),
+ MessageTemplate::new_with_user("Validation error", tr, user),
)),
}
}
--- /dev/null
+use std::{fs::File, sync::LazyLock};
+
+use ron::de::from_reader;
+use rustc_hash::FxHashMap;
+use serde::Deserialize;
+use tracing::{event, Level};
+
+use crate::consts;
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Clone)]
+pub enum Sentence {
+ ProfileTitle,
+ MainTitle,
+ CreateNewRecipe,
+ UnpublishedRecipes,
+ UntitledRecipe,
+
+ EmailAddress,
+ Password,
+
+ // Sign in page.
+ SignInMenu,
+ SignInTitle,
+ SignInButton,
+ WrongEmailOrPassword,
+
+ // Sign up page.
+ SignUpMenu,
+ SignUpTitle,
+ SignUpButton,
+ ChooseAPassword,
+ ReEnterPassword,
+ AccountMustBeValidatedFirst,
+ InvalidEmail,
+ PasswordDontMatch,
+ InvalidPassword,
+ EmailAlreadyTaken,
+ UnableToSendEmail,
+
+ // Reset password page.
+ LostPassword,
+ AskResetButton,
+}
+
+#[derive(Clone)]
+pub struct Tr {
+ lang: &'static Language,
+}
+
+impl Tr {
+ pub fn new(code: &str) -> Self {
+ for lang in TRANSLATIONS.iter() {
+ if lang.code == code {
+ return Self { lang };
+ }
+ }
+
+ event!(
+ Level::WARN,
+ "Unable to find translation for language {}",
+ code
+ );
+
+ Tr::new("en")
+ }
+
+ pub fn t(&self, sentence: Sentence) -> String {
+ match self.lang.translation.get(&sentence) {
+ Some(str) => str.clone(),
+ None => format!(
+ "Translation missing, lang: {}/{}, element: {:?}",
+ self.lang.name, self.lang.code, sentence
+ ),
+ }
+ }
+
+ pub fn tp(&self, sentence: Sentence, params: &[Box<dyn ToString>]) -> String {
+ match self.lang.translation.get(&sentence) {
+ Some(str) => {
+ let mut result = str.clone();
+ for p in params {
+ result = result.replacen("{}", &p.to_string(), 1);
+ }
+ result
+ }
+ None => format!(
+ "Translation missing, lang: {}/{}, element: {:?}",
+ self.lang.name, self.lang.code, sentence
+ ),
+ }
+ }
+
+ pub fn available_languages() -> Vec<(&'static str, &'static str)> {
+ TRANSLATIONS
+ .iter()
+ .map(|tr| (tr.code.as_ref(), tr.name.as_ref()))
+ .collect()
+ }
+
+ pub fn available_codes() -> Vec<&'static str> {
+ TRANSLATIONS.iter().map(|tr| tr.code.as_ref()).collect()
+ }
+}
+
+// #[macro_export]
+// macro_rules! t {
+// ($self:expr, $str:expr) => {
+// $self.t($str)
+// };
+// ($self:expr, $str:expr, $( $x:expr ),+ ) => {
+// {
+// let mut result = $self.t($str);
+// $( result = result.replacen("{}", &$x.to_string(), 1); )+
+// result
+// }
+// };
+// }
+
+#[derive(Debug, Deserialize, Clone)]
+struct Language {
+ code: String,
+ name: String,
+ translation: FxHashMap<Sentence, String>,
+}
+
+static TRANSLATIONS: LazyLock<Vec<Language>> =
+ LazyLock::new(|| match File::open(consts::TRANSLATION_FILE) {
+ Ok(file) => from_reader(file).unwrap_or_else(|error| {
+ panic!(
+ "Failed to read translation file {}: {}",
+ consts::TRANSLATION_FILE,
+ error
+ )
+ }),
+ Err(error) => {
+ panic!(
+ "Failed to open translation file {}: {}",
+ consts::TRANSLATION_FILE,
+ error
+ )
+ }
+ });
{% block main_container %}
<div class="content">
+ <h1></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" />
{% match user %}
{% when Some with (user) %}
- <a class="create-recipe" href="/recipe/new" >Create a new recipe</a>
+ <a class="create-recipe" href="/recipe/new" >{{ tr.t(Sentence::CreateNewRecipe) }}</a>
<span><a href="/user/edit">
{% if user.name == "" %}
{{ user.email }}
</a> / <a href="/signout" />Sign out</a></span>
{% when None %}
<span>
- <a href="/signin" >Sign in</a>/<a href="/signup">Sign up</a>/<a href="/ask_reset_password">Lost password</a>
+ <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 %}
<pre><code>
{% endif %}
- {{ message|markdown }}
+ {{ message }}
{% if as_code %}
</code></pre>
{% when Some with (user) %}
<div class="content" id="user-edit">
- <h1>Profile</h1>
+ <h1>{{ tr.t(Sentence::ProfileTitle) }}</h1>
+
<form action="/user/edit" method="post">
<label for="input-name">Name</label>
autofocus="autofocus" />
<label for="input-email">Email (need to be revalidated if changed)</label>
- <input id="input-email" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
+ <input id="input-email" type="email"
+ name="email" value="{{ email }}"
+ autocapitalize="none" autocomplete="email" autofocus="autofocus" />
{{ message_email }}
{% if !recipe.description.is_empty() %}
<div class="recipe-description" >
- {{ recipe.description.clone()|markdown }}
+ {{ recipe.description.clone() }}
</div>
{% endif %}
</div>
{% macro recipe_item(id, title, class) %}
<a href="/recipe/view/{{ id }}" class="{{ class }}" id="recipe-{{ id }}">
{% if title == "" %}
- {# TODO: Translation #}
- Untitled recipe
+ {{ tr.t(Sentence::UntitledRecipe) }}
{% else %}
{{ title }}
{% endif %}
<div id="recipes-list">
{% if !recipes.unpublished.is_empty() %}
- Unpublished recipes
+ {{ tr.t(Sentence::UnpublishedRecipes) }}
{% endif %}
<nav class="recipes-list-unpublished">
<div class="content" id="sign-in">
- <h1>Sign in</h1>
+ <h1>{{ tr.t(Sentence::SignInTitle) }}</h1>
<form action="/signin" method="post">
- <label for="input-email">Email address</label>
+ <label for="input-email">{{ tr.t(Sentence::EmailAddress) }}</label>
<input id="input-email" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
- <label for="input-password">Password</label>
+ <label for="input-password">{{ tr.t(Sentence::Password) }}</label>
<input id="input-password" type="password" name="password" autocomplete="current-password" />
- <input type="submit" value="Sign in" />
+ <input type="submit" value="{{ tr.t(Sentence::SignInMenu) }}" />
</form>
{{ message }}
</div>
<div class="content" id="sign-up">
- <h1>Sign up</h1>
+ <h1>{{ tr.t(Sentence::SignUpTitle) }}</h1>
<form action="/signup" method="post">
- <label for="input-email">Your email address</label>
- <input id="input-email" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
+ <label for="input-email">{{ tr.t(Sentence::EmailAddress) }}</label>
+ <input id="input-email" type="email"
+ name="email" value="{{ email }}"
+ autocapitalize="none" autocomplete="email" autofocus="autofocus" />
{{ message_email }}
- <label for="input-password-1">Choose a password (minimum 8 characters)</label>
+ <label for="input-password-1">
+ {{ tr.tp(Sentence::ChooseAPassword, [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="Sign up" />
+ <input type="submit" name="commit" value="{{ tr.t(Sentence::SignUpButton) }}" />
</form>
{{ message }}
</div>
-<a class="title" href="/">~~ Recettes de cuisine ~~</a>
\ No newline at end of file
+<a class="title" href="/">{{ tr.t(Sentence::MainTitle) }}</a>
\ No newline at end of file
--- /dev/null
+[
+ (
+ code: "en",
+ name: "English",
+ translation: {
+ ProfileTitle: "Profile",
+ MainTitle: "Cooking Recipes",
+ CreateNewRecipe: "Create a new recipe",
+ UnpublishedRecipes: "Unpublished recipes",
+ UntitledRecipe: "Untitled recipe",
+
+ EmailAddress: "Email address",
+ Password: "Password",
+
+ SignInMenu: "Sign in",
+ SignInTitle: "Sign in",
+ SignInButton: "Sign in",
+ WrongEmailOrPassword: "Wrong email or password",
+ AccountMustBeValidatedFirst: "This account must be validated first",
+ InvalidEmail: "Invalid email",
+ PasswordDontMatch: "Passwords don't match",
+ InvalidPassword: "Password must have at least {} characters",
+ EmailAlreadyTaken: "This email is not available",
+ UnableToSendEmail: "Unable to send the validation email",
+
+ SignUpMenu: "Sign up",
+ SignUpTitle: "Sign up",
+ SignUpButton: "Sign up",
+ ChooseAPassword: "Choose a password (minimum {} characters)",
+ ReEnterPassword: "Re-enter password",
+
+ LostPassword: "Lost password",
+ AskResetButton: "Ask reset",
+ }
+ ),
+ (
+ code: "fr",
+ name: "Français",
+ translation: {
+ ProfileTitle: "Profile",
+ MainTitle: "Recette de Cuisine",
+ CreateNewRecipe: "Créer une nouvelle recette",
+ UnpublishedRecipes: "Recettes non-publiés",
+ UntitledRecipe: "Recette sans nom",
+
+ EmailAddress: "Adresse email",
+ Password: "Mot de passe",
+
+ SignInMenu: "Se connecter",
+ SignInTitle: "Se connecter",
+ SignInButton: "Se connecter",
+ WrongEmailOrPassword: "Mot de passe ou email invalide",
+ AccountMustBeValidatedFirst: "Ce compte doit d'abord être validé",
+ InvalidEmail: "Adresse email invalide",
+ PasswordDontMatch: "Les mots de passe ne correspondent pas",
+ InvalidPassword: "Le mot de passe doit avoir au moins {} caractères",
+ EmailAlreadyTaken: "Cette adresse email n'est pas disponible",
+ UnableToSendEmail: "L'email de validation n'a pas pu être envoyé",
+
+ SignUpMenu: "S'inscrire",
+ SignUpTitle: "Inscription",
+ SignUpButton: "Valider",
+ 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",
+ }
+ )
+]
\ No newline at end of file
--- /dev/null
+pub const MIN_PASSWORD_SIZE: usize = 8;
+pub mod consts;
pub mod ron_api;
pub mod utils;
use regex::Regex;
+use crate::consts;
+
pub enum EmailValidation {
Ok,
NotValid,
}
pub fn validate_password(password: &str) -> PasswordValidation {
- if password.len() < 8 {
+ if password.len() < consts::MIN_PASSWORD_SIZE {
PasswordValidation::TooShort
} else {
PasswordValidation::Ok
invoke_ssh [rm -rf recipes/static]
copy_ssh ./backend/static/ $destination
copy_ssh ./backend/sql/ $destination
+ copy_ssh ./backend/translation.ron $destination
invoke_ssh [chmod u+x recipes/recipes]
invoke_ssh [sudo systemctl start recipes]
print "Deployment finished"
};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
-use web_sys::{
- Element, Event, HtmlDialogElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement,
- KeyboardEvent,
-};
+use web_sys::{Element, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement, KeyboardEvent};
-use common::ron_api::{self, Ingredient};
+use common::ron_api;
use crate::{
modal_dialog, request,