Translation (WIP)

This commit is contained in:
Greg Burri 2025-01-05 22:38:46 +01:00
parent 9b0fcec5e2
commit e9873c1943
29 changed files with 586 additions and 285 deletions

265
Cargo.lock generated
View file

@ -120,67 +120,11 @@ dependencies = [
"password-hash", "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]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.83" version = "0.1.84"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" checksum = "1b1244b10dcd56c92219da4e14caa97e312079e185f04ba3eea25061561dc0a0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -391,9 +335,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.6" version = "1.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d6dbb628b8f8555f86d0323c2eb39e3ec81901f4b83e091db8a6a76d316a333" checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7"
dependencies = [ dependencies = [
"shlex", "shlex",
] ]
@ -483,21 +427,6 @@ dependencies = [
"serde", "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]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@ -640,12 +569,6 @@ dependencies = [
"unicode-xid", "unicode-xid",
] ]
[[package]]
name = "deunicode"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -709,12 +632,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "entities"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.1" version = "1.0.1"
@ -776,6 +693,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.1" version = "1.2.1"
@ -1126,14 +1049,19 @@ name = "hashbrown"
version = "0.15.2" version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]] [[package]]
name = "hashlink" name = "hashlink"
version = "0.9.1" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [ dependencies = [
"hashbrown 0.14.5", "hashbrown 0.15.2",
] ]
[[package]] [[package]]
@ -1473,9 +1401,9 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.13.0" version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [ dependencies = [
"either", "either",
] ]
@ -1811,12 +1739,6 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@ -1995,8 +1917,6 @@ name = "recipes"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"askama",
"askama_axum",
"axum", "axum",
"axum-extra", "axum-extra",
"chrono", "chrono",
@ -2007,7 +1927,10 @@ dependencies = [
"lettre", "lettre",
"rand", "rand",
"rand_core", "rand_core",
"rinja",
"rinja_axum",
"ron", "ron",
"rustc-hash",
"serde", "serde",
"sqlx", "sqlx",
"thiserror 2.0.9", "thiserror 2.0.9",
@ -2086,6 +2009,58 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "ron" name = "ron"
version = "0.8.1" version = "0.8.1"
@ -2124,6 +2099,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497"
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "0.38.42" version = "0.38.42"
@ -2326,16 +2307,6 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.13.2" version = "1.13.2"
@ -2374,21 +2345,11 @@ dependencies = [
"der", "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]] [[package]]
name = "sqlx" name = "sqlx"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93334716a037193fac19df402f8571269c84a00852f6a7066b5d2616dcd64d3e" checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f"
dependencies = [ dependencies = [
"sqlx-core", "sqlx-core",
"sqlx-macros", "sqlx-macros",
@ -2399,38 +2360,32 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-core" name = "sqlx-core"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d8060b456358185f7d50c55d9b5066ad956956fddec42ee2e8567134a8936e" checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0"
dependencies = [ dependencies = [
"atoi",
"byteorder",
"bytes", "bytes",
"chrono", "chrono",
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
"either", "either",
"event-listener", "event-listener",
"futures-channel",
"futures-core", "futures-core",
"futures-intrusive", "futures-intrusive",
"futures-io", "futures-io",
"futures-util", "futures-util",
"hashbrown 0.14.5", "hashbrown 0.15.2",
"hashlink", "hashlink",
"hex",
"indexmap", "indexmap",
"log", "log",
"memchr", "memchr",
"once_cell", "once_cell",
"paste",
"percent-encoding", "percent-encoding",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"smallvec", "smallvec",
"sqlformat", "thiserror 2.0.9",
"thiserror 1.0.69",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
@ -2439,9 +2394,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-macros" name = "sqlx-macros"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cac0692bcc9de3b073e8d747391827297e075c7710ff6276d9f7a1f3d58c6657" checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2452,9 +2407,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-macros-core" name = "sqlx-macros-core"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1804e8a7c7865599c9c79be146dc8a9fd8cc86935fa641d3ea58e5f0688abaa5" checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad"
dependencies = [ dependencies = [
"dotenvy", "dotenvy",
"either", "either",
@ -2478,9 +2433,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-mysql" name = "sqlx-mysql"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64bb4714269afa44aef2755150a0fc19d756fb580a67db8885608cf02f47d06a" checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64 0.22.1", "base64 0.22.1",
@ -2514,16 +2469,16 @@ dependencies = [
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror 1.0.69", "thiserror 2.0.9",
"tracing", "tracing",
"whoami", "whoami",
] ]
[[package]] [[package]]
name = "sqlx-postgres" name = "sqlx-postgres"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fa91a732d854c5d7726349bb4bb879bb9478993ceb764247660aee25f67c2f8" checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64 0.22.1", "base64 0.22.1",
@ -2535,7 +2490,6 @@ dependencies = [
"etcetera", "etcetera",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-util", "futures-util",
"hex", "hex",
"hkdf", "hkdf",
@ -2553,16 +2507,16 @@ dependencies = [
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror 1.0.69", "thiserror 2.0.9",
"tracing", "tracing",
"whoami", "whoami",
] ]
[[package]] [[package]]
name = "sqlx-sqlite" name = "sqlx-sqlite"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680" checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540"
dependencies = [ dependencies = [
"atoi", "atoi",
"chrono", "chrono",
@ -2626,9 +2580,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.93" version = "2.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058" checksum = "46f71c0377baf4ef1cc3e3402ded576dccc315800fbc62dfc7fe04b009773b4a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2654,12 +2608,13 @@ dependencies = [
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.14.0" version = "3.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"fastrand", "fastrand",
"getrandom",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.59.0", "windows-sys 0.59.0",
@ -2966,12 +2921,6 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "typed-arena"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.17.0" version = "1.17.0"
@ -3023,12 +2972,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unicode_categories"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"

View file

@ -22,18 +22,14 @@ chrono = "0.4"
ron = "0.8" ron = "0.8"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
itertools = "0.13" itertools = "0.14"
rustc-hash = "2.1"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] } sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] }
askama = { version = "0.12", features = [ rinja = { version = "0.3", features = ["with-axum"] }
"with-axum", rinja_axum = "0.3"
"mime",
"mime_guess",
"markdown",
] }
askama_axum = "0.4"
argon2 = { version = "0.5", features = ["default", "std"] } argon2 = { version = "0.5", features = ["default", "std"] }
rand_core = { version = "0.6", features = ["std"] } rand_core = { version = "0.6", features = ["std"] }

View file

@ -10,6 +10,7 @@ CREATE TABLE [User] (
[email] TEXT NOT NULL, [email] TEXT NOT NULL,
[name] TEXT NOT NULL DEFAULT '', [name] TEXT NOT NULL DEFAULT '',
[default_servings] INTEGER DEFAULT 4, [default_servings] INTEGER DEFAULT 4,
[lang] TEXT NOT NULL DEFAULT 'en',
[password] TEXT NOT NULL, -- argon2(password_plain, salt). [password] TEXT NOT NULL, -- argon2(password_plain, salt).

View file

@ -1,6 +1,7 @@
use std::{sync::LazyLock, time::Duration}; use std::{sync::LazyLock, time::Duration};
pub const FILE_CONF: &str = "conf.ron"; pub const FILE_CONF: &str = "conf.ron";
pub const TRANSLATION_FILE: &str = "translation.ron";
pub const DB_DIRECTORY: &str = "data"; pub const DB_DIRECTORY: &str = "data";
pub const DB_FILENAME: &str = "recipes.sqlite"; pub const DB_FILENAME: &str = "recipes.sqlite";
pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql"; pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql";

View file

@ -102,8 +102,8 @@ WHERE [Ingredient].[id] = $1 AND [user_id] = $2
.map_err(DBError::from) .map_err(DBError::from)
} }
pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> { pub async fn get_recipe(&self, id: i64, with_groups: bool) -> Result<Option<model::Recipe>> {
sqlx::query_as( match sqlx::query_as::<_, model::Recipe>(
r#" r#"
SELECT SELECT
[id], [user_id], [title], [lang], [id], [user_id], [title], [lang],
@ -114,8 +114,14 @@ FROM [Recipe] WHERE [id] = $1
) )
.bind(id) .bind(id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await?
.map_err(DBError::from) {
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> { pub async fn create_recipe(&self, user_id: i64) -> Result<i64> {
@ -543,8 +549,6 @@ ORDER BY [name]
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use axum::routing::connect;
use super::*; use super::*;
#[tokio::test] #[tokio::test]
@ -555,7 +559,7 @@ mod tests {
let recipe_id = connection.create_recipe(user_id).await?; let recipe_id = connection.create_recipe(user_id).await?;
connection.set_recipe_title(recipe_id, "Crêpe").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()); assert_eq!(recipe.title, "Crêpe".to_string());
Ok(()) Ok(())
@ -581,7 +585,7 @@ mod tests {
connection.set_recipe_language(recipe_id, "fr").await?; connection.set_recipe_language(recipe_id, "fr").await?;
connection.set_recipe_is_published(recipe_id, true).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.id, recipe_id);
assert_eq!(recipe.title, "Ouiche"); assert_eq!(recipe.title, "Ouiche");

View file

@ -76,7 +76,7 @@ FROM [UserLoginToken] WHERE [token] = $1
} }
pub async fn load_user(&self, user_id: i64) -> Result<Option<model::User>> { 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) .bind(user_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
@ -102,13 +102,14 @@ FROM [UserLoginToken] WHERE [token] = $1
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await?; .await?;
let new_email = new_email.map(str::trim);
let email_changed = new_email.is_some_and(|new_email| new_email != email); let email_changed = new_email.is_some_and(|new_email| new_email != email);
// Check if email not already taken. // Check if email not already taken.
let validation_token = if email_changed { let validation_token = if email_changed {
if sqlx::query_scalar::<_, i64>( if sqlx::query_scalar(
r#" r#"
SELECT COUNT(*) SELECT COUNT(*) > 0
FROM [User] FROM [User]
WHERE [email] = $1 WHERE [email] = $1
"#, "#,
@ -116,7 +117,6 @@ WHERE [email] = $1
.bind(new_email.unwrap()) .bind(new_email.unwrap())
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await? .await?
> 0
{ {
return Ok(UpdateUserResult::EmailAlreadyTaken); return Ok(UpdateUserResult::EmailAlreadyTaken);
} }
@ -148,7 +148,7 @@ WHERE [id] = $1
) )
.bind(user_id) .bind(user_id)
.bind(new_email.unwrap_or(&email)) .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)) .bind(hashed_new_password.unwrap_or(hashed_password))
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;

View file

@ -7,6 +7,7 @@ pub struct User {
pub id: i64, pub id: i64,
pub name: String, pub name: String,
pub email: String, pub email: String,
pub lang: String,
} }
#[derive(FromRow)] #[derive(FromRow)]
@ -30,8 +31,9 @@ pub struct Recipe {
pub servings: Option<u32>, pub servings: Option<u32>,
pub is_published: bool, pub is_published: bool,
// pub tags: Vec<String>,
// pub groups: Vec<Group>, #[sqlx(skip)]
pub groups: Vec<Group>,
} }
#[derive(FromRow)] #[derive(FromRow)]

View file

@ -1,6 +1,9 @@
use askama::Template; use rinja_axum::Template;
use crate::data::model; use crate::{
data::model,
translation::{Sentence, Tr},
};
pub struct Recipes { pub struct Recipes {
pub published: Vec<(i64, String)>, pub published: Vec<(i64, String)>,
@ -18,6 +21,8 @@ impl Recipes {
#[template(path = "home.html")] #[template(path = "home.html")]
pub struct HomeTemplate { pub struct HomeTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub tr: Tr,
pub recipes: Recipes, pub recipes: Recipes,
} }
@ -25,23 +30,26 @@ pub struct HomeTemplate {
#[template(path = "message.html")] #[template(path = "message.html")]
pub struct MessageTemplate { pub struct MessageTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub tr: Tr,
pub message: String, pub message: String,
pub as_code: bool, // Display the message in <pre> markup. pub as_code: bool, // Display the message in <pre> markup.
} }
impl MessageTemplate { impl MessageTemplate {
pub fn new(message: &str) -> MessageTemplate { pub fn new(message: &str, tr: Tr) -> MessageTemplate {
MessageTemplate { MessageTemplate {
user: None, user: None,
tr,
message: message.to_string(), message: message.to_string(),
as_code: false, 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 { MessageTemplate {
user, user,
tr,
message: message.to_string(), message: message.to_string(),
as_code: false, as_code: false,
} }
@ -52,6 +60,7 @@ impl MessageTemplate {
#[template(path = "sign_up_form.html")] #[template(path = "sign_up_form.html")]
pub struct SignUpFormTemplate { pub struct SignUpFormTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub tr: Tr,
pub email: String, pub email: String,
pub message: String, pub message: String,
@ -63,6 +72,7 @@ pub struct SignUpFormTemplate {
#[template(path = "sign_in_form.html")] #[template(path = "sign_in_form.html")]
pub struct SignInFormTemplate { pub struct SignInFormTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub tr: Tr,
pub email: String, pub email: String,
pub message: String, pub message: String,
@ -72,6 +82,7 @@ pub struct SignInFormTemplate {
#[template(path = "ask_reset_password.html")] #[template(path = "ask_reset_password.html")]
pub struct AskResetPasswordTemplate { pub struct AskResetPasswordTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub tr: Tr,
pub email: String, pub email: String,
pub message: String, pub message: String,
@ -82,6 +93,7 @@ pub struct AskResetPasswordTemplate {
#[template(path = "reset_password.html")] #[template(path = "reset_password.html")]
pub struct ResetPasswordTemplate { pub struct ResetPasswordTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub tr: Tr,
pub reset_token: String, pub reset_token: String,
pub message: String, pub message: String,
@ -92,6 +104,7 @@ pub struct ResetPasswordTemplate {
#[template(path = "profile.html")] #[template(path = "profile.html")]
pub struct ProfileTemplate { pub struct ProfileTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub tr: Tr,
pub username: String, pub username: String,
pub email: String, pub email: String,
@ -104,6 +117,8 @@ pub struct ProfileTemplate {
#[template(path = "recipe_view.html")] #[template(path = "recipe_view.html")]
pub struct RecipeViewTemplate { pub struct RecipeViewTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub tr: Tr,
pub recipes: Recipes, pub recipes: Recipes,
pub recipe: model::Recipe, pub recipe: model::Recipe,
@ -113,6 +128,8 @@ pub struct RecipeViewTemplate {
#[template(path = "recipe_edit.html")] #[template(path = "recipe_edit.html")]
pub struct RecipeEditTemplate { pub struct RecipeEditTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub tr: Tr,
pub recipes: Recipes, pub recipes: Recipes,
pub recipe: model::Recipe, pub recipe: model::Recipe,
@ -123,5 +140,7 @@ pub struct RecipeEditTemplate {
#[template(path = "recipes_list_fragment.html")] #[template(path = "recipes_list_fragment.html")]
pub struct RecipesListFragmentTemplate { pub struct RecipesListFragmentTemplate {
pub user: Option<model::User>, pub user: Option<model::User>,
pub tr: Tr,
pub recipes: Recipes, pub recipes: Recipes,
} }

View file

@ -1,7 +1,7 @@
use std::{net::SocketAddr, path::Path}; use std::{net::SocketAddr, path::Path};
use axum::{ use axum::{
extract::{ConnectInfo, FromRef, Request, State}, extract::{ConnectInfo, Extension, FromRef, Request, State},
http::StatusCode, http::StatusCode,
middleware::{self, Next}, middleware::{self, Next},
response::{Response, Result}, response::{Response, Result},
@ -12,10 +12,12 @@ use axum_extra::extract::cookie::CookieJar;
use chrono::prelude::*; use chrono::prelude::*;
use clap::Parser; use clap::Parser;
use config::Config; use config::Config;
use itertools::Itertools;
use tower_http::{services::ServeDir, trace::TraceLayer}; use tower_http::{services::ServeDir, trace::TraceLayer};
use tracing::{event, Level}; use tracing::{event, Level};
use data::{db, model}; use data::{db, model};
use translation::Tr;
mod config; mod config;
mod consts; mod consts;
@ -26,6 +28,7 @@ mod html_templates;
mod ron_extractor; mod ron_extractor;
mod ron_utils; mod ron_utils;
mod services; mod services;
mod translation;
mod utils; mod utils;
#[derive(Clone)] #[derive(Clone)]
@ -191,6 +194,7 @@ async fn main() {
.fallback(services::not_found) .fallback(services::not_found)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
// FIXME: Should be 'route_layer' but it doesn't work for 'fallback(..)'. // FIXME: Should be 'route_layer' but it doesn't work for 'fallback(..)'.
.layer(middleware::from_fn(translation))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
user_authentication, user_authentication,
@ -218,6 +222,39 @@ async fn user_authentication(
Ok(next.run(req).await) 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( async fn get_current_user(
connection: db::Connection, connection: db::Connection,
jar: &CookieJar, jar: &CookieJar,

View file

@ -10,7 +10,7 @@ use axum::{
use crate::{ use crate::{
data::{db, model}, data::{db, model},
html_templates::*, html_templates::*,
ron_utils, ron_utils, translation,
}; };
pub mod fragments; pub mod fragments;
@ -19,7 +19,11 @@ pub mod ron;
pub mod user; pub mod user;
// Will embed RON error in HTML page. // 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; let response = next.run(req).await;
if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) { if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) {
@ -32,6 +36,7 @@ pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
user: None, user: None,
message, message,
as_code: true, as_code: true,
tr,
} }
.into_response()); .into_response());
} }
@ -46,6 +51,7 @@ pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
pub async fn home_page( pub async fn home_page(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
let recipes = Recipes { let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?, published: connection.get_all_published_recipe_titles().await?,
@ -59,15 +65,18 @@ pub async fn home_page(
current_id: None, current_id: None,
}; };
Ok(HomeTemplate { user, recipes }) Ok(HomeTemplate { user, recipes, tr })
} }
///// 404 ///// ///// 404 /////
#[debug_handler] #[debug_handler]
pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse { pub async fn not_found(
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> impl IntoResponse {
( (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
MessageTemplate::new_with_user("404: Not found", user), MessageTemplate::new_with_user("404: Not found", tr, user),
) )
} }

View file

@ -9,6 +9,7 @@ use serde::Deserialize;
use crate::{ use crate::{
data::{db, model}, data::{db, model},
html_templates::*, html_templates::*,
translation,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
@ -21,6 +22,7 @@ pub async fn recipes_list_fragments(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
current_recipe: Query<CurrentRecipeId>, current_recipe: Query<CurrentRecipeId>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
let recipes = Recipes { let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?, published: connection.get_all_published_recipe_titles().await?,
@ -33,5 +35,5 @@ pub async fn recipes_list_fragments(
}, },
current_id: current_recipe.current_recipe_id, current_id: current_recipe.current_recipe_id,
}; };
Ok(RecipesListFragmentTemplate { user, recipes }) Ok(RecipesListFragmentTemplate { user, tr, recipes })
} }

View file

@ -9,20 +9,20 @@ use crate::{
consts, consts,
data::{db, model}, data::{db, model},
html_templates::*, html_templates::*,
translation,
}; };
///// RECIPE /////
#[debug_handler] #[debug_handler]
pub async fn create( pub async fn create(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<Response> { ) -> Result<Response> {
if let Some(user) = user { if let Some(user) = user {
let recipe_id = connection.create_recipe(user.id).await?; let recipe_id = connection.create_recipe(user.id).await?;
Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response()) Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response())
} else { } else {
Ok(MessageTemplate::new("Not logged in").into_response()) Ok(MessageTemplate::new("Not logged in", tr).into_response())
} }
} }
@ -30,10 +30,11 @@ pub async fn create(
pub async fn edit_recipe( pub async fn edit_recipe(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Path(recipe_id): Path<i64>, Path(recipe_id): Path<i64>,
) -> Result<Response> { ) -> Result<Response> {
if let Some(user) = user { 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 { if recipe.user_id == user.id {
let recipes = Recipes { let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?, published: connection.get_all_published_recipe_titles().await?,
@ -45,19 +46,20 @@ pub async fn edit_recipe(
Ok(RecipeEditTemplate { Ok(RecipeEditTemplate {
user: Some(user), user: Some(user),
tr,
recipes, recipes,
recipe, recipe,
languages: *consts::LANGUAGES, languages: *consts::LANGUAGES,
} }
.into_response()) .into_response())
} else { } 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 { } else {
Ok(MessageTemplate::new("Recipe not found").into_response()) Ok(MessageTemplate::new("Recipe not found", tr).into_response())
} }
} else { } else {
Ok(MessageTemplate::new("Not logged in").into_response()) Ok(MessageTemplate::new("Not logged in", tr).into_response())
} }
} }
@ -65,15 +67,17 @@ pub async fn edit_recipe(
pub async fn view( pub async fn view(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Path(recipe_id): Path<i64>, Path(recipe_id): Path<i64>,
) -> Result<Response> { ) -> Result<Response> {
match connection.get_recipe(recipe_id).await? { match connection.get_recipe(recipe_id, true).await? {
Some(recipe) => { Some(recipe) => {
if !recipe.is_published if !recipe.is_published
&& (user.is_none() || recipe.user_id != user.as_ref().unwrap().id) && (user.is_none() || recipe.user_id != user.as_ref().unwrap().id)
{ {
return Ok(MessageTemplate::new_with_user( return Ok(MessageTemplate::new_with_user(
&format!("Not allowed the view the recipe {}", recipe_id), &format!("Not allowed the view the recipe {}", recipe_id),
tr,
user, user,
) )
.into_response()); .into_response());
@ -93,6 +97,7 @@ pub async fn view(
Ok(RecipeViewTemplate { Ok(RecipeViewTemplate {
user, user,
tr,
recipes, recipes,
recipe, recipe,
} }
@ -100,6 +105,7 @@ pub async fn view(
} }
None => Ok(MessageTemplate::new_with_user( None => Ok(MessageTemplate::new_with_user(
&format!("Cannot find the recipe {}", recipe_id), &format!("Cannot find the recipe {}", recipe_id),
tr,
user, user,
) )
.into_response()), .into_response()),

View file

@ -19,6 +19,7 @@ use crate::{
data::{db, model}, data::{db, model},
email, email,
html_templates::*, html_templates::*,
translation::{self, Sentence},
utils, AppState, utils, AppState,
}; };
@ -27,9 +28,11 @@ use crate::{
#[debug_handler] #[debug_handler]
pub async fn sign_up_get( pub async fn sign_up_get(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
Ok(SignUpFormTemplate { Ok(SignUpFormTemplate {
user, user,
tr,
email: String::new(), email: String::new(),
message: String::new(), message: String::new(),
message_email: String::new(), message_email: String::new(),
@ -59,34 +62,37 @@ pub async fn sign_up_post(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
State(config): State<Config>, State(config): State<Config>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Form(form_data): Form<SignUpFormData>, Form(form_data): Form<SignUpFormData>,
) -> Result<Response> { ) -> Result<Response> {
fn error_response( fn error_response(
error: SignUpError, error: SignUpError,
form_data: &SignUpFormData, form_data: &SignUpFormData,
user: Option<model::User>, user: Option<model::User>,
tr: translation::Tr,
) -> Result<Response> { ) -> Result<Response> {
Ok(SignUpFormTemplate { Ok(SignUpFormTemplate {
user, user,
email: form_data.email.clone(), email: form_data.email.clone(),
message_email: match error { message_email: match error {
SignUpError::InvalidEmail => "Invalid email", SignUpError::InvalidEmail => tr.t(Sentence::InvalidEmail),
_ => "", _ => String::new(),
} },
.to_string(),
message_password: match error { message_password: match error {
SignUpError::PasswordsNotEqual => "Passwords don't match", SignUpError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
SignUpError::InvalidPassword => "Password must have at least eight characters", SignUpError::InvalidPassword => tr.tp(
_ => "", Sentence::InvalidPassword,
} &[Box::new(common::consts::MIN_PASSWORD_SIZE)],
.to_string(), ),
_ => String::new(),
},
message: match error { message: match error {
SignUpError::UserAlreadyExists => "This email is not available", SignUpError::UserAlreadyExists => tr.t(Sentence::EmailAlreadyTaken),
SignUpError::DatabaseError => "Database error", SignUpError::DatabaseError => "Database error".to_string(),
SignUpError::UnableSendEmail => "Unable to send the validation email", SignUpError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail),
_ => "", _ => String::new(),
} },
.to_string(), tr,
} }
.into_response()) .into_response())
} }
@ -95,17 +101,17 @@ pub async fn sign_up_post(
if let common::utils::EmailValidation::NotValid = if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email) 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 { 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 = if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1) 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 match connection
@ -113,7 +119,7 @@ pub async fn sign_up_post(
.await .await
{ {
Ok(db::user::SignUpResult::UserAlreadyExists) => { 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)) => { Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => {
let url = utils::get_url_from_host(&host); let url = utils::get_url_from_host(&host);
@ -133,16 +139,16 @@ pub async fn sign_up_post(
Ok(()) => Ok( Ok(()) => Ok(
MessageTemplate::new_with_user( MessageTemplate::new_with_user(
"An email has been sent, follow the link to validate your account", "An email has been sent, follow the link to validate your account",
user).into_response()), tr, user).into_response()),
Err(_) => { Err(_) => {
// error!("Email validation error: {}", error); // TODO: log // error!("Email validation error: {}", error); // TODO: log
error_response(SignUpError::UnableSendEmail, &form_data, user) error_response(SignUpError::UnableSendEmail, &form_data, user, tr)
} }
} }
} }
Err(_) => { Err(_) => {
// error!("Signup database error: {}", error); // TODO: log // error!("Signup database error: {}", error); // TODO: log
error_response(SignUpError::DatabaseError, &form_data, user) error_response(SignUpError::DatabaseError, &form_data, user, tr)
} }
} }
} }
@ -151,6 +157,7 @@ pub async fn sign_up_post(
pub async fn sign_up_validation( pub async fn sign_up_validation(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
ConnectInfo(addr): ConnectInfo<SocketAddr>, ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>, Query(query): Query<HashMap<String, String>>,
headers: HeaderMap, headers: HeaderMap,
@ -159,7 +166,7 @@ pub async fn sign_up_validation(
if user.is_some() { if user.is_some() {
return Ok(( return Ok((
jar, 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 (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@ -183,6 +190,7 @@ pub async fn sign_up_validation(
jar, jar,
MessageTemplate::new_with_user( MessageTemplate::new_with_user(
"Email validation successful, your account has been created", "Email validation successful, your account has been created",
tr,
user, user,
), ),
)) ))
@ -191,18 +199,23 @@ pub async fn sign_up_validation(
jar, jar,
MessageTemplate::new_with_user( MessageTemplate::new_with_user(
"The validation has expired. Try to sign up again", "The validation has expired. Try to sign up again",
tr,
user, user,
), ),
)), )),
db::user::ValidationResult::UnknownUser => Ok(( db::user::ValidationResult::UnknownUser => Ok((
jar, 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(( None => Ok((
jar, jar,
MessageTemplate::new_with_user("Validation error", user), MessageTemplate::new_with_user("Validation error", tr, user),
)), )),
} }
} }
@ -212,9 +225,11 @@ pub async fn sign_up_validation(
#[debug_handler] #[debug_handler]
pub async fn sign_in_get( pub async fn sign_in_get(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
Ok(SignInFormTemplate { Ok(SignInFormTemplate {
user, user,
tr,
email: String::new(), email: String::new(),
message: String::new(), message: String::new(),
}) })
@ -231,6 +246,7 @@ pub async fn sign_in_post(
ConnectInfo(addr): ConnectInfo<SocketAddr>, ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
headers: HeaderMap, headers: HeaderMap,
Form(form_data): Form<SignInFormData>, Form(form_data): Form<SignInFormData>,
) -> Result<(CookieJar, Response)> { ) -> Result<(CookieJar, Response)> {
@ -251,7 +267,8 @@ pub async fn sign_in_post(
SignInFormTemplate { SignInFormTemplate {
user, user,
email: form_data.email, email: form_data.email,
message: "This account must be validated first".to_string(), message: tr.t(Sentence::AccountMustBeValidatedFirst),
tr,
} }
.into_response(), .into_response(),
)), )),
@ -260,7 +277,8 @@ pub async fn sign_in_post(
SignInFormTemplate { SignInFormTemplate {
user, user,
email: form_data.email, email: form_data.email,
message: "Wrong email or password".to_string(), message: tr.t(Sentence::WrongEmailOrPassword),
tr,
} }
.into_response(), .into_response(),
)), )),
@ -292,16 +310,19 @@ pub async fn sign_out(
#[debug_handler] #[debug_handler]
pub async fn ask_reset_password_get( pub async fn ask_reset_password_get(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<Response> { ) -> Result<Response> {
if user.is_some() { if user.is_some() {
Ok(MessageTemplate::new_with_user( Ok(MessageTemplate::new_with_user(
"Can't ask to reset password when already logged in", "Can't ask to reset password when already logged in",
tr,
user, user,
) )
.into_response()) .into_response())
} else { } else {
Ok(AskResetPasswordTemplate { Ok(AskResetPasswordTemplate {
user, user,
tr,
email: String::new(), email: String::new(),
message: String::new(), message: String::new(),
message_email: String::new(), message_email: String::new(),
@ -329,15 +350,18 @@ pub async fn ask_reset_password_post(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
State(config): State<Config>, State(config): State<Config>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Form(form_data): Form<AskResetPasswordForm>, Form(form_data): Form<AskResetPasswordForm>,
) -> Result<Response> { ) -> Result<Response> {
fn error_response( fn error_response(
error: AskResetPasswordError, error: AskResetPasswordError,
email: &str, email: &str,
user: Option<model::User>, user: Option<model::User>,
tr: translation::Tr,
) -> Result<Response> { ) -> Result<Response> {
Ok(AskResetPasswordTemplate { Ok(AskResetPasswordTemplate {
user, user,
tr,
email: email.to_string(), email: email.to_string(),
message_email: match error { message_email: match error {
AskResetPasswordError::InvalidEmail => "Invalid email", AskResetPasswordError::InvalidEmail => "Invalid email",
@ -362,7 +386,12 @@ pub async fn ask_reset_password_post(
if let common::utils::EmailValidation::NotValid = if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email) 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 match connection
@ -376,10 +405,14 @@ pub async fn ask_reset_password_post(
AskResetPasswordError::EmailAlreadyReset, AskResetPasswordError::EmailAlreadyReset,
&form_data.email, &form_data.email,
user, 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)) => { Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => {
let url = utils::get_url_from_host(&host); let url = utils::get_url_from_host(&host);
match email::send_email( match email::send_email(
@ -396,6 +429,7 @@ pub async fn ask_reset_password_post(
{ {
Ok(()) => Ok(MessageTemplate::new_with_user( Ok(()) => Ok(MessageTemplate::new_with_user(
"An email has been sent, follow the link to reset your password.", "An email has been sent, follow the link to reset your password.",
tr,
user, user,
) )
.into_response()), .into_response()),
@ -405,13 +439,19 @@ pub async fn ask_reset_password_post(
AskResetPasswordError::UnableSendEmail, AskResetPasswordError::UnableSendEmail,
&form_data.email, &form_data.email,
user, user,
tr,
) )
} }
} }
} }
Err(error) => { Err(error) => {
event!(Level::ERROR, "{}", error); event!(Level::ERROR, "{}", error);
error_response(AskResetPasswordError::DatabaseError, &form_data.email, user) error_response(
AskResetPasswordError::DatabaseError,
&form_data.email,
user,
tr,
)
} }
} }
} }
@ -419,18 +459,20 @@ pub async fn ask_reset_password_post(
#[debug_handler] #[debug_handler]
pub async fn reset_password_get( pub async fn reset_password_get(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Query(query): Query<HashMap<String, String>>, Query(query): Query<HashMap<String, String>>,
) -> Result<Response> { ) -> Result<Response> {
if let Some(reset_token) = query.get("reset_token") { if let Some(reset_token) = query.get("reset_token") {
Ok(ResetPasswordTemplate { Ok(ResetPasswordTemplate {
user, user,
tr,
reset_token: reset_token.to_string(), reset_token: reset_token.to_string(),
message: String::new(), message: String::new(),
message_password: String::new(), message_password: String::new(),
} }
.into_response()) .into_response())
} else { } else {
Ok(MessageTemplate::new_with_user("Reset token missing", user).into_response()) Ok(MessageTemplate::new_with_user("Reset token missing", tr, user).into_response())
} }
} }
@ -452,15 +494,18 @@ enum ResetPasswordError {
pub async fn reset_password_post( pub async fn reset_password_post(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Form(form_data): Form<ResetPasswordForm>, Form(form_data): Form<ResetPasswordForm>,
) -> Result<Response> { ) -> Result<Response> {
fn error_response( fn error_response(
error: ResetPasswordError, error: ResetPasswordError,
form_data: &ResetPasswordForm, form_data: &ResetPasswordForm,
user: Option<model::User>, user: Option<model::User>,
tr: translation::Tr,
) -> Result<Response> { ) -> Result<Response> {
Ok(ResetPasswordTemplate { Ok(ResetPasswordTemplate {
user, user,
tr,
reset_token: form_data.reset_token.clone(), reset_token: form_data.reset_token.clone(),
message_password: match error { message_password: match error {
ResetPasswordError::PasswordsNotEqual => "Passwords don't match", ResetPasswordError::PasswordsNotEqual => "Passwords don't match",
@ -481,13 +526,13 @@ pub async fn reset_password_post(
} }
if form_data.password_1 != form_data.password_2 { 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 = if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1) 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 match connection
@ -498,34 +543,39 @@ pub async fn reset_password_post(
) )
.await .await
{ {
Ok(db::user::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user( Ok(db::user::ResetPasswordResult::Ok) => {
"Your password has been reset", Ok(
user, MessageTemplate::new_with_user("Your password has been reset", tr, user)
.into_response(),
) )
.into_response()),
Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
error_response(ResetPasswordError::TokenExpired, &form_data, user)
} }
Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user), Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
error_response(ResetPasswordError::TokenExpired, &form_data, user, tr)
}
Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user, tr),
} }
} }
/// EDIT PROFILE /// /// EDIT PROFILE ///
#[debug_handler] #[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 { if let Some(user) = user {
ProfileTemplate { ProfileTemplate {
username: user.name.clone(), username: user.name.clone(),
email: user.email.clone(), email: user.email.clone(),
user: Some(user),
message: String::new(), message: String::new(),
message_email: String::new(), message_email: String::new(),
message_password: String::new(), message_password: String::new(),
user: Some(user),
tr,
} }
.into_response() .into_response()
} else { } else {
MessageTemplate::new("Not logged in").into_response() MessageTemplate::new("Not logged in", tr).into_response()
} }
} }
@ -552,6 +602,7 @@ pub async fn edit_user_post(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
State(config): State<Config>, State(config): State<Config>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Form(form_data): Form<EditUserForm>, Form(form_data): Form<EditUserForm>,
) -> Result<Response> { ) -> Result<Response> {
if let Some(user) = user { if let Some(user) = user {
@ -559,6 +610,7 @@ pub async fn edit_user_post(
error: ProfileUpdateError, error: ProfileUpdateError,
form_data: &EditUserForm, form_data: &EditUserForm,
user: model::User, user: model::User,
tr: translation::Tr,
) -> Result<Response> { ) -> Result<Response> {
Ok(ProfileTemplate { Ok(ProfileTemplate {
user: Some(user), user: Some(user),
@ -584,6 +636,7 @@ pub async fn edit_user_post(
_ => "", _ => "",
} }
.to_string(), .to_string(),
tr,
} }
.into_response()) .into_response())
} }
@ -591,17 +644,17 @@ pub async fn edit_user_post(
if let common::utils::EmailValidation::NotValid = if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email) 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() { 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 { 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 = if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1) 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()) Some(form_data.password_1.as_ref())
} else { } else {
@ -621,7 +674,7 @@ pub async fn edit_user_post(
.await .await
{ {
Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => { 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)) => { Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
let url = utils::get_url_from_host(&host); let url = utils::get_url_from_host(&host);
@ -644,14 +697,17 @@ pub async fn edit_user_post(
} }
Err(_) => { Err(_) => {
// error!("Email validation error: {}", error); // TODO: log // 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) => { Ok(db::user::UpdateUserResult::Ok) => {
message = "Profile saved"; 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. // Reload after update.
@ -664,10 +720,11 @@ pub async fn edit_user_post(
message: message.to_string(), message: message.to_string(),
message_email: String::new(), message_email: String::new(),
message_password: String::new(), message_password: String::new(),
tr,
} }
.into_response()) .into_response())
} else { } else {
Ok(MessageTemplate::new("Not logged in").into_response()) Ok(MessageTemplate::new("Not logged in", tr).into_response())
} }
} }
@ -675,6 +732,7 @@ pub async fn edit_user_post(
pub async fn email_revalidation( pub async fn email_revalidation(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
ConnectInfo(addr): ConnectInfo<SocketAddr>, ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>, Query(query): Query<HashMap<String, String>>,
headers: HeaderMap, headers: HeaderMap,
@ -683,7 +741,7 @@ pub async fn email_revalidation(
if user.is_some() { if user.is_some() {
return Ok(( return Ok((
jar, 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 (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@ -705,13 +763,14 @@ pub async fn email_revalidation(
let user = connection.load_user(user_id).await?; let user = connection.load_user(user_id).await?;
Ok(( Ok((
jar, jar,
MessageTemplate::new_with_user("Email validation successful", user), MessageTemplate::new_with_user("Email validation successful", tr, user),
)) ))
} }
db::user::ValidationResult::ValidationExpired => Ok(( db::user::ValidationResult::ValidationExpired => Ok((
jar, jar,
MessageTemplate::new_with_user( MessageTemplate::new_with_user(
"The validation has expired. Try to sign up again with the same email", "The validation has expired. Try to sign up again with the same email",
tr,
user, user,
), ),
)), )),
@ -719,6 +778,7 @@ pub async fn email_revalidation(
jar, jar,
MessageTemplate::new_with_user( MessageTemplate::new_with_user(
"Validation error. Try to sign up again with the same email", "Validation error. Try to sign up again with the same email",
tr,
user, user,
), ),
)), )),
@ -726,7 +786,7 @@ pub async fn email_revalidation(
} }
None => Ok(( None => Ok((
jar, jar,
MessageTemplate::new_with_user("Validation error", user), MessageTemplate::new_with_user("Validation error", tr, user),
)), )),
} }
} }

142
backend/src/translation.rs Normal file
View file

@ -0,0 +1,142 @@
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
)
}
});

View file

@ -2,6 +2,7 @@
{% block main_container %} {% block main_container %}
<div class="content"> <div class="content">
<h1></h1>
<form action="/ask_reset_password" method="post"> <form action="/ask_reset_password" method="post">
<label for="email_field">Your email address</label> <label for="email_field">Your email address</label>
<input id="email_field" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" /> <input id="email_field" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />

View file

@ -6,7 +6,7 @@
{% match user %} {% match user %}
{% when Some with (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"> <span><a href="/user/edit">
{% if user.name == "" %} {% if user.name == "" %}
{{ user.email }} {{ user.email }}
@ -16,7 +16,7 @@
</a> / <a href="/signout" />Sign out</a></span> </a> / <a href="/signout" />Sign out</a></span>
{% when None %} {% when None %}
<span> <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> </span>
{% endmatch %} {% endmatch %}

View file

@ -7,7 +7,7 @@
<pre><code> <pre><code>
{% endif %} {% endif %}
{{ message|markdown }} {{ message }}
{% if as_code %} {% if as_code %}
</code></pre> </code></pre>

View file

@ -6,7 +6,8 @@
{% when Some with (user) %} {% when Some with (user) %}
<div class="content" id="user-edit"> <div class="content" id="user-edit">
<h1>Profile</h1> <h1>{{ tr.t(Sentence::ProfileTitle) }}</h1>
<form action="/user/edit" method="post"> <form action="/user/edit" method="post">
<label for="input-name">Name</label> <label for="input-name">Name</label>
@ -20,7 +21,9 @@
autofocus="autofocus" /> autofocus="autofocus" />
<label for="input-email">Email (need to be revalidated if changed)</label> <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 }} {{ message_email }}

View file

@ -11,7 +11,7 @@
{% if !recipe.description.is_empty() %} {% if !recipe.description.is_empty() %}
<div class="recipe-description" > <div class="recipe-description" >
{{ recipe.description.clone()|markdown }} {{ recipe.description.clone() }}
</div> </div>
{% endif %} {% endif %}
</div> </div>

View file

@ -1,8 +1,7 @@
{% macro recipe_item(id, title, class) %} {% macro recipe_item(id, title, class) %}
<a href="/recipe/view/{{ id }}" class="{{ class }}" id="recipe-{{ id }}"> <a href="/recipe/view/{{ id }}" class="{{ class }}" id="recipe-{{ id }}">
{% if title == "" %} {% if title == "" %}
{# TODO: Translation #} {{ tr.t(Sentence::UntitledRecipe) }}
Untitled recipe
{% else %} {% else %}
{{ title }} {{ title }}
{% endif %} {% endif %}
@ -13,7 +12,7 @@
<div id="recipes-list"> <div id="recipes-list">
{% if !recipes.unpublished.is_empty() %} {% if !recipes.unpublished.is_empty() %}
Unpublished recipes {{ tr.t(Sentence::UnpublishedRecipes) }}
{% endif %} {% endif %}
<nav class="recipes-list-unpublished"> <nav class="recipes-list-unpublished">

View file

@ -4,16 +4,16 @@
<div class="content" id="sign-in"> <div class="content" id="sign-in">
<h1>Sign in</h1> <h1>{{ tr.t(Sentence::SignInTitle) }}</h1>
<form action="/signin" method="post"> <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" /> <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 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> </form>
{{ message }} {{ message }}
</div> </div>

View file

@ -4,23 +4,27 @@
<div class="content" id="sign-up"> <div class="content" id="sign-up">
<h1>Sign up</h1> <h1>{{ tr.t(Sentence::SignUpTitle) }}</h1>
<form action="/signup" method="post"> <form action="/signup" method="post">
<label for="input-email">Your 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" /> <input id="input-email" type="email"
name="email" value="{{ email }}"
autocapitalize="none" autocomplete="email" autofocus="autofocus" />
{{ message_email }} {{ 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" /> <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" /> <input id="input-password-2" type="password" name="password_2" autocomplete="new-password" />
{{ message_password }} {{ message_password }}
<input type="submit" name="commit" value="Sign up" /> <input type="submit" name="commit" value="{{ tr.t(Sentence::SignUpButton) }}" />
</form> </form>
{{ message }} {{ message }}
</div> </div>

View file

@ -1 +1 @@
<a class="title" href="/">~~ Recettes de cuisine ~~</a> <a class="title" href="/">{{ tr.t(Sentence::MainTitle) }}</a>

70
backend/translation.ron Normal file
View file

@ -0,0 +1,70 @@
[
(
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",
}
)
]

1
common/src/consts.rs Normal file
View file

@ -0,0 +1 @@
pub const MIN_PASSWORD_SIZE: usize = 8;

View file

@ -1,2 +1,3 @@
pub mod consts;
pub mod ron_api; pub mod ron_api;
pub mod utils; pub mod utils;

View file

@ -2,6 +2,8 @@ use std::sync::LazyLock;
use regex::Regex; use regex::Regex;
use crate::consts;
pub enum EmailValidation { pub enum EmailValidation {
Ok, Ok,
NotValid, NotValid,
@ -28,7 +30,7 @@ pub enum PasswordValidation {
} }
pub fn validate_password(password: &str) -> PasswordValidation { pub fn validate_password(password: &str) -> PasswordValidation {
if password.len() < 8 { if password.len() < consts::MIN_PASSWORD_SIZE {
PasswordValidation::TooShort PasswordValidation::TooShort
} else { } else {
PasswordValidation::Ok PasswordValidation::Ok

View file

@ -29,6 +29,7 @@ def main [host: string, destination: string, ssh_key: path] {
invoke_ssh [rm -rf recipes/static] invoke_ssh [rm -rf recipes/static]
copy_ssh ./backend/static/ $destination copy_ssh ./backend/static/ $destination
copy_ssh ./backend/sql/ $destination copy_ssh ./backend/sql/ $destination
copy_ssh ./backend/translation.ron $destination
invoke_ssh [chmod u+x recipes/recipes] invoke_ssh [chmod u+x recipes/recipes]
invoke_ssh [sudo systemctl start recipes] invoke_ssh [sudo systemctl start recipes]
print "Deployment finished" print "Deployment finished"

View file

@ -6,12 +6,9 @@ use gloo::{
}; };
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use web_sys::{ use web_sys::{Element, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement, KeyboardEvent};
Element, Event, HtmlDialogElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement,
KeyboardEvent,
};
use common::ron_api::{self, Ingredient}; use common::ron_api;
use crate::{ use crate::{
modal_dialog, request, modal_dialog, request,