diff --git a/Cargo.lock b/Cargo.lock index 55f5356a8..029ad940a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,9 +97,9 @@ checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" [[package]] name = "atomic" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" dependencies = [ "bytemuck", ] @@ -557,7 +557,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -594,7 +594,7 @@ version = "0.10.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ - "atomic 0.6.0", + "atomic 0.6.1", "pear", "serde", "toml", @@ -613,6 +613,50 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ad0989667548f06ccd0e306ed56b61bd4d35458d54df5ec7587c0e8ed5e94" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "flume" version = "0.11.1" @@ -840,7 +884,7 @@ dependencies = [ "futures-timer", "futures-util", "getrandom 0.3.3", - "hashbrown 0.15.3", + "hashbrown 0.15.4", "nonzero_ext", "parking_lot", "portable-atomic", @@ -872,9 +916,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" dependencies = [ "atomic-waker", "bytes", @@ -897,9 +941,9 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.15.3" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" dependencies = [ "allocator-api2", "equivalent", @@ -912,7 +956,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.3", + "hashbrown 0.15.4", ] [[package]] @@ -1061,7 +1105,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "h2 0.4.10", + "h2 0.4.11", "http 1.3.1", "http-body 1.0.1", "httparse", @@ -1106,9 +1150,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" dependencies = [ "base64 0.22.1", "bytes", @@ -1268,7 +1312,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", - "hashbrown 0.15.3", + "hashbrown 0.15.4", "serde", ] @@ -1287,6 +1331,25 @@ dependencies = [ "generic-array", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "io-uring" version = "0.7.8" @@ -1826,10 +1889,15 @@ version = "0.1.0" dependencies = [ "chrono", "derive_more", + "fluent", + "fluent-syntax", "getrandom 0.3.3", "log", "serde", "sqlx", + "thiserror 1.0.69", + "tokio", + "unic-langid", ] [[package]] @@ -1845,13 +1913,26 @@ dependencies = [ "serde_json", "serde_urlencoded", "sqlx", + "tokio", + "unic-langid", +] + +[[package]] +name = "pointercrate-core-macros" +version = "0.1.0" +dependencies = [ + "quote", + "syn", ] [[package]] name = "pointercrate-core-pages" version = "0.1.0" dependencies = [ + "log", "maud", + "pointercrate-core", + "unic-langid", ] [[package]] @@ -1879,6 +1960,7 @@ dependencies = [ "nonzero_ext", "pointercrate-core", "pointercrate-core-api", + "pointercrate-core-macros", "pointercrate-core-pages", "pointercrate-demonlist", "pointercrate-demonlist-pages", @@ -1902,6 +1984,7 @@ dependencies = [ "log", "maud", "pointercrate-core", + "pointercrate-core-macros", "pointercrate-core-pages", "pointercrate-demonlist", "pointercrate-integrate", @@ -1919,6 +2002,7 @@ dependencies = [ "maud", "pointercrate-core", "pointercrate-core-api", + "pointercrate-core-macros", "pointercrate-core-pages", "pointercrate-demonlist", "pointercrate-demonlist-api", @@ -1927,6 +2011,7 @@ dependencies = [ "pointercrate-user-api", "pointercrate-user-pages", "rocket", + "unic-langid", ] [[package]] @@ -1954,6 +2039,7 @@ dependencies = [ "dotenv", "pointercrate-core", "pointercrate-core-api", + "pointercrate-core-pages", "pointercrate-demonlist", "pointercrate-demonlist-api", "pointercrate-user", @@ -1964,6 +2050,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sqlx", + "unic-langid", ] [[package]] @@ -1993,10 +2080,13 @@ dependencies = [ "nonzero_ext", "pointercrate-core", "pointercrate-core-api", + "pointercrate-core-macros", + "pointercrate-core-pages", "pointercrate-user", "pointercrate-user-pages", "reqwest", "rocket", + "serde_urlencoded", "sqlx", ] @@ -2007,6 +2097,7 @@ dependencies = [ "async-trait", "maud", "pointercrate-core", + "pointercrate-core-api", "pointercrate-core-pages", "pointercrate-user", "sqlx", @@ -2042,6 +2133,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.95" @@ -2245,7 +2342,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2 0.4.10", + "h2 0.4.11", "http 1.3.1", "http-body 1.0.1", "http-body-util", @@ -2397,6 +2494,18 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.0.7" @@ -2412,9 +2521,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.28" +version = "0.23.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" +checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" dependencies = [ "once_cell", "rustls-pki-types", @@ -2434,9 +2543,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.3" +version = "0.103.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" dependencies = [ "ring", "rustls-pki-types", @@ -2499,6 +2608,21 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.2.0", +] + +[[package]] +name = "self_cell" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" + [[package]] name = "serde" version = "1.0.219" @@ -2716,7 +2840,7 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.3", + "hashbrown 0.15.4", "hashlink", "indexmap", "log", @@ -3318,6 +3442,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.1", +] + [[package]] name = "typenum" version = "1.18.0" @@ -3343,6 +3476,49 @@ dependencies = [ "version_check", ] +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3692,6 +3868,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3716,13 +3901,29 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3735,6 +3936,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3747,6 +3954,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3759,12 +3972,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3777,6 +4002,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3789,6 +4020,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3801,6 +4038,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3813,6 +4056,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "winnow" version = "0.7.11" diff --git a/Cargo.toml b/Cargo.toml index d3392d49d..837fd080b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "pointercrate-core", "pointercrate-core-api", "pointercrate-core-pages", + "pointercrate-core-macros", "pointercrate-demonlist", "pointercrate-demonlist-api", "pointercrate-demonlist-pages", diff --git a/pointercrate-core-api/Cargo.toml b/pointercrate-core-api/Cargo.toml index a5176d757..a909e8424 100644 --- a/pointercrate-core-api/Cargo.toml +++ b/pointercrate-core-api/Cargo.toml @@ -16,3 +16,5 @@ sqlx = { version = "0.8", default-features = false, features = [ "runtime-tokio- log = "0.4.27" serde_urlencoded = "0.7.0" maud = "0.27.0" +unic-langid = "0.9.5" +tokio = "1.46.1" \ No newline at end of file diff --git a/pointercrate-core-api/src/lib.rs b/pointercrate-core-api/src/lib.rs index ffe9d847e..853f074c1 100644 --- a/pointercrate-core-api/src/lib.rs +++ b/pointercrate-core-api/src/lib.rs @@ -1,6 +1,8 @@ pub mod error; pub mod etag; +pub mod localization; pub mod maintenance; pub mod pagination; +pub mod preferences; pub mod query; pub mod response; diff --git a/pointercrate-core-api/src/localization.rs b/pointercrate-core-api/src/localization.rs new file mode 100644 index 000000000..16a1d226a --- /dev/null +++ b/pointercrate-core-api/src/localization.rs @@ -0,0 +1,29 @@ +use crate::preferences::{ClientPreferences, PreferenceManager}; +use crate::{tryo_result, tryo_state}; +use pointercrate_core::error::CoreError; +use pointercrate_core::localization::LocaleConfiguration; +use rocket::{ + request::{FromRequest, Outcome}, + Request, +}; +use unic_langid::subtags::Language; + +pub const LOCALE_COOKIE_NAME: &str = "locale"; + +pub struct ClientLocale(pub Language); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ClientLocale { + type Error = CoreError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let preference_manager = tryo_state!(request, PreferenceManager); + let preferences = ClientPreferences::from_cookies(request.cookies(), preference_manager); + let language = tryo_result!(preferences + .get(LOCALE_COOKIE_NAME) + .ok_or_else(|| CoreError::internal_server_error("locale set not registered with preference manager"))); + let lang_id = LocaleConfiguration::get().by_code(language); + + Outcome::Success(ClientLocale(lang_id.language)) + } +} diff --git a/pointercrate-core-api/src/maintenance.rs b/pointercrate-core-api/src/maintenance.rs index 53ef16959..98c42febe 100644 --- a/pointercrate-core-api/src/maintenance.rs +++ b/pointercrate-core-api/src/maintenance.rs @@ -1,7 +1,7 @@ //! Module providing a "maintenance mode" fairing (middleware) -use crate::error::Result; -use pointercrate_core::error::CoreError; +use crate::{error::Result, localization::ClientLocale}; +use pointercrate_core::{error::CoreError, localization::LANGUAGE}; use rocket::{ fairing::{Fairing, Info, Kind}, http::Method, @@ -49,7 +49,9 @@ impl Fairing for MaintenanceFairing { } } +// we can't use the #[localized] proc-macro here due to issues related to imports +// (https://github.com/stadust/pointercrate/pull/232#discussion_r2118293806) #[rocket::get("/maintenance")] -async fn maintenance() -> Result<()> { - Err(CoreError::ReadOnlyMaintenance.into()) +async fn maintenance(locale: ClientLocale) -> Result<()> { + LANGUAGE.scope(locale.0, async { Err(CoreError::ReadOnlyMaintenance.into()) }).await } diff --git a/pointercrate-core-api/src/preferences.rs b/pointercrate-core-api/src/preferences.rs new file mode 100644 index 000000000..97855b948 --- /dev/null +++ b/pointercrate-core-api/src/preferences.rs @@ -0,0 +1,87 @@ +use std::collections::HashMap; + +use crate::localization::LOCALE_COOKIE_NAME; +use pointercrate_core::error::CoreError; +use pointercrate_core::localization::LocaleConfiguration; +use rocket::{ + http::CookieJar, + request::{FromRequest, Outcome}, + Request, +}; + +/// A request guard which stores the preferences sent from the client. +pub struct ClientPreferences<'k, 'v>(HashMap<&'k str, &'v str>); + +impl<'k: 'v, 'v> ClientPreferences<'k, 'v> { + /// Retrieve a particular preference which was sent to us from the client. + /// + /// `T` must implement `From`, which [`String`] already + /// implements, in case the untouched cookie value is what needs to be handled. + pub fn get(&self, name: &'k str) -> Option<&'v str> { + self.0.get(name).map(|&s| s) + } + + pub fn from_cookies(cookies: &'v CookieJar<'v>, preference_manager: &'k PreferenceManager) -> Self { + ClientPreferences( + preference_manager + .0 + .iter() + .map(|(name, default)| { + ( + name.as_ref(), + cookies + .get(&format!("preference-{}", name)) + .map(|cookie| cookie.value()) + .unwrap_or(default) + .as_ref(), + ) + }) + .collect(), + ) + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ClientPreferences<'r, 'r> { + type Error = CoreError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let preference_manager = match request.rocket().state::() { + Some(preference_manager) => preference_manager, + _ => return Outcome::Success(ClientPreferences(HashMap::new())), // return an empty preferences hashmap if this instance doesnt support preferences + }; + + let preferences = ClientPreferences::from_cookies(request.cookies(), preference_manager); + + Outcome::Success(preferences) + } +} + +/// A configuration state to manage all of your pointercrate instance's +/// client preferences. +#[derive(Default)] +pub struct PreferenceManager(HashMap); + +impl PreferenceManager { + /// Append a new preference to this [`PreferenceManager`]. `name` represents + /// the name of the cookie which stores the value of this preference. + /// + /// Note that the cookie name is prefixed with `"preference-"`, so creating a + /// preference with the `name` value as `"theme"` would result in the cookie + /// sent from the client being named `"preference-theme"`. + /// + /// If the cookie was not received, its value will default to `default`. + pub fn preference(mut self, name: impl Into, default: impl Into) -> Self { + self.0.insert(name.into(), default.into()); + + self + } + + /// Automatically register the preferences needed to store active locales. + /// + /// Requires the global localization context to have been set up via [`LocalesLoader::commit`], + /// otherwise will panic. + pub fn with_localization(self) -> Self { + self.preference(LOCALE_COOKIE_NAME, LocaleConfiguration::get().fallback.as_str()) + } +} diff --git a/pointercrate-core-api/src/response.rs b/pointercrate-core-api/src/response.rs index 2ba1dc454..174f973f2 100644 --- a/pointercrate-core-api/src/response.rs +++ b/pointercrate-core-api/src/response.rs @@ -1,10 +1,17 @@ -use crate::etag::Tagged; -use maud::{html, DOCTYPE}; -use pointercrate_core::etag::Taggable; +use crate::localization::LOCALE_COOKIE_NAME; +use crate::{ + etag::Tagged, + preferences::{ClientPreferences, PreferenceManager}, +}; +use maud::{html, Render, DOCTYPE}; +use pointercrate_core::localization::LocaleConfiguration; +use pointercrate_core::{etag::Taggable, localization::LANGUAGE}; use pointercrate_core_pages::{ head::{Head, HeadLike}, PageConfiguration, PageFragment, }; +use rocket::tokio::runtime::Handle; +use rocket::tokio::task::block_in_place; use rocket::{ http::{ContentType, Header, Status}, response::Responder, @@ -30,24 +37,46 @@ impl HeadLike for Page { impl<'r, 'o: 'r> Responder<'r, 'o> for Page { fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'o> { - let page_config = request.rocket().state::().ok_or(Status::InternalServerError)?; + let preference_manager = request.rocket().state::().ok_or(Status::InternalServerError)?; + let preferences = ClientPreferences::from_cookies(request.cookies(), preference_manager); + + let language = preferences.get(LOCALE_COOKIE_NAME).ok_or(Status::InternalServerError)?; + let lang_id = LocaleConfiguration::get().by_code(language); + + let (page_config, nav_bar, footer) = block_in_place(move || { + Handle::current().block_on(async { + LANGUAGE + .scope(lang_id.language, async { + let page_config = request + .rocket() + .state:: PageConfiguration>() + .ok_or(Status::InternalServerError)?(); + + let nav_bar = page_config.nav_bar.render(); + let footer = page_config.footer.render(); + + Ok((page_config, nav_bar, footer)) + }) + .await + }) + })?; let fragment = self.0; let rendered_fragment = html! { (DOCTYPE) - html lang="en" prefix="og: http://opg.me/ns#" { + html lang=(lang_id) prefix="og: http://opg.me/ns#" { head { (page_config.head) (fragment.head) } body { div.content { - (page_config.nav_bar) + (nav_bar) (fragment.body) div #bg {} } - (page_config.footer) + (footer) } } } diff --git a/pointercrate-core-macros/Cargo.toml b/pointercrate-core-macros/Cargo.toml new file mode 100644 index 000000000..c1f3dedc0 --- /dev/null +++ b/pointercrate-core-macros/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pointercrate-core-macros" +version = "0.1.0" +authors.workspace = true +description.workspace = true +homepage.workspace = true +edition.workspace = true +repository.workspace = true + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2.0.101", features = ["full"] } +quote = "1.0.40" \ No newline at end of file diff --git a/pointercrate-core-macros/src/lib.rs b/pointercrate-core-macros/src/lib.rs new file mode 100644 index 000000000..2735eeb9e --- /dev/null +++ b/pointercrate-core-macros/src/lib.rs @@ -0,0 +1,65 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse2, parse_macro_input, parse_quote, ItemFn}; + +/// A procedural macro for automatically wrapping a request handler inside a tokio::task_local! +/// [`LocalKey`] scope for `LANGUAGE`, with the value of the [`ClientLocale`] request guard. +/// +/// Use of this macro eliminates the need for writing and maintaining boilerplate code caused +/// by manually wrapping the request handler body inside a `LANGUAGE` scope, while also +/// having to take in a [`ClientLocale`] guard and handling that properly. +/// +/// This macro should be used for any endpoint whose request handler calls a translation +/// function at some point, so basically any page or API endpoint (API endpoints need +/// to be localized because errors are also translated) +#[proc_macro_attribute] +pub fn localized(_: TokenStream, input: TokenStream) -> TokenStream { + let mut f = parse_macro_input!(input as ItemFn); + + // modify the request handler to automatically take in our [`ClientLocale`] request + // guard (defined in pointercrate-core-api/src/localization.rs) + f.sig + .inputs + .push(parse_quote! { __locale: pointercrate_core_api::localization::ClientLocale }); + + let block = &f.block; + let block = quote! { + { + pointercrate_core::localization::LANGUAGE.scope(__locale.0, async { + #block + }).await + } + }; + + f.block = parse2(block).unwrap(); + + TokenStream::from(quote!(#f)) +} + +/// Identical behaviour to `#[localized]`, but modified to support error catchers. +#[proc_macro_attribute] +pub fn localized_catcher(_: TokenStream, input: TokenStream) -> TokenStream { + let mut f = parse_macro_input!(input as ItemFn); + + f.sig.inputs.push(parse_quote! { __request: &rocket::Request<'_> }); + + let block = &f.block; + let block = quote! { + { + use rocket::request::FromRequest; + + let __locale = match pointercrate_core_api::localization::ClientLocale::from_request(__request).await { + rocket::request::Outcome::Success(locale) => locale, + _ => return pointercrate_core_api::error::ErrorResponder::from(pointercrate_core::error::CoreError::internal_server_error("An error occurred while trying to extract requested locale. Check your locale fallbacks!")), + }; + + pointercrate_core::localization::LANGUAGE.scope(__locale.0, async { + #block + }).await + } + }; + + f.block = parse2(block).unwrap(); + + TokenStream::from(quote!(#f)) +} diff --git a/pointercrate-core-pages/Cargo.toml b/pointercrate-core-pages/Cargo.toml index 43560d3ae..a68e9ffb7 100644 --- a/pointercrate-core-pages/Cargo.toml +++ b/pointercrate-core-pages/Cargo.toml @@ -8,3 +8,6 @@ edition.workspace = true [dependencies] maud = "0.27.0" +pointercrate-core = {path = "../pointercrate-core"} +unic-langid = "0.9.5" +log = "0.4.27" \ No newline at end of file diff --git a/pointercrate-core-pages/src/footer.rs b/pointercrate-core-pages/src/footer.rs index 12e10bf9b..c834095f3 100644 --- a/pointercrate-core-pages/src/footer.rs +++ b/pointercrate-core-pages/src/footer.rs @@ -1,4 +1,5 @@ use maud::{html, Markup, PreEscaped, Render}; +use pointercrate_core::localization::tr; pub struct Footer { copyright_notice: Markup, @@ -20,15 +21,15 @@ impl Footer { self } - pub fn with_link(mut self, href: &'static str, text: &'static str) -> Self { + pub fn with_link>(mut self, href: &'static str, text: T) -> Self { self.twitter_links.push(Link::new(href, text)); self } } pub enum FooterColumn { - LinkList { heading: &'static str, links: Vec }, - Arbitrary { heading: &'static str, content: Markup }, + LinkList { heading: String, links: Vec }, + Arbitrary { heading: String, content: Markup }, } pub struct Link { @@ -91,7 +92,9 @@ impl Render for Footer { } div style="display: flex; justify-content: center; align-items: center" { i class = "fab fa-twitter fa-2x" {} - (PreEscaped("  Tweet Us:")) + (PreEscaped( + format!("  {}", tr("footer-tweet")) + )) @for link in &self.twitter_links { (PreEscaped("          ")) a href=(link.href) target="_blank" style = "color:#666" {(link.text)} diff --git a/pointercrate-core-pages/src/lib.rs b/pointercrate-core-pages/src/lib.rs index 8c5326c8a..d5a4162b5 100644 --- a/pointercrate-core-pages/src/lib.rs +++ b/pointercrate-core-pages/src/lib.rs @@ -43,6 +43,7 @@ impl PageConfiguration { .script("/static/core/js/ui.js") .script("/static/core/js/nav.js") .script("/static/core/js/misc.js") + .module("/static/core/js/modules/localization.js") .stylesheet("/static/core/css/icon.css") .stylesheet("/static/core/css/nav.css") .stylesheet("/static/core/css/main.css") diff --git a/pointercrate-core-pages/src/navigation.rs b/pointercrate-core-pages/src/navigation.rs index a2b47869e..fac7d9204 100644 --- a/pointercrate-core-pages/src/navigation.rs +++ b/pointercrate-core-pages/src/navigation.rs @@ -1,4 +1,6 @@ use maud::{html, Markup, Render}; +use pointercrate_core::localization::LocaleConfiguration; +use unic_langid::subtags::Region; pub struct TopLevelNavigationBarItem { item: NavigationBarItem, @@ -6,14 +8,14 @@ pub struct TopLevelNavigationBarItem { } impl TopLevelNavigationBarItem { - pub fn new(link: &'static str, content: Markup) -> Self { + pub fn new(link: Option<&'static str>, content: Markup) -> Self { TopLevelNavigationBarItem { item: NavigationBarItem { link, content }, sub_levels: vec![], } } - pub fn with_sub_item(mut self, link: &'static str, content: Markup) -> Self { + pub fn with_sub_item(mut self, link: Option<&'static str>, content: Markup) -> Self { self.sub_levels.push(NavigationBarItem { link, content }); self } @@ -21,7 +23,7 @@ impl TopLevelNavigationBarItem { struct NavigationBarItem { content: Markup, - link: &'static str, + link: Option<&'static str>, } pub struct NavigationBar { @@ -40,13 +42,27 @@ impl NavigationBar { } } -struct NavGroup(T); +struct NavGroup { + inner: T, + id: Option<&'static str>, + nohide: bool, +} + +impl NavGroup { + pub fn new(inner: T) -> Self { + Self { + inner, + id: None, + nohide: false, + } + } +} impl Render for NavGroup { fn render(&self) -> Markup { html! { - div.nav-group { - (self.0) + div.nav-group.nav-nohide[self.nohide] id = [self.id] { + (self.inner) } } } @@ -55,7 +71,7 @@ impl Render for NavGroup { impl Render for TopLevelNavigationBarItem { fn render(&self) -> Markup { html! { - a.nav-item.hover.white href = (self.item.link) { + a.nav-item.hover.white href = [self.item.link] { (self.item.content) @if !self.sub_levels.is_empty() { i.fas.fa-sort-down style = "height: 50%; padding-left: 5px" {} @@ -65,7 +81,7 @@ impl Render for TopLevelNavigationBarItem { ul.nav-hover-dropdown { @for sub_item in &self.sub_levels { li { - a.white.hover href = (sub_item.link) { (sub_item.content)} + a.white.hover href = [sub_item.link] { (sub_item.content)} } } } @@ -85,7 +101,10 @@ impl Render for NavigationBar { } } @for item in &self.items { - (NavGroup(item)) + (NavGroup::new(item)) + } + @if let Some(locales_dropdown) = locale_selection_dropdown() { + (locales_dropdown) } div.nav-item.collapse-button.nav-nohide { div.hamburger.hover { @@ -106,3 +125,55 @@ impl Render for NavigationBar { } } } + +fn flag(region: Option) -> Markup { + html! [ + @if let Some(region) = region { + span.flag-icon style = (format!(r#"background-image: url("/static/demonlist/images/flags/{}.svg");"#, region.as_str().to_ascii_lowercase())) {} + } + ] +} + +fn locale_selection_dropdown() -> Option> { + let config = LocaleConfiguration::get(); + let locales = config.locales(); + + let active_locale = config.active_locale(); + + if locales.len() < 2 { + return None; + } + + let mut dropdown = TopLevelNavigationBarItem::new( + None, + html! { + span.flex { + (flag(active_locale.region)) + span #active-language style = "margin-left: 8px" { (active_locale.language.as_str().to_uppercase()) } + } + }, + ); + + for locale in locales { + if locale == active_locale { + // this locale is currently selected, don't add it to the dropdown + continue; + } + + dropdown = dropdown.with_sub_item( + None, + html! { + span data-lang = (locale) { + (flag(locale.region)) + span style = "margin-left: 10px" { (locale.language.as_str().to_uppercase()) } + } + }, + ); + } + + Some(NavGroup { + inner: dropdown, + id: Some("language-selector"), + nohide: true, + }) +} diff --git a/pointercrate-core-pages/src/util.rs b/pointercrate-core-pages/src/util.rs index c6159ac25..eaeacec4e 100644 --- a/pointercrate-core-pages/src/util.rs +++ b/pointercrate-core-pages/src/util.rs @@ -1,4 +1,5 @@ use maud::{html, Markup}; +use pointercrate_core::localization::tr; use std::fmt::Display; // FIXME: these should probably be turned into proper structs as well at some point @@ -12,8 +13,8 @@ pub fn paginator(id: &str, endpoint: &str) -> Markup { ul.selection-list style = "position: absolute; top: 0px; bottom:0px; left: 0px; right:0px" {} } div.flex.no-stretch style = "font-variant: small-caps; font-weight: bolder; justify-content: space-around"{ - div.button.small.prev { "Previous" } - div.button.small.next { "Next" } + div.button.small.prev { (tr("paginator-previous")) } + div.button.small.next { (tr("paginator-next")) } } } } @@ -23,15 +24,15 @@ pub fn filtered_paginator(id: &str, endpoint: &str) -> Markup { html! { div.flex.col.paginator #(id) data-endpoint=(endpoint) { div.search.seperated.no-stretch { - input placeholder = "Enter to search..." type = "text" style = "height: 1em"; + input placeholder = (tr("filtered-paginator-placeholder")) type = "text" style = "height: 1em"; } p.info-red.output style = "margin: 5px 0px"{} div style="min-height: 400px; position:relative; flex-grow:1" { ul.selection-list style = "position: absolute; top: 0px; bottom:0px; left: 0px; right:0px" {} } div.flex.no-stretch style = "font-variant: small-caps; font-weight: bolder; justify-content: space-around"{ - div.button.small.prev { "Previous" } - div.button.small.next { "Next" } + div.button.small.prev { (tr("paginator-previous")) } + div.button.small.next { (tr("paginator-next")) } } } } @@ -55,13 +56,16 @@ pub fn dropdown(default_entry: &str, default_item: Markup, filter_items: impl It } } -pub fn simple_dropdown(dropdown_id: &str, default: Option, items: impl Iterator) -> Markup { +/// Items should be structured as `(, )` where the internal value is consistent across languages +pub fn simple_dropdown( + dropdown_id: &str, default: Option<(T1, T2)>, items: impl Iterator, +) -> Markup { html! { div.dropdown-menu.js-search.no-stretch #(dropdown_id) { div { @match default { Some(ref default) => { - input type="text" autocomplete="off" data-default=(default) style = "font-weight: bold;"; + input type="text" autocomplete="off" data-default=(default.0) style = "font-weight: bold;"; } None => { input type="text" autocomplete="off" style = "font-weight: bold;"; @@ -72,16 +76,16 @@ pub fn simple_dropdown(dropdown_id: &str, default: Option, item div.menu { ul { @if let Some(ref default) = default { - li.white.underlined.hover data-value=(default) data-display=(default) { + li.white.underlined.hover data-value=(default.0) data-display=(default.1) { b { - (default) + (default.1) } } } @for item in items { - li.white.hover data-value=(item) data-display = (item) { + li.white.hover data-value=(item.0) data-display = (item.1) { b { - (item) + (item.1) } } } diff --git a/pointercrate-core-pages/static/ftl/en-us/error.ftl b/pointercrate-core-pages/static/ftl/en-us/error.ftl new file mode 100644 index 000000000..25361dbd6 --- /dev/null +++ b/pointercrate-core-pages/static/ftl/en-us/error.ftl @@ -0,0 +1,26 @@ +error-core-badrequest = The browser (or proxy) sent a request that this server could not understand. +error-core-invalidheadervalue = The value for the header '{ $header }' could not be processed. +error-core-unauthorized = The server could not verify that you are authorized to access the URL requested. You either supplied the wrong credentials (e.g. a bad password) or your browser doesn't understand how to supply the credentials required. +error-core-forbidden = You don't have the permission to access the requested resource. It is either read-protected or not readable by the server. +error-core-missingpermissions = You do not have the pointercrate permissions to perform this request. Required is: { $required-permission }, which isn't contained in any of your permissions. +error-core-notfound = The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again. +error-core-methodnotallowed = The method is not allowed for the requested URL. +error-core-conflict = A conflict happened while processing the request. The resource might have been modified while the request was being processed. +error-core-lengthrequired = A request with this methods requires a valid 'Content-Length' header. +error-core-preconditionfailed = The precondition on the request for the URL failed positive evaluation. +error-core-payloadtoolarge = The data value transmitted exceeds the capacity limit. +error-core-unsupportedmediatype = The server does not support the media type transmitted in the request/no media type was specified. Expected one '{ $expected-type }'. +error-core-unprocessableentity = The request was well-formed but was unable to be followed due to semantic errors. +error-core-invalidpaginationlimit = Invalid value for the 'limit' parameter. It must be between 1 and 100. +error-core-invalidurlscheme = Invalid URL scheme. Only 'http' and 'https' are supported. +error-core-urlauthenticated = The provided URL contains authentication information. For security reasons it has been rejected. +error-core-invalidurlformat = The given URL does not lead to a video. The URL format for the given host has to be '{ $expected-format }'. +error-core-aftersmallerbefore = The 'after' value provided for pagination is smaller than the 'before' value. This would result in an empty response and is most likely a bug. +error-core-mutuallyexclusive = Your request contains mutually exclusive fields. Please restrict yourself to one of them. +error-core-preconditionrequired = This request is required to be conditional; try using "If-Match". +error-core-ratelimited = { $message } Try again in { $remaining-duration }. +error-core-internalservererror = The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application. Please notify a server administrator and have them look at the server logs! +error-core-databaseerror = Internally, an invalid database access has been made. Please notify a server administrator and have them look at the server logs! +error-core-querytimeout = Internally, a database query timed out. This could be due to high server load, or because of a logic error resulting in a deadlock. If this issue persists after retrying, please notify a server administrator! +error-core-databaseconnectionerror = Failed to retrieve connection to the database. The server might be temporarily overloaded. +error-core-readonlymaintenance = The website is currently in read-only maintenance mode. diff --git a/pointercrate-core-pages/static/ftl/en-us/footer.ftl b/pointercrate-core-pages/static/ftl/en-us/footer.ftl new file mode 100644 index 000000000..99870aa8e --- /dev/null +++ b/pointercrate-core-pages/static/ftl/en-us/footer.ftl @@ -0,0 +1,2 @@ +footer-tweet = Tweet Us: +footer-developer = Developer \ No newline at end of file diff --git a/pointercrate-core-pages/static/ftl/en-us/ui.ftl b/pointercrate-core-pages/static/ftl/en-us/ui.ftl new file mode 100644 index 000000000..d479e6ce8 --- /dev/null +++ b/pointercrate-core-pages/static/ftl/en-us/ui.ftl @@ -0,0 +1,9 @@ +edit-success = Edit successful! +edit-notmodified = Nothing changed. + +dropdown-placeholder = Click to select + +filtered-paginator-placeholder = Enter to search... + +paginator-previous = Previous +paginator-next = Next \ No newline at end of file diff --git a/pointercrate-core-pages/static/ftl/ru-ru/error.ftl b/pointercrate-core-pages/static/ftl/ru-ru/error.ftl new file mode 100644 index 000000000..9ba88d163 --- /dev/null +++ b/pointercrate-core-pages/static/ftl/ru-ru/error.ftl @@ -0,0 +1,26 @@ +error-core-badrequest = Браузер (либо прокси) отправил запрос, который сервер не смог обработать. +error-core-invalidheadervalue = Значение заголовка '{ $header }' не прошло проверку. +error-core-unauthorized = Сервер не смог подтвердить разрешение для доступа к запрашиваемой вами ссылке. Либо вы используете неверные учетные данные (напр. неверный пароль), либо ваш браузер не понимает, как работать с требуемыми учетными данными. +error-core-forbidden = У вас нет разрешения на доступ к запрашиваемому ресурсу. Либо он доступен только для чтения, либо не может быть прочитан сервером. +error-core-missingpermissions = У вас нет нужных прав pointercrate для выполнения данного запроса. Требуется: { $required-permission }, чего нет в имеющихся у вас правах. +error-core-notfound = Запрашиваемая ссылка не была найдена на сервере. Если вы ввели ссылку вручную, проверьте ее написание и попробуйте еще раз. +error-core-methodnotallowed = Данный метод не разрешен для запрашиваемой ссылки. +error-core-conflict = При обработке запроса случился конфликт данных. Ресурс мог быть изменен во время обработки запроса. +error-core-lengthrequired = Запрос с этим методом требует верного заголовка 'Content-Length'. +error-core-preconditionfailed = Предварительное условие к запросу для этой ссылки не прошло положительное освидительствование. +error-core-payloadtoolarge = Переданное значение превышает лимит емкости. +error-core-unsupportedmediatype = Сервер не поддерживает передаваемый в запросе тип медиа, либо тип медиа не был указан вовсе. Ожидался '{ $expected-type }'. +error-core-unprocessableentity = Запрос был правильно оформлен, но не смог быть обработан из-за семантических ошибок. +error-core-invalidpaginationlimit = Неверное значение параметра 'limit'. Он должен быть между 1 и 100. +error-core-invalidurlscheme = Неверная схема URL. Поддерживаются только 'http' и 'https'. +error-core-urlauthenticated = Переданная ссылка содержит учетные данные. В связи с безопасностью она была отклонена. +error-core-invalidurlformat = Данная ссылка не ведет к видео. Формат ссылки для данного хоста должен быть '{ $expected-format }'. +error-core-aftersmallerbefore = Значение 'after', переданное для пагинации, меньше, чем значение 'before'. Это приведет к пустому запросу и, скорее всего, является багом. +error-core-mutuallyexclusive = Ваш запрос содержит взаимоисключающие поля. Пожалуйста, используйте лишь одним из них. +error-core-preconditionrequired = Этот запрос требует предварительного условия; попробуйте использовать "If-Match". +error-core-ratelimited = { $message } Попробуйте еще раз через { $remaining-duration }. +error-core-internalservererror = Сервер наткнулся на внутреннюю ошибку и не смог обработать ваш запрос. Либо сервер перегружен, либо в приложении содержится ошибка. Пожалуйста, свяжитесь с серверным администратором и попросите его просмотреть логи сервера! +error-core-databaseerror = Был произведен внутренний запрос к базе данных. Пожалуйста, свяжитесь с серверным администратором и попросите его просмотреть логи сервера! +error-core-querytimeout = Время ожидания внутреннего запроса к базе данных истекло. Это могло произойти из-за высокой нагрузки на сервер или логической ошибки, которая привела к тупику. Если проблема осталась после повторных попыток, свяжитесь с серверным администратором! +error-core-databaseconnectionerror = Не удалось получить соединение с базой данных. Сервер может быть временно перегружен. +error-core-readonlymaintenance = Сайт находится на техобслуживании и доступен только для чтения. diff --git a/pointercrate-core-pages/static/ftl/ru-ru/footer.ftl b/pointercrate-core-pages/static/ftl/ru-ru/footer.ftl new file mode 100644 index 000000000..f7dc68fb3 --- /dev/null +++ b/pointercrate-core-pages/static/ftl/ru-ru/footer.ftl @@ -0,0 +1,2 @@ +footer-tweet = Твитните нам: +footer-developer = Разработчик \ No newline at end of file diff --git a/pointercrate-core-pages/static/ftl/ru-ru/ui.ftl b/pointercrate-core-pages/static/ftl/ru-ru/ui.ftl new file mode 100644 index 000000000..bb9549fd2 --- /dev/null +++ b/pointercrate-core-pages/static/ftl/ru-ru/ui.ftl @@ -0,0 +1,9 @@ +edit-success = Успешно изменено! +edit-notmodified = Ничего не изменилось. + +dropdown-placeholder = Нажмите, чтобы выбрать + +filtered-paginator-placeholder = Введите для поиска... + +paginator-previous = Предыдущая +paginator-next = Следующая \ No newline at end of file diff --git a/pointercrate-core-pages/static/js/modules/form.js b/pointercrate-core-pages/static/js/modules/form.js index 241b17c39..fe44479a3 100644 --- a/pointercrate-core-pages/static/js/modules/form.js +++ b/pointercrate-core-pages/static/js/modules/form.js @@ -1,3 +1,5 @@ +import { tr } from "/static/core/js/modules/localization.js"; + /** * Class for those dropdown selectors we use throughout the website */ @@ -22,7 +24,7 @@ export class Dropdown { this.html = html; this.input = this.html.getElementsByTagName("input")[0]; if (this.input.dataset.default === undefined && !this.input.placeholder) - this.input.placeholder = "Click to select"; + this.input.placeholder = tr("core", "ui", "dropdown-placeholder"); this.menu = $(this.html.getElementsByClassName("menu")[0]); // we need jquery for the animations this.ul = this.html.getElementsByTagName("ul")[0]; @@ -300,8 +302,8 @@ export function setupDropdownEditor( backend .edit(data) .then((was304) => { - if (was304) output.setSuccess("Nothing changed!"); - else output.setSuccess("Edit successful!"); + if (was304) output.setSuccess( tr("core", "ui", "edit-notmodified") ); + else output.setSuccess( tr("core", "ui", "edit-success") ); }) .catch((response) => displayError(output)(response)); }); @@ -401,9 +403,9 @@ export function setupEditorDialog( .edit(dataTransform(data)) .then((was304) => { if (was304) { - output.setSuccess("Nothing changed"); + output.setSuccess( tr("core", "ui", "edit-notmodified") ); } else { - output.setSuccess("Edit successful!"); + output.setSuccess( tr("core", "ui", "edit-success") ); } }) .catch((response) => { @@ -1232,6 +1234,7 @@ export function displayError(output, specialCodes = {}) { output.setError( "FrontEnd JavaScript Error. Please notify an administrator and tell them as accurately as possible how to replicate this bug!" ); + console.error(response); throw new Error("FrontendError"); } }; diff --git a/pointercrate-core-pages/static/js/modules/localization.js b/pointercrate-core-pages/static/js/modules/localization.js new file mode 100644 index 000000000..9b841e605 --- /dev/null +++ b/pointercrate-core-pages/static/js/modules/localization.js @@ -0,0 +1,85 @@ +class LanguageSelector { + constructor(group) { + this.group = $(group); + + this.activeLanguage = document.getElementById("active-language"); + + // add selection listeners to language items + Array.from(group.querySelectorAll("[data-lang]")) + .map(language => { + this.addSelectionListener(language.parentNode, "click"); + }); + } + + setLanguage(code) { + let exp = new Date(); + exp.setFullYear(exp.getFullYear() + 1); + + document.cookie = `preference-locale=${code}; expires=${exp.toUTCString()}; path=/;`; + + window.location.reload(); + } + + addSelectionListener(button, event) { + let code = button.querySelector("[data-lang]").dataset.lang; + + button.addEventListener(event, () => { + this.setLanguage(code); + }); + } +} + +import { FluentBundle } from "https://cdn.jsdelivr.net/npm/@fluent/bundle@0.18.0/esm/bundle.js"; +import { FluentResource } from "https://cdn.jsdelivr.net/npm/@fluent/bundle@0.18.0/esm/resource.js"; + +window.fluentBundle = new FluentBundle(document.documentElement.lang); +window.requestedResources = []; + +export function loadResource(category, resource) { + // check if the resource file for this category/resource pair was already requested, so we + // dont request it again + if (window.requestedResources.some(item => item[0] == category && item[1] == resource)) { + return; + } + + let xhr = new XMLHttpRequest(); + xhr.open("GET", `/static${category ? "/" + category : ""}/ftl/${document.documentElement.lang.toLowerCase()}/${resource}.ftl`, false); + xhr.send(); + window.requestedResources.push([category, resource]); + + if (xhr.status != 200) return console.error(xhr.status, xhr.statusText); + + let fluentResource = new FluentResource(xhr.responseText); + window.fluentBundle.addResource(fluentResource); +} + +export function tr(category, resource, text_id) { + loadResource(category, resource); + + let [id, attribute] = text_id.split("."); + let message = window.fluentBundle.getMessage(id); + + if (message) { + return attribute ? message.attributes[attribute] : message.value; + } + return text_id; +} + +export function trp(category, resource, text_id, args) { + loadResource(category, resource); + + let pattern = tr(category, resource, text_id); + return window.fluentBundle.formatPattern(pattern, args); +} + +$(window).on("load", function () { + let languageSelectorGroup = document.getElementById("language-selector"); + + if (languageSelectorGroup) { + let languageSelector = new LanguageSelector(languageSelectorGroup); + + Array.from(document.querySelectorAll("span[data-lang]")) + .map((element) => element.parentElement) + .forEach((button) => languageSelector.addSelectionListener(button, "touchend")) + } +}); diff --git a/pointercrate-core/Cargo.toml b/pointercrate-core/Cargo.toml index 72cf87cb9..c8683228e 100644 --- a/pointercrate-core/Cargo.toml +++ b/pointercrate-core/Cargo.toml @@ -10,6 +10,11 @@ edition.workspace = true serde = "1.0.219" derive_more = { version = "2.0.1", features = ["display"] } sqlx = { version = "0.8", default-features = false, features = [ "runtime-tokio-native-tls", "macros", "postgres", "chrono", "migrate"] } +fluent = "0.16.1" +tokio = "1.44.2" log = "0.4.27" chrono = {version = "0.4.41", features = ["serde"]} getrandom = "0.3.3" +unic-langid = "0.9.5" +thiserror = "1.0.69" +fluent-syntax = "0.11.0" diff --git a/pointercrate-core/src/error.rs b/pointercrate-core/src/error.rs index a6dee3c04..e455a142f 100644 --- a/pointercrate-core/src/error.rs +++ b/pointercrate-core/src/error.rs @@ -1,8 +1,7 @@ -use crate::permission::Permission; -use derive_more::Display; +use crate::{localization::tr, permission::Permission, trp}; use log::error; use serde::Serialize; -use std::{error::Error, time::Duration}; +use std::{error::Error, fmt::Display, time::Duration}; pub type Result = std::result::Result; @@ -13,19 +12,17 @@ pub trait PointercrateError: Error + Serialize + From { } } -#[derive(Serialize, Display, Debug, Eq, PartialEq, Clone)] +#[derive(Serialize, Debug, Eq, PartialEq, Clone)] #[serde(untagged)] pub enum CoreError { /// Generic `400 BAD REQUEST` error /// /// Error Code `40000` - #[display("The browser (or proxy) sent a request that this server could not understand.")] BadRequest, /// `400 BAD REQUEST' error returned when a header value was malformed /// /// Error Code `40002` - #[display("The value for the header '{}' could not be processed", header)] InvalidHeaderValue { /// The name of the malformed header header: &'static str, @@ -34,29 +31,17 @@ pub enum CoreError { /// `401 UNAUTHORIZED` /// /// Error code 40100 - #[display( - "The server could not verify that you are authorized to access the URL requested. You either supplied the wrong credentials \ - (e.g. a bad password) or your browser doesn't understand how to supply the credentials required." - )] Unauthorized, /// `403 FORBIDDEN` /// /// Error Code `40300` - #[display( - "You don't have the permission to access the requested resource. It is either read-protected or not readable by the server." - )] Forbidden, /// `403 FORBIDDEN` error that contains the permissions the client needs to have to perform the /// request /// /// Error Code `40301` - #[display( - "You do not have the pointercrate permissions to perform this request. Required is: {}, which isn't contained in any of \ - your permissions", - required - )] MissingPermissions { /// The permissions required to perform the request required: Permission, @@ -65,13 +50,11 @@ pub enum CoreError { /// `404 NOT FOUND` /// /// Error Code `40400` - #[display("The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.")] NotFound, /// `405 METHOD NOT ALLOWED` /// /// Error Code `40500` - #[display("The method is not allowed for the requested URL.")] MethodNotAllowed, /// `409 CONFLICT`. This variant is returned if a `DELETE` or `PATCH` request is being handled, @@ -79,16 +62,11 @@ pub enum CoreError { /// concurrent modification. /// /// Error Code `40900` - #[display( - "A conflict happened while processing the request. The resource might have been modified while the request was being \ - processed." - )] Conflict, /// `411 LENGTH REQUIRED` /// /// Error Code `41100` - #[display("A request with this methods requires a valid 'Content-Length' header")] LengthRequired, /// `412 PRECONDITION FAILED`. This variant is returned if a `DELETE` or `PATCH` request is @@ -96,22 +74,16 @@ pub enum CoreError { /// in the database /// /// Error Code `41200` - #[display("The precondition on the request for the URL failed positive evaluation")] PreconditionFailed, /// `413 PAYLOAD TOO LARGE` /// /// Error Code `41300` - #[display("The data value transmitted exceeds the capacity limit.")] PayloadTooLarge, /// `415 UNSUPPORTED MEDIA TYPE` /// /// Error Code `41500` - #[display( - "The server does not support the media type transmitted in the request/no media type was specified. Expected one '{}'", - expected - )] UnsupportedMediaType { /// The expected media type for the request body expected: &'static str, @@ -120,35 +92,27 @@ pub enum CoreError { /// `422 UNPROCESSABLE ENTITY` /// /// Error Code `42200` - #[display("The request was well-formed but was unable to be followed due to semantic errors.")] UnprocessableEntity, /// `422 UNPRECESSABLE ENTITY` variant returned if the `limit` parameter provided for /// pagination is too large or too small /// /// Error Code `42207` - #[display("Invalid value for the 'limit' parameter. It must be between 1 and 100")] InvalidPaginationLimit, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42222` - #[display("Invalid URL scheme. Only 'http' and 'https' are supported")] InvalidUrlScheme, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42223` - #[display("The provided URL contains authentication information. For security reasons it has been rejected")] UrlAuthenticated, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42225` - #[display( - "The given URL does not lead to a video. The URL format for the given host has to be '{}'", - expected - )] InvalidUrlFormat { /// A hint as to how the format is expected to look expected: &'static str, @@ -157,28 +121,21 @@ pub enum CoreError { /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42227` - #[display( - "The 'after' value provided for pagination is smaller than the 'before' value. This would result in an empty response is \ - most likely a bug" - )] AfterSmallerBefore, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42229` - #[display("Your request contains mutually exclusive fields. Please restrict yourself to one of them")] MutuallyExclusive, /// `428 PRECONDITION REQUIRED` /// /// Error Code `42800` - #[display("This request is required to be conditional; try using \"If-Match\"")] PreconditionRequired, /// `429 TOO MANY REQUESTS` /// /// Error Code `42900` - #[display("{} Try again in {:.2?}", message, remaining)] Ratelimited { #[serde(skip)] message: String, @@ -187,40 +144,27 @@ pub enum CoreError { }, /// `500 INTERNAL SERVER ERROR` - #[display( - "The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there \ - is an error in the application. Please notify a server administrator and have them look at the server logs!" - )] InternalServerError, /// `500 INTERNAL SERVER ERROR` /// /// Error Code `50003` - #[display( - "Internally, an invalid database access has been made. Please notify a server administrator and have them look at the \ - server logs!" - )] DatabaseError, /// `500 INTERNAL SERVER ERROR` reported when postgres terminates a query due to hitting `statement_timeout` /// /// Error Code `50004` - #[display( - "Internally, a database query timed out. This could be due to high server load, or because of a logic error resulting in a deadlock. If this issue persists after retrying, please notify a server administrator!" - )] QueryTimeout, /// `500 INTERNAL SERVER ERROR` variant returned if the server fails to acquire a database /// connection /// /// Error Code `50005` - #[display("Failed to retrieve connection to the database. The server might be temporarily overloaded.")] DatabaseConnectionError, /// `503 SERVICE UNAVAILABLE` variant returned by all non-GET (e.g. all possible mutating) requests if the server is in maintenance mode. /// /// Error Core `50301` - #[display("The website is currently in read-only maintenance mode.")] ReadOnlyMaintenance, } @@ -275,6 +219,48 @@ impl PointercrateError for CoreError { } } +impl Display for CoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + CoreError::BadRequest => tr("error-core-badrequest"), + CoreError::InvalidHeaderValue { header } => trp!("error-core-badrequest", ("header", header)), + CoreError::Unauthorized => tr("error-core-unauthorized"), + CoreError::Forbidden => tr("error-core-badrequest"), + CoreError::MissingPermissions { required } => + trp!("error-core-missingpermissions", ("required-permission", tr(required.text_id()))), + CoreError::NotFound => tr("error-core-notfound"), + CoreError::MethodNotAllowed => tr("error-core-methodnotallowed"), + CoreError::Conflict => tr("error-core-conflict"), + CoreError::LengthRequired => tr("error-core-lengthrequired"), + CoreError::PreconditionFailed => tr("error-core-preconditionfailed"), + CoreError::PayloadTooLarge => tr("error-core-payloadtoolarge"), + CoreError::UnsupportedMediaType { expected } => trp!("error-core-unsupportedmediatype", ("expected-type", expected)), + CoreError::UnprocessableEntity => tr("error-core-unprocessableentity"), + CoreError::InvalidPaginationLimit => tr("error-core-invalidpaginationlimit"), + CoreError::InvalidUrlScheme => tr("error-core-invalidurlscheme"), + CoreError::UrlAuthenticated => tr("error-core-urlauthenticated"), + CoreError::InvalidUrlFormat { expected } => trp!("error-core-invalidurlformat", ("expected-format", expected)), + CoreError::AfterSmallerBefore => tr("error-core-aftersmallerbefore"), + CoreError::MutuallyExclusive => tr("error-core-mutuallyexclusive"), + CoreError::PreconditionRequired => tr("error-core-preconditionrequired"), + CoreError::Ratelimited { message, remaining } => trp!( + "error-core-ratelimited", + ("message", message), + ("remaining-duration", format!("{:.2?}", remaining)) + ), + CoreError::InternalServerError { .. } => tr("error-core-internalservererror"), + CoreError::DatabaseError => tr("error-core-databaseerror"), + CoreError::QueryTimeout => tr("error-core-querytimeout"), + CoreError::DatabaseConnectionError => tr("error-core-databaseconnectionerror"), + CoreError::ReadOnlyMaintenance => tr("error-core-readonlymaintenance"), + } + ) + } +} + impl From for CoreError { fn from(error: sqlx::Error) -> Self { /* diff --git a/pointercrate-core/src/lib.rs b/pointercrate-core/src/lib.rs index caa449d46..38cde07bc 100644 --- a/pointercrate-core/src/lib.rs +++ b/pointercrate-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod audit; pub mod config; pub mod error; pub mod etag; +pub mod localization; pub mod pagination; pub mod permission; pub mod pool; diff --git a/pointercrate-core/src/localization.rs b/pointercrate-core/src/localization.rs new file mode 100644 index 000000000..3c97cbba6 --- /dev/null +++ b/pointercrate-core/src/localization.rs @@ -0,0 +1,266 @@ +use crate::error::log_internal_server_error; +pub use fluent::FluentValue; +use fluent::{concurrent::FluentBundle, FluentArgs, FluentError, FluentMessage, FluentResource}; +use fluent_syntax::parser::ParserError; +use std::collections::hash_map::Entry; +use std::os::unix::prelude::OsStrExt; +use std::{collections::HashMap, fs::read_dir, path::Path, sync::OnceLock}; +use tokio::task_local; +use unic_langid::subtags::Language; +use unic_langid::{LanguageIdentifier, LanguageIdentifierError}; + +static LOCALES: OnceLock = OnceLock::new(); + +pub struct LocalesLoader { + bundles: HashMap>, +} + +pub struct LocaleConfiguration { + bundles: HashMap>, + pub fallback: Language, +} + +#[derive(thiserror::Error, Debug)] +pub enum LoaderError { + #[error("I/O Error while reading ftl files: {0}")] + Io(#[from] std::io::Error), + #[error("Encountered directory whose name is not a language identifier: {0}")] + LanguageIdentifier(#[from] LanguageIdentifierError), + #[error("Error(s) parsing fluent resource file: {0:?}")] + FluentParsing(Vec), + #[error("Fluent Resource Conflict(s): {0:?}")] + FluentConflict(Vec), + #[error("The same language is registered with non-equal language identifiers (e.g. en-gb vs en-us)")] + InconsistentLangIds, +} + +impl LocalesLoader { + pub fn load(resource_dirs: &[impl AsRef]) -> Result { + // Cannot use log::warn in this function, because it gets ran before rocket configures logging. + let mut bundles = HashMap::>::new(); + + let mut text_ids = Vec::new(); + + for path in resource_dirs { + for dir_entry in read_dir(path)? { + let dir_entry = dir_entry?; + + if !dir_entry.path().is_dir() { + eprintln!("Expected layout for localization directories is [...]/static/{{lang1,lang2,lang3}}/*.ftl. Unexpectedly found non-directory {:?}, ignoring", dir_entry.path()); + continue; + } + + let lang_id = LanguageIdentifier::from_bytes(dir_entry.file_name().as_bytes())?; + + let bundle = match bundles.entry(lang_id.language) { + Entry::Occupied(bundle) => { + if bundle.get().locales[0] != lang_id { + return Err(LoaderError::InconsistentLangIds); + } + + bundle.into_mut() + }, + Entry::Vacant(entry) => entry.insert(FluentBundle::new_concurrent(vec![lang_id])), + }; + + for ftl_file in read_dir(dir_entry.path())? { + let ftl_file = ftl_file?; + + if !ftl_file.path().is_file() { + eprintln!("Expected layout for localization directories is [...]/static/{{lang1,lang2,lang3}}/*.ftl. Unexpectedly found non-file {:?}, ignoring", ftl_file.path()); + continue; + } + + let source = FluentResource::try_new(std::fs::read_to_string(&ftl_file.path())?) + .map_err(|(_, errors)| LoaderError::FluentParsing(errors))?; + + for entry in source.entries() { + if let fluent_syntax::ast::Entry::Message(msg) = entry { + text_ids.push(msg.id.name.to_string()); + } + } + + bundle.add_resource(source).map_err(|errors| LoaderError::FluentConflict(errors))? + } + } + } + + for bundle in bundles.values() { + for text_id in &text_ids { + if !bundle.has_message(text_id) { + eprintln!("Localization Files for language {} are missing key {}!", bundle.locales[0], text_id); + } + } + } + + Ok(LocalesLoader { bundles }) + } + + /// Set the `LOCALES` [`OnceLock`] to use this set of loaded locales + pub fn commit(self, fallback: Language) { + assert!(self.bundles.contains_key(&fallback)); + + let config = LocaleConfiguration { + bundles: self.bundles, + fallback, + }; + LOCALES + .set(config) + .unwrap_or_else(|_| panic!("LOCALES OnceLock already initialized")); + } + + /// Function setting up an empty [`LocaleConfiguration`] that will fail to localize + /// all keys. Mostly useful for integration tests. + pub fn empty() { + // Code assumes that the fallback has an entry in the hashmap, so create a dummy bundle + let mut bundles = HashMap::new(); + let lang_id = LanguageIdentifier::default(); + let lang = lang_id.language; + bundles.insert(lang, FluentBundle::new_concurrent(vec![lang_id])); + let empty = LocaleConfiguration { bundles, fallback: lang }; + _ = LOCALES.set(empty) + } +} + +impl LocaleConfiguration { + pub fn get() -> &'static Self { + LOCALES + .get() + .expect("Locales were not properly initialized. Please ensure that the locales have been loaded correctly!") + } + + pub fn active_locale(&self) -> &LanguageIdentifier { + self.bundles + .get(&task_lang()) + .map(|bundle| &bundle.locales[0]) + .unwrap_or(&self.bundles[&self.fallback].locales[0]) + } + + /// Returns a [`LanguageIdentifier`] whose string representation matches the given `code`. + /// If one is not found, the [`LanguageIdentifier`] associated with the fallback language will be returned. + pub fn by_code(&self, code: &str) -> &LanguageIdentifier { + // Can unwrap, there's an assertion in `commit()` to assert the fallback language exists + let fallback_locale = &self.bundles[&self.fallback].locales[0]; + + self.locales() + .find(|lang_id| lang_id.to_string().eq_ignore_ascii_case(code)) + .unwrap_or(fallback_locale) + } + + pub fn locales(&self) -> impl ExactSizeIterator { + self.bundles.values().map(|bundle| &bundle.locales[0]) + } + + fn get_message(&self, lang: &Language, text_id: &str) -> Option<(&FluentBundle, FluentMessage<'_>)> { + // Can unwrap, there's an assertion in `commit()` to assert the fallback language exists + let fallback_bundle = &self.bundles[&self.fallback]; + let bundle = self.bundles.get(lang).unwrap_or_else(|| { + log_internal_server_error(format!("Request for language that has no bundle associated with it: {}", lang)); + + fallback_bundle + }); + + bundle + .get_message(text_id) + .map(|msg| (bundle, msg)) + .or_else(|| fallback_bundle.get_message(text_id).map(|msg| (bundle, msg))) + } + + pub fn lookup<'a>(&self, lang: &Language, text_id: &str, args: Option<&HashMap<&str, FluentValue<'a>>>) -> String { + let (key, maybe_attr) = match text_id.split_once(".") { + Some((key, attr)) => (key, Some(attr)), + None => (text_id, None), + }; + + let Some((bundle, message)) = self.get_message(lang, key) else { + #[cfg(not(test))] + log_internal_server_error(format!("Invalid fluent key: {}", text_id)); + + return text_id.to_string(); + }; + + let pattern = match maybe_attr + .and_then(|attr| message.get_attribute(attr).map(|a| a.value())) + .or_else(|| message.value()) + { + Some(pattern) => pattern, + None => { + #[cfg(not(test))] + log_internal_server_error(format!("Invalid fluent attributes for key {}: {:?}", text_id, maybe_attr)); + + return text_id.to_string(); + }, + }; + + let fluent_args = match args { + Some(args) => { + let mut fluent_args = FluentArgs::new(); + args.iter().for_each(|(arg, value)| fluent_args.set(arg.to_string(), value.clone())); + + Some(fluent_args) + }, + None => None, + }; + + // todo: leverage fluent's formatting error handling for better error messages + bundle.format_pattern(pattern, fluent_args.as_ref(), &mut Vec::new()).to_string() + } +} + +task_local! { + pub static LANGUAGE: Language; +} + +/// Utility function for easily retrieving the current [`LanguageIdentifier`] inside the +/// `task_local!` [`LocalKey`] scope of wherever this is called from. +pub fn task_lang() -> Language { + LANGUAGE.with(|lang| *lang) +} + +/// A utility function for fetching a translated message associated with the +/// given `text_id`. The language of the returned message depends on the value +/// of the `tokio::task_local!` `LANGUAGE` [`LocalKey`] variable. The translations +/// are stored in the `locales` directory. +/// +/// This function call must be nested inside a [`LocalKey`] scope. +pub fn tr(text_id: &str) -> String { + LANGUAGE + .try_with(|lang| LocaleConfiguration::get().lookup(lang, text_id, None)) + .unwrap_or_else(|err| { + log_internal_server_error(format!("Localization Failure: Call tr from invalid context: {:?}", err)); + + text_id.to_owned() + }) +} + +/// Like [`tr`], except this function must be used for fetching translations +/// containing variables. +/// +/// Example with English translation: +/// ```ignore +/// assert_eq!( +/// trp!("demon-score", ("percent", 99)), +/// "Demonlist score (99%)", +/// ); +/// ``` +/// Source text: `demon-score = Demonlist score ({$percent}%)` +#[macro_export] +macro_rules! trp { + ($text_id:expr $(, ($key:expr, $value:expr) )* $(,)?) => {{ + use std::collections::HashMap; + use $crate::localization::{LANGUAGE, FluentValue, LocaleConfiguration}; + + let mut args_map: HashMap<&'static str, FluentValue<'_>> = HashMap::new(); + + $( + args_map.insert($key, FluentValue::from($value.clone())); + )* + + LANGUAGE.try_with(|lang| LocaleConfiguration::get().lookup(lang, $text_id, Some(&args_map))) + .unwrap_or_else(|err|{ + $crate::error::log_internal_server_error(format!("Localization Failure: Call trp! from invalid context: {:?}", err)); + + $text_id.to_owned() + }) + }}; +} diff --git a/pointercrate-core/src/permission.rs b/pointercrate-core/src/permission.rs index 7ca14ed7b..c6ba48a6e 100644 --- a/pointercrate-core/src/permission.rs +++ b/pointercrate-core/src/permission.rs @@ -5,21 +5,21 @@ use std::collections::{HashMap, HashSet}; #[derive(Serialize, Debug, Display, Eq, PartialEq, Clone, Copy, Hash)] #[serde(transparent)] -#[display("{}", name)] +#[display("{}", text_id)] pub struct Permission { - name: &'static str, + text_id: &'static str, #[serde(skip)] bit: u16, } impl Permission { - pub const fn new(name: &'static str, bit: u16) -> Permission { - Permission { name, bit } + pub const fn new(text_id: &'static str, bit: u16) -> Permission { + Permission { text_id, bit } } - pub fn name(&self) -> &str { - self.name + pub fn text_id(&self) -> &str { + self.text_id } pub fn bit(&self) -> u16 { diff --git a/pointercrate-demonlist-api/Cargo.toml b/pointercrate-demonlist-api/Cargo.toml index ad6e0e2f2..8b3555d3b 100644 --- a/pointercrate-demonlist-api/Cargo.toml +++ b/pointercrate-demonlist-api/Cargo.toml @@ -13,6 +13,7 @@ pointercrate-demonlist-pages = {path = "../pointercrate-demonlist-pages"} pointercrate-core = {path = "../pointercrate-core"} pointercrate-core-api = {path = "../pointercrate-core-api"} pointercrate-core-pages = {path = "../pointercrate-core-pages"} +pointercrate-core-macros = {path = "../pointercrate-core-macros"} pointercrate-user = {path = "../pointercrate-user"} pointercrate-user-api = {path = "../pointercrate-user-api"} pointercrate-integrate = {path = "../pointercrate-integrate"} diff --git a/pointercrate-demonlist-api/src/endpoints/demon.rs b/pointercrate-demonlist-api/src/endpoints/demon.rs index 324c81535..80d226699 100644 --- a/pointercrate-demonlist-api/src/endpoints/demon.rs +++ b/pointercrate-demonlist-api/src/endpoints/demon.rs @@ -7,6 +7,7 @@ use pointercrate_core_api::{ query::Query, response::Response2, }; +use pointercrate_core_macros::localized; use pointercrate_demonlist::{ creator::{Creator, PostCreator}, demon::{ @@ -21,11 +22,13 @@ use pointercrate_user::auth::ApiToken; use pointercrate_user_api::auth::Auth; use rocket::{http::Status, serde::json::Json, State}; +#[localized] #[rocket::get("/")] pub async fn paginate(pool: &State, pagination: Query) -> Result>>> { Ok(pagination_response("/api/v2/demons/", pagination.0, &mut *pool.connection().await?).await?) } +#[localized] #[rocket::get("/listed")] pub async fn paginate_listed( pool: &State, pagination: Query, @@ -33,11 +36,13 @@ pub async fn paginate_listed( Ok(pagination_response("/api/v2/demons/listed/", pagination.0, &mut *pool.connection().await?).await?) } +#[localized] #[rocket::get("/")] pub async fn get(demon_id: i32, pool: &State) -> Result> { Ok(Tagged(FullDemon::by_id(demon_id, &mut *pool.connection().await?).await?)) } +#[localized] #[rocket::get("//audit")] pub async fn audit(demon_id: i32, mut auth: Auth) -> Result>>> { auth.require_permission(LIST_ADMINISTRATOR)?; @@ -51,6 +56,7 @@ pub async fn audit(demon_id: i32, mut auth: Auth) -> Result/audit/movement")] pub async fn movement_log(demon_id: i32, pool: &State) -> Result>> { let log = pointercrate_demonlist::demon::audit::movement_log_for_demon(demon_id, &mut *pool.connection().await?).await?; @@ -62,6 +68,7 @@ pub async fn movement_log(demon_id: i32, pool: &State) -> Resu Ok(Json(log)) } +#[localized] #[rocket::post("/", data = "")] pub async fn post( mut auth: Auth, data: Json, ratelimits: &State, @@ -81,6 +88,7 @@ pub async fn post( .with_header("Location", format!("/api/v2/demons/{}/", demon_id))) } +#[localized] #[rocket::patch("/", data = "")] pub async fn patch( demon_id: i32, mut auth: Auth, precondition: Precondition, patch: Json, @@ -98,6 +106,7 @@ pub async fn patch( Ok(Tagged(demon)) } +#[localized] #[rocket::post("//creators", data = "")] pub async fn post_creator(demon_id: i32, mut auth: Auth, creator: Json) -> Result>> { auth.require_permission(LIST_MODERATOR)?; @@ -115,6 +124,7 @@ pub async fn post_creator(demon_id: i32, mut auth: Auth, creator: Json )) } +#[localized] #[rocket::delete("//creators/")] pub async fn delete_creator(demon_id: i32, player_id: i32, mut auth: Auth) -> Result { auth.require_permission(LIST_MODERATOR)?; diff --git a/pointercrate-demonlist-api/src/endpoints/nationality.rs b/pointercrate-demonlist-api/src/endpoints/nationality.rs index bd43219c5..01c914e2f 100644 --- a/pointercrate-demonlist-api/src/endpoints/nationality.rs +++ b/pointercrate-demonlist-api/src/endpoints/nationality.rs @@ -1,8 +1,10 @@ use pointercrate_core::pool::PointercratePool; use pointercrate_core_api::{error::Result, etag::Tagged, query::Query}; +use pointercrate_core_macros::localized; use pointercrate_demonlist::nationality::{Nationality, NationalityRankingPagination, NationalityRecord, RankedNation, Subdivision}; use rocket::{serde::json::Json, State}; +#[localized] #[rocket::get("//subdivisions")] pub async fn subdivisions(pool: &State, iso_code: String) -> Result>> { let mut connection = pool.connection().await?; @@ -13,11 +15,13 @@ pub async fn subdivisions(pool: &State, iso_code: String) -> R Ok(Json(nationality.subdivisions(&mut connection).await?)) } +#[localized] #[rocket::get("/ranking")] pub async fn ranking(pool: &State, pagination: Query) -> Result>> { Ok(Json(pagination.0.page(&mut *pool.connection().await?).await?)) } +#[localized] #[rocket::get("/")] pub async fn nation(pool: &State, iso_code: String) -> Result> { let mut connection = pool.connection().await?; diff --git a/pointercrate-demonlist-api/src/endpoints/player.rs b/pointercrate-demonlist-api/src/endpoints/player.rs index f2cc15abf..f550c4beb 100644 --- a/pointercrate-demonlist-api/src/endpoints/player.rs +++ b/pointercrate-demonlist-api/src/endpoints/player.rs @@ -6,6 +6,7 @@ use pointercrate_core_api::{ query::Query, response::Response2, }; +use pointercrate_core_macros::localized; use pointercrate_demonlist::{ error::DemonlistError, player::{ @@ -18,6 +19,7 @@ use pointercrate_user::{auth::ApiToken, MODERATOR}; use pointercrate_user_api::auth::Auth; use rocket::{http::Status, serde::json::Json, State}; +#[localized] #[rocket::get("/")] pub async fn paginate( pool: &State, query: Query, auth: Option>, @@ -35,11 +37,13 @@ pub async fn paginate( Ok(pagination_response("/api/v1/players/", pagination, &mut *pool.connection().await?).await?) } +#[localized] #[rocket::get("/ranking")] pub async fn ranking(pool: &State, query: Query) -> Result>>> { Ok(pagination_response("/api/v1/players/ranking/", query.0, &mut *pool.connection().await?).await?) } +#[localized] #[rocket::get("/me")] pub async fn get_me(auth: Option>, pool: &State) -> Result> { let Some(auth) = auth else { @@ -58,6 +62,7 @@ pub async fn get_me(auth: Option>, pool: &State Ok(Tagged(full_player)) } +#[localized] #[rocket::get("/")] pub async fn get(player_id: i32, pool: &State) -> Result> { let mut connection = pool.connection().await?; @@ -67,6 +72,7 @@ pub async fn get(player_id: i32, pool: &State) -> Result", data = "")] pub async fn patch( player_id: i32, mut auth: Auth, precondition: Precondition, patch: Json, @@ -84,6 +90,7 @@ pub async fn patch( Ok(Tagged(player)) } +#[localized] #[rocket::put("//claims")] pub async fn put_claim(player_id: i32, mut auth: Auth) -> Result>> { let user_id = auth.user.user().id; @@ -100,6 +107,7 @@ pub async fn put_claim(player_id: i32, mut auth: Auth) -> Result/claims/", data = "")] pub async fn patch_claim( player_id: i32, user_id: i32, mut auth: Auth, data: Json, @@ -143,6 +151,7 @@ pub async fn patch_claim( Ok(Json(claim)) } +#[localized] #[rocket::delete("//claims/")] pub async fn delete_claim(player_id: i32, user_id: i32, mut auth: Auth) -> Result { auth.require_permission(MODERATOR)?; @@ -155,6 +164,7 @@ pub async fn delete_claim(player_id: i32, user_id: i32, mut auth: Auth Ok(Status::NoContent) } +#[localized] #[rocket::get("/claims")] pub async fn paginate_claims( mut auth: Auth, pagination: Query, @@ -179,6 +189,7 @@ struct GeolocationResponse { } #[cfg(feature = "geolocation")] +#[localized] #[rocket::post("//geolocate")] pub async fn geolocate_nationality( player_id: i32, ip: std::net::IpAddr, mut auth: Auth, ratelimits: &State, diff --git a/pointercrate-demonlist-api/src/endpoints/record.rs b/pointercrate-demonlist-api/src/endpoints/record.rs index 0a0675516..5ea62f7b3 100644 --- a/pointercrate-demonlist-api/src/endpoints/record.rs +++ b/pointercrate-demonlist-api/src/endpoints/record.rs @@ -8,6 +8,7 @@ use pointercrate_core_api::{ query::Query, response::Response2, }; +use pointercrate_core_macros::localized; use pointercrate_demonlist::{ error::DemonlistError, player::claim::PlayerClaim, @@ -34,6 +35,7 @@ use std::net::IpAddr; /// `APPROVED` is allowed, UNLESS we also filter by player and the player we filter by match a /// verified claim of the user making the request, in which case access to all records is allowed /// (the `status` property does not get defaulted, and filtering on it is allowed) +#[localized] #[rocket::get("/")] pub async fn paginate(mut auth: Auth, query: Query) -> Result>>> { let mut pagination = query.0; @@ -57,6 +59,7 @@ pub async fn paginate(mut auth: Auth, query: Query) Ok(pagination_response("/api/v1/records/", pagination, &mut auth.connection).await?) } +#[localized] #[rocket::get("/", rank = 1)] pub async fn unauthed_pagination( pool: &State, query: Query, @@ -77,6 +80,7 @@ pub async fn unauthed_pagination( Ok(pagination_response("/api/v1/records/", pagination, &mut connection).await?) } +#[localized] #[rocket::post("/", data = "")] pub async fn submit( ip: IpAddr, auth: Option>, submission: Json, pool: &State, @@ -170,6 +174,7 @@ pub async fn submit( Ok(response) } +#[localized] #[rocket::get("/")] pub async fn get(record_id: i32, auth: Option>, pool: &State) -> Result> { let is_helper = auth.as_ref().is_some_and(|auth| auth.has_permission(LIST_HELPER)); @@ -193,6 +198,7 @@ pub async fn get(record_id: i32, auth: Option>, pool: &State/audit")] pub async fn audit(record_id: i32, mut auth: Auth) -> Result>>> { auth.require_permission(LIST_ADMINISTRATOR)?; @@ -206,6 +212,7 @@ pub async fn audit(record_id: i32, mut auth: Auth) -> Result", data = "")] pub async fn patch( record_id: i32, mut auth: Auth, precondition: Precondition, patch: Json, @@ -228,6 +235,7 @@ pub async fn patch( Ok(Tagged(record)) } +#[localized] #[rocket::delete("/")] pub async fn delete(record_id: i32, mut auth: Auth, precondition: Precondition) -> Result { let record = FullRecord::by_id(record_id, &mut auth.connection).await?; @@ -246,6 +254,7 @@ pub async fn delete(record_id: i32, mut auth: Auth, precondition: Prec Ok(Status::NoContent) } +#[localized] #[rocket::get("//notes")] pub async fn get_notes(record_id: i32, mut auth: Auth) -> Result>>> { let record_holder_id = sqlx::query!("SELECT player FROM records WHERE id = $1", record_id) @@ -273,6 +282,7 @@ pub async fn get_notes(record_id: i32, mut auth: Auth) -> Result/notes", data = "")] pub async fn add_note(record_id: i32, mut auth: Auth, data: Json) -> Result>> { auth.require_permission(LIST_HELPER)?; @@ -292,6 +302,7 @@ pub async fn add_note(record_id: i32, mut auth: Auth, data: Json/notes/", data = "")] pub async fn patch_note(record_id: i32, note_id: i32, mut auth: Auth, patch: Json) -> Result> { let note = Note::by_id(record_id, note_id, &mut auth.connection).await?; @@ -309,6 +320,7 @@ pub async fn patch_note(record_id: i32, note_id: i32, mut auth: Auth, Ok(Tagged(note)) } +#[localized] #[rocket::delete("//notes/")] pub async fn delete_note(record_id: i32, note_id: i32, mut auth: Auth) -> Result { let note = Note::by_id(record_id, note_id, &mut auth.connection).await?; diff --git a/pointercrate-demonlist-api/src/endpoints/submitter.rs b/pointercrate-demonlist-api/src/endpoints/submitter.rs index 972e07897..5ea7ad7a3 100644 --- a/pointercrate-demonlist-api/src/endpoints/submitter.rs +++ b/pointercrate-demonlist-api/src/endpoints/submitter.rs @@ -5,6 +5,7 @@ use pointercrate_core_api::{ query::Query, response::Response2, }; +use pointercrate_core_macros::localized; use pointercrate_demonlist::{ submitter::{PatchSubmitter, Submitter, SubmitterPagination}, LIST_MODERATOR, @@ -13,6 +14,7 @@ use pointercrate_user::auth::ApiToken; use pointercrate_user_api::auth::Auth; use rocket::serde::json::Json; +#[localized] #[rocket::get("/")] pub async fn paginate(mut auth: Auth, pagination: Query) -> Result>>> { auth.require_permission(LIST_MODERATOR)?; @@ -20,6 +22,7 @@ pub async fn paginate(mut auth: Auth, pagination: Query")] pub async fn get(submitter_id: i32, mut auth: Auth) -> Result> { auth.require_permission(LIST_MODERATOR)?; @@ -27,6 +30,7 @@ pub async fn get(submitter_id: i32, mut auth: Auth) -> Result", data = "")] pub async fn patch( submitter_id: i32, precondition: Precondition, mut auth: Auth, patch: Json, diff --git a/pointercrate-demonlist-api/src/pages.rs b/pointercrate-demonlist-api/src/pages.rs index 537ec9efe..de437ccf2 100644 --- a/pointercrate-demonlist-api/src/pages.rs +++ b/pointercrate-demonlist-api/src/pages.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use pointercrate_core_macros::localized; use rocket::{response::Redirect, State}; use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, Utc}; @@ -30,6 +31,7 @@ use rand::Rng; use rocket::{futures::StreamExt, http::CookieJar}; use sqlx::PgConnection; +#[localized] #[rocket::get("/?&")] pub async fn overview( pool: &State, timemachine: Option, submitter: Option, cookies: &CookieJar<'_>, @@ -76,9 +78,9 @@ pub async fn overview( Ok(Page::new(OverviewPage { team: Team { - admins: User::by_permission(LIST_ADMINISTRATOR, &mut connection).await?, - moderators: User::by_permission(LIST_MODERATOR, &mut connection).await?, - helpers: User::by_permission(LIST_HELPER, &mut connection).await?, + admins: User::by_permission(LIST_ADMINISTRATOR, &mut *connection).await?, + moderators: User::by_permission(LIST_MODERATOR, &mut *connection).await?, + helpers: User::by_permission(LIST_HELPER, &mut *connection).await?, }, demonlist, time_machine: tardis, @@ -106,6 +108,7 @@ pub async fn demon_permalink(demon_id: i32, pool: &State) -> R Ok(Redirect::to(rocket::uri!("/demonlist", demon_page(position)))) } +#[localized] #[rocket::get("/")] pub async fn demon_page(position: i16, pool: &State, gd: &State) -> Result { let mut connection = pool.connection().await?; @@ -150,31 +153,34 @@ pub async fn demon_page(position: i16, pool: &State, gd: &Stat Ok(Page::new(DemonPage { team: Team { - admins: User::by_permission(LIST_ADMINISTRATOR, &mut connection).await?, - moderators: User::by_permission(LIST_MODERATOR, &mut connection).await?, - helpers: User::by_permission(LIST_HELPER, &mut connection).await?, + admins: User::by_permission(LIST_ADMINISTRATOR, &mut *connection).await?, + moderators: User::by_permission(LIST_MODERATOR, &mut *connection).await?, + helpers: User::by_permission(LIST_HELPER, &mut *connection).await?, }, - demonlist: current_list(&mut connection).await?, + demonlist: current_list(&mut *connection).await?, movements: modifications, integration: gd.load_level_for_demon(&full_demon.demon).await, data: full_demon, })) } +#[localized] #[rocket::get("/statsviewer")] pub async fn stats_viewer(pool: &State) -> Result { let mut connection = pool.connection().await?; Ok(Page::new(IndividualStatsViewer { - nationalities_in_use: Nationality::used(&mut connection).await?, + nationalities_in_use: Nationality::used(&mut *connection).await?, })) } +#[localized] #[rocket::get("/statsviewer/nations")] pub async fn nation_stats_viewer() -> Page { Page::new(pointercrate_demonlist_pages::statsviewer::national::nation_based_stats_viewer()) } +#[localized] #[rocket::get("/statsviewer/heatmap.css")] pub async fn heatmap_css(pool: &State) -> Result> { let mut connection = pool.connection().await?; diff --git a/pointercrate-demonlist-api/src/ratelimits.rs b/pointercrate-demonlist-api/src/ratelimits.rs index 3d74ea6ae..d09feed19 100644 --- a/pointercrate-demonlist-api/src/ratelimits.rs +++ b/pointercrate-demonlist-api/src/ratelimits.rs @@ -1,18 +1,18 @@ -use std::net::IpAddr; - +use pointercrate_core::localization::tr; use pointercrate_core::ratelimits; +use std::net::IpAddr; ratelimits! { DemonlistRatelimits { - record_submission[3u32 per 1200 per IpAddr] => "You're submitting too many records too fast!", + record_submission[3u32 per 1200 per IpAddr] => tr("error-demonlist-ratelimit-submit"), - record_submission_global[20u32 per 3600] => "Too many records are being submitted right now!", + record_submission_global[20u32 per 3600] => tr("error-demonlist-ratelimit-record-submit-global"), - new_submitters[7u32 per 3600] => "DDoS protection ratelimit", + new_submitters[7u32 per 3600] => tr("error-demonlist-ratelimit-new-submitters"), - geolocate[1u32 per 2_678_400 per IpAddr] => "You can only geolocate once per month!", + geolocate[1u32 per 2_678_400 per IpAddr] => tr("error-demonlist-ratelimit-geolocate"), - add_demon[1u32 per 60] => "Please don't spam the button, rSteel", + add_demon[1u32 per 60] => tr("error-demonlist-ratelimit-add-demon"), } } diff --git a/pointercrate-demonlist-pages/Cargo.toml b/pointercrate-demonlist-pages/Cargo.toml index a1209a575..78224235d 100644 --- a/pointercrate-demonlist-pages/Cargo.toml +++ b/pointercrate-demonlist-pages/Cargo.toml @@ -9,6 +9,7 @@ edition.workspace = true [dependencies] pointercrate-core = {path = "../pointercrate-core"} pointercrate-core-pages = {path = "../pointercrate-core-pages"} +pointercrate-core-macros = {path = "../pointercrate-core-macros"} pointercrate-user = {path = "../pointercrate-user"} pointercrate-user-pages = {path = "../pointercrate-user-pages"} pointercrate-demonlist = {path = "../pointercrate-demonlist"} diff --git a/pointercrate-demonlist-pages/src/account/demons.rs b/pointercrate-demonlist-pages/src/account/demons.rs index 0791191cf..a4ea3e5f8 100644 --- a/pointercrate-demonlist-pages/src/account/demons.rs +++ b/pointercrate-demonlist-pages/src/account/demons.rs @@ -1,6 +1,6 @@ use crate::components::{player_selection_dialog, player_selection_dropdown}; use maud::{html, Markup, PreEscaped}; -use pointercrate_core::permission::PermissionsManager; +use pointercrate_core::{localization::tr, permission::PermissionsManager, trp}; use pointercrate_core_pages::util::filtered_paginator; use pointercrate_demonlist::LIST_MODERATOR; use pointercrate_user::auth::{AuthenticatedUser, NonMutating}; @@ -28,7 +28,7 @@ impl AccountPageTab for DemonsTab { i class = "fa fa-shower fa-2x" aria-hidden="true" {} (PreEscaped("  ")) b { - "Demons" + (tr("demons")) } } } @@ -41,30 +41,30 @@ impl AccountPageTab for DemonsTab { (demon_submitter()) div.panel.fade { h2.underlined.pad { - "Demon Manager" + (tr("demon-manager")) } div.flex.viewer { (filtered_paginator("demon-pagination", "/api/v2/demons/listed/")) p.viewer-welcome { - "Click on a demon on the left to get started!" + (tr("demon-viewer.welcome")) } div.viewer-content { div.flex.col{ h3 style = "font-size:1.1em; margin: 10px 0" { - "Demon #" + (tr("demon-viewer")) i #demon-demon-id {} " - " i.fa.fa-pencil-alt.clickable #demon-name-pen aria-hidden = "true" {} (PreEscaped(" ")) i #demon-demon-name {} } - iframe."ratio-16-9"#demon-video style="width:90%; margin: 15px 5%" allowfullscreen="" {"Verification Video"} + iframe."ratio-16-9"#demon-video style="width:90%; margin: 15px 5%" allowfullscreen="" {(tr("demon-video"))} p.info-red.output style = "margin: 10px" {} p.info-green.output style = "margin: 10px" {} div.stats-container.flex.space { span{ b { - i.fa.fa-pencil-alt.clickable #demon-video-pen aria-hidden = "true" {} " Verification Video:" + i.fa.fa-pencil-alt.clickable #demon-video-pen aria-hidden = "true" {} " " (tr("demon-viewer.video-field")) } br; a.link #demon-video-link target = "_blank" {} @@ -73,7 +73,7 @@ impl AccountPageTab for DemonsTab { div.stats-container.flex.space { span{ b { - i.fa.fa-pencil-alt.clickable #demon-thumbnail-pen aria-hidden = "true" {} " Thumbnail:" + i.fa.fa-pencil-alt.clickable #demon-thumbnail-pen aria-hidden = "true" {} " " (tr("demon-viewer.thumbnail-field")) } br; a.link #demon-thumbnail-link target = "_blank" {} @@ -82,14 +82,14 @@ impl AccountPageTab for DemonsTab { div.stats-container.flex.space { span{ b { - i.fa.fa-pencil-alt.clickable #demon-position-pen aria-hidden = "true" {} " Position:" + i.fa.fa-pencil-alt.clickable #demon-position-pen aria-hidden = "true" {} " " (tr("demon-viewer.position-field")) } br; span #demon-position {} } span{ b { - i.fa.fa-pencil-alt.clickable #demon-requirement-pen aria-hidden = "true" {} " Requirement:" + i.fa.fa-pencil-alt.clickable #demon-requirement-pen aria-hidden = "true" {} " " (tr("demon-viewer.requirement-field")) } br; span #demon-requirement {} @@ -98,14 +98,14 @@ impl AccountPageTab for DemonsTab { div.stats-container.flex.space { span{ b { - i.fa.fa-pencil-alt.clickable #demon-publisher-pen aria-hidden = "true" {} " Publisher:" + i.fa.fa-pencil-alt.clickable #demon-publisher-pen aria-hidden = "true" {} " " (tr("demon-viewer.publisher-field")) } br; span #demon-publisher {} } span{ b { - i.fa.fa-pencil-alt.clickable #demon-verifier-pen aria-hidden = "true" {} " Verifier:" + i.fa.fa-pencil-alt.clickable #demon-verifier-pen aria-hidden = "true" {} " " (tr("demon-viewer.verifier-field")) } br; span #demon-verifier {} @@ -114,7 +114,7 @@ impl AccountPageTab for DemonsTab { div.stats-container.flex.space { span{ i.fa.fa-plus.clickable #demon-add-creator-pen aria-hidden = "true" {} b { - " Creators:" + " " (tr("demon-viewer.creators-field")) } br; span #demon-creators {} @@ -147,11 +147,11 @@ pub(super) fn submit_panel() -> Markup { section.panel.fade.js-scroll-anim data-anim = "fade" { div.underlined { h2 { - "Add Demon:" + (tr("demon-add-panel")) } } a.blue.hover.button.js-scroll data-destination = "demon-submitter" data-reveal = "true" { - "Add a demon!" + (tr("demon-add-panel.button")) } } } @@ -163,20 +163,20 @@ fn change_name_dialog() -> Markup { div.dialog #demon-name-dialog { span.plus.cross.hover {} h2.underlined.pad { - "Change demon name:" + (tr("demon-name-dialog")) } p style = "max-width: 400px"{ - "Change the name of this demon. Multiple demons with the same name ARE supported!" + (tr("demon-name-dialog.info")) } form.flex.col novalidate = "" { p.info-red.output {} p.info-green.output {} span.form-input #demon-name-edit { - label for = "name" {"Name:"} + label for = "name" {(tr("demon-name-dialog.name-field")) } input name = "name" type = "text" required = ""; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = "Edit"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = (tr("demon-name-dialog.submit")); } } } @@ -189,20 +189,20 @@ fn change_requirement_dialog() -> Markup { div.dialog #demon-requirement-dialog { span.plus.cross.hover {} h2.underlined.pad { - "Change demon requirement:" + (tr("demon-requirement-dialog")) } p style = "max-width: 400px"{ - "Change the record requirement for this demon. Has be lie between 0 and and 100 (inclusive)." + (tr("demon-requirement-dialog.info")) } form.flex.col novalidate = "" { p.info-red.output {} p.info-green.output {} span.form-input #demon-requirement-edit { - label for = "requirement" {"Requirement:"} + label for = "requirement" {(tr("demon-requirement-dialog.requirement-field")) } input name = "requirement" type = "number" min = "0" max="100" required = ""; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = "Edit"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = (tr("demon-requirement-dialog.submit")); } } } @@ -215,20 +215,20 @@ fn change_position_dialog() -> Markup { div.dialog #demon-position-dialog { span.plus.cross.hover {} h2.underlined.pad { - "Change demon position:" + (tr("demon-position-dialog")) } p style = "max-width: 400px"{ - "Change the position of this demon. Has be be greater than 0 and be at most the current list size." + (tr("demon-position-dialog.info")) } form.flex.col novalidate = "" { p.info-red.output {} p.info-green.output {} span.form-input #demon-position-edit { - label for = "position" {"Position:"} + label for = "position" {(tr("demon-position-dialog.position-field")) } input name = "position" type = "number" min = "1" required = ""; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = "Edit"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = (tr("demon-position-dialog.submit")); } } } @@ -241,20 +241,20 @@ fn change_video_dialog() -> Markup { div.dialog #demon-video-dialog { span.plus.cross.hover {} h2.underlined.pad { - "Change verification video link:" + (tr("demon-video-dialog")) } p style = "max-width: 400px"{ - "Change the verification video link for this record. Leave empty to remove the verification video." + (tr("demon-video-dialog.info")) } form.flex.col novalidate = "" { p.info-red.output {} p.info-green.output {} span.form-input #demon-video-edit { - label for = "video" {"Video link:"} + label for = "video" {(tr("demon-video-dialog.video-field")) } input name = "video" type = "url"; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = "Edit"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = (tr("demon-video-dialog.submit")); } } } @@ -267,24 +267,30 @@ fn change_thumbnail_dialog() -> Markup { div.dialog #demon-thumbnail-dialog { span.plus.cross.hover {} h2.underlined.pad { - "Change thumbnail link:" + (tr("demon-thumbnail-dialog")) } p style = "max-width: 400px"{ - "Change the thumbnail link for this record. To link it to the thumbnail of a youtube video, set it to " - i { - "https://i.ytimg.com/vi/" b{"VIDEO_ID"} "/mqdefault.jpg" - } - "." + (PreEscaped(trp!( + "demon-thumbnail-dialog.info", + ( + "video-id", + html! { + "https://i.ytimg.com/vi/" + i { (tr("demon-thumbnail-dialog.info-videoid")) } + "/mqdefault.jpg" + }.into_string() + ), + ))) } form.flex.col novalidate = "" { p.info-red.output {} p.info-green.output {} span.form-input #demon-thumbnail-edit { - label for = "thumbnail" {"Thumbnail link:"} + label for = "thumbnail" {(tr("demon-thumbnail-dialog.thumbnail-field")) } input required="" name = "thumbnail" type = "url"; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = "Edit"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = (tr("demon-thumbnail-dialog.submit")); } } } @@ -295,9 +301,9 @@ fn change_verifier_dialog() -> Markup { player_selection_dialog( "demon-verifier-dialog", "demon-verifier-edit", - "Change demon verifier:", - "Type the new verifier of the demon into the text field below. If the player already exists, it will appear as a suggestion below the text field. Then click the button below.", - "Edit", + &tr("demon-verifier-dialog"), + &tr("demon-verifier-dialog.info"), + &tr("demon-verifier-dialog.submit"), "verifier", ) } @@ -306,10 +312,10 @@ fn change_publisher_dialog() -> Markup { player_selection_dialog( "demon-publisher-dialog", "demon-publisher-edit", - "Change demon publisher:", - "Type the new publisher of the demon into the text field below. If the player already exists, it will appear as a suggestion below the text field. Then click the button below.", - "Edit", - "publisher" + &tr("demon-publisher-dialog"), + &tr("demon-publisher-dialog.info"), + &tr("demon-publisher-dialog.submit"), + "publisher", ) } @@ -317,10 +323,10 @@ fn add_creator_dialog() -> Markup { player_selection_dialog( "demon-add-creator-dialog", "demon-creator-add", - "Add creator:", - "Type the creator to add to this demon into the text field below. If the player already exists, it will appear as a suggestion below the text field. Then click the button below.", - "Add Creator", - "creator" + &tr("demon-creator-dialog"), + &tr("demon-creator-dialog.info"), + &tr("demon-creator-dialog.submit"), + "creator", ) } @@ -331,64 +337,64 @@ fn demon_submitter() -> Markup { div.flex { form #demon-submission-form novalidate = "" { div.underlined { - h2 {"Add demon:"} + h2 {(tr("demon-add-form")) } } p.info-red.output {} p.info-green.output {} span.form-input.flex.col #demon-add-name { label for = "name" { - "Demon name:" + (tr("demon-add-form.name-field")) } input type = "text" name = "name" required=""; p.error {} } span.form-input.flex.col #demon-add-level-id { label for = "level_id" { - "Geometry Dash Level ID:" + (tr("demon-add-form.levelid-field")) } input type = "number" name = "level_id" min = "1"; p.error {} } span.form-input.flex.col #demon-add-position { label for = "position" { - "Position:" + (tr("demon-add-form.position-field")) } input type = "number" name = "position" required="" min="1"; p.error {} } span.form-input.flex.col #demon-add-requirement { label for = "requirement" { - "Requirement:" + (tr("demon-add-form.requirement-field")) } input type = "number" name = "requirement" required="" min="0" max = "100"; p.error {} } span.form-input.flex.col data-type = "dropdown" { - label{"Verifier:"} + label{(tr("demon-add-form.verifier-field")) } br; (player_selection_dropdown("demon-add-verifier", "/api/v1/players/", "name", "verifier")) p.error {} } span.form-input.flex.col data-type = "dropdown" { - label {"Publisher:"} + label {(tr("demon-add-form.publisher-field")) } br; (player_selection_dropdown("demon-add-publisher", "/api/v1/players/", "name", "publisher")) p.error {} } span.form-input.flex.col #demon-add-video { label for = "video" { - "Verification Video:" + (tr("demon-add-form.video-field")) } input type = "url" name = "video"; p.error {} } span { i.fa.fa-plus.clickable #add-demon-add-creator-pen aria-hidden = "true" {} i { - " Creators: " + " " (tr("demon-add-form.creators-field")) } span #demon-add-creators {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Add Demon"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("demon-add-form.submit")) ; } } } diff --git a/pointercrate-demonlist-pages/src/account/list_integration.rs b/pointercrate-demonlist-pages/src/account/list_integration.rs index 8f7a693c7..1a3324357 100644 --- a/pointercrate-demonlist-pages/src/account/list_integration.rs +++ b/pointercrate-demonlist-pages/src/account/list_integration.rs @@ -1,7 +1,7 @@ use crate::components::P; use log::error; use maud::{html, Markup, PreEscaped}; -use pointercrate_core::{error::PointercrateError, permission::PermissionsManager}; +use pointercrate_core::{error::PointercrateError, localization::tr, permission::PermissionsManager, trp}; use pointercrate_core_pages::{ error::ErrorFragment, util::{filtered_paginator, paginator}, @@ -33,7 +33,7 @@ impl AccountPageTab for ListIntegrationTab { fn tab(&self) -> Markup { html! { b { - "List Integration" + (tr("list-integration")) } (PreEscaped("  ")) i class = "fa fa-list fa-2x" aria-hidden="true" {} @@ -65,7 +65,7 @@ impl AccountPageTab for ListIntegrationTab { span style = "font-size: 1.3em" { i.fa.fa-pencil-alt.clickable #player-claim-pen aria-hidden = "true" {} (PreEscaped(" ")) b { - "Claimed Player: " + (tr("claimed-player")) ": " } @match player_claim { Some(ref claim) => { @@ -82,11 +82,11 @@ impl AccountPageTab for ListIntegrationTab { @match player_claim { Some(ref claim) if claim.verified => { i style="margin-right: 15px;" { - "Verified" + (tr("claimed-player.verified")) } span.arrow.hover {} }, - Some(_) => i{"Unverified"}, + Some(_) => i{(tr("claimed-player.unverified"))}, _ => {} } } @@ -99,21 +99,27 @@ impl AccountPageTab for ListIntegrationTab { p.info-green.output style = "margin: 10px 0" {} div.flex.no-stretch style="justify-content: space-between; align-items: center" { b { - "Geolocate statsviewer flag:" + (tr("claim-geolocate")) } a.button.blue.hover #claims-geolocate-nationality { - "Go" + (tr("claim-geolocate.submit")) } } p { - "Clicking the above button let's you set your claimed player's statsviewer flag via IP Geolocation. To offer this functionality, pointercrate uses " - a.link href = "https://www.abstractapi.com/ip-geolocation-api" { "abstract's IP geolocation API"} - ". Clicking the above button also counts as your consent for pointercrate to send your IP to abstract." + (PreEscaped(trp!( + "claim-geolocate.info", + ( + "info-api-link", + html! { + a.link href = "https://www.abstractapi.com/ip-geolocation-api" { (tr("claim-geolocate.info-api-link")) } + }.into_string() + ) + ))) } } div.cb-container.flex.no-stretch style="justify-content: space-between; align-items: center" { b { - "Lock Submissions:" + (tr("claim-lock-submissions")) } @if claim.lock_submissions { input #lock-submissions-checkbox type = "checkbox" name = "lock_submissions" checked = ""; @@ -124,7 +130,7 @@ impl AccountPageTab for ListIntegrationTab { span.checkmark {} } p { - "Whether submissions for your claimed player should be locked, meaning only you will be able to submit records for your claimed player (and only while logged in to this account holding the verified claim)" + (tr("claim-lock-submissions.info")) } } } @@ -134,14 +140,35 @@ impl AccountPageTab for ListIntegrationTab { @if claim.verified { div.panel.fade { h2.pad.underlined { - "Your claimed player's records" + (tr("claim-records")) } p { - "A list of your claimed player's records, including all under consideration and rejected records and all submissions. Use this to track the status of your submissions. Clicking on a record will pull up any public notes a list mod left on the given record. The background color of each record tells you whether the record is " - span.ok { "Approved" } ", " - span.warn { "Unchecked" } ", " - span.err { "Rejected" } " or " - span.consider { "Under Consideration" } "." + (PreEscaped(trp!( + "claim-records.info", + ( + "record-approved-styled", + html! { + span.ok { (tr("record-approved")) } + }.into_string() + ),( + "record-submitted-styled", + html! { + span.warn { (tr("record-submitted")) } + }.into_string() + ), + ( + "record-rejected-styled", + html! { + span.err { (tr("record-rejected")) } + }.into_string() + ), + ( + "record-underconsideration-styled", + html! { + span.consider { (tr("record-underconsideration")) } + }.into_string() + ) + ))) } (paginator("claims-record-pagination", "/api/v1/records/")) } @@ -150,16 +177,16 @@ impl AccountPageTab for ListIntegrationTab { @if is_moderator { div.panel.fade { h2.pad.underlined { - "Manage Claims" + (tr("claim-manager")) } p { - "Manage claims using the interface below. The list can be filtered by player and user using the panels on the right. Invalid claims should be deleted using the trash icon. " + (tr("claim-manager.info-a")) br; - "To verify a claim, click the checkmark. Only verify claims you have verified to be correct (this will probably mean talking to the player that's being claimed, and asking if they initiated the claim themselves, or if the claim is malicious)." + (tr("claim-manager.info-b")) br; - "Once a claim on a player is verified, all other unverified claims on that player are auto-deleted. Users cannot put new, unverified claims on players that have a verified claim on them." + (tr("claim-manager.info-c")) br; - "A claim with a green background is verified, a claim with a blue background is unverified/unchecked" + (tr("claim-manager.info-d")) } (filtered_paginator("claim-pagination", "/api/v1/players/claims/")) } @@ -168,32 +195,40 @@ impl AccountPageTab for ListIntegrationTab { div.right { div.panel.fade style = "display: none;"{ h2.underlined.pad { - "Initiate Claim" + (tr("claim-initiate-panel")) } p { - "Select the player you wish to claim below" + (tr("claim-initiate-panel.info")) } (filtered_paginator("claims-initiate-claim-pagination", "/api/v1/players/")) } div.panel.fade { h2.underlined.pad { - "Claiming 101" + (tr("claim-info-panel")) } p { - "Player claiming is the process of associated a demonlist player with a pointercrate user account. A verified claim allows you to to modify some of the player's properties, such as nationality. " + (tr("claim-info-panel.info-a")) br; - "To initiate a claim, click the pen left of the 'Claimed Player' heading. Once initiated, you have an unverified claim on a player. These claims will then be manually verified by members of the pointercrate team. You can request verification in " a.link href=(self.0) {"this discord server"} "." + (PreEscaped(trp!( + "claim-info-panel.info-b", + ( + "discord", + html! { + a.link href = (&self.0) { (tr("claim-info-panel.info-discord")) } + }.into_string() + ) + ))) br; - "You cannot initiate a claim on a player that already has a verified claim by a different user on it. " + (tr("claim-info-panel.info-c")) } } @if is_moderator { div.panel.fade { h2.underlined.pad { - "Record video" + (tr("claim-video-panel")) } p { - "Clicking a claim in the 'Manage Claims' panel will pull up a random video of an approved record by the claimed player." + (tr("claim-video-panel.info")) } iframe."ratio-16-9"#claim-video style="width:100%;" allowfullscreen="" {} } diff --git a/pointercrate-demonlist-pages/src/account/players.rs b/pointercrate-demonlist-pages/src/account/players.rs index fc46a0a2f..9c6e8b394 100644 --- a/pointercrate-demonlist-pages/src/account/players.rs +++ b/pointercrate-demonlist-pages/src/account/players.rs @@ -1,5 +1,5 @@ use maud::{html, Markup, PreEscaped}; -use pointercrate_core::{error::PointercrateError, permission::PermissionsManager}; +use pointercrate_core::{error::PointercrateError, localization::tr, permission::PermissionsManager}; use pointercrate_core_pages::{error::ErrorFragment, util::filtered_paginator}; use pointercrate_demonlist::{nationality::Nationality, LIST_MODERATOR}; use pointercrate_user::auth::{AuthenticatedUser, NonMutating}; @@ -25,7 +25,7 @@ impl AccountPageTab for PlayersPage { fn tab(&self) -> Markup { html! { b { - "Players" + (tr("players")) } (PreEscaped("  ")) i class = "fa fa-beer fa-2x" aria-hidden="true" {} @@ -51,30 +51,30 @@ impl AccountPageTab for PlayersPage { div.left { div.panel.fade style = "overflow: initial"{ h2.underlined.pad { - "Player Manager" + (tr("player-manager")) } div.flex.viewer { (filtered_paginator("player-pagination", "/api/v1/players/")) p.viewer-welcome { - "Click on a player on the left to get started!" + (tr("player-viewer.welcome")) } div.viewer-content { div.flex.col{ h3 style = "font-size:1.1em; margin: 10px 0" { - "Player #" + (tr("player-viewer")) i #player-player-id {} " - " i.fa.fa-pencil-alt.clickable #player-name-pen aria-hidden = "true" {} (PreEscaped(" ")) i #player-player-name {} } p { - "Welcome to the player manager. Here you can ban or unban players. Banning a player will delete all records of theirs which are in the submitted or under consideration state. All approved records will instead be set to rejected." + (tr("player-viewer.info")) } p.info-red.output style = "margin: 10px" {} p.info-green.output style = "margin: 10px" {} div.stats-container.flex.space { span { b { - "Banned:" + (tr("player-banned")) } br; div.dropdown-menu.js-search #edit-player-banned style = "max-width: 50px" { @@ -83,19 +83,19 @@ impl AccountPageTab for PlayersPage { } div.menu { ul { - li.white.hover data-value="true" {"yes"} - li.white.hover data-value="false" {"no"} + li.white.hover data-value="true" {(tr("player-banned.yes"))} + li.white.hover data-value="false" {(tr("player-banned.no"))} } } } } span { b { - "Nationality:" + (tr("player-nationality")) } br; p { - "Note that this is to be understood as 'Country of legal residency' and nothing else. No exceptions. " + (tr("player-nationality.info")) } div.dropdown-menu.js-search #edit-player-nationality data-default = "None" { div { @@ -103,7 +103,7 @@ impl AccountPageTab for PlayersPage { } div.menu { ul { - li.white.hover.underlined data-value = "None" {"None"} + li.white.hover.underlined data-value = "None" {(tr("player-nationality.none")) } @for nation in nationalities { li.white.hover data-value = {(nation.iso_country_code)} data-display = {(nation.nation)} { span class = "flag-icon" style={"background-image: url(/static/demonlist/images/flags/" (nation.iso_country_code.to_lowercase()) ".svg"} {} @@ -121,7 +121,7 @@ impl AccountPageTab for PlayersPage { div.stats-container.flex.space { span { b { - "Political Subdivision:" + (tr("player-subdivision")) } br; div.dropdown-menu.js-search #edit-player-subdivision data-default = "None" { @@ -130,13 +130,13 @@ impl AccountPageTab for PlayersPage { } div.menu { ul { - li.white.hover.underlined data-value = "None" {"None"} + li.white.hover.underlined data-value = "None" {(tr("player-subdivision.none")) } } } } } } - span.button.blue.hover #player-list-records style = "margin: 15px auto 0px" {"Show records in record manager"}; + span.button.blue.hover #player-list-records style = "margin: 15px auto 0px" {(tr("player-viewer.records-redirect")) }; } } } @@ -155,19 +155,19 @@ fn player_selector() -> Markup { html! { div.panel.fade { h2.underlined.pad { - "Search player by ID" + (tr("player-idsearch-panel")) } p { - "Players can be uniquely identified by ID. Entering a players's ID below will select it on the left (provided the player exists)" + (tr("player-idsearch-panel.info")) } form.flex.col #player-search-by-player-id-form novalidate = "" { p.info-red.output {} span.form-input #search-player-id { - label for = "id" {"Player ID:"} + label for = "id" {(tr("player-idsearch-panel.id-field")) } input required = "" type = "number" name = "id" min = "0" style="width:93%"; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Find by ID"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("player-idsearch-panel.submit")); } } } @@ -179,20 +179,20 @@ fn change_name_dialog() -> Markup { div.dialog #player-name-dialog { span.plus.cross.hover {} h2.underlined.pad { - "Change player name:" + (tr("player-name-dialog")) } p style = "max-width: 400px"{ - "Change the name of this player. This will update their name on every one of their records. If a player with the new name already exists, the player objects will be merged, with the new object receiving the ID of the player you are currently editing. In this case, the record lists of the players are merged and their creator/verifier/publisher information is updated. Internally, each record is moved to to the new player, an on conflicts the same rules apply as when editing a record's holder." + (tr("player-name-dialog.info")) } form.flex.col novalidate = "" { p.info-red.output {} p.info-green.output {} span.form-input #player-name-edit { - label for = "name" {"Name:"} + label for = "name" {(tr("player-name-dialog.name-field")) } input name = "name" type = "text" required = ""; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = "Edit"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = (tr("player-name-dialog.submit")); } } } diff --git a/pointercrate-demonlist-pages/src/account/records.rs b/pointercrate-demonlist-pages/src/account/records.rs index 1bacb0181..2558aa281 100644 --- a/pointercrate-demonlist-pages/src/account/records.rs +++ b/pointercrate-demonlist-pages/src/account/records.rs @@ -3,7 +3,7 @@ use crate::components::{ submitter::{submit_panel, RecordSubmitter}, }; use maud::{html, Markup, PreEscaped}; -use pointercrate_core::{error::PointercrateError, permission::PermissionsManager}; +use pointercrate_core::{error::PointercrateError, localization::tr, permission::PermissionsManager, trp}; use pointercrate_core_pages::{ error::ErrorFragment, util::{dropdown, paginator}, @@ -35,7 +35,7 @@ impl AccountPageTab for RecordsPage { fn tab(&self) -> Markup { html! { b { - "Records" + (tr("records")) } (PreEscaped("  ")) i class = "fa fa-trophy fa-2x" aria-hidden="true" {} @@ -64,7 +64,7 @@ impl AccountPageTab for RecordsPage { (note_adder()) div.panel.fade #record-notes-container style = "display:none" { div.white.hover.clickable #add-record-note-open { - b {"Add Note"} + b {(tr("record-note"))} } div #record-notes {} // populated by javascript when a record is clicked } @@ -88,22 +88,22 @@ fn record_manager(demons: &[Demon]) -> Markup { html! { div.panel.fade #record-manager { h2.underlined.pad { - "Record Manager - " + (tr("record-manager")) " - " (dropdown("All", html! { li.white.hover.underlined data-value = "All" - {"All Demons"} - }, demons.iter().map(|demon| html!(li.white.hover data-value = (demon.base.id) data-display = (demon.base.name) {b{"#"(demon.base.position) " - " (demon.base.name)} br; {"by "(demon.publisher.name)}})))) + {(tr("record-manager.all-option"))} + }, demons.iter().map(|demon| html!(li.white.hover data-value = (demon.base.id) data-display = (demon.base.name) {b{"#"(demon.base.position) " - " (demon.base.name)} br; {(trp!("demon-listed.publisher", ("publisher", demon.publisher.name)))}})))) } div.flex.viewer { (paginator("record-pagination", "/api/v1/records/")) p.viewer-welcome { - "Click on a record on the left to get started!" + (tr("record-viewer.welcome")) } div.viewer-content { div.flex.col { h3 style = "font-size:1.1em; margin-top: 10px" { i.fa.fa-clipboard.clickable #record-copy-info aria-hidden = "true" {} - " Record #" + " " (tr("record-viewer")) i #record-id {} " - " div.dropdown-menu.js-search #edit-record-status style = "max-width: 220px" { @@ -112,10 +112,10 @@ fn record_manager(demons: &[Demon]) -> Markup { } div.menu { ul { - li.white.hover data-value="approved" {"Approved"} - li.white.hover data-value="rejected" {"Rejected"} - li.white.hover data-value="under consideration" {"Under Consideration"} - li.white.hover data-value="submitted" {"Submitted"} + li.white.hover data-value="approved" {(tr("record-approved"))} + li.white.hover data-value="rejected" {(tr("record-rejected"))} + li.white.hover data-value="under consideration" {(tr("record-underconsideration"))} + li.white.hover data-value="submitted" {(tr("record-submitted"))} } } } @@ -127,7 +127,7 @@ fn record_manager(demons: &[Demon]) -> Markup { div.stats-container.flex.space { span { b { - i.fa.fa-pencil-alt.clickable #record-video-pen aria-hidden = "true" {} " Video Link:" + i.fa.fa-pencil-alt.clickable #record-video-pen aria-hidden = "true" {} " " (tr("record-videolink")) } br; a.link #record-video-link target = "_blank" {} @@ -135,7 +135,7 @@ fn record_manager(demons: &[Demon]) -> Markup { } div.stats-container.flex.space { span { - b { "Raw Footage:" } + b { (tr("record-rawfootage")) } br; a.link #record-raw-footage-link target = "_blank" {} } @@ -143,14 +143,14 @@ fn record_manager(demons: &[Demon]) -> Markup { div.stats-container.flex.space { span { b { - i.fa.fa-pencil-alt.clickable #record-demon-pen aria-hidden = "true" {} " Demon:" + i.fa.fa-pencil-alt.clickable #record-demon-pen aria-hidden = "true" {} " " (tr("record-demon")) } br; span #record-demon {} } span { b { - i.fa.fa-pencil-alt.clickable #record-holder-pen aria-hidden = "true" {} " Record Holder:" + i.fa.fa-pencil-alt.clickable #record-holder-pen aria-hidden = "true" {} " " (tr("record-holder")) } br; span #record-holder {} @@ -159,20 +159,20 @@ fn record_manager(demons: &[Demon]) -> Markup { div.stats-container.flex.space { span { b { - i.fa.fa-pencil-alt.clickable #record-progress-pen aria-hidden = "true" {} " Progress:" + i.fa.fa-pencil-alt.clickable #record-progress-pen aria-hidden = "true" {} " " (tr("record-progress")) } br; span #record-progress {} } span { b { - "Submitter ID:" + (tr("record-submitter")) } br; span #record-submitter {} } } - span.button.red.hover #record-delete style = "margin: 15px auto 0px" {"Delete Record"}; + span.button.red.hover #record-delete style = "margin: 15px auto 0px" {(tr("record-viewer.delete"))}; } } @@ -185,39 +185,35 @@ fn manager_help() -> Markup { html! { div.panel.fade { h1.underlined.pad { - "Manage Records" + (tr("record-manager-help")) } p { - "Use the list on the left to select records for editing/viewing. Use the panel on the right to filter the record list by status, player, etc.. Clicking the 'All Demons' field at the top allows to filter by demon." + (tr("record-manager-help.a")) } p { - "There are four possible record states a record can be in: " i { "'rejected', 'approved', 'submitted'" } " and " i { "'under consideration'" } ". For simplicity of explanation we will assume that 'Bob' is a player and 'Cataclysm' is a demon he has a record on." + (PreEscaped(tr("record-manager-help.b"))) ul { li { - b{"Rejected: "} "If the record is 'rejected', it means that Bob has no other record in other states on Cataclysm and no submissions for Bob on Cataclysm are possible. Conversely, this means if Bob has a record on Catalysm that's not rejected, we immediately know that no rejected record for Bob on Cataclysm exists. " - br; - "Rejecting any record of Bob's on Cataclysm will delete all other record's of Bob on Cataclysm to ensure the above uniqueness" + b {(tr("record-rejected")) ": "} (PreEscaped(tr("record-manager-help.rejected"))) } li { - b{"Approved: "} "If the record is 'approved', it means that no submissions with less progress than the 'approved' record exist or are permitted." - br; - "Changing a record to 'approved' will delete all submissions for Bob on Cataclysm with less progress" + b {(tr("record-approved")) ": "} (PreEscaped(tr("record-manager-help.approved"))) } li { - b {"Submitted: "} "If the record is 'submitted', no further constraints on uniqueness are in place. This means that multiple submissions for Bob on Cataclysm are possible, as long as they provide different video links. However, due to the above, all duplicates are deleted as soon as one of the submissions is accepted or rejected" + b {(tr("record-submitted")) ": "} (PreEscaped(tr("record-manager-help.submitted"))) } li { - b {"Under Consideration: "} "If the record is 'under consideration' it is conceptually still a submission. The only difference is, that no more submissions for Bob on Cataclysm are allowed now." + b {(tr("record-underconsideration")) ": "} (PreEscaped(tr("record-manager-help.underconsideration"))) } } } p { - b { "Note: " } - "If a player is banned, they cannot have accepted/submitted records on the list. All records marked as 'submitted' are deleted, all others are changed to 'rejected'" + b { (tr("record-manager-help.note")) ": " } + (PreEscaped(tr("record-manager-help.note-a"))) } p { - b { "Note: " } - "Banning a submitter will delete all their submissions that still have the status 'Submitted'. Records submitted by them that were already accepted/rejected will not be affected" + b { (tr("record-manager-help.note")) ": " } + (PreEscaped(tr("record-manager-help.note-b"))) } } } @@ -227,29 +223,29 @@ fn status_selector() -> Markup { // FIXME: no vec let dropdown_items = vec![ html! { - li.white.hover data-value = "approved" {"Approved"} + li.white.hover data-value = "approved" {(tr("record-approved"))} }, html! { - li.white.hover data-value = "submitted" {"Submitted"} + li.white.hover data-value = "submitted" {(tr("record-submitted"))} }, html! { - li.white.hover data-value = "rejected" {"Rejected"} + li.white.hover data-value = "rejected" {(tr("record-rejected"))} }, html! { - li.white.hover data-value = "under consideration" {"Under Consideration"} + li.white.hover data-value = "under consideration" {(tr("record-underconsideration"))} }, ]; html! { div.panel.fade #status-filter-panel style = "overflow: visible" { h2.underlined.pad { - "Filter" + (tr("record-status-filter-panel")) } p { - "Filter by record status" + (tr("record-status-filter-panel.info")) } (dropdown("All", html! { - li.white.hover.underlined data-value = "All" {"All"} + li.white.hover.underlined data-value = "All" {(tr("record-status-filter-all"))} }, dropdown_items.into_iter())) } } @@ -259,28 +255,28 @@ fn player_selector() -> Markup { html! { div.panel.fade { h2.underlined.pad { - "Filter by player" + (tr("record-playersearch-panel")) } p { - "Players can be uniquely identified by name and ID. Entering either in the appropriate place below will filter the view on the left. Reset by clicking \"Find ...\" when the text field is empty." + (tr("record-playersearch-panel.info")) } form.flex.col.underlined.pad #record-filter-by-player-id-form novalidate = "" { p.info-red.output {} span.form-input #record-player-id { - label for = "id" {"Player ID:"} + label for = "id" {(tr("record-playersearch-panel.id-field")) } input required = "" type = "number" name = "id" min = "0" style="width:93%"; // FIXME: I have no clue why the input thinks it's a special snowflake and fucks up its width, but I dont have the time to fix it p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Find by ID"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("record-playersearch-panel.id-submit")); } form.flex.col #record-filter-by-player-name-form novalidate = "" { p.info-red.output {} span.form-input #record-player-name { - label for = "name" {"Player name:"} + label for = "name" {(tr("record-playersearch-panel.name-field")) } input required = "" type = "text" name = "name"; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Find by name"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("record-playersearch-panel.name-submit")); } } } @@ -290,19 +286,19 @@ fn record_selector() -> Markup { html! { div.panel.fade { h2.underlined.pad { - "Search record by ID" + (tr("record-idsearch-panel")) } p { - "Records can be uniquely identified by ID. Entering a record's ID below will select it on the left (provided the record exists)" + (tr("record-idsearch-panel.info")) } form.flex.col #record-search-by-record-id-form novalidate = "" { p.info-red.output {} span.form-input #record-record-id { - label for = "id" {"Record ID:"} + label for = "id" {(tr("record-idsearch-panel.id-field")) } input required = "" type = "number" name = "id" min = "0" style="width:93%"; // FIXME: I have no clue why the input thinks it's a special snowflake and fucks up its width, but I dont have the time to fix it p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Find by ID"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("record-idsearch-panel.submit")); } } } @@ -314,18 +310,18 @@ fn note_adder() -> Markup { span.plus.cross.hover {} div style="display: flex;align-items: center;justify-content: space-between;" { div.button.blue.hover.small style = "width: 100px; margin-bottom: 10px"{ - "Add" + (tr("record-note.submit")) } div.cb-container.flex.no-stretch style="justify-content: space-between; align-items: center" { b { - "Public note:" + (tr("record-note.public-checkbox")) } input #add-note-is-public-checkbox type = "checkbox" name = "is_public"; span.checkmark {} } } p.info-red.output {} - textarea style = "width: 100%" placeholder = "Add note here. Click 'Add' above when done!"{} + textarea style = "width: 100%" placeholder = (tr("record-note.placeholder")) {} } } } @@ -336,20 +332,20 @@ fn change_progress_dialog() -> Markup { div.dialog #record-progress-dialog { span.plus.cross.hover {} h2.underlined.pad { - "Change record progress:" + (tr("record-progress-dialog")) } p style = "max-width: 400px"{ - "Change the progress value of this record. Has to be between the demon's record requirement and 100 (inclusive)." + (tr("record-progress-dialog.info")) } form.flex.col novalidate = "" { p.info-red.output {} p.info-green.output {} span.form-input #record-progress-edit { - label for = "progress" {"Progress:"} + label for = "progress" {(tr("record-progress-dialog.progress-field")) } input name = "progress" type = "number" min = "0" max="100" required = ""; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = "Edit"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = (tr("record-progress-dialog.submit")); } } } @@ -362,20 +358,20 @@ fn change_video_dialog() -> Markup { div.dialog #record-video-dialog { span.plus.cross.hover {} h2.underlined.pad { - "Change video link:" + (tr("record-videolink-dialog")) } p style = "max-width: 400px"{ - "Change the video link for this record. Note that as a list mod, you can leave the text field empty to remove the video from this record." + (tr("record-videolink-dialog.info")) } form.flex.col novalidate = "" { p.info-red.output {} p.info-green.output {} span.form-input #record-video-edit { - label for = "video" {"Video link:"} + label for = "video" {(tr("record-videolink-dialog.videolink-field")) } input name = "video" type = "url"; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = "Edit"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = (tr("record-videolink-dialog.submit")); } } } @@ -386,10 +382,10 @@ fn change_holder_dialog() -> Markup { player_selection_dialog( "record-holder-dialog", "_edit-holder-record", - "Change record holder:", - "Type the new holder of the record into the text field below. If the player already exists, it will appear as a suggestion below the text field. Then click the button below.", - "Edit", - "player" + &tr("record-holder-dialog"), + &tr("record-holder-dialog.info"), + &tr("record-holder-dialog.submit"), + "player", ) } @@ -399,11 +395,11 @@ fn change_demon_dialog(demons: &[Demon]) -> Markup { div.dialog #record-demon-dialog style="overflow: initial;" { span.plus.cross.hover {} h2.underlined.pad { - "Change record demon:" + (tr("record-demon-dialog")) } div.flex.col { p { - "Change the demon associated with this record. Search up the demon this record should be associated with below. Then click it to modify the record" + (tr("record-videolink-dialog.info")) } (demon_dropdown("edit-demon-record", demons.iter())) } diff --git a/pointercrate-demonlist-pages/src/account/submitters.rs b/pointercrate-demonlist-pages/src/account/submitters.rs index dc6d3b8b1..c2218688d 100644 --- a/pointercrate-demonlist-pages/src/account/submitters.rs +++ b/pointercrate-demonlist-pages/src/account/submitters.rs @@ -1,5 +1,5 @@ use maud::{html, Markup, PreEscaped}; -use pointercrate_core::permission::PermissionsManager; +use pointercrate_core::{localization::tr, permission::PermissionsManager}; use pointercrate_core_pages::util::paginator; use pointercrate_demonlist::LIST_MODERATOR; use pointercrate_user::auth::{AuthenticatedUser, NonMutating}; @@ -25,7 +25,7 @@ impl AccountPageTab for SubmittersPage { fn tab(&self) -> Markup { html! { b { - "Submitters" + (tr("submitters")) } (PreEscaped("  ")) i class = "fa fa-eye fa-2x" aria-hidden="true" {} @@ -39,31 +39,31 @@ impl AccountPageTab for SubmittersPage { div.left { div.panel.fade { h2.underlined.pad { - "Submitter Manager" + (tr("submitter-manager")) } div.flex.viewer { (paginator("submitter-pagination", "/api/v1/submitters/")) p.viewer-welcome { - "Click on a submitter on the left to get started!" + (tr("submitter-viewer.welcome")) } div.viewer-content { div.flex.col{ h3 style = "font-size:1.1em; margin: 10px 0" { - "Submitter #" + (tr("submitter-viewer")) i #submitter-submitter-id {} } p { - "Welcome to the submitter manager. Here you can ban or unban submitters with an absolute revolutionary UI that totally isn't a stright up copy of the player UI, just with even more emptiness. " + (tr("submitter-viewer.info-a")) } p { - "Banning a submitter will delete all records they have submitted and which are still in the 'submitted' state. All submissions of their which are approved, rejected or under consideration are untouched. " + (tr("submitter-viewer.info-b")) } p.info-red.output style = "margin: 10px" {} p.info-green.output style = "margin: 10px" {} div.stats-container.flex.space { span { b { - "Banned:" + (tr("submitter-banned")) } br; div.dropdown-menu.js-search #edit-submitter-banned style = "max-width: 50px" { @@ -72,14 +72,14 @@ impl AccountPageTab for SubmittersPage { } div.menu { ul { - li.white.hover data-value="true" {"yes"} - li.white.hover data-value="false" {"no"} + li.white.hover data-value="true" {(tr("submitter-banned.yes"))} + li.white.hover data-value="false" {(tr("submitter-banned.no"))} } } } } } - span.button.blue.hover #submitter-list-records style = "margin: 15px auto 0px" {"Show records in record manager"}; + span.button.blue.hover #submitter-list-records style = "margin: 15px auto 0px" {(tr("submitter-viewer.records-redirect"))}; } } } @@ -97,19 +97,19 @@ fn submitter_selector() -> Markup { html! { div.panel.fade { h2.underlined.pad { - "Search submitter by ID" + (tr("submitter-idsearch-panel")) } p { - "Submitters can be uniquely identified by ID. Entering a submitters's ID below will select it on the left (provided the submitter exists)" + (tr("submitter-idsearch-panel.info")) } form.flex.col #submitter-search-by-id-form novalidate = "" { p.info-red.output {} span.form-input #search-submitter-id { - label for = "id" {"Submitter ID:"} + label for = "id" {(tr("submitter-idsearch-panel.id-field")) } input required = "" type = "number" name = "id" min = "0" style="width:93%"; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Find by ID"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("submitter-idsearch-panel.submit")); } } } diff --git a/pointercrate-demonlist-pages/src/components/mod.rs b/pointercrate-demonlist-pages/src/components/mod.rs index 60c2073f4..72424fcc7 100644 --- a/pointercrate-demonlist-pages/src/components/mod.rs +++ b/pointercrate-demonlist-pages/src/components/mod.rs @@ -1,6 +1,7 @@ //! Module containing various UI components that are used across a variety of demonlist pages use maud::{html, Markup, Render}; +use pointercrate_core::{localization::tr, trp}; use pointercrate_demonlist::demon::Demon; use pointercrate_demonlist::player::DatabasePlayer; @@ -17,7 +18,7 @@ pub fn demon_dropdown<'a>(dropdown_id: &str, demons: impl Iterator { @@ -7,8 +8,8 @@ pub struct RecordSubmitter<'a> { demons: &'a [Demon], } -impl RecordSubmitter<'_> { - pub fn new(visible: bool, demons: &[Demon]) -> RecordSubmitter { +impl<'a> RecordSubmitter<'a> { + pub fn new(visible: bool, demons: &'a [Demon]) -> RecordSubmitter<'a> { RecordSubmitter { initially_visible: visible, demons, @@ -23,84 +24,92 @@ impl Render for RecordSubmitter<'_> { span.plus.cross.hover {} form #submission-form novalidate = "" { div.underlined { - h2 {"Record Submission"} + h2 { (tr("record-submission")) } } p.info-red.output {} p.info-green.output {} h3 { - "Demon:" + (tr("record-submission.demon")) } p { - "The demon the record was made on. Only demons in the top " (config::extended_list_size()) " are accepted. This excludes legacy demons!" + (trp!("record-submission.demon-info", ("list-size", config::extended_list_size()))) } span.form-input data-type = "dropdown" { (demon_dropdown("id_demon", self.demons.iter().filter(|demon| demon.base.position <= config::extended_list_size()))) p.error {} } h3 { - "Holder:" + (tr("record-submission.holder")) } p { - "The player holding the record. Start typing to see suggestions of existing players. If this is your first submission, write your name, as you wish it to appear on the website, into the text field (ignoring any suggestions)." + (tr("record-submission.holder-info")) } span.form-input.flex.col data-type = "dropdown" { (player_selection_dropdown("id_player", "/api/v1/players/", "name", "player")) p.error {} } h3 { - "Progress:" + (tr("record-submission.progress")) } p { - "The progress made as percentage. Only values greater than or equal to the demons record requirement and smaller than or equal to 100 are accepted!" + (tr("record-submission.progress-info")) } span.form-input.flex.col #id_progress { - input type = "number" name = "progress" required="" placeholder = "e. g. '50', '98'" min="0" max="100"; + input type = "number" name = "progress" required="" placeholder = (tr("record-submission.progress-placeholder")) min="0" max="100"; p.error {} } h3 { - "Video: " + (tr("record-submission.video")) } p { - "A proof video of the legitimacy of the given record. If the record was achieved on stream, but wasn't uploaded anywhere else, please provide a twitch link to that stream." + (tr("record-submission.video-info")) br {} - i { "Note: " } - "Please pay attention to only submit well-formed URLs!" + i { (tr("record-submission.note")) ": " } + (tr("record-submission.video-note")) } span.form-input.flex.col #id_video { - input type = "url" name = "video" required = "" placeholder = "e.g. 'https://youtu.be/cHEGAqOgddA'" ; + input type = "url" name = "video" required = "" placeholder = (tr("record-submission.video-placeholder")) ; p.error {} } h3 { - "Raw footage: " + (tr("record-submission.raw-footage")) } p { - "The unedited and untrimmed video for this completion, uploaded to a non-compressing (e.g. not YouTube) file-sharing service such as google drive. If the record was achieved on stream (meaning there is no recording), please provide a link to the stream VOD" + (tr("record-submission.raw-footage-info-a")) } p { - "Any personal information possibly contained within raw footage (e.g. names, sensitive conversations) will be kept strictly confidential and will not be shared outside of the demonlist team. Conversely, you acknowledge that you might inadvertently share such information by providing raw footage. You have the right to request deletion of your record note by contacting a list administrator." + (tr("record-submission.raw-footage-info-b")) } p { - i {"Note: "} "This is required for every record submitted to the list!" + i { (tr("record-submission.note")) ": " } (tr("record-submission.raw-footage-note")) } span.form-input.flex.col #submit-raw-footage { input type = "url" name = "raw_footage" required = "" placeholder = "https://drive.google.com/file/d/.../view?usp=sharing" {} p.error {} } h3 { - "Notes or comments: " + (tr("record-submission.notes")) } p { - "Provide any additional notes you'd like to pass on to the list moderator receiving your submission." + (tr("record-submission.notes-info")) } span.form-input.flex.col #submit-note { - textarea name = "note" placeholder = "Your dreams and hopes for this record... or something like that" {} + textarea name = "note" placeholder = (tr("record-submission.notes-placeholder")) {} p.error {} } p { - "By submitting the record you acknowledge the " a.link href = "/guidelines" {"submission guidelines"} "." - } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Submit record"; + (PreEscaped(trp!( + "record-submission.guidelines", + ( + "guidelines-link", + html! { + a.link href = "/guidelines" { (tr("record-submission.guidelines-link")) } + }.into_string() + ) + ))) + } + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("record-submission.submit")); } } } @@ -112,14 +121,14 @@ pub(crate) fn submit_panel() -> Markup { section #submit.panel.fade.js-scroll-anim data-anim = "fade" { div.underlined { h2 { - "Submit Records" + (tr("record-submission-panel")) } } p { - "Note: Please do not submit nonsense, it only makes it harder for us all and will get you banned. Also note that the form rejects duplicate submissions." + (tr("record-submission-panel.info")) } a.blue.hover.button.js-scroll data-destination = "submitter" data-reveal = "true" { - "Submit a record!" + (tr("record-submission-panel.redirect")) } } } diff --git a/pointercrate-demonlist-pages/src/components/team.rs b/pointercrate-demonlist-pages/src/components/team.rs index 9fbf54172..43a9cf6c1 100644 --- a/pointercrate-demonlist-pages/src/components/team.rs +++ b/pointercrate-demonlist-pages/src/components/team.rs @@ -1,4 +1,5 @@ use maud::{html, Markup, Render}; +use pointercrate_core::localization::tr; use pointercrate_user::User; pub struct Team { @@ -26,11 +27,11 @@ impl Render for Team { section.panel.fade.js-scroll-anim #editors data-anim = "fade" { div.underlined { h2 { - "List Editors" + (tr("editors-panel")) } } p { - "Contact any of these people if you have problems with the list or want to see a specific thing changed." + (tr("editors-panel.info")) } ul style = "line-height: 30px" { @for admin in &self.admins { @@ -44,11 +45,11 @@ impl Render for Team { } div.underlined { h2 { - "List Helpers" + (tr("helpers-panel")) } } p { - "Contact these people if you have any questions regarding why a specific record was rejected. Do not needlessly bug them about checking submissions though!" + (tr("helpers-panel.info")) } ul style = "line-height: 30px" { @for helper in &self.helpers { diff --git a/pointercrate-demonlist-pages/src/components/time_machine.rs b/pointercrate-demonlist-pages/src/components/time_machine.rs index 41d65b4fa..b5383bd5f 100644 --- a/pointercrate-demonlist-pages/src/components/time_machine.rs +++ b/pointercrate-demonlist-pages/src/components/time_machine.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Datelike, FixedOffset}; use maud::{html, Markup, Render}; +use pointercrate_core::localization::tr; use pointercrate_demonlist::demon::TimeShiftedDemon; pub enum Tardis { @@ -51,7 +52,7 @@ impl Render for Tardis { Tardis::Activated { destination, show_destination, ..} if *show_destination => { div.panel.fade.blue.flex style="align-items: center;" { span style = "text-align: end"{ - "You are currently looking at the demonlist how it was on" + (tr("time-machine.active-info")) br; b { @match destination.day() { @@ -62,7 +63,7 @@ impl Render for Tardis { } } } - a.white.button href = "/demonlist/" onclick=r#"document.cookie = "when=""# style = "margin-left: 15px"{ b{"Go to present" }} + a.white.button href = "/demonlist/" onclick=r#"document.cookie = "when=""# style = "margin-left: 15px"{ b{ (tr("time-machine.return")) }} } }, _ => {} @@ -71,19 +72,19 @@ impl Render for Tardis { span.plus.cross.hover {} form #time-machine-form novalidate = "" { div.underlined { - h2 {"Time Machine"} + h2 { (tr("time-machine")) } } p { - "Enter the date you want to view the demonlist at below. For technical reasons, the earliest possible date is January 4th 2017. Note however that data before August 4th 2017 is only provided on a best-effort basis and not guaranteed to be 100% accurate. Particularly data from before April 4th 2017 contains significant errors!" + (tr("time-machine.info")) } div.flex { span.form-input #time-machine-destination data-type = "datetime-local" { - h3 {"Destination:"} + h3 { (tr("time-machine.destination-field")) } input name="time-machine-destination" type="datetime-local" min="2017-01-04T00:00" required; p.error {} } } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Go!"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("time-machine.submit")); } } } diff --git a/pointercrate-demonlist-pages/src/demon_page.rs b/pointercrate-demonlist-pages/src/demon_page.rs index 31d1690cf..5bb6b086f 100644 --- a/pointercrate-demonlist-pages/src/demon_page.rs +++ b/pointercrate-demonlist-pages/src/demon_page.rs @@ -7,7 +7,8 @@ use crate::{ statsviewer::stats_viewer_panel, }; use chrono::NaiveDateTime; -use maud::{html, Markup, PreEscaped}; +use maud::{html, Markup, PreEscaped, Render}; +use pointercrate_core::{localization::tr, trp}; use pointercrate_core_pages::{head::HeadLike, PageFragment}; use pointercrate_demonlist::{ config::{self as list_config, extended_list_size}, @@ -154,7 +155,7 @@ impl DemonPage { (self.demon_panel()) div.panel.fade.js-scroll-anim.js-collapse data-anim = "fade" { h2.underlined.pad { - "Position History" + (tr("movements")) span.arrow.hover #history-trigger {} } div.js-collapse-content style="display:none" { @@ -164,16 +165,16 @@ impl DemonPage { tbody #history-table-body { tr { th.blue { - "Date" + (tr("movements.date")) } th.blue { - "Change" + (tr("movements.change")) } th.blue { - "New Position" + (tr("movements-newposition")) } th.blue { - "Reason" + (tr("movements-reason")) } } } @@ -210,10 +211,17 @@ impl DemonPage { let verified_and_published = html! { @if self.data.demon.publisher == self.data.demon.verifier { - "verified and published by " (P(&self.data.demon.publisher, None)) + (PreEscaped(trp!( + "demon-headline.same-verifier-publisher", + ("publisher", P(&self.data.demon.publisher, None).render().into_string()) + ))) } @else { - "published by " (P(&self.data.demon.publisher, None)) ", verified by " (P(&self.data.demon.verifier, None)) + (PreEscaped(trp!( + "demon-headline.unique-verifier-publisher", + ("publisher", P(&self.data.demon.publisher, None).render().into_string()), + ("verifier", P(&self.data.demon.verifier, None).render().into_string()) + ))) } }; @@ -239,35 +247,57 @@ impl DemonPage { "#, self.data.demon.base.id))) h3 { - "by " @match &self.data.creators[..] { - [] => { "Unknown, " (verified_and_published) }, + [] => { (PreEscaped(trp!( + "demon-headline.no-creators", + ("verified-and-published", verified_and_published.into_string()) + ))) }, [creator] => { - (P(creator, None)) @if creator == &self.data.demon.publisher && creator == &self.data.demon.verifier { /* Nothing */ } @else if creator != &self.data.demon.publisher && creator != &self.data.demon.verifier { - ", " (verified_and_published) + (PreEscaped(trp!( + "demon-headline.one-creator", + ("creator", P(creator, None).render().into_string()), + ("verified-and-published", verified_and_published.into_string()) + ))) } @else if creator == &self.data.demon.publisher { - ", verified by " (P(&self.data.demon.verifier, None)) + (PreEscaped(trp!( + "demon-headline.one-creator-is-publisher", + ("creator", P(creator, None).render().into_string()), + ("verifier", P(&self.data.demon.verifier, None).render().into_string()) + ))) } @else { - ", published by " (P(&self.data.demon.publisher, None)) + (PreEscaped(trp!( + "demon-headline.one-creator-is-verifier", + ("creator", P(creator, None).render().into_string()), + ("publisher", P(&self.data.demon.publisher, None).render().into_string()) + ))) } }, [creator1, creator2] => { - (P(creator1, None)) " and " (P(creator2, None)) - ", " (verified_and_published) + (PreEscaped(trp!( + "demon-headline.two-creators", + ("creator1", P(creator1, None).render().into_string()), + ("creator2", P(creator2, None).render().into_string()), + ("verified-and-published", verified_and_published.into_string()) + ))) }, [creator1, rest @ ..] => { - (creator1.name) " and " - div.tooltip.underdotted { - "more" - div.tooltiptext.fade { - (rest.iter().map(|player| player.name.as_ref()).collect::>().join(", ")) - } - } - ", " (verified_and_published) + (PreEscaped(trp!( + "demon-headline.more-creators", + ("creator", P(creator1, None).render().into_string()), + ("more", html! { + div.tooltip.underdotted { + (tr("demon-headline.more-creators-tooltip")) + div.tooltiptext.fade { + (rest.iter().map(|player| player.name.as_ref()).collect::>().join(", ")) + } + } + }.into_string()), + ("verified-and-published", verified_and_published.into_string()) + ))) } } } @@ -290,21 +320,21 @@ impl DemonPage { @if let Some(ref level) = self.integration { span { b { - "Level Password: " + (tr("demon-password")) } br; (level.level_data.password.as_processed().map(|pw| pw.to_string()).unwrap_or("Unknown".to_string())) } span { b { - "Level ID: " + (tr("demon-id")) } br; (level.level_id) } span { b { - "Level length: " + (tr("demon-length")) } br; @match level.level_data.level_data { @@ -318,7 +348,7 @@ impl DemonPage { } span { b { - "Object count: " + (tr("demon-objects")) } br; @match level.level_data.level_data { @@ -328,7 +358,7 @@ impl DemonPage { } span { b { - "In-Game Difficulty: " + (tr("demon-difficulty")) } br; @match level.difficulty { @@ -346,7 +376,7 @@ impl DemonPage { } span { b { - "Created in:" + (tr("demon-gdversion")) } br; (level.gd_version) @@ -354,7 +384,7 @@ impl DemonPage { @if let Some(ref song) = level.custom_song { span style = "width: 100%"{ b { - "Newgrounds Song:" + (tr("demon-ngsong")) } br; @match song.link { @@ -367,7 +397,7 @@ impl DemonPage { @if position <= list_config::extended_list_size() { span { b { - "Demonlist score (100%): " + (trp!("demon-score", ("percent", 100.0))) } br; (format!("{:.2}", score100)) @@ -376,7 +406,7 @@ impl DemonPage { @if position <= list_config::list_size(){ span { b { - "Demonlist score (" (self.data.demon.requirement) "%): " + (trp!("demon-score", ("percent", self.data.demon.requirement))) } br; (format!("{:.2}", score_requirement)) @@ -396,26 +426,22 @@ impl DemonPage { section.records.panel.fade.js-scroll-anim data-anim = "fade" { div.underlined.pad { h2 { - "Records" + (tr("demon-records")) } @if position <= list_config::list_size() { h3 { - (self.data.demon.requirement) "% or better required to qualify" + (trp!("demon-records-qualify", ("percent", self.data.demon.requirement))) } } @else if position <= list_config::extended_list_size() { h3 { - "100% required to qualify" + (trp!("demon-records-qualify", ("percent", 100.0))) } } @if !self.data.records.is_empty() { h4 { @let records_registered_100_count = self.data.records.iter().filter(|record| record.progress == 100).count(); - (self.data.records.len()) - " records registered, out of which " - (records_registered_100_count) - @if records_registered_100_count == 1 { " is" } @else { " are" } - " 100%" + (trp!("demon-records-total", ("num-records", self.data.records.len()), ("num-completions", records_registered_100_count))) } } } @@ -435,13 +461,13 @@ impl DemonPage { tr { th.blue {} th.blue { - "Record Holder" + (tr("record-holder")) } th.blue { - "Progress" + (tr("record-progress")) } th.video-link.blue { - "Video Proof" + (tr("record-videoproof")) } } @for record in &self.data.records { diff --git a/pointercrate-demonlist-pages/src/lib.rs b/pointercrate-demonlist-pages/src/lib.rs index 26c0b5d37..e2a9cb82d 100644 --- a/pointercrate-demonlist-pages/src/lib.rs +++ b/pointercrate-demonlist-pages/src/lib.rs @@ -1,5 +1,6 @@ use maud::{html, Markup}; +use pointercrate_core::localization::tr; use pointercrate_demonlist::{config, demon::Demon}; pub mod account; @@ -9,38 +10,12 @@ pub mod overview; pub mod statsviewer; struct ListSection { - name: &'static str, - description: &'static str, + name: String, + description: String, id: &'static str, numbered: bool, } -static MAIN_SECTION: ListSection = ListSection { - name: "Main List", - description: "The main section of the Demonlist. These demons are the hardest rated levels in the game. Records are accepted above a \ - given threshold and award a large amount of points!", - id: "mainlist", - numbered: true, -}; - -static EXTENDED_SECTION: ListSection = ListSection { - name: "Extended List", - description: "These are demons that dont qualify for the main section of the list, but are still of high relevance. Only 100% records \ - are accepted for these demons! Note that non-100% that were submitted/approved before a demon fell off the main list \ - will be retained", - id: "extended", - numbered: true, -}; - -static LEGACY_SECTION: ListSection = ListSection { - name: "Legacy List", - description: "These are demons that used to be on the list, but got pushed off as new demons were added. They are here for nostalgic \ - reasons. This list is in no order whatsoever and will not be maintained any longer at all. This means no new records \ - will be added for these demons.", - id: "legacy", - numbered: false, -}; - fn dropdowns(all_demons: &[&Demon], current: Option<&Demon>) -> Markup { let (main, extended, legacy) = if all_demons.len() < config::list_size() as usize { (all_demons, Default::default(), Default::default()) @@ -60,11 +35,11 @@ fn dropdowns(all_demons: &[&Demon], current: Option<&Demon>) -> Markup { html! { nav.flex.wrap.m-center.fade #lists style="text-align: center;" { // The drop down for the main list: - (dropdown(&MAIN_SECTION, main, current)) + (dropdown(&ListSection { name: tr("main-list"), description: tr("main-list.info"), id: "mainlist", numbered: true }, main, current)) // The drop down for the extended list: - (dropdown(&EXTENDED_SECTION, extended, current)) + (dropdown(&ListSection { name: tr("extended-list"), description: tr("extended-list.info"), id: "extended", numbered: true }, extended, current)) // The drop down for the legacy list: - (dropdown(&LEGACY_SECTION, legacy, current)) + (dropdown(&ListSection { name: tr("legacy-list"), description: tr("legacy-list.info"), id: "legacy", numbered: false }, legacy, current)) } } } @@ -127,13 +102,13 @@ fn rules_panel() -> Markup { html! { section #rules.panel.fade.js-scroll-anim data-anim = "fade" { h2.underlined.pad.clickable { - "Guidelines" + (tr("guidelines-panel")) } p { - "All demonlist operations are carried out in accordance to our guidelines. Be sure to check them before submitting a record to ensure a flawless experience!" + (tr("guidelines-panel.info")) } a.blue.hover.button href = "/guidelines/" { - "Read the guidelines!" + (tr("guidelines-panel.button")) } } } @@ -144,7 +119,7 @@ fn discord_panel() -> Markup { section.panel.fade.js-scroll-anim #discord data-anim = "fade" { iframe.js-delay-attr style = "width: 100%; height: 400px;" allowtransparency="true" frameborder = "0" data-attr = "src" data-attr-value = "https://discordapp.com/widget?id=395654171422097420&theme=light" {} p { - "Join the official Demonlist discord server, where you can get in touch with the demonlist team!" + (tr("discord-panel-info")) } } } diff --git a/pointercrate-demonlist-pages/src/overview.rs b/pointercrate-demonlist-pages/src/overview.rs index 9bcccc028..a1730e438 100644 --- a/pointercrate-demonlist-pages/src/overview.rs +++ b/pointercrate-demonlist-pages/src/overview.rs @@ -7,7 +7,8 @@ use crate::{ }, statsviewer::stats_viewer_panel, }; -use maud::{html, Markup, PreEscaped}; +use maud::{html, Markup, PreEscaped, Render}; +use pointercrate_core::{localization::tr, trp}; use pointercrate_core_pages::{head::HeadLike, PageFragment}; use pointercrate_demonlist::player::FullPlayer; use pointercrate_demonlist::{ @@ -157,23 +158,40 @@ impl OverviewPage { } } h3 style = "text-align: left" { - "published by " (P(&demon.publisher, None)) + (PreEscaped(trp!( + "demon-info", + ( + "publisher", + P(&demon.publisher, None).render().into_string() + ) + ))) } div style="text-align: left; font-size: 0.8em" { @if let Some(current_position) = current_position { @if current_position > list_config::extended_list_size() { - "Currently Legacy" + (tr("time-machine.active-position-legacy")) } @else { - "Currently #"(current_position) + (trp!( + "time-machine.active-position", + ("position", current_position) + )) } } @else { @if demon.base.position > config::list_size() { - (total_score) " points" + (trp!( + "demon-info.score-short", + ("score", total_score) + )) } @ else { - (minimal_score) " (" (demon.requirement) "%) — " (total_score) " (100%) points" + (trp!( + "demon-info.score", + ("minimal-score", minimal_score), + ("requirement", demon.requirement), + ("total-score", total_score) + )) } } } @@ -181,7 +199,12 @@ impl OverviewPage { @if progress > 0 && current_position.is_none() { div.flex.col style = "font-weight: bold; text-align: right" { span style = "font-size: 3em" { (progress) "%" } - span style = "font-size: 0.8em" { (progress_score) " points" } + span style = "font-size: 0.8em" { + (trp!( + "demon-info.score-short", + ("score", progress_score) + )) + } } } } diff --git a/pointercrate-demonlist-pages/src/statsviewer/individual.rs b/pointercrate-demonlist-pages/src/statsviewer/individual.rs index 605ab2415..e061f1202 100644 --- a/pointercrate-demonlist-pages/src/statsviewer/individual.rs +++ b/pointercrate-demonlist-pages/src/statsviewer/individual.rs @@ -1,5 +1,6 @@ use crate::statsviewer::stats_viewer_html; -use maud::{html, Markup}; +use maud::{html, Markup, PreEscaped}; +use pointercrate_core::{localization::tr, trp}; use pointercrate_core_pages::{head::HeadLike, PageFragment}; use pointercrate_demonlist::nationality::Nationality; @@ -28,10 +29,10 @@ impl IndividualStatsViewer { html! { nav.flex.wrap.m-center.fade #statsviewers style="text-align: center; z-index: 1" { a.button.white.hover.no-shadow href="/demonlist/statsviewer/"{ - b {"Individual"} + b {(tr("statsviewer-individual"))} } a.button.white.hover.no-shadow href="/demonlist/statsviewer/nations/" { - b {"Nations"} + b {(tr("statsviewer-nation"))} } } div #world-map-wrapper { @@ -39,7 +40,7 @@ impl IndividualStatsViewer { } div.flex.m-center.container { main.left { - (stats_viewer_html(Some(&self.nationalities_in_use), super::standard_stats_viewer_rows())) + (stats_viewer_html(Some(&self.nationalities_in_use), super::standard_stats_viewer_rows(), false)) } aside.right { (super::demon_sorting_panel()) @@ -47,17 +48,24 @@ impl IndividualStatsViewer { (super::hide_subdivision_panel()) section.panel.fade style = "overflow: initial;" { h3.underlined { - "Political Subdivision:" + (tr("subdivision-panel")) } p { - "For the " - span.tooltip { - "following countries" - span.tooltiptext.fade { - "Argentina, Australia, Brazil, Canada, Chile, Colombia, Finland, France, Germany, Italy, Mexico, Netherlands, Norway, Peru, Poland, Russian Federation, South Korea, Spain, Ukraine, United Kingdom, United States" - } - } - " you can select a state/province from the dropdown below to focus the stats viewer to that state/province." + (PreEscaped(trp!( + "subdivision-panel.info", + ( + "countries", + html! { + span.tooltip { + (tr("subdivision-panel.info-countries")) + + span.tooltiptext.fade { + r#"Argentina, Australia, Brazil, Canada, Chile, Colombia, Finland, France, Germany, Italy, Mexico, Netherlands, Norway, Peru, Poland, Russian Federation, South Korea, Spain, Ukraine, United Kingdom, United States"# + } + } + }.into_string() + ) + ))) } div.dropdown-menu.js-search #subdivision-dropdown data-default = "None" { div{ @@ -65,7 +73,7 @@ impl IndividualStatsViewer { } div.menu { ul { - li.white.hover.underlined data-value = "None" {"None"} + li.white.hover.underlined data-value = "None" {(tr("subdivision-panel.option-none"))} } } } diff --git a/pointercrate-demonlist-pages/src/statsviewer/mod.rs b/pointercrate-demonlist-pages/src/statsviewer/mod.rs index a53b4e11e..cefd8133f 100644 --- a/pointercrate-demonlist-pages/src/statsviewer/mod.rs +++ b/pointercrate-demonlist-pages/src/statsviewer/mod.rs @@ -1,4 +1,5 @@ use maud::{html, Markup, PreEscaped}; +use pointercrate_core::localization::tr; use pointercrate_core_pages::util::{dropdown, filtered_paginator, simple_dropdown}; use pointercrate_demonlist::nationality::Nationality; @@ -10,14 +11,14 @@ pub(crate) fn stats_viewer_panel() -> Markup { section #stats.panel.fade.js-scroll-anim data-anim = "fade" { div.underlined { h2 { - "Stats Viewer" + (tr("statsviewer-panel")) } } p { - "Get a detailed overview of who completed the most, created the most demons or beat the hardest demons! There is even a leaderboard to compare yourself to the very best!" + (tr("statsviewer-panel.info")) } a.blue.hover.button #show-stats-viewer href = "/demonlist/statsviewer/ "{ - "Open the stats viewer!" + (tr("statsviewer-panel.button")) } } } @@ -27,12 +28,22 @@ fn continent_panel() -> Markup { html! { section.panel.fade style="overflow:initial"{ h3.underlined { - "Continent" + (tr("continent-panel")) } p { - "Select a continent below to focus the stats viewer to that continent. Select 'All' to reset selection." + (tr("continent-panel.info")) } - (simple_dropdown("continent-dropdown", Some("All"), vec!["Asia", "Europe", "Australia", "Africa", "North America", "South America", "Central America"].into_iter())) + (simple_dropdown("continent-dropdown", + Some(("All", tr("continent-panel.option-all"))), + vec![ + ("Asia", tr("continent-panel.option-asia")), + ("Europe", tr("continent-panel.option-europe")), + ("Australia", tr("continent-panel.option-australia")), + ("Africa", tr("continent-panel.option-africa")), + ("North America", tr("continent-panel.option-northamerica")), + ("South America", tr("continent-panel.option-southamerica")), + ("Central America", tr("continent-panel.option-centralamerica")) + ].into_iter())) } } } @@ -41,12 +52,16 @@ fn demon_sorting_panel() -> Markup { html! { section.panel.fade style="overflow:initial" { h3.underlined { - "Demon Sorting" + (tr("demon-sorting-panel")) } p { - "The order in which completed demons should be listed" + (tr("demon-sorting-panel.info")) } - (simple_dropdown("demon-sorting-mode-dropdown", Some("Alphabetical"), vec!["Position"].into_iter())) + (simple_dropdown("demon-sorting-mode-dropdown", + Some(("Alphabetical", tr("demon-sorting-panel.option-alphabetical"))), + vec![ + ("Position", tr("demon-sorting-panel.option-position")) + ].into_iter())) } } } @@ -55,13 +70,13 @@ fn hide_subdivision_panel() -> Markup { html! { section.panel.fade { h3.underlined { - "Show subdivisions" + (tr("toggle-subdivision-panel")) } p { - "Whether the map should display political subdivisions" + (tr("toggle-subdivision-panel.info")) } div.cb-container.flex.no-stretch style="margin-bottom:10px" { - i {"Show political subdivisions"} + i {(tr("toggle-subdivision-panel.option-toggle"))} input #show-subdivisions-checkbox type = "checkbox" checked=""; span.checkmark {} } @@ -69,40 +84,42 @@ fn hide_subdivision_panel() -> Markup { } } -struct StatsViewerRow(Vec<(&'static str, &'static str)>); +struct StatsViewerRow(Vec<(String, &'static str)>); fn standard_stats_viewer_rows() -> Vec { vec![ - StatsViewerRow(vec![("Demonlist rank", "rank"), ("Demonlist score", "score")]), - StatsViewerRow(vec![("Demonlist stats", "stats"), ("Hardest demon", "hardest")]), - StatsViewerRow(vec![("Demons completed", "beaten")]), - StatsViewerRow(vec![("Main List Demons completed", "main-beaten")]), - StatsViewerRow(vec![("Extended List Demons completed", "extended-beaten")]), - StatsViewerRow(vec![("Legacy List Demons completed", "legacy-beaten")]), + StatsViewerRow(vec![(tr("statsviewer.rank"), "rank"), (tr("statsviewer.score"), "score")]), + StatsViewerRow(vec![(tr("statsviewer.stats"), "stats"), (tr("statsviewer.hardest"), "hardest")]), + StatsViewerRow(vec![(tr("statsviewer.completed"), "beaten")]), + StatsViewerRow(vec![(tr("statsviewer.completed-main"), "main-beaten")]), + StatsViewerRow(vec![(tr("statsviewer.completed-extended"), "extended-beaten")]), + StatsViewerRow(vec![(tr("statsviewer.completed-legacy"), "legacy-beaten")]), StatsViewerRow(vec![ - ("Demons created", "created"), - ("Demons published", "published"), - ("Demons verified", "verified"), + (tr("statsviewer.created"), "created"), + (tr("statsviewer.published"), "published"), + (tr("statsviewer.verified"), "verified"), ]), - StatsViewerRow(vec![("Progress on", "progress")]), + StatsViewerRow(vec![(tr("statsviewer.progress"), "progress")]), ] } -fn stats_viewer_html(nations: Option<&[Nationality]>, rows: Vec) -> Markup { +fn stats_viewer_html(nations: Option<&[Nationality]>, rows: Vec, is_nation_stats_viewer: bool) -> Markup { + let international = &tr("statsviewer-individual.option-international"); + html! { section.panel.fade #statsviewer style="overflow:initial" { h2.underlined.pad { - "Stats Viewer" + (tr("statsviewer")) @if let Some(nations) = nations { " - " (dropdown("International", html! { - li.white.hover.underlined data-value = "International" data-display = "International" { + li.white.hover.underlined data-value = "International" data-display = (international) { span.em.em-world_map {} (PreEscaped(" ")) b {"WORLD"} br; - span style = "font-size: 90%; font-style: italic" { "International" } + span style = "font-size: 90%; font-style: italic" { (international) } } }, nations.iter().map(|nation| html! { @@ -120,7 +137,11 @@ fn stats_viewer_html(nations: Option<&[Nationality]>, rows: Vec) div.flex.viewer { (filtered_paginator("stats-viewer-pagination", "/api/v1/players/ranking/")) p.viewer-welcome { - "Click on a player's name on the left to get started!" + @if is_nation_stats_viewer + { (tr("statsviewer-nation.welcome")) } + @else + { (tr("statsviewer-individual.welcome")) } + } div.viewer-content { div { diff --git a/pointercrate-demonlist-pages/src/statsviewer/national.rs b/pointercrate-demonlist-pages/src/statsviewer/national.rs index 37fabd7fb..1ec2debdf 100644 --- a/pointercrate-demonlist-pages/src/statsviewer/national.rs +++ b/pointercrate-demonlist-pages/src/statsviewer/national.rs @@ -1,5 +1,6 @@ use crate::statsviewer::{stats_viewer_html, StatsViewerRow}; use maud::{html, Markup}; +use pointercrate_core::localization::tr; use pointercrate_core_pages::{head::HeadLike, PageFragment}; pub fn nation_based_stats_viewer() -> PageFragment { @@ -18,16 +19,16 @@ pub fn nation_based_stats_viewer() -> PageFragment { fn nation_based_stats_viewer_html() -> Markup { let mut rows = super::standard_stats_viewer_rows(); - rows[0].0.insert(1, ("Players", "players")); - rows.push(StatsViewerRow(vec![("Unbeaten demons", "unbeaten")])); + rows[0].0.insert(1, (tr("statsviewer-nation.players"), "players")); + rows.push(StatsViewerRow(vec![(tr("statsviewer-nation.unbeaten"), "unbeaten")])); html! { nav.flex.wrap.m-center.fade #statsviewers style="text-align: center; z-index: 1" { a.button.white.hover.no-shadow href="/demonlist/statsviewer/"{ - b {"Individual"} + b {(tr("statsviewer-individual"))} } a.button.white.hover.no-shadow href="/demonlist/statsviewer/nations/" { - b {"Nations"} + b {(tr("statsviewer-nation"))} } } div #world-map-wrapper { @@ -35,7 +36,7 @@ fn nation_based_stats_viewer_html() -> Markup { } div.flex.m-center.container { main.left { - (stats_viewer_html(None, rows)) + (stats_viewer_html(None, rows, true)) } aside.right { (super::demon_sorting_panel()) diff --git a/pointercrate-demonlist-pages/static/ftl/en-us/demon.ftl b/pointercrate-demonlist-pages/static/ftl/en-us/demon.ftl new file mode 100644 index 000000000..51f8b9c2e --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/en-us/demon.ftl @@ -0,0 +1,186 @@ +## Demon information, including information fetched by dash-rs +## Fields included in forms may have validators +demon-name = Demon Name + .validator-valuemissing = Please specify a name + +demon-password = Level Password + +demon-id = Level ID + .validator-rangeunderflow = Level ID must be positive + +demon-length = Level Length + +demon-objects = Object Count + +demon-difficulty = In-Game Difficulty + +demon-gdversion = Created In + +demon-ngsong = Newgrounds Song + +demon-score = Demonlist score ({$percent}%) + +demon-video = Verification Video + .validator-typemismatch = Please enter a valid URL + +demon-thumbnail = Thumbnail + .validator-typemismatch = Please enter a valid URL + .validator-valuemissing = Please enter a URL + +demon-position = Position + .validator-rangeunderflow = Demon position must be at least 1 + .validator-badinput = Demon position must be a valid integer + .validator-stepmismatch = Demon position mustn't be a decimal + .validator-valuemissing = Please specify a position + +demon-requirement = Requirement + .validator-rangeunderflow = Record requirement cannot be negative + .validator-rangeoverflow = Record requirement cannot be larger than 100% + .validator-badinput = Record requirement must be a valid integer + .validator-stepmismatch = Record requirement mustn't be a decimal + .validator-valuemissing = Please specify a requirement value + +demon-publisher = Publisher + .validator-valuemissing = Please specify a publisher + +demon-verifier = Verifier + .validator-valuemissing = Please specify a verifier + +demon-creators = Creators + +demon-headline-by = by { $creator } +demon-headline-verified-by = verified by { $verifier } +demon-headline-published-by = published by { $publisher } + +# { $verified-and-published } represents two possible variations of text +# either .same-verifier-publisher OR .unique-verifier-publisher +# +# { $more } in .more-creators is transformed into a tooltip listing all of +# a demon's creators, with the text being .more-creators-tooltip +demon-headline = by { $creator } + .same-verifier-publisher = verified and published by { $publisher } + .unique-verifier-publisher = { demon-headline-published-by }, { demon-headline-verified-by } + + .no-creators = by Unknown, { $verified-and-published } + + .one-creator = { demon-headline-by }, { $verified-and-published } + .one-creator-is-publisher = { demon-headline-by }, verified by { $verifier } + .one-creator-is-verifier = { demon-headline-by }, published by { $publisher } + + .two-creators = by { $creator1 } and { $creator2 }, { $verified-and-published } + + .more-creators = { demon-headline-by } and { $more }, { $verified-and-published } + .more-creators-tooltip = more + +## Position history table +movements = Position History + .date = Date + .change = Change + +movements-newposition = New Position + .legacy = Legacy + +movements-reason = Reason + .added = Added to list + .addedabove = { $demon } was added above + .moved = Moved + .movedabove = { $demon } was moved up past this demon + .movedbelow = { $demon } was moved down past this demon + +## Records table +demon-records = Records + +demon-records-qualify = {$percent}% { $percent -> + [100] required to qualify + *[other] or better required to qualify +} + +demon-records-total = {$num-records} { $num-records -> + [one] record registered + *[other] records registered +}, out of which {$num-completions} { $num-completions -> + [one] is 100% + *[other] are 100% +} + +## Demons tab in user area +demons = Demons +demon-manager = Demon Manager + +demon-listed = {$demon} (ID: {$demon-id}) + .publisher = by {$publisher} + +demon-viewer = Demon # + .welcome = Click on a demon on the left to get started! + + .video-field = { demon-video }: + .thumbnail-field = { demon-thumbnail }: + .position-field = { demon-position }: + .requirement-field = { demon-requirement }: + .publisher-field = { demon-publisher }: + .verifier-field = { demon-verifier }: + .creators-field = { demon-creators }: + +demon-add-panel = Add Demon + .button = Add a demon! + +# Demon addition form +demon-add-form = Add Demon + .name-field = { demon-name }: + .name-validator-valuemissing = Please provide a name for the demon + + .levelid-field = Geometry Dash Level ID: + .position-field = { demon-position }: + .requirement-field = { demon-requirement }: + .verifier-field = { demon-verifier }: + .publisher-field = { demon-publisher }: + .video-field = { demon-video }: + .creators-field = { demon-creators }: + + .submit = Add Demon + + .edit-success = Successfully added demon! + +# Demon viewer dialogs +demon-video-dialog = Change verification video link + .info = Change the verification video link for this record. Leave empty to remove the verification video. + .video-field = Video link: + .submit = Edit + +demon-name-dialog = Change demon name + .info = Change the name of this demon. Multiple demons with the same name ARE supported! + .name-field = Name: + .submit = Edit + +# { $video-id } will be replaced by https://i.ytimg.com/vi/{.info-videoid}/mqdefault.jpg but italicized +# in english, this looks like https://i.ytimg.com/vi/VIDEO_ID/mqdefault.jpg +demon-thumbnail-dialog = Change thumbnail link + .info = Change the thumbnail link for this record. To link it to the thumbnail of a youtube video, set it to { $video-id }. + .info-videoid = VIDEO_ID + + .thumbnail-field = Thumbnail link: + .submit = Edit + +demon-position-dialog = Change demon position + .info = Change the position of this demon. Has be be greater than 0 and be at most the current list size. + .position-field = Position: + .submit = Edit + +demon-requirement-dialog = Change demon requirement + .info = Change the record requirement for this demon. Has be lie between 0 and and 100 (inclusive). + .requirement-field = Requirement: + .submit = Edit + +demon-publisher-dialog = Change demon publisher + .info = Type the new publisher of the demon into the text field below. If the player already exists, it will appear as a suggestion below the text field. Then click the button below. + .submit = Edit + +demon-verifier-dialog = Change demon verifier + .info = Type the new verifier of the demon into the text field below. If the player already exists, it will appear as a suggestion below the text field. Then click the button below. + .submit = Edit + +demon-creator-dialog = Add creator + .info = Type the creator to add to this demon into the text field below. If the player already exists, it will appear as a suggestion below the text field. Then click the button below. + .submit = Add Creator + + .edit-success = Successfully added creator! \ No newline at end of file diff --git a/pointercrate-demonlist-pages/static/ftl/en-us/error.ftl b/pointercrate-demonlist-pages/static/ftl/en-us/error.ftl new file mode 100644 index 000000000..1b48b823e --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/en-us/error.ftl @@ -0,0 +1,41 @@ +error-demonlist-malformedvideourl = Malformed video URL +error-demonlist-bannedfromsubmissions = You are banned from submitting records to the demonlist! +error-demonlist-claimunverified = Your claim on this player is unverified +error-demonlist-vpsdetected = IP geolocation attempt through VPS detected +error-demonlist-nothirdpartysubmissions = This player has requested that only they themselves can submit their records +error-demonlist-submitternotfound = No submitter with id { $id } found +error-demonlist-notenotfound = No note with id { $note-id } found on record with id { $record-id } +error-demonlist-creatornotfound = Player with id { $player-id } is no creator of demon with id { demon-id } +error-demonlist-nationalitynotfound = No nationality with iso code { $iso-code } found +error-demonlist-subdivisionnotfound = No subdivision with code { $subdivision-code } found in nation { $nation-code } +error-demonlist-playernotfound = No player with id { $player-id } found +error-demonlist-playernotfoundname = No player with name { $player-name } found +error-demonlist-demonnotfound = No demon with id { $demon-id } found +error-demonlist-demonnotfoundname = No demon with name { $demon-name } found +error-demonlist-demonnotfoundposition = No demon at position { $demon-position } found +error-demonlist-recordnotfound = No record with id { $record-id } found +error-demonlist-claimnotfound = No claim by user { $member-id } on player { $player-id } found +error-demonlist-creatorexists = This player is already registered as a creator on this demon +error-demonlist-duplicatevideo = This video is already used by record #{ $record-id } +error-demonlist-nonationset = Attempt to set subdivision without nation +error-demonlist-conflictingclaims = The players '{ $player-1 }' and '{ $player-2 }' have verified claims by different pointercrate users +error-demonlist-invalidrequirement = Record requirement needs to be greater than -1 and smaller than 101 +error-demonlist-invalidposition = Demon position needs to be greater than or equal to 1 and smaller than or equal to { $maximal } +error-demonlist-invalidprogress = Record progress must lie between { $requirement } and 100%! +error-demonlist-submissionexists = This record is already { $record-status } (existing record: { $record-id }) +error-demonlist-playerbanned = The given player is banned and thus cannot have non-rejected records on the list! +error-demonlist-submitlegacy = You cannot submit records for legacy demons +error-demonlist-non100extended = Only 100% records can be submitted for the extended section of the list +error-demonlist-unsupportedvideohost = The given video host is not supported. Supported are 'youtube', 'vimeo', 'everyplay', 'twitch' and 'bilibili' +error-demonlist-demonnamenotunique = There are multiple demons with the given name +error-demonlist-noteempty = Notes mustn't be empty! +error-demonlist-alreadyclaimed = This player already has a verified claim associated with them +error-demonlist-rawrequired = Raw footage much be provided to submit this record +error-demonlist-malformedrawurl = Raw footage needs to be a valid URL +error-demonlist-invalidlevelid = Level ID needs to be positive + +error-demonlist-ratelimit-record-submit = You're submitting too many records too fast! +error-demonlist-ratelimit-record-submit-global = Too many records are being submitted right now! +error-demonlist-ratelimit-new-submitters = DDoS protection ratelimit +error-demonlist-ratelimit-geolocate = You can only geolocate once per month! +error-demonlist-ratelimit-add-demon = Please don't spam the button, rSteel \ No newline at end of file diff --git a/pointercrate-demonlist-pages/static/ftl/en-us/overview.ftl b/pointercrate-demonlist-pages/static/ftl/en-us/overview.ftl new file mode 100644 index 000000000..25a921c60 --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/en-us/overview.ftl @@ -0,0 +1,49 @@ +main-list = Main List + .info = The main section of the Demonlist. These demons are the hardest rated levels in the game. Records are accepted above a given threshold and award a large amount of points! + +extended-list = Extended List + .info = These are demons that dont qualify for the main section of the list, but are still of high relevance. Only 100% records are accepted for these demons! Note that non-100% that were submitted/approved before a demon fell off the main list will be retained. + +legacy-list = Legacy List + .info = These are demons that used to be on the list, but got pushed off as new demons were added. They are here for nostalgic reasons. This list is in no order whatsoever and will not be maintained any longer at all. This means no new records will be added for these demons. + +demon-info = published by { $publisher } + .score = { $minimal-score } ({ $requirement }%) — { $total-score } (100%) points + .score-short = { $score } points + +## Time machine +time-machine = Time Machine + .info = Enter the date you want to view the demonlist at below. For technical reasons, the earliest possible date is January 4th 2017. Note however that data before August 4th 2017 is only provided on a best-effort basis and not guaranteed to be 100% accurate. Particularly data from before April 4th 2017 contains significant errors! + + .destination-field = Destination: + .submit = Go! + + .destination-validator-valuemissing = Please specify a value + .destination-validator-rangeunderflow = You cannot go back in time that far! + + .active-position = Currently #{ $position } + .active-position-legacy = Currently Legacy + + .active-info = You are currently looking at the demonlist how it was on + .return = Go to present + +## Sidebar panels +editors-panel = List Editors + .info = Contact any of these people if you have problems with the list or want to see a specific thing changed. + +helpers-panel = List Helpers + .info = Contact these people if you have any questions regarding why a specific record was rejected. Do not needlessly bug them about checking submissions though! + +guidelines-panel = Guidelines + .info = All demonlist operations are carried out in accordance to our guidelines. Be sure to check them before submitting a record to ensure a flawless experience! + .button = Read the guidelines! + +submission-panel = Submit Records + .info = Please do not submit nonsense, it only makes it harder for us all and will get you banned. Also note that the form rejects duplicate submissions. + .button = Submit a record! + +statsviewer-panel = Stats Viewer + .info = Get a detailed overview of who completed the most, created the most demons or beat the hardest demons! There is even a leaderboard to compare yourself to the very best! + .button = Open the stats viewer! + +discord-panel-info = Join the official Demonlist discord server, where you can get in touch with the demonlist team! diff --git a/pointercrate-demonlist-pages/static/ftl/en-us/player.ftl b/pointercrate-demonlist-pages/static/ftl/en-us/player.ftl new file mode 100644 index 000000000..c3cc2251c --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/en-us/player.ftl @@ -0,0 +1,93 @@ +player-banned = Banned + .yes = Yes + .no = No + +player-nationality = Nationality + .info = Note that this is to be understood as 'Country of legal residency' and nothing else. No exceptions. + .none = None + +player-subdivision = Political Subdivision + .none = None + +## Players tab +players = Players + +player-manager = Player Manager + +player-viewer = Player # + .welcome = Click on a player on the left to get started! + + .info = Welcome to the player manager. Here you can ban or unban players. Banning a player will delete all records of theirs which are in the submitted or under consideration state. All approved records will instead be set to rejected. + .records-redirect = Show records in record manager + +player-idsearch-panel = Search player by ID + .info = Players can be uniquely identified by ID. Entering a players's ID below will select it on the left (provided the player exists) + .id-field = Player ID: + + .submit = Find by ID + + .id-validator-valuemissing = Player ID required + +player-name-dialog = Change player name + .info = Change the name of this player. This will update their name on every one of their records. If a player with the new name already exists, the player objects will be merged, with the new object receiving the ID of the player you are currently editing. In this case, the record lists of the players are merged and their creator/verifier/publisher information is updated. Internally, each record is moved to to the new player, an on conflicts the same rules apply as when editing a record's holder. + .name-field = Name: + + .submit = Edit + + .name-validator-valuemissing = Please provide a name for the player + +## List integration tab +list-integration = List Integration + +claimed-player = Claimed Player + .verified = Verified + .unverified = Unverified + +# .info-api-link is turned into a clickable link to the geolocation API +# pointercrate uses, and replaces { $info-api-link } +claim-geolocate = Geolocate statsviewer flag + .info = Clicking the above button let's you set your claimed player's statsviewer flag via IP Geolocation. To offer this functionality, pointercrate uses { $info-api-link }. Clicking the above button also counts as your consent for pointercrate to send your IP to abstract. + .info-api-link = abstract's IP geolocation API + + .submit = Go + + .edit-success = Set nationality to { $nationality } + .edit-success-subdivision = Set nationality to { $nationality }/{ $subdivision } + +claim-lock-submissions = Lock submissions + .info = Whether submissions for your claimed player should be locked, meaning only you will be able to submit records for your claimed player (and only while logged in to this account holding the verified claim) + + .edit-success = Successfully applied change + +claim-records = Your claimed player's records + .info = A list of your claimed player's records, including all under consideration and rejected records and all submissions. Use this to track the status of your submissions. Clicking on a record will pull up any public notes a list mod left on the given record. The background color of each record tells you whether the record is { $record-approved-styled }, { $record-submitted-styled }, { $record-rejected-styled } or { $record-underconsideration-styled }. + + .record-notes = Notes for record { $record-id }: + .record-notes-none = No public notes on this record! + +claim-manager = Manage Claims + .info-a = Manage claims using the interface below. The list can be filtered by player and user using the panels on the right. Invalid claims should be deleted using the trash icon. + .info-b = To verify a claim, click the checkmark. Only verify claims you have verified to be correct (this will probably mean talking to the player that's being claimed, and asking if they initiated the claim themselves, or if the claim is malicious). + .info-c = Once a claim on a player is verified, all other unverified claims on that player are auto-deleted. Users cannot put new, unverified claims on players that have a verified claim on them. + .info-d = A claim with a green background is verified, a claim with a blue background is unverified/unchecked. + + .claim-no-records = The claimed player ({ $player-id }) does not have an approved record on the list + +claim-listed-user = Claim by user: +claim-listed-player = Claim on player: + +claim-initiate-panel = Initiate Claim + .info = Select the player you wish to claim below + +# { $discord } is replaced by .info-discord, which is turned into a +# clickable link to Pointercrate Central by default (this can be modified +# in pointercrate-example/src/main.rs) +claim-info-panel = Claiming 101 + .info-a = Player claiming is the process of associated a demonlist player with a pointercrate user account. A verified claim allows you to to modify some of the player's properties, such as nationality. + .info-b = To initiate a claim, click the pen left of the 'Claimed Player' heading. Once initiated, you have an unverified claim on a player. These claims will then be manually verified by members of the pointercrate team. You can request verification in { $discord }. + .info-c = You cannot initiate a claim on a player that already has a verified claim by a different user on it. + + .info-discord = this discord server + +claim-video-panel = Record video + .info = Clicking a claim in the 'Manage Claims' panel will pull up a random video of an approved record by the claimed player. \ No newline at end of file diff --git a/pointercrate-demonlist-pages/static/ftl/en-us/record.ftl b/pointercrate-demonlist-pages/static/ftl/en-us/record.ftl new file mode 100644 index 000000000..54ba474c5 --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/en-us/record.ftl @@ -0,0 +1,123 @@ +## Commonly referenced record data +record-submitted = Submitted +record-underconsideration = Under Consideration +record-approved = Approved +record-rejected = Rejected + +record-videolink = Video Link +record-videoproof = Video Proof +record-rawfootage = Raw Footage +record-demon = Demon +record-holder = Record Holder +record-progress = Progress +record-submitter = Submitter ID + +## Records tab (user area) +records = Records +record-manager = Record Manager + .all-option = All Demons + +record-listed = Record #{ $record-id } + .progress = { $percent }% on { $demon } + +record-viewer = Record # + .welcome = Click on a record on the left to get started! + .delete = Delete Record + + .copy-data-success = Copied record data to clipboard! + .copy-data-error = Error copying to clipboard + + .confirm-delete = Are you sure? This will irrevocably delete this record and all notes made on it! + +record-note = Add Note + .placeholder = Add note here. Click 'Add' above when done! + .public-checkbox = Public note + + .submit = Add + +record-note-listed = Record Note #{ $note-id } + .confirm-delete = This action will irrevocably delete this note. Proceed? + + .author = This note was left by { $author }. + .author-submitter = This note was left as a comment by the submitter. + .editors = This note was subsequently modified by: { $editors }. + .transferred = This note was not originally left on this record. + .public = This note is public. + +record-status-filter-panel = Filter + .info = Filter by record status + +record-status-filter-all = All + +record-idsearch-panel = Search record by ID + .info = Records can be uniquely identified by ID. Entering a record's ID below will select it on the left (provided the record exists) + .id-field = Record ID: + + .submit = Find by ID + + .id-validator-valuemissing = Record ID required + +record-playersearch-panel = Filter by player + .info = Players can be uniquely identified by name and ID. Entering either in the appropriate place below will filter the view on the left. Reset by clicking "Find ..." when the text field is empty. + + .id-field = Player ID: + .id-submit = Find by ID + + .name-field = Player name: + .name-submit = Find by name + +# Record viewer dialogs +record-videolink-dialog = Change video link + .info = Change the video link for this record. Note that as a list mod, you can leave the text field empty to remove the video from this record. + .videolink-field = Video link: + + .submit = Edit + + .videolink-validator-typemismatch = Please enter a valid URL + +record-demon-dialog = Change record demon + .info = Change the demon associated with this record. Search up the demon this record should be associated with below. Then click it to modify the record + +record-holder-dialog = Change record holder + .info = Type the new holder of the record into the text field below. If the player already exists, it will appear as a suggestion below the text field. Then click the button below. + .submit = Edit + +record-progress-dialog = Change record progress + .info = Change the progress value of this record. Has to be between the demon's record requirement and 100 (inclusive). + .progress-field = Progress: + + .submit = Edit + + .progress-validator-rangeunderflow = Record progress cannot be negative + .progress-validator-rangeoverflow = Record progress cannot be larger than 100% + .progress-validator-badinput = Record progress must be a valid integer + .progress-validator-stepmismatch = Record progress mustn't be a decimal + .progress-validator-valuemissing = Please enter a progress value + +# The giant information box below the record manager, split +# into different sections here +# +# Each section (except .a and .b) will begin with a bolded version of +# the appropriate record state, or a bolded version of .note for .note-a/b +# attributes +# +record-manager-help = Manage Records + .a = Use the list on the left to select records for editing/viewing. Use the panel on the right to filter the record list by status, player, etc.. Clicking the { record-status-filter-all } field at the top allows to filter by demon. + + .b = There are four possible record states a record can be in: { record-rejected }, { record-approved }, { record-submitted } and { record-underconsideration }. For simplicity of explanation we will assume that Bob is a player and Cataclysm is a demon he has a record on. + + .rejected = If the record is { record-rejected }, it means that Bob has no other record in other states on Cataclysm and no submissions for Bob on Cataclysm are possible. Conversely, this means if Bob has a record on Catalysm thats not rejected, we immediately know that no rejected record for Bob on Cataclysm exists. + Rejecting any record of Bobs on Cataclysm will delete all other records of Bob on Cataclysm to ensure the above uniqueness. + + .approved = If the record is { record-approved }, it means that no submissions with less progress than the { record-approved } record exist or are permitted. + Changing a record to { record-approved } will delete all submissions for Bob on Cataclysm with less progress. + + .submitted = If the record is { record-submitted }, no further constraints on uniqueness are in place. This means that multiple submissions for Bob on Cataclysm are possible, as long as they provide different video links. However, due to the above, all duplicates are deleted as soon as one of the submissions is accepted or rejected. + + .underconsideration = If the record is { record-underconsideration } it is conceptually still a submission. The only difference is, that no more submissions for Bob on Cataclysm are allowed now. + + .note = Note + + .note-a = If a player is banned, they cannot have { record-approved }/{ record-submitted } records on the list. All records marked as { record-submitted } are deleted, all others are changed to { record-rejected }. + + .note-b = Banning a submitter will delete all their submissions that still have the status { record-submitted }. Records submitted by them that were already { record-approved }/{ record-rejected } will not be affected. \ No newline at end of file diff --git a/pointercrate-demonlist-pages/static/ftl/en-us/statsviewer.ftl b/pointercrate-demonlist-pages/static/ftl/en-us/statsviewer.ftl new file mode 100644 index 000000000..4ab0d4da7 --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/en-us/statsviewer.ftl @@ -0,0 +1,76 @@ +statsviewer = Stats Viewer + .rank = Demonlist rank + .score = Demonlist score + .stats = Demonlist stats + .hardest = Hardest demon + + .completed = Demons completed + .completed-main = Main list demons completed + .completed-extended = Extended list demons completed + .completed-legacy = Legacy list demons completed + + .created = Demons created + .published = Demons published + .verified = Demons verified + .progress = Progress on + + .stats-value = { $main } Main, { $extended } Extended, { $legacy } Legacy + .value-none = None + +statsviewer-individual = Individual + .welcome = Click on a player's name on the left to get started! + + .option-international = International + +statsviewer-nation = Nations + .welcome = Click on a country's name on the left to get started! + + .players = Players + .unbeaten = Unbeaten demons + + .created-tooltip = (Co)created by { $players } { $players -> + [one] player + *[other] players + } in this country: + .published-tooltip = Published by: + .verified-tooltip = Verified by: + .beaten-tooltip = Beaten by { $players } { $players -> + [one] player + *[other] players + } in this country: + .progress-tooltip = Achieved by { $players } { $players -> + [one] player + *[other] players + } in this country: + +demon-sorting-panel = Demon Sorting + .info = The order in which completed demons should be listed + + .option-alphabetical = Alphabetical + .option-position = Position + +continent-panel = Continent + .info = Select a continent below to focus the stats viewer to that continent. Select 'All' to reset selection. + + .option-all = All + + .option-asia = Asia + .option-europe = Europe + .option-australia = Australia + .option-africa = Africa + .option-northamerica = North America + .option-southamerica = South America + .option-centralamerica = Central America + +toggle-subdivision-panel = Show Subdivisions + .info = Whether the map should display political subdivisions. + + .option-toggle = Show political subdivisions + +# { $countries } will be replaced with .info-countries, which will be +# turned into a tooltip listing all of the selectable countries +subdivision-panel = Political Subdivision + .info = For the { $countries } you can select a state/province from the dropdown below to focus the stats viewer to that state/province. + .info-countries = following countries + + .option-none = None diff --git a/pointercrate-demonlist-pages/static/ftl/en-us/submitter.ftl b/pointercrate-demonlist-pages/static/ftl/en-us/submitter.ftl new file mode 100644 index 000000000..554267652 --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/en-us/submitter.ftl @@ -0,0 +1,89 @@ +submitter-banned = Banned + .yes = Yes + .no = No + +## Record submitter +record-submission-panel = Submit Records + .info = Note: Please do not submit nonsense, it only makes it harder for us all and will get you banned. Also note that the form rejects duplicate submissions. + .redirect = Submit a record! + +# .note will prefix all notes in the record submission panel +# (not to be confused with record notes) +# +# { $guidelines-link } will be replaced by .guidelines-link, +# which is turned into a clickable link to the submission guidelines +record-submission = Record Submission + .note = Note + + .demon = Demon + .demon-info = The demon the record was made on. Only demons in the top {$list-size} are accepted. This excludes legacy demons! + + .demon-validator-valuemissing = Please specify a demon + + .holder = Holder + .holder-info = The player holding the record. Start typing to see suggestions of existing players. If this is your first submission, write your name, as you wish it to appear on the website, into the text field (ignoring any suggestions). + + .holder-input-placeholder = Start typing for suggestions... + + .holder-validator-valuemissing = Please specify a record holder + .holder-validator-rangeoverflow = Due to Geometry Dash's limitations I know that no player has such a long name + + .progress = Progress + .progress-info = The progress made as percentage. Only values greater than or equal to the demons record requirement and smaller than or equal to 100 are accepted! + .progress-placeholder = e. g. '50', '98' + + .progress-validator-valuemissing = Please specify the record's progress + .progress-validator-rangeunderflow = Record progress cannot be negative + .progress-validator-rangeoverflow = Record progress cannot be larger than 100% + .progress-validator-badinput = Record progress must be a valid integer + .progress-validator-stepmismatch = Record progress mustn't be a decimal + + .video = Video + .video-info = A proof video of the legitimacy of the given record. If the record was achieved on stream, but wasn't uploaded anywhere else, please provide a twitch link to that stream. + .video-note = Please pay attention to only submit well-formed URLs! + .video-placeholder = e. g. https://youtu.be/cHEGAqOgddA + + .video-validator-valuemissing = Please specify a video so we can check the record's validity + .video-validator-typemismatch = Please enter a valid URL + + .raw-footage = Raw footage + .raw-footage-info-a = The unedited and untrimmed video for this completion, uploaded to a non-compressing (e.g. not YouTube) file-sharing service such as google drive. If the record was achieved on stream (meaning there is no recording), please provide a link to the stream VOD. + .raw-footage-info-b = Any personal information possibly contained within raw footage (e.g. names, sensitive conversations) will be kept strictly confidential and will not be shared outside of the demonlist team. Conversely, you acknowledge that you might inadvertently share such information by providing raw footage. You have the right to request deletion of your record note by contacting a list administrator. + .raw-footage-note = This is required for every record submitted to the list! + + .raw-footage-validator-typemismatch = Please enter a valid URL + + .notes = Notes or comments + .notes-info = Provide any additional notes you'd like to pass on to the list moderator receiving your submission. + .notes-placeholder = Your dreams and hopes for this record... or something like that + + .guidelines = By submitting the record you acknowledge the { $guidelines-link }. + .guidelines-link = submission guidelines + + .submit = Submit record + + .submission-success = Record successfully submitted. + .submission-success-queue = Record successfully submitted. It is { $queue-position } in the queue! + +## Submitters tab +submitters = Submitters + +submitter-manager = Submitter Manager + +submitter-viewer = Submitter # + .welcome = Click on a submitter on the left to get started! + + .info-a = Welcome to the submitter manager. Here you can ban or unban submitters with an absolute revolutionary UI that totally isn't a straight up copy of the player UI, just with even more emptiness. + .info-b = Banning a submitter will delete all records they have submitted and which are still in the 'submitted' state. All submissions of their which are approved, rejected or under consideration are untouched. + + .records-redirect = Show records in record manager + +submitter-listed = Submitter #{ $submitter-id } + +submitter-idsearch-panel = Search submitter by ID + .info = Submitters can be uniquely identified by ID. Entering a submitters's ID below will select it on the left (provided the submitter exists) + .id-field = Submitter ID: + + .submit = Find by ID + + .id-validator-valuemissing = Submitter ID required \ No newline at end of file diff --git a/pointercrate-demonlist-pages/static/ftl/ru-ru/demon.ftl b/pointercrate-demonlist-pages/static/ftl/ru-ru/demon.ftl new file mode 100644 index 000000000..0baa38003 --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/ru-ru/demon.ftl @@ -0,0 +1,190 @@ +## Demon information, including information fetched by dash-rs +## Fields included in forms may have validators +demon-name = Название демона + .validator-valuemissing = Пожалуйста, укажите имя + +demon-password = Пароль от уровня + +demon-id = ID уровня + .validator-rangeunderflow = ID уровня должен быть положительным + +demon-length = Длина уровня + +demon-objects = Число объектов + +demon-difficulty = Внутриигровая сложность + +demon-gdversion = Был создан в + +demon-ngsong = Песня на Newgrounds + +demon-score = Очки демонлиста ({$percent}%) + +demon-video = Видео верификации + .validator-typemismatch = Пожалуйста, укажите правильную ссылку + +demon-thumbnail = Превью + .validator-typemismatch = Пожалуйста, укажите правильную ссылку + .validator-valuemissing = Пожалуйста, введите ссылку + +demon-position = Позиция + .validator-rangeunderflow = Позиция уровня должна быть равна как минимум 1 + .validator-badinput = Позиция уровня должна быть целым числом + .validator-stepmismatch = Позиция уровня не должна быть дробной + .validator-valuemissing = Пожалуйста, укажите позицию + +demon-requirement = Требование + .validator-rangeunderflow = Требование к рекордам не может быть отрицательным + .validator-rangeoverflow = Требование к рекордам не может быть больше 100% + .validator-badinput = Требование к рекордам должно быть целым числом + .validator-stepmismatch = Требование к рекордам не должно быть дробным + .validator-valuemissing = Пожалуйста, укажите требование к рекордам + +demon-publisher = Публикатор + .validator-valuemissing = Пожалуйста, укажите публикатора + +demon-verifier = Верифер + .validator-valuemissing = Пожалуйста, укажите верифера + +demon-creators = Создатели + +demon-headline-by = от { $creator } +demon-headline-verified-by = был верифицирован { $verifier } +demon-headline-published-by = был опубликован { $publisher } + +# { $verified-and-published } represents two possible variations of text +# either .same-verifier-publisher OR .unique-verifier-publisher +# +# { $more } in .more-creators is transformed into a tooltip listing all of +# a demon's creators, with the text being .more-creators-tooltip +demon-headline = by { $creator } + .same-verifier-publisher = был верифицирован и опубликован { $publisher } + .unique-verifier-publisher = { demon-headline-published-by }, { demon-headline-verified-by } + + .no-creators = от Unknown, { $verified-and-published } + + .one-creator = { demon-headline-by }, { $verified-and-published } + .one-creator-is-publisher = { demon-headline-by }, был верифицирован { $verifier } + .one-creator-is-verifier = { demon-headline-by }, был опубликован { $publisher } + + .two-creators = от { $creator1 } и { $creator2 }, { $verified-and-published } + + .more-creators = { demon-headline-by } и { $more }, { $verified-and-published } + .more-creators-tooltip = других + +## Position history table +movements = История позиции + .date = Дата + .change = Изменение + +movements-newposition = Новая позиция + .legacy = Legacy + +movements-reason = Причина + .added = Добавлен в лист + .addedabove = { $demon } был добавлен выше + .moved = Подвинут + .movedabove = { $demon } был подвинут выше этого демона + .movedbelow = { $demon } был подвинут ниже этого демона + +## Records table +demon-records = Рекорды + +demon-records-qualify = {$percent}% { $percent -> + [100] требуется для квалификации + *[other] или выше требуется для квалификации +} + +demon-records-total = {$num-records} { $num-records -> + [one] рекорд зарегистрирован + [few] рекорда зарегистрировано + [many] рекордов зарегистрировано + *[other] рекордов зарегистрировано +}, из которых {$num-completions} { $num-completions -> + [one] рекорд - 100% + [few] рекорда - 100% + [many] рекордов - 100% + *[other] рекордов - 100% +} + +## Demons tab in user area +demons = Демоны +demon-manager = Менеджер демонов + +demon-listed = {$demon} (ID: {$demon-id}) + .publisher = от {$publisher} + +demon-viewer = Демон # + .welcome = Нажмите на демон слева для начала работы! + + .video-field = { demon-video }: + .thumbnail-field = { demon-thumbnail }: + .position-field = { demon-position }: + .requirement-field = { demon-requirement }: + .publisher-field = { demon-publisher }: + .verifier-field = { demon-verifier }: + .creators-field = { demon-creators }: + +demon-add-panel = Добавление демона + .button = Добавить демон! + +# Demon addition form +demon-add-form = Добавление демона + .name-field = { demon-name }: + .name-validator-valuemissing = Пожалуйста, укажите название демона + + .levelid-field = ID уровня в Geometry Dash: + .position-field = { demon-position }: + .requirement-field = { demon-requirement }: + .verifier-field = { demon-verifier }: + .publisher-field = { demon-publisher }: + .video-field = { demon-video }: + .creators-field = { demon-creators }: + + .submit = Добавить демон + + .edit-success = Демон добавлен успешно! + +# Demon viewer dialogs +demon-video-dialog = Изменение ссылки на видео с верификацией + .info = Здесь проходит изменение ссылки на видео с верификацией для этого демона. Оставьте ссылку пустой для удаления видео. + .video-field = Ссылка на видео: + .submit = Изменить + +demon-name-dialog = Изменение названия демона + .info = Здесь проходит изменение названия данного демона. Несколько демонов с одинаковыми именами могут спокойно существовать! + .name-field = Название: + .submit = Изменить + +# { $video-id } will be replaced by https://i.ytimg.com/vi/{.info-videoid}/mqdefault.jpg but italicized +# in english, this looks like https://i.ytimg.com/vi/VIDEO_ID/mqdefault.jpg +demon-thumbnail-dialog = Изменение ссылки на превью + .info = Здесь проходит изменение ссылки на превью для этого демона. Чтобы поставить превью конкретного видео на YouTube, измените значение на { $video-id }. + .info-videoid = VIDEO_ID + + .thumbnail-field = Ссылка на превью: + .submit = Изменить + +demon-position-dialog = Изменение позиции демона + .info = Здесь проходит изменение позиции данного демона. Позиция должна быть больше 0 и не больше текущего значения размера листа. + .position-field = Позиция: + .submit = Изменить + +demon-requirement-dialog = Изменение требования к рекордам на демоне + .info = Здесь проходит изменение требования к рекордам для этого демона. Требование Должно быть между 0 и 100 включительно. + .requirement-field = Требование: + .submit = Изменить + +demon-publisher-dialog = Изменение публикатора демона + .info = Здесь проходит введение нового публикатора демона через поле ниже. Если такой игрок уже существует, его имя появится в качестве предложения ниже поля ввода. После этого нажмите на кнопку ниже. + .submit = Изменить + +demon-verifier-dialog = Изменение верифера демона + .info = Здесь проходит введение нового верифера демона через поле ниже. Если такой игрок уже существует, его имя появится в качестве предложения ниже поля ввода. После этого нажмите на кнопку ниже. + .submit = Изменить + +demon-creator-dialog = Добавление креатора + .info = Здесь проходит добавление нового креатора для данного демона через поле ниже. Если такой игрок уже существует, его имя появится в качестве предложения ниже поля ввода. После этого нажмите на кнопку ниже. + .submit = Добавить креатора + + .edit-success = Креатор добавлен успешно! \ No newline at end of file diff --git a/pointercrate-demonlist-pages/static/ftl/ru-ru/error.ftl b/pointercrate-demonlist-pages/static/ftl/ru-ru/error.ftl new file mode 100644 index 000000000..795864459 --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/ru-ru/error.ftl @@ -0,0 +1,41 @@ +error-demonlist-malformedvideourl = Неправильная ссылка на видео +error-demonlist-bannedfromsubmissions = Вы забанены в демонлисте! +error-demonlist-claimunverified = Ваш запрос на присвоение профиля не подтвержден +error-demonlist-vpsdetected = Была обнаружена попытка IP геолокации через VPS +error-demonlist-nothirdpartysubmissions = Этот игрок указал, что только он сам может отправлять свои рекорды +error-demonlist-submitternotfound = Отправитель с id { $id } не был найден +error-demonlist-notenotfound = Записка с id { $note-id } не была найдена на рекорде с id { $record-id } +error-demonlist-creatornotfound = Игрок с id { $player-id } не является креатором демона с id { demon-id } +error-demonlist-nationalitynotfound = Страна с iso-кодом { $iso-code } не была найдена +error-demonlist-subdivisionnotfound = Регион с кодом { $subdivision-code } не был найден в стране { $nation-code } +error-demonlist-playernotfound = Игрок с id { $player-id } не был найден +error-demonlist-playernotfoundname = Игрок с именем { $player-name } не был найден +error-demonlist-demonnotfound = Демон с id { $demon-id } не был найден +error-demonlist-demonnotfoundname = Демон с именем { $demon-name } не был найден +error-demonlist-demonnotfoundposition = Демон на позиции { $demon-position } не был найден +error-demonlist-recordnotfound = Рекорд с id { $record-id } не был найден +error-demonlist-claimnotfound = Запрос пользователем { $member-id } на присвоение профиля { $player-id } не был найден +error-demonlist-creatorexists = Этот игрок уже указан как креатор на этом демоне +error-demonlist-duplicatevideo = Это видео уже используется рекордом #{ $record-id } +error-demonlist-nonationset = Попытка установить регион без страны +error-demonlist-conflictingclaims = Игроки '{ $player-1 }' и '{ $player-2 }' имеют подтвержденные присвоения разными пользователями pointercrate +error-demonlist-invalidrequirement = Требование к рекорду должно быть больше -1 и меньше 101 +error-demonlist-invalidposition = Позиция демона должна быть между 1 и { $maximal } +error-demonlist-invalidprogress = Прогресс на рекорде должен находиться между { $requirement } и 100%! +error-demonlist-submissionexists = Такой рекорд со статусом '{ $record-status }' уже существует (имеющийся рекорд: { $record-id }) +error-demonlist-playerbanned = Данный игрок забанен и потому не может иметь неотклоненные рекорды! +error-demonlist-submitlegacy = Вы не можете отправлять рекорды для legacy-демонов +error-demonlist-non100extended = Для extended-части листа можно отправлять только прохождения +error-demonlist-unsupportedvideohost = Переданный видеохостинг не поддерживается. Поддерживаются следующие: 'youtube', 'vimeo', 'everyplay', 'twitch' и 'bilibili' +error-demonlist-demonnamenotunique = С данным названием существует сразу несколько демонов +error-demonlist-noteempty = Записки не должны быть пустыми! +error-demonlist-alreadyclaimed = Этот игрок уже имеет связанное с собой присвоение профиля +error-demonlist-rawrequired = Для отправки рекорда необходимо предоставить необработанную запись +error-demonlist-malformedrawurl = Необработанная запись должна быть в виде правильно оформленной ссылки +error-demonlist-invalidlevelid = ID уровня должен быть положительным + +error-demonlist-ratelimit-record-submit = Вы отправляете слишком много рекордов слишком часто! +error-demonlist-ratelimit-record-submit-global = Слишком много рекордов отправляется на данный момент! +error-demonlist-ratelimit-new-submitters = Ограничение запросов для DDoS-защиты +error-demonlist-ratelimit-geolocate = Вы можете использовать геолокацию лишь раз в месяц! +error-demonlist-ratelimit-add-demon = Поаккуратнее с кнопкой бро \ No newline at end of file diff --git a/pointercrate-demonlist-pages/static/ftl/ru-ru/overview.ftl b/pointercrate-demonlist-pages/static/ftl/ru-ru/overview.ftl new file mode 100644 index 000000000..b2718f225 --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/ru-ru/overview.ftl @@ -0,0 +1,49 @@ +main-list = Main List + .info = Основная часть демонлиста. Эти уровни являются сложнейшими оцененными демонами в игре. Рекорды принимаются с прогрессом выше определенного значения и дают большое количество очков! + +extended-list = Extended List + .info = Эти демоны больше не подходят для основной части листа, но все еще имеют высокую актуальность. Для этих демонов принимаются только прохождения! Учтите, что не 100% рекорды, отправленные либо принятые до выпадения демона из main-листа сохранятся в списке. + +legacy-list = Legacy List + .info = Эти демоны раньше были в листе, но выпали из него по мере добавления новых. Они здесь находятся, чтобы поностальгировать. Эта часть листа никак не отсортирована и больше не будет поддерживаться. Это означает, что для этих демонов больше не принимаются рекорды. + +demon-info = был опубликован { $publisher } + .score = { $minimal-score } ({ $requirement }%) — { $total-score } (100%) очков + .score-short = { $score } очков + +## Time machine +time-machine = Машина времени + .info = Введите ниже дату, на основе которой вы хотите увидеть демонлист. По техническим причинам самая ранняя дата - 4 января 2017. Учтите, что данные до 4 августа 2017 являются примерными с максимальной правдоподобностью и не гарантируют 100% правильность информации. В особенности данные ранее 4 апреля 2017 содержат кучу ошибок! + + .destination-field = Цель: + .submit = Перейти! + + .destination-validator-valuemissing = Пожалуйста, укажите значение + .destination-validator-rangeunderflow = Вы не можете вернуться назад во времени настолько далеко! + + .active-position = Сейчас на #{ $position } + .active-position-legacy = Сейчас в Legacy-листе + + .active-info = Вы сейчас смотрите на демонлист, каким он был + .return = Вернуться к настоящему + +## Sidebar panels +editors-panel = Модераторы листа + .info = Свяжитесь с любым из этих людей, если у вас имеются проблемы с листом, или вы хотите предложить в нем конкретные изменения. + +helpers-panel = Помощники листа + .info = Связывайтесь с этими людьми по вопросам статуса ваших рекордов. Не стоит при этом приставать к ним слишком сильно по поводу проверки ваших рекордов! + +guidelines-panel = Правила + .info = Все действия в демонлисте проходят в соответствии с нашими правилами и методическими указаниями. Обязательно прочитайте их перед отправкой рекорда для избежания возможных проблем! + .button = Прочитать правила! + +submission-panel = Отправка рекордов + .info = Пожалуйста, не отправляйте всякую чушь; это лишь усложняет нашу работу и приведет к бану. Также учтите, что форма отклоняет дубликаты рекордов. + .button = Отправить рекорд! + +statsviewer-panel = Панель статистики + .info = Получите детальную статистику о том, кто прошел больше всех демонов, создал больше всех демонов либо прошел сложнейшие демоны! Тут даже есть таблица лидеров для сравнения себя с лучшими из лучших! + .button = Открыть панель статистики! + +discord-panel-info = Присоединяйтесь к официальному Discord-серверу демонлиста - тут вы сможете связаться с его командой! diff --git a/pointercrate-demonlist-pages/static/ftl/ru-ru/player.ftl b/pointercrate-demonlist-pages/static/ftl/ru-ru/player.ftl new file mode 100644 index 000000000..ed977633a --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/ru-ru/player.ftl @@ -0,0 +1,93 @@ +player-banned = Забанен + .yes = Да + .no = Нет + +player-nationality = Национальность + .info = Учтите, что это должно восприниматься как 'Официальная страна больше' и ничего более. Никаких исключений. + .none = Н/Д + +player-subdivision = Политическое подразделение + .none = Н/Д + +## Players tab +players = Игроки + +player-manager = Менеджер игроков + +player-viewer = Игрок # + .welcome = Нажмите на игрока слева для начала работы! + + .info = Добро пожаловать в менеджер игроков. Тут можно банить либо разбанивать игроков. Бан игрока приведет к удалению всех его отправленных либо находящихся на доп.рассмотре рекордов. Статус всех принятых рекордов будет изменен на отклоненный. + .records-redirect = Показать рекорды в менеджере рекордов + +player-idsearch-panel = Поиск игрока по ID + .info = Игроки уникально идентифицируются за счет ID. Введение ID игрока выберет его слева (при условии существования игрока) + .id-field = ID игрока: + + .submit = Найти по ID + + .id-validator-valuemissing = Требуется ID игрока + +player-name-dialog = Изменение имени игрока + .info = Здесь проходит изменение имени данного игрока. Это изменит имя на каждом из его рекордов. Если игрок с новым именем уже существует, их объекты будут объединены, а новый объект получит ID редактируемого игрока. В таком случае рекорды игроков объединяются, ровно как и вся прочая информация. Со стороны сервера каждый рекорд перемещается на нового игрока, и при конфликтах применяются те же правила, что и при редактировании владельца рекорда. + .name-field = Имя: + + .submit = Изменить + + .name-validator-valuemissing = Пожалуйста, укажите имя игрока + +## List integration tab +list-integration = Интеграция в листе + +claimed-player = Присвоенный профиль + .verified = Подтвержден + .unverified = Не подтвержден + +# .info-api-link is turned into a clickable link to the geolocation API +# pointercrate uses, and replaces { $info-api-link } +claim-geolocate = Указать флаг для панели статистики по геолокации + .info = Нажатие на кнопку выше позволит вам поставить флаг в панели статистики через IP-геолокацию. Для этой возможности pointercrate использует { $info-api-link }. Нажатие на кнопку выше также считается как ваше согласие на использование IP pointercrate для отправки его abstract. + .info-api-link = API IP-геолокации от abstract + + .submit = Найти + + .edit-success = Национальность указана как { $nationality } + .edit-success-subdivision = Национальность указана как { $nationality }/{ $subdivision } + +claim-lock-submissions = Ограничить отправку рекордов + .info = Ограничивает отправку ваших рекордов другими игроками, что означает возможность отправки рекордов только при условии входа в аккаунт с подтвержденным присвоением профиля нужного игрока. + + .edit-success = Изменение успешно применено + +claim-records = Рекорды на вашем профиле + .info = Список рекордов на вашем присвоенном профиле, включая все возможные их статусы. Используйте этот список для отслеживания статуса ваших рекордов. Нажатие на рекорд покажет все публичные заметки, которые модераторы листа оставили к этому рекорду. Цвет заднего фона на каждом рекорде показывает, является ли рекорд { $record-approved-styled }, { $record-submitted-styled }, { $record-rejected-styled } или { $record-underconsideration-styled }. + + .record-notes = Заметки для рекорда { $record-id }: + .record-notes-none = На данном рекорде отсутствуют публичные заметки! + +claim-manager = Менеджер присвоения + .info-a = Здесь проходит работа с присвоением профилей через интерфейс ниже. Список сортируется по профилям и пользователям через панели справа. Неправильные запросы на присвоение должны удаляться через кнопку с мусоркой. + .info-b = Для подтверждения присвоения нажмите на галочку. Удостоверьтесь, что запрос на присвоение правильный (для этого придется поговорить с игроком, профиль которого присваивается, и уточнить, сами ли они отправили запрос, либо же он принадлежит злоумышленнику). + .info-c = После подтверждения присвоения все другие запросы для данного профиля удаляются. Пользователи не могут отправить новый запрос на присвоение профилей, уже присвоенных ранее. + .info-d = Запрос с зеленым цветом подтвержден, запрос с зеленым цветом еще не был просмотрен. + + .claim-no-records = Присваиваемый профиль ({ $player-id }) не имеет принятых рекордов в листе + +claim-listed-user = Запрос от пользователя: +claim-listed-player = Присвоение профиля: + +claim-initiate-panel = Начать присвоение + .info = Выберите имя игрока, профиль которого хотите присвоить ниже + +# { $discord } is replaced by .info-discord, which is turned into a +# clickable link to Pointercrate Central by default (this can be modified +# in pointercrate-example/src/main.rs) +claim-info-panel = Присвоение 101 + .info-a = Присвоение профиля - это процесс ассоциации профиля в листе с аккаунтом пользователя pointercrate. Подтвержденное присвоение позволяет модифицировать некоторые характеристики игрока, такие как национальность. + .info-b = Для начала присвоения нажмите на иконку ручки слева от заголовка "Присвоенный игрок". После выбора профиля запрос все еще не подтвержден. Эти запросы подтверждаются вручную членами команды pointercrate. Вы можете попросить подтверждение запроса на { $discord }. + .info-c = Вы не можете присвоить профиль игрока, который уже был присвоен другим пользователем до этого. + + .info-discord = этом Discord-сервере + +claim-video-panel = Видео рекорда + .info = Нажатие на запрос в панели 'Менеджер присвоения' выведет случайное видео из принятого рекорда присваиваемым игроком. \ No newline at end of file diff --git a/pointercrate-demonlist-pages/static/ftl/ru-ru/record.ftl b/pointercrate-demonlist-pages/static/ftl/ru-ru/record.ftl new file mode 100644 index 000000000..a83a11b85 --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/ru-ru/record.ftl @@ -0,0 +1,123 @@ +## Commonly referenced record data +record-submitted = Отправлен +record-underconsideration = На рассмотрении +record-approved = Принят +record-rejected = Отклонен + +record-videolink = Ссылка на видео +record-videoproof = Видео-доказательства +record-rawfootage = Необработанная запись +record-demon = Демон +record-holder = Владелец рекорда +record-progress = Прогресс +record-submitter = ID отправителя + +## Records tab (user area) +records = Рекорды +record-manager = Менеджер рекордов + .all-option = Все демоны + +record-listed = Рекорд #{ $record-id } + .progress = { $percent }% на { $demon } + +record-viewer = Рекорд # + .welcome = Нажмите на рекорд слева для начала работы! + .delete = Удалить рекорд + + .copy-data-success = Данные о рекорде скопированы в буфер обмена! + .copy-data-error = Ошибка копирования в буфер обмена + + .confirm-delete = Вы уверены? Это невозвратно удалит этот рекорд и все заметки на нем! + +record-note = Добавить заметку + .placeholder = Здесь проходит добавление заметок. Нажмите 'Добавить' выше после написания! + .public-checkbox = Публичная заметка + + .submit = Добавить + +record-note-listed = Заметка #{ $note-id } + .confirm-delete = Это действие невозвратно удалит эту заметку. Продолжить? + + .author = Эта заметка была оставлена { $author }. + .author-submitter = Эта заметка была оставлена как комментарий от отправителя. + .editors = Эту заметка позже отредактировали: { $editors }. + .transferred = Эта заметка изначально не принадлежит этому рекорду. + .public = Эта заметка является публичной. + +record-status-filter-panel = Фильтрация + .info = Фильтрация по статусу рекордов + +record-status-filter-all = Все + +record-idsearch-panel = Найти рекорд по ID + .info = Рекорды можно уникально идентифицировать по их ID. Введение ID рекорда ниже выберет его слева (при условии его существования) + .id-field = ID рекорда: + + .submit = Найти по ID + + .id-validator-valuemissing = Требуется ID рекорда + +record-playersearch-panel = Фильтрация по игроку + .info = Игроков можно уникально идентифицировать по их имени и ID. Введение любого из них в соответствующем поле ниже отфильтрует список слева. Фильтр сбрасывается через нажатие на "Найти ..." с пустыми полями ввода. + + .id-field = ID игрока: + .id-submit = Найти по ID + + .name-field = Имя игрока: + .name-submit = Найти по имени + +# Record viewer dialogs +record-videolink-dialog = Изменение ссылки на видео + .info = Здесь проходит изменение ссылки на видео для данного рекорда. Учтите, что если вы являетесь модератором листа, вам положено оставить это поле пустым для удаления видео с рекорда. + .videolink-field = Ссылка на видео: + + .submit = Изменить + + .videolink-validator-typemismatch = Пожалуйста, введите правильную ссылку + +record-demon-dialog = Изменение демона в рекорде + .info = Здесь проходит изменение демона, связанного с данным рекордом. Ниже можно найти демон, с которым должен быть этот рекорд. После этого нажмите на него для изменения рекорда + +record-holder-dialog = Изменение владельца рекорда + .info = Здесь проходит изменение владельца данного рекорда через текстовое поле ниже. Если такой игрок уже существует, он появится под полем в качестве предложения. После этого нажмите на кнопку ниже. + .submit = Изменить + +record-progress-dialog = Изменение прогресса в рекорде + .info = Здесь проходит изменение значения прогресса для данного рекорда. Прогресс должен находиться между изначальным требованием для данного демона и 100 (включительно). + .progress-field = Прогресс: + + .submit = Изменить + + .progress-validator-rangeunderflow = Значение прогресса не может быть отрицательным + .progress-validator-rangeoverflow = Значение прогресса не может быть больше 100% + .progress-validator-badinput = Значение прогресса должно быть целым числом + .progress-validator-stepmismatch = Значение прогресса не должно быть дробным + .progress-validator-valuemissing = Пожалуйста, введите значение прогресса + +# The giant information box below the record manager, split +# into different sections here +# +# Each section (except .a and .b) will begin with a bolded version of +# the appropriate record state, or a bolded version of .note for .note-a/b +# attributes +# +record-manager-help = Работа с рекордами + .a = Используйте список слева для выбора рекордов и их последующего просмотра либо изменения. Используйте панель справа для фильтрации списка рекордов по статусу, игроку и т.д. Нажатие на поле { record-status-filter-all } сверху позволяет фильтровать по конкретному демону. + + .b = Рекорд может быть в 4 различных состояниях: { record-rejected }, { record-approved }, { record-submitted } и { record-underconsideration }. Для простоты объяснения представим, что существует игрок по имени Bob, имеющий рекорд на демоне Cataclysm. + + .rejected = Если рекорд { record-rejected }, это означает, что у Bob нет других рекордов на Cataclysm с другими статусами, и Bob далее не может отправить рекорды на Cataclysm. Также это означает, что если у Bob имеется неотклоненный рекорд на Cataclysm, мы можем тут же понять, что у Bob отклоненных рекордов на Cataclysm нет впринципе. + Отклонение любого рекорда от Bob на Cataclysm удалит все прочие рекорды Bob на Cataclysm для сохранения уникальности, описанной выше. + + .approved = Если рекорд { record-approved }, это означает, что у Bob нет других рекордов с меньшим прогрессом, чем существующий рекорд со статусом { record-approved }, и такие рекорды при этом запрещены для отправки. + Изменение статуса рекорда на { record-approved } удалит все рекорды от Bob на Cataclysm с меньшим прогрессом. + + .submitted = Если рекорд { record-submitted }, на нем нет никаких ограничений по уникальности. Это означает, что у Bob может быть сразу несколько отправленных рекордов на Cataclysm при условии того, что на каждом из них ссылки на видео разные. Стоит учесть, что по условиям выше все дубликаты удаляются, как только один из рекордов будет { record-approved } либо { record-rejected }. + + .underconsideration = Если рекорд { record-underconsideration }, концептуально он также является отправленным рекордом. Единственная разница заключается в том, что Bob больше не может отправлять рекорды на Cataclysm. + + .note = Заметки + + .note-a = Если игрок забанен, им запрещено иметь рекорды со статусом { record-approved } либо { record-submitted } в листе. Все рекорды, помеченные как '{ record-submitted }' будут удалены, все остальные поменяют статус на '{ record-rejected }'. + + .note-b = Бан отправителя приведет к удалению всех их рекордов со статусом '{ record-submitted }'. Отправленные ими рекорды, которые уже поменяли статус на { record-approved } либо { record-rejected } не будут затронуты. \ No newline at end of file diff --git a/pointercrate-demonlist-pages/static/ftl/ru-ru/statsviewer.ftl b/pointercrate-demonlist-pages/static/ftl/ru-ru/statsviewer.ftl new file mode 100644 index 000000000..efc005b2e --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/ru-ru/statsviewer.ftl @@ -0,0 +1,82 @@ +statsviewer = Панель статистики + .rank = Позиция в демонлисте + .score = Очки демонлиста + .stats = Статистика в демонлисте + .hardest = Сложнейший демон + + .completed = Пройденные демоны + .completed-main = Пройденные демоны из Main-листа + .completed-extended = Пройденные демоны из Extended-листа + .completed-legacy = Пройденные демоны из Legacy-листа + + .created = Созданные демоны + .published = Опубликованные демоны + .verified = Верифнутые демоны + .progress = Прогрессы + + .stats-value = { $main } Main, { $extended } Extended, { $legacy } Legacy + .value-none = Н/Д + +statsviewer-individual = Игроки + .welcome = Нажмите на имя игрока слева для начала работы! + + .option-international = Международный + +statsviewer-nation = Страны + .welcome = Нажмите на имя страны слева для начала работы! + + .players = Игроки + .unbeaten = Непройденные демоны + + .created-tooltip = Был создан { $players } { $players -> + [one] игрок + [few] игрока + [many] игроков + *[other] игроков + } в этой стране: + .published-tooltip = Был опубликован: + .verified-tooltip = Был верифицирован: + .beaten-tooltip = Был пройден { $players } { $players -> + [one] игрок + [few] игрока + [many] игроков + *[other] игроков + } в этой стране: + .progress-tooltip = Был достигнут { $players } { $players -> + [one] игрок + [few] игрока + [many] игроков + *[other] игроков + } в этой стране: + +demon-sorting-panel = Сортировка демонов + .info = Порядок, в котором пройденные демоны должны отображаться + + .option-alphabetical = По алфавиту + .option-position = По позиции + +continent-panel = Континент + .info = Выберите континент ниже, чтобы сфокусировать панель статистики на данном континенте. Выберите 'Все' для сброса фильтра. + + .option-all = Все + + .option-asia = Азия + .option-europe = Европа + .option-australia = Австралия + .option-africa = Африка + .option-northamerica = Северная Америка + .option-southamerica = Южная Америка + .option-centralamerica = Центральная Америка + +toggle-subdivision-panel = Показать подразделения + .info = Настройка отображения на карте политических подразделений. + + .option-toggle = Показать политические подразделения + +# { $countries } will be replaced with .info-countries, which will be +# turned into a tooltip listing all of the selectable countries +subdivision-panel = Политическое подразделение + .info = Для { $countries } вы можете выбрать штат либо регион из выпадающего списка ниже, чтобы сфокусировать панель статистики на выбранном штате либо регионе. + .info-countries = следующих стран + + .option-none = Н/Д diff --git a/pointercrate-demonlist-pages/static/ftl/ru-ru/submitter.ftl b/pointercrate-demonlist-pages/static/ftl/ru-ru/submitter.ftl new file mode 100644 index 000000000..a7c029c73 --- /dev/null +++ b/pointercrate-demonlist-pages/static/ftl/ru-ru/submitter.ftl @@ -0,0 +1,89 @@ +submitter-banned = Забанен + .yes = Да + .no = Нет + +## Record submitter +record-submission-panel = Отправление рекордов + .info = Заметка: Пожалуйста, не отправляйте всякую чушь, это лишь усложняет нам работу и приведет к бану. Также учтите, что форма отклоняет дубликаты уже существующихрекордов. + .redirect = Отправить рекорд! + +# .note will prefix all notes in the record submission panel +# (not to be confused with record notes) +# +# { $guidelines-link } will be replaced by .guidelines-link, +# which is turned into a clickable link to the submission guidelines +record-submission = Отправленный рекорд + .note = Заметка + + .demon = Демон + .demon-info = Демон, на котором был поставлен рекорд. Только демоны из топа {$list-size} здесь принимаются. Это исключает legacy-демоны! + + .demon-validator-valuemissing = Пожалуйста, укажите демон + + .holder = Владелец + .holder-info = Владеющий рекордом игрок. При введении ника ниже будут показаны предложения. Если это ваш первый рекорд. напишите свое имя ниже так, как вы бы хотели его видеть далее на сайте (игнорируя все предлагаемые варианты). + + .holder-input-placeholder = Начните писать для показа предложений... + + .holder-validator-valuemissing = Пожалуйста, укажите владельца игрока + .holder-validator-rangeoverflow = Из-за лимитов в самой Geometry Dash я знаю, что у игрока такого длинного имени быть не может + + .progress = Прогресс + .progress-info = Поставленный прогресс в виде процента. Принимаются только значения между минимальным требованиям для конкретного демона и 100%! + .progress-placeholder = напр. '50', '98' + + .progress-validator-valuemissing = Пожалуйста, укажите прогресс для рекорда + .progress-validator-rangeunderflow = Значение прогресса не может быть отрицательным + .progress-validator-rangeoverflow = Значение прогресса не может быть больше 100% + .progress-validator-badinput = Значение прогресса должно быть целым числом + .progress-validator-stepmismatch = Значение прогресса не должно быть дробнымRecord progress mustn't be a decimal + + .video = Видео + .video-info = Видео-доказательства честности данного рекорда. Если рекорд был поставлен на стриме, но не был опубликован где-то еще, предоставьте Twitch-ссылку на этот стрим. + .video-note = Внимательно вводите ссылки и соблюдайте их правильность! + .video-placeholder = напр. https://youtu.be/cHEGAqOgddA + + .video-validator-valuemissing = Пожалуйста, укажите видео, чтобы мы могли проверить легитимность рекорда + .video-validator-typemismatch = Пожалуйста, укажите правильную ссылку + + .raw-footage = Необработанная запись + .raw-footage-info-a = Неотредактированное и необрезанное видео, в котором ставится этот рекорд, выложенный на файлообменник без сжатия файлов (напр. не YouTube), по типу Google Диска. + .raw-footage-info-b = Любая личная информация, потенциально попавшая в необработанную запись (напр. имена либо конфиденциальная информация) будет храниться в строгой секретности и не будет выложена вне пределов команды демонлиста. Также это означает, что вы осознаете, что можете потенциально поделиться такой информацией, прикрепляя сюда необработанную запись. У вас есть право на запрос удаления необработанной записи с вашего рекорда через связь с администратором листа. + .raw-footage-note = Это требуется для каждого рекорда, который вы отправляете в лист! + + .raw-footage-validator-typemismatch = Пожалуйста, введите правильную ссылку + + .notes = Заметки и комментарии + .notes-info = Здесь проходит добавление любых доп. комментариев, которые вы хотите отправить проверяющему ваш рекорд модератору листа. + .notes-placeholder = Ваши мечты и надежды на этот рекорд... или что-то типа того + + .guidelines = Отправляя этот рекорд, вы соглашаетесь с { $guidelines-link }. + .guidelines-link = правилами отправки рекордов + + .submit = Отправить рекорд + + .submission-success = Рекорд успешно отправлен. + .submission-success-queue = Рекорд успешно отправлен. Он находится на { $queue-position } в очереди! + +## Submitters tab +submitters = Отправители + +submitter-manager = Менеджер отправителей + +submitter-viewer = Отправитель # + .welcome = Нажмите на отправителя слева для начала работы! + + .info-a = Добро пожаловать в менеджер отправителей. Здесь можно банить либо разбанивать отправителей через новый и революционный интерфейс, который совершенно не является тупой копиркой интерфейса игроков с еще меньшим выбором опций. + .info-b = Бан отправителя приведет к удалению всех отправленных ими рекордов, находящихся в статусе '{ record-submitted }'. Все принятые, отклоненные либо нахоядщиеся на доп. рассмотрении рекорды от них не будут затронуты. + + .records-redirect = Показать рекорды в менеджере рекордов + +submitter-listed = Отправитель #{ $submitter-id } + +submitter-idsearch-panel = Найти отправителя по ID + .info = Отправителей можно опознать по их ID. Введение ID отправителя ниже выберет его слева (при условии его существования) + .id-field = ID отправителя: + + .submit = Найти по ID + + .id-validator-valuemissing = Требуется ID отправителя \ No newline at end of file diff --git a/pointercrate-demonlist-pages/static/js/account/demon.js b/pointercrate-demonlist-pages/static/js/account/demon.js index 1325f4cf4..96dbbff75 100644 --- a/pointercrate-demonlist-pages/static/js/account/demon.js +++ b/pointercrate-demonlist-pages/static/js/account/demon.js @@ -21,6 +21,7 @@ import { setupEditorDialog, FormDialog, } from "/static/core/js/modules/form.js"; +import { loadResource, tr } from "/static/core/js/modules/localization.js"; export let demonManager; @@ -67,8 +68,8 @@ export class DemonManager extends FilteredPaginator { thumbnailForm.addValidators({ "demon-thumbnail-edit": { - "Please enter a valid URL": typeMismatch, - "Please enter a URL": valueMissing, + [tr("demonlist", "demon", "demon-thumbnail.validator-typemismatch")]: typeMismatch, + [tr("demonlist", "demon", "demon-thumbnail.validator-valuemissing")]: valueMissing, }, }); @@ -85,11 +86,11 @@ export class DemonManager extends FilteredPaginator { requirementForm.addValidators({ "demon-requirement-edit": { - "Record requirement cannot be negative": rangeUnderflow, - "Record requirement cannot be larger than 100%": rangeOverflow, - "Record requirement must be a valid integer": badInput, - "Record requirement mustn't be a decimal": stepMismatch, - "Please enter a requirement value": valueMissing, + [tr("demonlist", "demon", "demon-requirement.validator-underflow")]: rangeUnderflow, + [tr("demonlist", "demon", "demon-requirement.validator-rangeoverflow")]: rangeOverflow, + [tr("demonlist", "demon", "demon-requirement.validator-badinput")]: badInput, + [tr("demonlist", "demon", "demon-requirement.validator-stepmismatch")]: stepMismatch, + [tr("demonlist", "demon", "demon-requirement.validator-valuemissing")]: valueMissing, }, }); @@ -104,10 +105,10 @@ export class DemonManager extends FilteredPaginator { positionForm.addValidators({ "demon-position-edit": { - "Demon position must be at least 1": rangeUnderflow, - "Demon position must be a valid integer": badInput, - "Demon position mustn't be a decimal": stepMismatch, - "Please enter a position": valueMissing, + [tr("demonlist", "demon", "demon-position.validator-rangeunderflow")]: rangeUnderflow, + [tr("demonlist", "demon", "demon-position.validator-badinput")]: badInput, + [tr("demonlist", "demon", "demon-position.validator-stepmismatch")]: stepMismatch, + [tr("demonlist", "demon", "demon-position.validator-valuemissing")]: valueMissing, }, }); @@ -122,7 +123,7 @@ export class DemonManager extends FilteredPaginator { nameForm.addValidators({ "demon-name-edit": { - "Please provide a name for the demon": valueMissing, + [tr("demonlist", "demon", "demon-name.validator-valuemissing")]: valueMissing, }, }); setupEditorDialog( @@ -252,36 +253,43 @@ function createCreatorHtml(creator) { function setupDemonAdditionForm() { let form = new Form(document.getElementById("demon-submission-form")); - form.addValidators({ - "demon-add-name": { "Please specify a name": valueMissing }, + "demon-add-name": { [tr("demonlist", "demon", "demon-name.validator-valuemissing")]: valueMissing }, "demon-add-level-id": { - "Level ID must be positive": rangeUnderflow, + [tr("demonlist", "demon", "demon-id.validator-rangeunderflow")]: rangeUnderflow, }, "demon-add-position": { - "Please specify a position": valueMissing, - "Demon position cannot be smaller than 1": rangeUnderflow, - "Demon position must be a valid integer": badInput, - "Demon position must be integer": stepMismatch, + [tr("demonlist", "demon", "demon-position.validator-valuemissing")]: valueMissing, + [tr("demonlist", "demon", "demon-position.validator-rangeunderflow")]: rangeUnderflow, + [tr("demonlist", "demon", "demon-position.validator-badinput")]: badInput, + [tr("demonlist", "demon", "demon-position.validator-stepmismatch")]: stepMismatch, }, "demon-add-requirement": { - "Please specify a requirement for record progress on this demon": + [tr("demonlist", "demon", "demon-requirement.validator-valuemissing")]: valueMissing, - "Record requirement cannot be smaller than 0%": rangeUnderflow, - "Record requirement cannot be greater than 100%": rangeOverflow, - "Record requirement must be a valid integer": badInput, - "Record requirement must be integer": stepMismatch, + [tr("demonlist", "demon", "demon-requirement.validator-rangeunderflow")]: rangeUnderflow, + [tr("demonlist", "demon", "demon-requirement.validator-overflow")]: rangeOverflow, + [tr("demonlist", "demon", "demon-requirement.validator-badinput")]: badInput, + [tr("demonlist", "demon", "demon-requirement.validator-stepmismatch")]: stepMismatch, }, - "demon-add-verifier": { "Please specify a verifier": valueMissing }, - "demon-add-publisher": { "Please specify a publisher": valueMissing }, - "demon-add-video": { "Please enter a valid URL": typeMismatch }, + "demon-add-requirement": { + [tr("demonlist", "demon", "demon-requirement.validator-valuemissing")]: + valueMissing, + [tr("demonlist", "demon", "demon-requirement.validator-rangeunderflow")]: rangeUnderflow, + [tr("demonlist", "demon", "demon-requirement.validator-rangeoverflow")]: rangeOverflow, + [tr("demonlist", "demon", "demon-requirement.validator-badinput")]: badInput, + [tr("demonlist", "demon", "demon-requirement.validator-stepmismatch")]: stepMismatch, + }, + "demon-add-verifier": { [tr("demonlist", "demon", "demon-verifier.validator-valuemissing")]: valueMissing }, + "demon-add-publisher": { [tr("demonlist", "demon", "demon-publisher.validator-valuemissing")]: valueMissing }, + "demon-add-video": { [tr("demonlist", "demon", "demon-video.validator-typemismatch")]: typeMismatch }, }); form.creators = []; form.onSubmit(() => { let data = form.serialize(); - + data["creators"] = form.creators; post("/api/v2/demons/", {}, data) @@ -326,7 +334,7 @@ export function initialize() { ), }); - demonManager.output.setSuccess("Successfully added creator"); + demonManager.output.setSuccess(tr("demon-creator-dialog.edit-success")); }) .catch((response) => { displayError(creatorFormDialog.form)(response); diff --git a/pointercrate-demonlist-pages/static/js/account/integration.js b/pointercrate-demonlist-pages/static/js/account/integration.js index 8b44565f6..7cefeb121 100644 --- a/pointercrate-demonlist-pages/static/js/account/integration.js +++ b/pointercrate-demonlist-pages/static/js/account/integration.js @@ -14,6 +14,7 @@ import { } from "/static/demonlist/js/modules/demonlist.js"; import { Paginator } from "/static/core/js/modules/form.js"; import { generateRecord } from "/static/demonlist/js/modules/demonlist.js"; +import { loadResource, tr, trp } from "/static/core/js/modules/localization.js"; export let claimManager; @@ -33,11 +34,9 @@ class ClaimManager extends FilteredPaginator { {} ).then((response) => { if (response.data.length === 0) { - this.setError( - "The claimed player (" + - selected.dataset.playerId + - ") does not have an approved record on the list" - ); + this.setError(trp("demonlist", "player", "claim-manager.claim-no-records", { + ["player-id"]: selected.dataset.playerId, + })) document.getElementById("claim-video").removeAttribute("src"); } else { document.getElementById("claim-video").src = embedVideo( @@ -60,7 +59,7 @@ function generateClaim(claim) { let playerSpan = document.createElement("span"); let uname = document.createElement("b"); - uname.innerText = "Claim by user: "; + uname.innerText = tr("demonlist", "player", "claim-listed-user") + " "; userSpan.appendChild(uname); userSpan.appendChild( @@ -68,7 +67,7 @@ function generateClaim(claim) { ); let pname = document.createElement("b"); - pname.innerText = "Claim on player: "; + pname.innerText = tr("demonlist", "player", "claim-listed-player") + " "; playerSpan.appendChild(pname); playerSpan.appendChild( @@ -172,7 +171,9 @@ class ClaimedPlayerRecordPaginator extends Paginator { } let title = document.createElement("b"); - title.innerText = "Notes for record " + recordId + ":"; + title.innerText = trp("demonlist", "player", "claim-records.record-notes", { + ["record-id"]: recordId, + }); this.successOutput.appendChild(title); this.successOutput.appendChild(document.createElement("br")); @@ -189,7 +190,7 @@ class ClaimedPlayerRecordPaginator extends Paginator { this.successOutput.style.display = "block"; } else { - this.setSuccess("No public notes on this record!"); + this.setSuccess(tr("demonlist", "player", "claim-records.record-notes-none")); } }) .catch(displayError(this)); @@ -234,14 +235,14 @@ export function initialize() { .then((response) => { let nationality = response.data; if (nationality.subdivision) { - output.setSuccess( - "Set nationality to " + - nationality.nation + - "/" + - nationality.subdivision.name - ); + output.setSuccess(trp("demonlist", "player", "claim-geolocate.edit-success-subdivision", { + ["nationality"]: nationality.nation, + ["subdivision"]: nationality.subdivision.name, + })) } else { - output.setSuccess("Set nationality to " + nationality.nation); + output.setSuccess(trp("demonlist", "player", "claim-geolocate.edit-success", { + ["nationality"]: nationality.nation, + })) } }) .catch(displayError(output)); @@ -258,7 +259,7 @@ export function initialize() { { lock_submissions: lockSubmissionsCheckbox.checked } ) .then((_) => { - output.setSuccess("Successfully applied changed"); + output.setSuccess(tr("demonlist", "player", "claim-lock-submissions.edit-success")); }) .catch(displayError(output)); }); diff --git a/pointercrate-demonlist-pages/static/js/account/player.js b/pointercrate-demonlist-pages/static/js/account/player.js index 446ab5849..5b6fb85cf 100644 --- a/pointercrate-demonlist-pages/static/js/account/player.js +++ b/pointercrate-demonlist-pages/static/js/account/player.js @@ -15,6 +15,7 @@ import { get, } from "/static/core/js/modules/form.js"; import { recordManager, initialize as initRecords } from "./records.js"; +import { loadResource, tr } from "/static/core/js/modules/localization.js"; export let playerManager; @@ -79,7 +80,7 @@ class PlayerManager extends FilteredPaginator { this.currentObject.nationality.country_code ).then(() => { if (!this.currentObject.nationality.subdivision) { - this._subdivision.selectSilently("None"); + this._subdivision.selectSilently(tr("demonlist", "player", "player-subdivision.none")); } else { this._subdivision.selectSilently( this.currentObject.nationality.subdivision.iso_code @@ -87,8 +88,8 @@ class PlayerManager extends FilteredPaginator { } }); } else { - this._nationality.selectSilently("None"); - this._subdivision.selectSilently("None"); + this._nationality.selectSilently(tr("demonlist", "player", "player-nationality.none")); + this._subdivision.selectSilently(tr("demonlist", "player", "player-subdivision.none")); } } @@ -102,7 +103,7 @@ class PlayerManager extends FilteredPaginator { form.addValidators({ "player-name-edit": { - "Please provide a name for the player": valueMissing, + [tr("demonlist", "player", "player-name-dialog.name-validator-valuemissing")]: valueMissing, }, }); } @@ -114,7 +115,7 @@ function setupPlayerSearchPlayerIdForm() { ); var playerId = playerSearchByIdForm.input("search-player-id"); - playerId.addValidator(valueMissing, "Player ID required"); + playerId.addValidator(valueMissing, tr("demonlist", "player", "player-idsearch-panel.id-validator-valuemissing")); playerSearchByIdForm.onSubmit(function () { playerManager .selectArbitrary(parseInt(playerId.value)) @@ -144,5 +145,6 @@ export function initialize(tabber) { recordManager.updateQueryData("player", playerManager.currentObject.id); tabber.selectPane("3"); // definitely initializes the record manager } - }); + } + ); } diff --git a/pointercrate-demonlist-pages/static/js/account/records.js b/pointercrate-demonlist-pages/static/js/account/records.js index 8f3de248e..537007baa 100644 --- a/pointercrate-demonlist-pages/static/js/account/records.js +++ b/pointercrate-demonlist-pages/static/js/account/records.js @@ -26,6 +26,7 @@ import { generateRecord, embedVideo, } from "/static/demonlist/js/modules/demonlist.js"; +import { tr, trp } from "/static/core/js/modules/localization.js"; export let recordManager; @@ -96,9 +97,9 @@ class RecordManager extends Paginator { this.currentObject.video ) .then(() => - this.output.setSuccess("Copied record data to clipboard!") + this.output.setSuccess(tr("demonlist", "record", "record-viewer.copy-data-success")) ) - .catch(() => this.output.setError("Error copying to clipboard")); + .catch(() => this.output.setError(tr("demonlist", "record", "record-viewer.copy-data-error"))); }); } @@ -112,11 +113,11 @@ class RecordManager extends Paginator { form.addValidators({ "record-progress-edit": { - "Record progress cannot be negative": rangeUnderflow, - "Record progress cannot be larger than 100%": rangeOverflow, - "Record progress must be a valid integer": badInput, - "Record progress mustn't be a decimal": stepMismatch, - "Please enter a progress value": valueMissing, + [tr("demonlist", "record", "record-progress-dialog.progress-validator-rangeunderflow")]: rangeUnderflow, + [tr("demonlist", "record", "record-progress-dialog.progress-validator-rangeoverflow")]: rangeOverflow, + [tr("demonlist", "record", "record-progress-dialog.progress-validator-badinput")]: badInput, + [tr("demonlist", "record", "record-progress-dialog.progress-validator-stepmismatch")]: stepMismatch, + [tr("demonlist", "record", "record-progress-dialog.progress-validator-valuemissing")]: valueMissing, }, }); @@ -133,7 +134,7 @@ class RecordManager extends Paginator { form.addValidators({ "record-video-edit": { - "Please enter a valid URL": typeMismatch, + [tr("demonlist", "record", "record-videolink-dialog.videolink-validator-typemismatch")]: typeMismatch, }, }); @@ -233,7 +234,7 @@ function createNoteHtml(note) { closeX.style.transform = "scale(0.75)"; closeX.addEventListener("click", () => { - if (confirm("This action will irrevocably delete this note. Proceed?")) { + if (confirm(tr("demonlist", "record", "record-note-listed.confirm-delete"))) { del( "/api/v1/records/" + recordManager.currentObject.id + @@ -246,7 +247,9 @@ function createNoteHtml(note) { } let b = document.createElement("b"); - b.innerText = "Record Note #" + note.id; + b.innerText = trp("demonlist", "record", "record-note-listed", { + ["note-id"]: note.id, + }) let i = document.createElement("i"); i.innerText = note.content; @@ -257,25 +260,27 @@ function createNoteHtml(note) { if (note.author === null) { furtherInfo.innerText = - "This note was left as a comment by the submitter. "; + tr("demonlist", "record", "record-note-listed.author-submitter"); } else { - furtherInfo.innerText = "This note was left by " + note.author + ". "; + furtherInfo.innerText = trp("demonlist", "record", "record-note-listed.author", { + ["author"]: note.author, + }); } + furtherInfo.innerText += " "; if (note.editors.length) { - furtherInfo.innerText += - "This note was subsequently modified by: " + - note.editors.join(", ") + - ". "; + furtherInfo.innerText += trp("demonlist", "record", "record-note-listed.editors", { + ["editors"]: note.editors.join(", ") + }) + " "; } if (note.transferred) { furtherInfo.innerText += - "This not was not originally left on this record. "; + tr("demonlist", "record", "record-note-listed.transferred") + " "; } if (note.is_public) { - furtherInfo.innerText += "This note is public. "; + furtherInfo.innerText += tr("demonlist", "record", "record-note-listed.public") + " "; } if (isAdmin) noteDiv.appendChild(closeX); @@ -337,7 +342,7 @@ function setupRecordSearchRecordIdForm() { ); var recordId = recordSearchByIdForm.input("record-record-id"); - recordId.addValidator(valueMissing, "Record ID required"); + recordId.addValidator(valueMissing, tr("demonlist", "record", "record-idsearch-panel.id-validator-valuemissing")); recordSearchByIdForm.onSubmit(function () { recordManager .selectArbitrary(parseInt(recordId.value)) @@ -361,7 +366,9 @@ function setupRecordFilterPlayerNameForm() { let json = response.data; if (!json || json.length == 0) { - playerName.errorText = "No player with that name found!"; + playerName.errorText = trp("core", "error", "error-demonlist-playernotfoundname", { + ["player-name"]: playerName.value, + }); } else { recordManager.updateQueryData("player", json[0].id); } @@ -375,7 +382,7 @@ function setupEditRecordForm() { document.getElementById("record-delete").addEventListener("click", () => { if ( confirm( - "Are you sure? This will irrevocably delete this record and all notes made on it!" + tr("demonlist", "record", "record-viewer.confirm-delete") ) ) { del("/api/v1/records/" + recordManager.currentObject.id + "/", { diff --git a/pointercrate-demonlist-pages/static/js/account/submitter.js b/pointercrate-demonlist-pages/static/js/account/submitter.js index ff9a4b190..6aa60bf2c 100644 --- a/pointercrate-demonlist-pages/static/js/account/submitter.js +++ b/pointercrate-demonlist-pages/static/js/account/submitter.js @@ -8,6 +8,7 @@ import { PaginatorEditorBackend, } from "/static/core/js/modules/form.js"; import { recordManager, initialize as initRecords } from "./records.js"; +import { loadResource, tr, trp } from "/static/core/js/modules/localization.js"; export let submitterManager; @@ -23,7 +24,9 @@ function generateSubmitter(submitter) { li.classList.add("ok"); } - b.innerText = "Submitter #" + submitter.id; + b.innerText = trp("demonlist", "submitter", "submitter-listed", { + ["submitter-id"]: submitter.id, + }); li.appendChild(b); return li; @@ -66,7 +69,9 @@ function setupSubmitterSearchSubmitterIdForm() { ); var submitterId = submitterSearchByIdForm.input("search-submitter-id"); - submitterId.addValidator(valueMissing, "Submitter ID required"); + submitterSearchByIdForm.addErrorOverride(40401, "search-submitter-id"); + + submitterId.addValidator(valueMissing, tr("demonlist", "submitter", "submitter-idsearch-panel.id-validator-valuemissing")); submitterSearchByIdForm.onSubmit(function () { submitterManager .selectArbitrary(parseInt(submitterId.value)) @@ -99,5 +104,6 @@ export function initialize(tabber) { ); tabber.selectPane("3"); } - }); + } + ); } diff --git a/pointercrate-demonlist-pages/static/js/demonlist.js b/pointercrate-demonlist-pages/static/js/demonlist.js index ee1c35476..3665459a8 100644 --- a/pointercrate-demonlist-pages/static/js/demonlist.js +++ b/pointercrate-demonlist-pages/static/js/demonlist.js @@ -3,15 +3,16 @@ import { initializeTimeMachine, } from "/static/demonlist/js/modules/demonlist.js"; import { get } from "/static/core/js/modules/form.js"; +import { tr, trp } from "/static/core/js/modules/localization.js"; -$(document).ready(function () { - if (window.demon_id) { - initializePositionChart(); - initializeHistoryTable(); - } +$(window).on("load", function () { + if (window.demon_id) { + initializePositionChart(); + initializeHistoryTable(); + } - initializeRecordSubmitter(); - initializeTimeMachine(); + initializeRecordSubmitter(); + initializeTimeMachine(); }); function initializeHistoryTable() { @@ -53,7 +54,7 @@ function initializeHistoryTable() { entry["new_position"] > window.extended_list_length || lastPosition > window.extended_list_length ) { - cells[1].appendChild(document.createTextNode("Legacy")); + cells[1].appendChild(document.createTextNode(tr("demonlist", "demon", "movements-newposition.legacy"))); } else { cells[1].appendChild(arrow); cells[1].appendChild( @@ -73,21 +74,28 @@ function initializeHistoryTable() { let reason = null; if (entry["reason"] === "Added") { - reason = "Added to list"; + reason = tr("demonlist", "demon", "movements-reason.added"); } else if (entry["reason"] === "Moved") { - reason = "Moved"; + reason = tr("demonlist", "demon", "movements-reason.moved"); } else { if (entry["reason"]["OtherAddedAbove"] !== undefined) { let other = entry["reason"]["OtherAddedAbove"]["other"]; let name = other.name === null ? "A demon" : other["name"]; - reason = name + " was added above"; + reason = trp("demonlist", "demon", "movements-reason.addedabove", { + ["demon"]: name, + }); } else if (entry["reason"]["OtherMoved"] !== undefined) { let other = entry["reason"]["OtherMoved"]["other"]; - let verb = positionChange < 0 ? "down" : "up"; let name = other.name === null ? "A demon" : other["name"]; - reason = name + " was moved " + verb + " past this demon"; + reason = positionChange < 0 + ? trp("demonlist", "demon", "movements-reason.movedbelow", { + ["demon"]: name, + }) + : trp("demonlist", "demon", "movements-reason.movedabove", { + ["demon"]: name, + }) } } diff --git a/pointercrate-demonlist-pages/static/js/modules/demonlist.js b/pointercrate-demonlist-pages/static/js/modules/demonlist.js index c96471350..798f10fe3 100644 --- a/pointercrate-demonlist-pages/static/js/modules/demonlist.js +++ b/pointercrate-demonlist-pages/static/js/modules/demonlist.js @@ -17,6 +17,7 @@ import { setupEditorDialog, get, } from "/static/core/js/modules/form.js"; +import { tr, trp } from "/static/core/js/modules/localization.js"; export function embedVideo(video) { if (!video) return; @@ -43,10 +44,10 @@ export function initializeTimeMachine() { var timeMachineForm = new Form(formHtml); var destination = timeMachineForm.input("time-machine-destination"); - destination.addValidator(valueMissing, "Please specify a value"); + destination.addValidator(valueMissing, tr("demonlist", "overview", "time-machine.destination-validator-valuemissing")); destination.addValidator( rangeUnderflow, - "You cannot go back in time that far!" + tr("demonlist", "overview", "time-machine.destination-validator-rangeunderflow") ); var now = new Date(); @@ -89,35 +90,35 @@ export function initializeRecordSubmitter(submitApproved = false) { demon.addValidator( (input) => input.dropdown.selected !== undefined, - "Please specify a demon" + tr("demonlist", "submitter", "record-submission.demon-validator-valuemissing") ); demon.setTransform(parseInt); player.addValidator( (input) => input.value !== undefined, - "Please specify a record holder" + tr("demonlist", "submitter", "record-submission.holder-validator-valuemissing") ); player.addValidator( (input) => input.value === undefined || input.value.length <= 50, - "Due to Geometry Dash's limitations I know that no player has such a long name" + tr("demonlist", "submitter", "record-submission.holder-validator-rangeoverflow") ); - progress.addValidator(valueMissing, "Please specify the record's progress"); - progress.addValidator(rangeUnderflow, "Record progress cannot be negative"); + progress.addValidator(valueMissing, tr("demonlist", "submitter", "record-submission.progress-validator-valuemissing")); + progress.addValidator(rangeUnderflow, tr("demonlist", "submitter", "record-submission.progress-validator-rangeunderflow")); progress.addValidator( rangeOverflow, - "Record progress cannot be larger than 100%" + tr("demonlist", "submitter", "record-submission.progress-validator-rangeoverflow") ); - progress.addValidator(badInput, "Record progress must be a valid integer"); - progress.addValidator(stepMismatch, "Record progress mustn't be a decimal"); + progress.addValidator(badInput, tr("demonlist", "submitter", "record-submission.progress-validator-badinput")); + progress.addValidator(stepMismatch, tr("demonlist", "submitter", "record-submission.progress-validator-stepmismatch")); video.addValidator( valueMissing, - "Please specify a video so we can check the records validity" + tr("demonlist", "submitter", "record-submission.video-validator-valuemissing") ); - video.addValidator(typeMismatch, "Please enter a valid URL"); + video.addValidator(typeMismatch, tr("demonlist", "submitter", "record-submission.video-validator-typemismatch")); - rawFootage.addValidator(typeMismatch, "Please enter a valid URL"); + rawFootage.addValidator(typeMismatch, tr("demonlist", "submitter", "record-submission.raw-footage-validator-typemismatch")); submissionForm.onSubmit(function () { let data = submissionForm.serialize(); @@ -132,9 +133,11 @@ export function initializeRecordSubmitter(submitApproved = false) { if (queue_position) submissionForm.setSuccess( - `Record successfully submitted. It is #${queue_position} in the queue!` + trp("demonlist", "submitter", "record-submission.submission-success.queue", { + ["queue-position"]: queue_position, + }) ); - else submissionForm.setSuccess("Record successfully submitted."); + else submissionForm.setSuccess(tr("demonlist", "submitter", "record-submission.submission-success")); submissionForm.clear(); }) .catch((response) => { @@ -260,10 +263,15 @@ export function generateDemon(demon) { li.appendChild(b); li.appendChild( - document.createTextNode(demon.name + " (ID: " + demon.id + ")") + document.createTextNode(trp("demonlist", "demon", "demon-listed", { + ["demon"]: demon.name, + ["demon-id"]: demon.id.toString(), + })) ); li.appendChild(document.createElement("br")); - li.appendChild(document.createTextNode("by " + demon.publisher.name)); + li.appendChild(document.createTextNode(trp("demonlist", "demon", "demon-listed.publisher", { + ["publisher"]: demon.publisher.name, + }))); return li; } @@ -291,7 +299,9 @@ export function generateRecord(record) { break; } - recordId.appendChild(document.createTextNode("Record #" + record.id)); + recordId.appendChild(document.createTextNode(trp("demonlist", "record", "record-listed", { + ["record-id"]: record.id.toString(), + }))); li.appendChild(recordId); li.appendChild(document.createElement("br")); @@ -300,7 +310,10 @@ export function generateRecord(record) { ); li.appendChild(document.createElement("br")); li.appendChild( - document.createTextNode(record.progress + "% on " + record.demon.name) + document.createTextNode(trp("demonlist", "record", "record-listed.progress", { + ["percent"]: record.progress, + ["demon"]: record.demon.name, + })) ); li.appendChild(document.createElement("br")); diff --git a/pointercrate-demonlist-pages/static/js/modules/statsviewer.js b/pointercrate-demonlist-pages/static/js/modules/statsviewer.js index 6712a66d0..9f69faf6c 100644 --- a/pointercrate-demonlist-pages/static/js/modules/statsviewer.js +++ b/pointercrate-demonlist-pages/static/js/modules/statsviewer.js @@ -10,6 +10,7 @@ import { get, Viewer, } from "/static/core/js/modules/form.js"; +import { tr, trp } from "/static/core/js/modules/localization.js"; export class StatsViewer extends FilteredPaginator { /** @@ -139,14 +140,18 @@ export class StatsViewer extends FilteredPaginator { this._hardest.removeChild(this._hardest.lastChild); this._hardest.appendChild( hardest === undefined - ? document.createTextNode("None") + ? document.createTextNode(tr("demonlist", "statsviewer", "statsviewer.value-none")) : this.formatDemon(hardest) ); } setCompletionNumber(main, extended, legacy) { this._amountBeaten.textContent = - main + " Main, " + extended + " Extended, " + legacy + " Legacy "; + trp("demonlist", "statsviewer", "statsviewer.stats-value", { + ["main"]: main, + ["extended"]: extended, + ["legacy"]: legacy, + }); } formatDemon(demon, link, dontStyle) { @@ -218,7 +223,7 @@ export function formatInto(parent, childs) { // remove trailing dash parent.removeChild(parent.lastChild); } else { - parent.appendChild(document.createTextNode("None")); + parent.appendChild(document.createTextNode(tr("demonlist", "statsviewer", "statsviewer.value-none"))); } } diff --git a/pointercrate-demonlist-pages/static/js/statsviewer/individual.js b/pointercrate-demonlist-pages/static/js/statsviewer/individual.js index cfa670d70..2df4f96a8 100644 --- a/pointercrate-demonlist-pages/static/js/statsviewer/individual.js +++ b/pointercrate-demonlist-pages/static/js/statsviewer/individual.js @@ -1,3 +1,4 @@ +import { tr } from "/static/core/js/modules/localization.js"; import { displayError, Dropdown, get } from "/static/core/js/modules/form.js"; import { getCountryFlag, @@ -87,11 +88,11 @@ class IndividualStatsViewer extends StatsViewer { let hardest = playerData.verified .concat(beaten.map((record) => record.demon)) .reduce((acc, next) => (acc.position > next.position ? next : acc), { - name: "None", - position: 321321321321, + name: tr("demonlist", "statsviewer", "statsviewer.value-none"), + position: 321321321, }); - this.setHardest(hardest.name === "None" ? undefined : hardest); + this.setHardest(hardest.name === tr("demonlist", "statsviewer", "statsviewer.value-none") ? undefined : hardest); let non100Records = playerData.records.filter( (record) => record.progress !== 100 @@ -166,6 +167,7 @@ $(window).on("load", function () { else map.hideSubdivisions(); }); + window.statsViewer = new IndividualStatsViewer( document.getElementById("statsviewer") ); @@ -238,19 +240,71 @@ $(window).on("load", function () { subdivisionDropdown.selectSilently(subdivisionCode) ); - statsViewer.dropdown.selectSilently(countryCode); + window.statsViewer.initialize(); - statsViewer.updateQueryData2({ - nation: countryCode, - subdivision: subdivisionCode, + new Dropdown(document.getElementById("continent-dropdown")).addEventListener( + (selected) => { + if (selected === "All") { + window.statsViewer.updateQueryData("continent", undefined); + map.resetContinentHighlight(); + } else { + window.statsViewer.updateQueryData("continent", selected); + map.highlightContinent(selected); + } + } + ); + + let subdivisionDropdown = new Dropdown( + document.getElementById("subdivision-dropdown") + ); + + subdivisionDropdown.addEventListener((selected) => { + if (selected === "None") { + map.deselectSubdivision(); + statsViewer.updateQueryData("subdivision", undefined); + } else { + let countryCode = statsViewer.queryData["nation"]; + + map.select(countryCode, selected); + statsViewer.updateQueryData2({ + nation: countryCode, + subdivision: selected, + }); + } }); - }); - map.addDeselectionListener(() => { - statsViewer.dropdown.selectSilently("International"); - subdivisionDropdown.clearOptions(); - statsViewer.updateQueryData2({ nation: undefined, subdivision: undefined }); - }); + statsViewer.dropdown.addEventListener((selected) => { + if (selected === "International") { + map.deselect(); + } else { + map.select(selected); + } + + // if 'countryCode == International' we send a nonsense request which results in a 404 and causes the dropdown to clear. That's exactly what we want, though. + populateSubdivisionDropdown(subdivisionDropdown, selected); + + statsViewer.updateQueryData("subdivision", undefined); + }); + + map.addSelectionListener((countryCode, subdivisionCode) => { + populateSubdivisionDropdown(subdivisionDropdown, countryCode).then(() => + subdivisionDropdown.selectSilently(subdivisionCode) + ); + + statsViewer.dropdown.selectSilently(countryCode); + + statsViewer.updateQueryData2({ + nation: countryCode, + subdivision: subdivisionCode, + }); + }); + + map.addDeselectionListener(() => { + statsViewer.dropdown.selectSilently("International"); + subdivisionDropdown.clearOptions(); + statsViewer.updateQueryData2({ nation: undefined, subdivision: undefined }); + }); + }) }); function generateStatsViewerPlayer(player) { diff --git a/pointercrate-demonlist-pages/static/js/statsviewer/nation.js b/pointercrate-demonlist-pages/static/js/statsviewer/nation.js index 382a6f68e..aaf428dd8 100644 --- a/pointercrate-demonlist-pages/static/js/statsviewer/nation.js +++ b/pointercrate-demonlist-pages/static/js/statsviewer/nation.js @@ -5,6 +5,7 @@ import { } from "/static/demonlist/js/modules/statsviewer.js"; import { Dropdown } from "/static/core/js/modules/form.js"; import { getCountryFlag } from "/static/demonlist/js/modules/demonlist.js"; +import { tr, trp } from "/static/core/js/modules/localization.js"; class NationStatsViewer extends StatsViewer { constructor(html) { @@ -129,11 +130,9 @@ class NationStatsViewer extends StatsViewer { (creation) => { return this.makeTooltip( this.formatDemon(creation.demon), - "(Co)created by " + - creation.players.length + - " player" + - (creation.players.length === 1 ? "" : "s") + - " in this country: ", + trp("demonlist", "statsviewer", "statsviewer-nation.created-tooltip", { + ["players"]: creation.players.length, + }).replaceAll(" ", " ") + " ", creation.players.join(", ") ); } @@ -145,7 +144,7 @@ class NationStatsViewer extends StatsViewer { (verification) => { return this.makeTooltip( this.formatDemon(verification.demon), - "Verified by: ", + tr("demonlist", "statsviewer", "statsviewer-nation.verified-tooltip").replaceAll(" ", " ") + " ", verification.players.join(", ") ); } @@ -157,7 +156,7 @@ class NationStatsViewer extends StatsViewer { (publication) => { return this.makeTooltip( this.formatDemon(publication.demon), - "Published by: ", + tr("demonlist", "statsviewer", "statsviewer-nation.published-tooltip").replaceAll(" ", " ") + " ", publication.players.join(", ") ); } @@ -172,11 +171,9 @@ class NationStatsViewer extends StatsViewer { (creation) => { return this.makeTooltip( this.formatDemon(creation.demon), - "(Co)created by " + - creation.players.length + - " player" + - (creation.players.length === 1 ? "" : "s") + - " in this country: ", + trp("demonlist", "statsviewer", "statsviewer-nation.created-tooltip", { + ["players"]: creation.players.length, + }).replaceAll(" ", " ") + " ", creation.players.join(", ") ); } @@ -191,7 +188,7 @@ class NationStatsViewer extends StatsViewer { (publication) => { return this.makeTooltip( this.formatDemon(publication.demon), - "Published by: ", + tr("demonlist", "statsviewer", "statsviewer-nation.published-tooltip").replaceAll(" ", " ") + " ", publication.players.join(", ") ); } @@ -205,7 +202,7 @@ class NationStatsViewer extends StatsViewer { (verification) => { return this.makeTooltip( this.formatDemon(verification.demon), - "Verified by: ", + tr("demonlist", "statsviewer", "statsviewer-nation.verified-tooltip").replaceAll(" ", " ") + " ", verification.players.join(", ") ); } @@ -251,19 +248,20 @@ class NationStatsViewer extends StatsViewer { formatDemonFromRecord(record, dontStyle) { let baseElement = this.formatDemon(record.demon, null, dontStyle); - + if (record.progress !== 100) baseElement.appendChild( document.createTextNode(" (" + record.progress + "%)") ); let title = - (record.progress === 100 ? "Beaten" : "Achieved") + - " by " + - record.players.length + - " player" + - (record.players.length === 1 ? "" : "s") + - " in this country: "; + (record.progress === 100 + ? trp("demonlist", "statsviewer", "statsviewer-nation.beaten-tooltip", { + ["players"]: record.players.length, + }) + : trp("demonlist", "statsviewer", "statsviewer-nation.progress-tooltip", { + ["players"]: record.players.length, + })).replaceAll(" ", " ") + " "; return this.makeTooltip( baseElement, diff --git a/pointercrate-demonlist/src/error.rs b/pointercrate-demonlist/src/error.rs index 125ec47d5..6e16e9e6f 100644 --- a/pointercrate-demonlist/src/error.rs +++ b/pointercrate-demonlist/src/error.rs @@ -1,31 +1,31 @@ +use std::fmt::Display; + use crate::{demon::MinimalDemon, record::RecordStatus}; -use derive_more::Display; -use pointercrate_core::error::{CoreError, PointercrateError}; +use pointercrate_core::{ + error::{CoreError, PointercrateError}, + localization::tr, + trp, +}; use serde::Serialize; pub type Result = std::result::Result; -#[derive(Serialize, Display, Debug, Eq, PartialEq, Clone)] +#[derive(Serialize, Debug, Eq, PartialEq, Clone)] #[serde(untagged)] pub enum DemonlistError { - #[display("{}", _0)] Core(CoreError), - #[display("Malformed video URL")] MalformedVideoUrl, /// `403 FORBIDDEN` error returned if someone with an IP-address that's banned from submitting /// records tries to submit a record /// /// Error Code `40304` - #[display("You are banned from submitting records to the demonlist!")] BannedFromSubmissions, - #[display("You claim on this player is unverified")] ClaimUnverified, - #[display("IP Geolocation attempt through VPS detected")] VpsDetected, /// `403 FORBIDDEN` variant returned when someone tries to submit a records for a player who @@ -35,75 +35,89 @@ pub enum DemonlistError { /// can submit records for this player. /// /// Error Code `40308` - #[display("This player has requested that only they themselves can submit their records")] NoThirdPartySubmissions, - #[display("No submitter with id {} found", id)] - SubmitterNotFound { id: i32 }, + SubmitterNotFound { + id: i32, + }, - #[display("No note with id {} found on record with id {}", note_id, record_id)] - NoteNotFound { note_id: i32, record_id: i32 }, + NoteNotFound { + note_id: i32, + record_id: i32, + }, - #[display("Player with id {} is no creator of demon with id {}", player_id, demon_id)] - CreatorNotFound { demon_id: i32, player_id: i32 }, + CreatorNotFound { + demon_id: i32, + player_id: i32, + }, - #[display("No nationality with iso code {} found", iso_code)] - NationalityNotFound { iso_code: String }, + NationalityNotFound { + iso_code: String, + }, - #[display("No subdivision with code {} found in nation {}", subdivision_code, nation_code)] - SubdivisionNotFound { subdivision_code: String, nation_code: String }, + SubdivisionNotFound { + subdivision_code: String, + nation_code: String, + }, - #[display("No player with id {} found", player_id)] - PlayerNotFound { player_id: i32 }, + PlayerNotFound { + player_id: i32, + }, - #[display("No player with name {} found", player_name)] - PlayerNotFoundName { player_name: String }, + PlayerNotFoundName { + player_name: String, + }, - #[display("No demon with id {} found", demon_id)] - DemonNotFound { demon_id: i32 }, + DemonNotFound { + demon_id: i32, + }, - #[display("No demon with name {} found", demon_name)] - DemonNotFoundName { demon_name: String }, + DemonNotFoundName { + demon_name: String, + }, - #[display("No demon at position {} found", demon_position)] - DemonNotFoundPosition { demon_position: i16 }, + DemonNotFoundPosition { + demon_position: i16, + }, - #[display("No record with id {} found", record_id)] - RecordNotFound { record_id: i32 }, + RecordNotFound { + record_id: i32, + }, - #[display("No claim by user {} on player {} found", member_id, player_id)] - ClaimNotFound { member_id: i32, player_id: i32 }, + ClaimNotFound { + member_id: i32, + player_id: i32, + }, - #[display("This player is already registered as a creator on this demon")] CreatorExists, /// `409 CONFLICT` variant /// /// Error Code `40906` - #[display("This video is already used by record #{}", id)] - DuplicateVideo { id: i32 }, + DuplicateVideo { + id: i32, + }, /// `409 CONFLICT` variant /// /// Error Code `40907` - #[display("Attempt to set subdivision without nation")] NoNationSet, - #[display("The players '{}' and '{}' have verified claims by different pointercrate users", player1, player2)] - ConflictingClaims { player1: String, player2: String }, + ConflictingClaims { + player1: String, + player2: String, + }, /// `422 UNPROCESSABLE ENTITY` variant returned if attempted to create a demon with a record /// requirements outside of [0, 100] /// /// Error Code `42212` - #[display("Record requirement needs to be greater than -1 and smaller than 101")] InvalidRequirement, /// `422 UNPROCESSABLE ENTITY` variant returned if attempted to create a demon with a position, /// that would leave "holes" in the list, or is smaller than 1 /// /// Error Code `42213` - #[display("Demon position needs to be greater than or equal to 1 and smaller than or equal to {}", maximal)] InvalidPosition { /// The maximal position a new demon can be added at maximal: i16, @@ -112,7 +126,6 @@ pub enum DemonlistError { /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42215` - #[display("Record progress must lie between {} and 100%!", requirement)] InvalidProgress { /// The [`Demon`]'s record requirement requirement: i16, @@ -120,7 +133,6 @@ pub enum DemonlistError { /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42217` - #[display("This record is already {} (existing record: {})", status, existing)] SubmissionExists { /// The [`RecordStatus`] of the existing [`Record`] status: RecordStatus, @@ -132,61 +144,53 @@ pub enum DemonlistError { /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42218` - #[display("The given player is banned and thus cannot have non-rejected records on the list!")] PlayerBanned, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code 42219 - #[display("You cannot submit records for legacy demons")] SubmitLegacy, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code 42220 - #[display("Only 100% records can be submitted for the extended section of the list")] Non100Extended, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42224` - #[display("The given video host is not supported. Supported are 'youtube', 'vimeo', 'everyplay', 'twitch' and 'bilibili'")] UnsupportedVideoHost, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42228` - #[display("There are multiple demons with the given name")] - DemonNameNotUnique { demons: Vec }, + DemonNameNotUnique { + demons: Vec, + }, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42230` - #[display("Notes mustn't be empty!")] NoteEmpty, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42231` - #[display("This player already have a verified claim associated with them")] AlreadyClaimed, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42232` - #[display("Raw footage much be provided to submit this record")] RawRequired, //hehe /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42233` - #[display("Raw footage needs to be a valid URL")] MalformedRawUrl, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42235` - #[display("Level ID needs to be positive")] InvalidLevelId, } @@ -237,6 +241,74 @@ impl PointercrateError for DemonlistError { } } +impl Display for DemonlistError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + DemonlistError::Core(core) => { + return core.fmt(f); + }, + DemonlistError::MalformedVideoUrl => tr("error-demonlist-malformedvideourl"), + DemonlistError::BannedFromSubmissions => tr("error-demonlist-bannedfromsubmissions"), + DemonlistError::ClaimUnverified => tr("error-demonlist-claimunverified"), + DemonlistError::VpsDetected => tr("error-demonlist-vpsdetected"), + DemonlistError::NoThirdPartySubmissions => tr("nothirdpartysubmissions-error-malformedvideourl"), + DemonlistError::SubmitterNotFound { id } => trp!("error-demonlist-submitternotfound", ("id", id)), + DemonlistError::NoteNotFound { note_id, record_id } => { + trp!("error-demonlist-notenotfound", ("note-id", note_id), ("record-id", record_id)) + }, + DemonlistError::CreatorNotFound { demon_id, player_id } => { + trp!("error-demonlist-creatornotfound", ("player-id", player_id), ("demon-id", demon_id)) + }, + DemonlistError::NationalityNotFound { iso_code } => trp!("error-demonlist-nationalitynotfound", ("iso-code", iso_code)), + DemonlistError::SubdivisionNotFound { + subdivision_code, + nation_code, + } => trp!( + "error-demonlist-subdivisionnotfound", + ("subdivision-code", subdivision_code), + ("nation-code", nation_code) + ), + DemonlistError::PlayerNotFound { player_id } => trp!("error-demonlist-playernotfound", ("player-id", player_id)), + DemonlistError::PlayerNotFoundName { player_name } => + trp!("error-demonlist-playernotfoundname", ("player-name", player_name)), + DemonlistError::DemonNotFound { demon_id } => trp!("error-demonlist-demonnotfound", ("demon-id", demon_id)), + DemonlistError::DemonNotFoundName { demon_name } => trp!("error-demonlist-demonnotfoundname", ("demon-name", demon_name)), + DemonlistError::DemonNotFoundPosition { demon_position } => + trp!("error-demonlist-demonnotfoundposition", ("demon-position", demon_position)), + DemonlistError::RecordNotFound { record_id } => trp!("error-demonlist-recordnotfound", ("record-id", record_id)), + DemonlistError::ClaimNotFound { member_id, player_id } => + trp!("error-demonlist-claimnotfound", ("member-id", member_id), ("player-id", player_id)), + DemonlistError::CreatorExists => tr("error-demonlist-creatorexists"), + DemonlistError::DuplicateVideo { id } => trp!("error-demonlist-duplicatevideo", ("record-id", id)), + DemonlistError::NoNationSet => tr("error-demonlist-nonationset"), + DemonlistError::ConflictingClaims { player1, player2 } => + trp!("error-demonlist-conflictingclaims", ("player-1", player1), ("player-2", player2)), + DemonlistError::InvalidRequirement => tr("error-demonlist-invalidrequirement"), + DemonlistError::InvalidPosition { maximal } => trp!("error-demonlist-invalidposition", ("maximal", maximal)), + DemonlistError::InvalidProgress { requirement } => trp!("error-demonlist-invalidprogress", ("requirement", requirement)), + DemonlistError::SubmissionExists { status, existing } => trp!( + "error-demonlist-submissionexists", + ("record-status", format!("{}", status)), + ("record-id", existing) + ), + DemonlistError::PlayerBanned => tr("error-demonlist-playerbanned"), + DemonlistError::SubmitLegacy => tr("error-demonlist-submitlegacy"), + DemonlistError::Non100Extended => tr("error-demonlist-non100extended"), + DemonlistError::UnsupportedVideoHost => tr("error-demonlist-unsupportedvideohost"), + DemonlistError::DemonNameNotUnique { .. } => tr("error-demonlist-demonnamenotunique"), + DemonlistError::NoteEmpty => tr("error-demonlist-noteempty"), + DemonlistError::AlreadyClaimed => tr("error-demonlist-alreadyclaimed"), + DemonlistError::RawRequired => tr("error-demonlist-rawrequired"), + DemonlistError::MalformedRawUrl => tr("error-demonlist-malformedrawurl"), + DemonlistError::InvalidLevelId => tr("error-demonlist-invalidlevelid"), + } + ) + } +} + impl From for DemonlistError { fn from(error: CoreError) -> Self { DemonlistError::Core(error) diff --git a/pointercrate-demonlist/src/lib.rs b/pointercrate-demonlist/src/lib.rs index 5d8df2e38..a95f99bb2 100644 --- a/pointercrate-demonlist/src/lib.rs +++ b/pointercrate-demonlist/src/lib.rs @@ -12,9 +12,9 @@ pub mod record; pub mod submitter; mod video; -pub const LIST_HELPER: Permission = Permission::new("List Helper", 0x2); -pub const LIST_MODERATOR: Permission = Permission::new("List Moderator", 0x4); -pub const LIST_ADMINISTRATOR: Permission = Permission::new("List Administrator", 0x8); +pub const LIST_HELPER: Permission = Permission::new("user-permissions.list-helper", 0x2); +pub const LIST_MODERATOR: Permission = Permission::new("user-permissions.list-moderator", 0x4); +pub const LIST_ADMINISTRATOR: Permission = Permission::new("user-permissions.list-administrator", 0x8); pub fn default_permissions_manager() -> PermissionsManager { PermissionsManager::new(vec![ADMINISTRATOR, LIST_HELPER, LIST_MODERATOR, LIST_ADMINISTRATOR]) diff --git a/pointercrate-demonlist/src/player/patch.rs b/pointercrate-demonlist/src/player/patch.rs index cef7faad3..74d4e365a 100644 --- a/pointercrate-demonlist/src/player/patch.rs +++ b/pointercrate-demonlist/src/player/patch.rs @@ -81,7 +81,7 @@ impl FullPlayer { let name = name.trim().to_string(); // Nothing to be done - if name == self.player.base.name.as_ref() { + if name == self.player.base.name { return Ok(()); } else if name.to_lowercase() != self.player.base.name.to_lowercase() { // If they are equal case insensitively, we're only doing a cosmetic rename, which won't diff --git a/pointercrate-example/.env.sample b/pointercrate-example/.env.sample index 186a275bc..1d9459c70 100644 --- a/pointercrate-example/.env.sample +++ b/pointercrate-example/.env.sample @@ -8,4 +8,4 @@ LIST_SIZE=75 EXTENDED_LIST_SIZE=150 # The port on which rocket should list for incoming HTTP requests -ROCKET_PORT=1971 +ROCKET_PORT=1971 \ No newline at end of file diff --git a/pointercrate-example/Cargo.toml b/pointercrate-example/Cargo.toml index 4214fc5e7..61a18ba21 100644 --- a/pointercrate-example/Cargo.toml +++ b/pointercrate-example/Cargo.toml @@ -12,6 +12,7 @@ maud = "0.27.0" pointercrate-core = { version = "0.1.0", path = "../pointercrate-core" } pointercrate-core-api = { version = "0.1.0", path = "../pointercrate-core-api" } pointercrate-core-pages = { version = "0.1.0", path = "../pointercrate-core-pages" } +pointercrate-core-macros = { version = "0.1.0", path = "../pointercrate-core-macros" } pointercrate-demonlist = { version = "0.1.0", path = "../pointercrate-demonlist" } pointercrate-demonlist-api = { version = "0.1.0", path = "../pointercrate-demonlist-api" } pointercrate-demonlist-pages = { version = "0.1.0", path = "../pointercrate-demonlist-pages" } @@ -19,6 +20,7 @@ pointercrate-user = { version = "0.1.0", path = "../pointercrate-user" } pointercrate-user-api = { version = "0.1.0", path = "../pointercrate-user-api", features = ["legacy_accounts"] } pointercrate-user-pages = { version = "0.1.0", path = "../pointercrate-user-pages", features = ["legacy_accounts"] } rocket = "0.5.1" +unic-langid = { version = "0.9.5", features = ["macros"] } [features] oauth2 = ["pointercrate-user-api/oauth2", "pointercrate-user-pages/oauth2"] \ No newline at end of file diff --git a/pointercrate-example/src/main.rs b/pointercrate-example/src/main.rs index 1faf9dbda..e53796cae 100644 --- a/pointercrate-example/src/main.rs +++ b/pointercrate-example/src/main.rs @@ -1,7 +1,9 @@ use maud::html; -use pointercrate_core::error::CoreError; +use pointercrate_core::localization::LocalesLoader; use pointercrate_core::pool::PointercratePool; -use pointercrate_core_api::{error::ErrorResponder, maintenance::MaintenanceFairing}; +use pointercrate_core::{error::CoreError, localization::tr}; +use pointercrate_core_api::{error::ErrorResponder, maintenance::MaintenanceFairing, preferences::PreferenceManager}; +use pointercrate_core_macros::localized_catcher; use pointercrate_core_pages::{ footer::{Footer, FooterColumn, Link}, navigation::{NavigationBar, TopLevelNavigationBarItem}, @@ -14,14 +16,17 @@ use pointercrate_demonlist_pages::account::{ use pointercrate_user::MODERATOR; use pointercrate_user_pages::account::{profile::ProfileTab, users::UsersTab, AccountPageConfig}; use rocket::{fs::FileServer, response::Redirect, uri}; +use unic_langid::lang; +use unic_langid::subtags::Language; /// A catcher for 404 errors (e.g. when a user tried to navigate to a URL that /// does not exist) /// /// An [`ErrorResponder`] will return either a JSON or an HTML error page, /// depending on what `Accept` headers are set on the request. +#[localized_catcher] #[rocket::catch(404)] -fn catch_404() -> ErrorResponder { +async fn catch_404() -> ErrorResponder { // `CoreError` contains various generic error conditions that might happen // anywhere across the website. `CoreError::NotFound` is a generic 404 NOT FOUND // error with code 40400. @@ -31,14 +36,16 @@ fn catch_404() -> ErrorResponder { /// Failures in json deserialization of request bodies will just return /// an immediate 422 response. This catcher is needed to translate them into a pointercrate /// error response. +#[localized_catcher] #[rocket::catch(422)] -fn catch_422() -> ErrorResponder { +async fn catch_422() -> ErrorResponder { CoreError::UnprocessableEntity.into() } /// Failures from the authorization FromRequest implementations can return 401s +#[localized_catcher] #[rocket::catch(401)] -fn catch_401() -> ErrorResponder { +async fn catch_401() -> ErrorResponder { CoreError::Unauthorized.into() } @@ -48,11 +55,23 @@ fn home() -> Redirect { Redirect::to(uri!("/demonlist/")) } +const DEFAULT_LOCALE: Language = lang!("en"); + #[rocket::launch] async fn rocket() -> _ { // Load the configuration from your .env file dotenv::dotenv().unwrap(); + // Load the translation files + LocalesLoader::load(&[ + "pointercrate-core-pages/static/ftl/", + "pointercrate-demonlist-pages/static/ftl/", + "pointercrate-user-pages/static/ftl/", + "pointercrate-example/static/ftl/", + ]) + .expect("Failed to load localization files") + .commit(DEFAULT_LOCALE); + // Initialize a database connection pool to the database specified by the // DATABASE_URL environment variable let pool = PointercratePool::init().await; @@ -62,7 +81,8 @@ async fn rocket() -> _ { // Tell it about the connection pool to use (individual handlers can get hold of this pool by declaring an argument of type `&State`) .manage(pool) // Tell pointercrate's core components about navigation bar and footers, so that it knows how to render the website - .manage(page_configuration()) + // We are passing is as a function pointer so the page can load it in a different language each time a page is rendered + .manage(page_configuration as fn() -> PageConfiguration) // Register our 404 catcher .register("/", rocket::catchers![catch_401, catch_404, catch_422]) // Register our home page @@ -80,6 +100,12 @@ async fn rocket() -> _ { let rocket = rocket.manage(permissions_manager); + // Define the preferences our website supports. Preferences are sent to us from + // the client via cookies. + let preference_manager = PreferenceManager::default().with_localization(); + + let rocket = rocket.manage(preference_manager); + // Set up which tabs can show up in the "user area" of your website. Anything // that implements the [`AccountPageTab`] trait can be displayed here. Note that // tabs will only be visible for users for which @@ -121,6 +147,7 @@ async fn rocket() -> _ { .mount("/static/core", FileServer::from("pointercrate-core-pages/static")) .mount("/static/demonlist", FileServer::from("pointercrate-demonlist-pages/static")) .mount("/static/user", FileServer::from("pointercrate-user-pages/static")) + .mount("/static/example", FileServer::from("pointercrate-example/static")) } /// Constructs a [`PageConfiguration`] for your site. @@ -135,30 +162,29 @@ fn page_configuration() -> PageConfiguration { let nav_bar = NavigationBar::new("/static/images/path/to/your/logo.png") .with_item( TopLevelNavigationBarItem::new( - "/demonlist/", - // Pointercrate uses the "maud" create as its templating engine. - // It allows you to describe HTML via Rust macros that allow you to dynamically generate content using - // a Rust-like syntax and by interpolating and Rust variables from surrounding scopes (as long as the - // implement the `Render` trait). See https://maud.lambda.xyz/ for details. - html! { - span { - "Demonlist" - } - }, - ) - // Add a drop down to the demonlist item, just like on pointercrate.com - .with_sub_item("/demonlist/statsviewer/", html! {"Stats Viewer"}) - .with_sub_item("/demonlist/?submitter=true", html! {"Record Submitter"}) - .with_sub_item("/demonlist/?timemachine=true", html! {"Time Machine"}), + Some("/demonlist/"), + // Pointercrate uses the "maud" create as its templating engine. + // It allows you to describe HTML via Rust macros that allow you to dynamically generate content using + // a Rust-like syntax and by interpolating and Rust variables from surrounding scopes (as long as the + // implement the `Render` trait). See https://maud.lambda.xyz/ for details. + html! { + span { + (tr("nav-demonlist")) + } + }, + ) + // Add a drop down to the demonlist item, just like on pointercrate.com + .with_sub_item(Some("/demonlist/statsviewer/"), html! { (tr("nav-demonlist.stats-viewer")) }) + .with_sub_item(Some("/demonlist/?submitter=true"), html! { (tr("nav-demonlist.record-submitter")) }) + .with_sub_item(Some("/demonlist/?timemachine=true"), html! { (tr("nav-demonlist.time-machine")) }), ) - .with_item(TopLevelNavigationBarItem::new( - "/login/", + .with_item(TopLevelNavigationBarItem::new(Some("/login/"), { html! { span { - "User Area" + (tr("nav-userarea")) } - }, - )); + } + })); // A footer consists of a copyright notice, an arbitrary amount of columns // displayed below it, side-by-side, and potentially some social media links to @@ -172,21 +198,21 @@ fn page_configuration() -> PageConfiguration { }) // Add a column with links for various list-related highlights .with_column(FooterColumn::LinkList { - heading: "Demonlist", + heading: tr("footer-demonlist"), links: vec![ - Link::new("/demonlist/1/", "Current Top Demon"), + Link::new("/demonlist/1/", tr("footer-demonlist.top-demon")), Link::new( format!("/demonlist/{}/", pointercrate_demonlist::config::list_size() + 1), - "Extended List", + tr("footer-demonlist.extended-list"), ), Link::new( format!("/demonlist/{}/", pointercrate_demonlist::config::extended_list_size() + 1), - "Legacy List", + tr("footer-demonlist.legacy-list"), ), ], }) // Some links to social media, for example your twitter - .with_link("https://twitter.com/stadust1971", "Pointercrate Developer"); + .with_link("https://twitter.com/stadust1971", tr("footer-developer")); // Stitching it all together into a page configuration PageConfiguration::new("", nav_bar, footer) diff --git a/pointercrate-example/static/ftl/en-us/footer.ftl b/pointercrate-example/static/ftl/en-us/footer.ftl new file mode 100644 index 000000000..67eb85792 --- /dev/null +++ b/pointercrate-example/static/ftl/en-us/footer.ftl @@ -0,0 +1,4 @@ +footer-demonlist = Demonlist + .top-demon = Current Top Demon + .extended-list = { extended-list } + .legacy-list = { legacy-list } diff --git a/pointercrate-example/static/ftl/en-us/nav.ftl b/pointercrate-example/static/ftl/en-us/nav.ftl new file mode 100644 index 000000000..bca3d5e8d --- /dev/null +++ b/pointercrate-example/static/ftl/en-us/nav.ftl @@ -0,0 +1,6 @@ +nav-demonlist = Demonlist + .stats-viewer = Stats Viewer + .record-submitter = Record Submitter + .time-machine = Time Machine + +nav-userarea = User Area \ No newline at end of file diff --git a/pointercrate-example/static/ftl/ru-ru/footer.ftl b/pointercrate-example/static/ftl/ru-ru/footer.ftl new file mode 100644 index 000000000..2fbe89ea3 --- /dev/null +++ b/pointercrate-example/static/ftl/ru-ru/footer.ftl @@ -0,0 +1,4 @@ +footer-demonlist = Демонлист + .top-demon = Нынешний сложнейший демон + .extended-list = { extended-list } + .legacy-list = { legacy-list } diff --git a/pointercrate-example/static/ftl/ru-ru/nav.ftl b/pointercrate-example/static/ftl/ru-ru/nav.ftl new file mode 100644 index 000000000..15f107e42 --- /dev/null +++ b/pointercrate-example/static/ftl/ru-ru/nav.ftl @@ -0,0 +1,6 @@ +nav-demonlist = Демонлист + .stats-viewer = Панель статистики + .record-submitter = Форма отправки рекордов + .time-machine = Машина времени + +nav-userarea = Личный кабинет \ No newline at end of file diff --git a/pointercrate-integrate/src/gd.rs b/pointercrate-integrate/src/gd.rs index afc2fdb1c..9b8c97efd 100644 --- a/pointercrate-integrate/src/gd.rs +++ b/pointercrate-integrate/src/gd.rs @@ -21,6 +21,7 @@ pub use dash_rs::{ }; use reqwest::header::HeaderMap; +// No need to localize these, they are internal only and never returned to the user ratelimits! { IntegrationRatelimits { demon_refresh[1u32 per 86400 per i32] => "Only one refresh per day per demon", diff --git a/pointercrate-test/Cargo.toml b/pointercrate-test/Cargo.toml index b49dd221f..d963911c4 100644 --- a/pointercrate-test/Cargo.toml +++ b/pointercrate-test/Cargo.toml @@ -11,6 +11,7 @@ pointercrate-demonlist = {path = "../pointercrate-demonlist"} pointercrate-demonlist-api = {path = "../pointercrate-demonlist-api"} pointercrate-core = {path = "../pointercrate-core"} pointercrate-core-api = {path = "../pointercrate-core-api"} +pointercrate-core-pages = {path = "../pointercrate-core-pages"} pointercrate-user = {path = "../pointercrate-user"} pointercrate-user-api = {path = "../pointercrate-user-api"} pointercrate-user-pages = {path = "../pointercrate-user-pages", features = ["legacy_accounts"]} @@ -20,3 +21,4 @@ rocket = "0.5.1" serde_json = "1.0.140" dotenv = "0.15.0" serde_urlencoded = "0.7.1" +unic-langid = { version = "0.9.5", features = [ "macros" ]} diff --git a/pointercrate-test/src/demonlist.rs b/pointercrate-test/src/demonlist.rs index f2878e477..e4b6c2a0a 100644 --- a/pointercrate-test/src/demonlist.rs +++ b/pointercrate-test/src/demonlist.rs @@ -1,6 +1,8 @@ use crate::{TestClient, TestRequest}; use pointercrate_core::etag::Taggable; +use pointercrate_core::localization::LocalesLoader; use pointercrate_core::{permission::PermissionsManager, pool::PointercratePool}; +use pointercrate_core_api::preferences::PreferenceManager; use pointercrate_demonlist::demon::FullDemon; use pointercrate_demonlist::{ player::{claim::PlayerClaim, FullPlayer}, @@ -24,9 +26,12 @@ pub async fn setup_rocket(pool: Pool) -> (TestClient, PoolConnection

) -> (TestClient, PoolConnection

, ratelimits: &State, pool: &State, @@ -48,6 +50,7 @@ pub async fn register( .status(Status::Created)) } +#[localized] #[rocket::post("/")] pub async fn login( auth: std::result::Result, UserError>, ip: IpAddr, ratelimits: &State, @@ -64,6 +67,7 @@ pub async fn login( .with_header("etag", auth.user.user().etag_string())) } +#[localized] #[rocket::post("/invalidate")] pub async fn invalidate(mut auth: Auth) -> Result { auth.user.invalidate_all_tokens(&mut auth.connection).await?; @@ -72,11 +76,13 @@ pub async fn invalidate(mut auth: Auth) -> Result { Ok(Status::NoContent) } +#[localized] #[rocket::get("/me")] -pub fn get_me(auth: Auth) -> Tagged { +pub async fn get_me(auth: Auth) -> Tagged { Tagged(auth.user.into_user()) } +#[localized] #[rocket::patch("/me", data = "")] pub async fn patch_me( mut auth: Auth, patch: Json, pred: Precondition, @@ -96,6 +102,7 @@ pub async fn patch_me( } } +#[localized] #[rocket::delete("/me")] pub async fn delete_me(mut auth: Auth, pred: Precondition) -> Result { pred.require_etag_match(auth.user.user())?; diff --git a/pointercrate-user-api/src/endpoints/user.rs b/pointercrate-user-api/src/endpoints/user.rs index 4609a9edf..f75cbc7e8 100644 --- a/pointercrate-user-api/src/endpoints/user.rs +++ b/pointercrate-user-api/src/endpoints/user.rs @@ -7,11 +7,13 @@ use pointercrate_core_api::{ query::Query, response::Response2, }; +use pointercrate_core_macros::localized; use pointercrate_user::{auth::ApiToken, error::UserError, PatchUser, User, UserPagination, ADMINISTRATOR, MODERATOR}; use rocket::{http::Status, serde::json::Json}; use crate::auth::Auth; +#[localized] #[rocket::get("/")] pub async fn paginate(mut auth: Auth, data: Query) -> Result>>> { let mut pagination = data.0; @@ -36,6 +38,7 @@ pub async fn paginate(mut auth: Auth, data: Query) -> Ok(pagination_response("/api/v1/users", pagination, &mut auth.connection).await?) } +#[localized] #[rocket::get("/")] pub async fn get_user(mut auth: Auth, user_id: i32) -> Result> { let user = User::by_id(user_id, &mut auth.connection).await?; @@ -53,6 +56,7 @@ pub async fn get_user(mut auth: Auth, user_id: i32) -> Result", data = "")] pub async fn patch_user( mut auth: Auth, precondition: Precondition, user_id: i32, mut patch: Json, @@ -107,6 +111,7 @@ pub async fn patch_user( Ok(Tagged(user)) } +#[localized] #[rocket::delete("/")] pub async fn delete_user(mut auth: Auth, precondition: Precondition, user_id: i32) -> Result { auth.require_permission(ADMINISTRATOR)?; diff --git a/pointercrate-user-api/src/pages.rs b/pointercrate-user-api/src/pages.rs index a0d6fa790..3698fc9b8 100644 --- a/pointercrate-user-api/src/pages.rs +++ b/pointercrate-user-api/src/pages.rs @@ -1,9 +1,9 @@ use crate::{auth::Auth, ratelimits::UserRatelimits}; use pointercrate_core::permission::PermissionsManager; use pointercrate_core_api::response::Page; -use pointercrate_user::auth::AuthenticatedUser; +use pointercrate_core_macros::localized; use pointercrate_user::{ - auth::{NonMutating, PasswordOrBrowser}, + auth::{AuthenticatedUser, NonMutating, PasswordOrBrowser}, error::UserError, }; use pointercrate_user_pages::account::AccountPageConfig; @@ -50,6 +50,7 @@ fn build_cookies(user: &AuthenticatedUser, cookies: &CookieJa Ok(()) } +#[localized] #[rocket::get("/login")] pub async fn login_page(auth: Option>) -> Result { auth.map(|_| Redirect::to(rocket::uri!(account_page))) @@ -57,6 +58,7 @@ pub async fn login_page(auth: Option>) -> Result, UserError>, ip: IpAddr, ratelimits: &State, cookies: &CookieJar<'_>, @@ -70,12 +72,14 @@ pub async fn login( Ok(Status::NoContent) } +#[localized] #[rocket::get("/register")] pub async fn register_page() -> Page { Page::new(pointercrate_user_pages::register::registration_page()) } #[cfg(feature = "legacy_accounts")] +#[localized] #[rocket::post("/register", data = "")] pub async fn register( ip: IpAddr, ratelimits: &State, cookies: &CookieJar<'_>, registration: Json, @@ -99,6 +103,7 @@ pub async fn register( Ok(Status::Created) } +#[localized] #[rocket::get("/account")] pub async fn account_page( auth: Option>, permissions: &State, tabs: &State, @@ -118,6 +123,7 @@ pub async fn logout(_auth: Auth, cookies: &CookieJar<'_>) -> Redire } #[cfg(feature = "oauth2")] +#[localized] #[rocket::post("/oauth/google", data = "")] pub async fn google_oauth_login( payload: Json, auth: Option>, key_store: &State, @@ -151,6 +157,7 @@ pub async fn google_oauth_login( } #[cfg(feature = "oauth2")] +#[localized] #[rocket::post("/oauth/google/register", data = "")] pub async fn google_oauth_register( payload: Json, key_store: &State, ip: IpAddr, pool: &State, diff --git a/pointercrate-user-api/src/ratelimits.rs b/pointercrate-user-api/src/ratelimits.rs index 9ee362782..c69306c8a 100644 --- a/pointercrate-user-api/src/ratelimits.rs +++ b/pointercrate-user-api/src/ratelimits.rs @@ -1,11 +1,11 @@ -use std::net::IpAddr; - +use pointercrate_core::localization::tr; use pointercrate_core::ratelimits; +use std::net::IpAddr; ratelimits! { UserRatelimits { - registrations[1u32 per 86400 per IpAddr] => "Too many registrations!", - soft_registrations[5u32 per 21600 per IpAddr] => "Too many failed registration attempts!", - login_attempts[3u32 per 1800 per IpAddr] => "Too many login attempts!", + registrations[1u32 per 86400 per IpAddr] => tr("error-user-ratelimit-registration"), + soft_registrations[5u32 per 21600 per IpAddr] => tr("error-user-ratelimit-soft-registration"), + login_attempts[3u32 per 1800 per IpAddr] => tr("error-user-ratelimit-login"), } } diff --git a/pointercrate-user-pages/Cargo.toml b/pointercrate-user-pages/Cargo.toml index 7163f4a2f..291448021 100644 --- a/pointercrate-user-pages/Cargo.toml +++ b/pointercrate-user-pages/Cargo.toml @@ -9,6 +9,7 @@ edition.workspace = true [dependencies] maud = "0.27.0" pointercrate-core = {path = "../pointercrate-core"} +pointercrate-core-api = {path = "../pointercrate-core-api"} pointercrate-user = {path = "../pointercrate-user"} pointercrate-core-pages = {path = "../pointercrate-core-pages"} async-trait = "0.1.88" diff --git a/pointercrate-user-pages/src/account/profile.rs b/pointercrate-user-pages/src/account/profile.rs index 01a842010..de35166e8 100644 --- a/pointercrate-user-pages/src/account/profile.rs +++ b/pointercrate-user-pages/src/account/profile.rs @@ -1,6 +1,10 @@ use crate::account::AccountPageTab; use maud::{html, Markup, PreEscaped}; -use pointercrate_core::permission::PermissionsManager; +use pointercrate_core::{ + localization::{task_lang, tr}, + permission::PermissionsManager, + trp, +}; use pointercrate_core_pages::head::Script; use pointercrate_user::{ auth::{AuthenticatedUser, NonMutating}, @@ -35,7 +39,7 @@ impl AccountPageTab for ProfileTab { fn tab(&self) -> Markup { html! { b { - "Profile" + (tr("profile")) } (PreEscaped("  ")) i class = "fa fa-user fa-2x" aria-hidden="true" {} @@ -48,29 +52,31 @@ impl AccountPageTab for ProfileTab { let user = authenticated_user.user(); let permissions = permissions.bits_to_permissions(user.permissions); - let permission_string = permissions.iter().map(|perm| perm.name()).collect::>().join(", "); + let permission_string = permissions.iter().map(|perm| tr(perm.text_id())).collect::>().join(", "); + + let lang = task_lang(); html! { div.left { div.panel.fade { h1.underlined.pad { - "Profile - " (user.name()) + (trp!("profile.header", ("username", user.name()))) } div.flex.space.wrap #things { p.info-red.output style = "margin: 10px" {} p.info-green.output style = "margin: 10px" {} span { b { - "Username: " + (tr("profile-username")) ": " } (user.name) p { - "The name you registered under and which you use to log in to pointercrate. This name is unique to your account, and cannot be changed" + (tr("profile-username.info")) } } span { b { - i.fa.fa-pencil-alt.clickable #display-name-pen aria-hidden = "true" {} " Display name: " + i.fa.fa-pencil-alt.clickable #display-name-pen aria-hidden = "true" {} " " (tr("profile-display-name")) ": " } i #profile-display-name { @match user.display_name { @@ -79,12 +85,12 @@ impl AccountPageTab for ProfileTab { } } p { - "If set, this name will be displayed instead of your username. Display names aren't unique and you cannot use your display name to login to your pointercrate account." + (tr("profile-display-name.info")) } } span { b { - i.fa.fa-pencil-alt.clickable #youtube-pen aria-hidden = "true" {} " YouTube channel: " + i.fa.fa-pencil-alt.clickable #youtube-pen aria-hidden = "true" {} " " (tr("profile-youtube")) ": " } i #profile-youtube-channel { @match user.youtube_channel { @@ -93,23 +99,23 @@ impl AccountPageTab for ProfileTab { } } p { - "A link to your YouTube channel, if you have one. If set, all mentions of your name will turn into links to it." + (tr("profile-youtube.info")) } } span { b { - "Permissions: " + (tr("profile-permissions")) ": " } (permission_string) p { - "The permissions you have on pointercrate. 'List ...' means you're a member of the demonlist team. 'Moderator' and 'Administrator' mean you're part of pointercrate's staff team." + (tr("profile-permissions.info")) } } } div.flex.no-stretch { - input.button.red.hover #delete-account type = "button" style = "margin: 15px auto 0px;" value="Delete My Account"; + input.button.red.hover #delete-account type = "button" style = "margin: 15px auto 0px;" value=(tr("profile-delete-account")); @if authenticated_user.is_legacy() { - input.button.blue.hover #change-password type = "button" style = "margin: 15px auto 0px;" value="Change Password"; + input.button.blue.hover #change-password type = "button" style = "margin: 15px auto 0px;" value=(tr("profile-change-password")); } } } @@ -117,22 +123,22 @@ impl AccountPageTab for ProfileTab { div.right { div.panel.fade { h2.underlined.pad { - "Logout" + (tr("profile-logout")) } p { - "Log out of your pointercrate account in this browser." + (tr("profile-logout.info")) } a.red.hover.button href = "/logout" style = "margin: 15px auto 0px; display: inline-block" { - "Logout" + (tr("profile-logout.button")) } } @if cfg!(feature = "oauth2") && authenticated_user.is_legacy() { div.panel.fade { h2.underlined.pad { - "Link With Google" + (tr("profile-oauth")) } p { - "Enable signing in to your pointercrate account via Google oauth. More secure than password login, and avoids account lock-outs due to forgotten passwords. Linking a Google account is irreversible, and you cannot change the linked Google account later on!" + (tr("profile-oauth.info")) } div #g_id_onload data-ux_mode="popup" @@ -141,36 +147,37 @@ impl AccountPageTab for ProfileTab { data-client_id=(config::google_client_id()) data-callback="googleOauthCallback" {} - div .g_id_signin data-text="continue_with" style="margin: 10px 0px" {} + script src=(format!("https://accounts.google.com/gsi/client?hl={}", &lang)) async {} + div .g_id_signin data-text="continue_with" style="margin: 10px 0px" data-locale=(lang) {} p.error #g-signin-error style="text-align: left" {} } } div.panel.fade { h2.underlined.pad { - "Get access token" + (tr("profile-get-token")) } p { - "Your pointercrate access token allows you, or programs authorized by you, to make API calls on your behalf. They do not allow modifications of your account however." + (tr("profile-get-token.info")) } div.overlined.pad #token-area style = "display: none" { - b {"Your access token is:"} + b {(tr("profile-get-token.view-header")) } textarea #access-token readonly="" style = "resize: none; width: 100%; margin-top: 8px; min-height:75px" {} } form.flex.col #get-token-form novalidate = "" { p.info-red.output {} - input.blue.hover.button type = "submit" style = "margin: 15px auto 0px;" value="Get access token"; + input.blue.hover.button type = "submit" style = "margin: 15px auto 0px;" value=(tr("profile-get-token.button")); } } div.panel.fade { h2.underlined.pad { - "Invalidate tokens" + (tr("profile-invalidate-tokens")) } p { - "If one of your access tokens ever got leaked, you can invalidate them here. Invalidating will cause all access tokens to your account to stop functioning. This includes the one stored inside the browser currently, meaning you'll have to log in again after this action!" + (tr("profile-invalidate-tokens.info")) } form.flex.col #invalidate-form novalidate = "" { p.info-red.output {} - input.blue.hover.button type = "submit" style = "margin: 15px auto 0px;" value="Invalidate all access tokens"; + input.blue.hover.button type = "submit" style = "margin: 15px auto 0px;" value=(tr("profile-invalidate-tokens.button")); } } } @@ -190,17 +197,17 @@ fn edit_display_name_dialog() -> Markup { div.dialog #edit-dn-dialog { span.plus.cross.hover {} h2.underlined.pad { - "Edit Display Name:" + (tr("profile-display-name.dialog-header")) } form.flex.col novalidate = "" { p.info-red.output {} p.info-green.output {} span.form-input #edit-dn { - label for = "display_name" {"New display name:"} + label for = "display_name" {(tr("profile-display-name.dialog-newname")) } input type = "text" name = "display_name"; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Edit"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("profile-display-name.dialog-submit")); } } } @@ -213,17 +220,17 @@ fn edit_youtube_link_dialog() -> Markup { div.dialog #edit-yt-dialog { span.plus.cross.hover {} h2.underlined.pad { - "Edit YouTube Channel Link:" + (tr("profile-youtube.dialog-header")) } form.flex.col novalidate = "" { p.info-red.output {} p.info-green.output {} span.form-input #edit-yt { - label for = "youtube_channel" {"New YouTube link:"} + label for = "youtube_channel" {(tr("profile-youtube.dialog-newlink")) } input type = "url" name = "youtube_channel"; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Edit"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("profile-youtube.dialog-submit")); } } } @@ -236,30 +243,30 @@ fn change_password_dialog() -> Markup { div.dialog #edit-pw-dialog { span.plus.cross.hover {} h2.underlined.pad { - "Change Password:" + (tr("profile-change-password.dialog-header")) } p { - "To make profile related edits, re-entering your password below is required. " i{"Changing"} " your password will log you out and redirect to the login page. It will further invalidate all access tokens to your account" + (tr("profile-change-password.dialog-info")) } form.flex.col novalidate = "" { p.info-red.output {} p.info-green.output {} span.form-input #edit-pw { - label for = "password" {"New password:"} + label for = "password" {(tr("profile-change-password.dialog-newpassword")) } input type = "password" name = "password" minlength = "10"; p.error {} } span.form-input #edit-pw-repeat { - label for = "password2" {"Repeat new password:"} + label for = "password2" {(tr("profile-change-password.dialog-repeatnewpassword")) } input type = "password" minlength = "10"; p.error {} } span.overlined.pad.form-input #auth-pw { - label {"Authenticate:"} + label {(tr("profile-change-password.dialog-authenticate")) } input type = "password" minlength = "10" required = ""; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Edit"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("profile-change-password.dialog-submit")); } } } @@ -272,14 +279,14 @@ fn delete_account_dialog() -> Markup { div.dialog #delete-acc-dialog { span.plus.cross.hover {} h2.underlined.pad { - "Delete Account:" + (tr("profile-delete-account.dialog-header")) } p { - "Deletion of your account is irreversible!" + (tr("profile-delete-account.dialog-info")) } form.flex.col novalidate = "" { p.info-red.output {} - input.button.red.hover type = "submit" style = "margin: 15px auto 0px;" value="Delete"; + input.button.red.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("profile-delete-account.dialog-submit")); } } } diff --git a/pointercrate-user-pages/src/account/users.rs b/pointercrate-user-pages/src/account/users.rs index 5b694a3f1..be66e47f9 100644 --- a/pointercrate-user-pages/src/account/users.rs +++ b/pointercrate-user-pages/src/account/users.rs @@ -1,6 +1,9 @@ use crate::account::AccountPageTab; use maud::{html, Markup, PreEscaped}; -use pointercrate_core::permission::{Permission, PermissionsManager}; +use pointercrate_core::{ + localization::tr, + permission::{Permission, PermissionsManager}, +}; use pointercrate_core_pages::util::filtered_paginator; use pointercrate_user::{ auth::{AuthenticatedUser, NonMutating}, @@ -33,7 +36,7 @@ impl AccountPageTab for UsersTab { fn tab(&self) -> Markup { html! { b { - "Users" + (tr("users")) } (PreEscaped("  ")) i class = "fa fa-users fa-2x" aria-hidden="true" {} @@ -53,33 +56,33 @@ impl AccountPageTab for UsersTab { div.left { div.panel.fade { h2.underlined.pad { - "Pointercrate Account Manager" + (tr("user-viewer")) } div.flex.viewer { (filtered_paginator("user-pagination", "/api/v1/users/")) p.viewer-welcome { - "Click on a user on the left to get started!" + (tr("user-viewer.welcome")) } div.viewer-content { div.stats-container.flex.space { span { b { - "Username:" + (tr("user-username")) } br; span #user-user-name {} } span { b { - "Display Name:" + (tr("user-displayname")) } br; span #user-display-name {} } span { b { - "User ID:" + (tr("user-id")) } br; span #user-user-id {} @@ -92,16 +95,16 @@ impl AccountPageTab for UsersTab { @if !assignable_permissions.is_empty() { div.stats-container.flex.space.col style = "align-items: center" { b { - "Permissions:" + (tr("user-permissions")) } @for permission in assignable_permissions { - @let name_in_snake_case = permission.name().to_lowercase().replace(' ', "-"); + @let permission_name = tr(permission.text_id()); - label.cb-container.form-input #(name_in_snake_case) for = (name_in_snake_case) data-bit = (permission.bit()) { + label.cb-container.form-input #(permission.text_id()) for = (permission.text_id()) data-bit = (permission.bit()) { i { - (permission.name()) + (permission_name) } - input type = "checkbox" name = (name_in_snake_case); + input type = "checkbox" name = (permission.text_id()); span.checkmark {} } } @@ -109,9 +112,9 @@ impl AccountPageTab for UsersTab { } div.flex.no-stretch { @if user.user().has_permission(ADMINISTRATOR) { - input.button.red.hover #delete-user type = "button" style = "margin: 15px auto 0px;" value="Delete user"; + input.button.red.hover #delete-user type = "button" style = "margin: 15px auto 0px;" value=(tr("user-viewer.delete-user")); } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Edit user"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("user-viewer.edit-user")); } } } @@ -122,19 +125,19 @@ impl AccountPageTab for UsersTab { div.right { div.panel.fade { h2.underlined.pad { - "Find users" + (tr("user-idsearch-panel")) } p { - "Users can be uniquely identified by name and ID. To modify a user's account, you need their ID. If you know neither, try looking in the list below" + (tr("user-idsearch-panel.info")) } form.flex.col.pad #find-id-form novalidate = "" { p.info-red.output {} span.form-input #find-id { - label for = "id" {"User ID:"} + label for = "id" {(tr("user-idsearch-panel.id-field")) } input required = "" type = "number" name = "id" min = "0" style="width:93%"; // FIXME: I have no clue why the input thinks it's a special snowflake and fucks up its width, but I dont have the time to fix it p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Find by ID"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("user-idsearch-panel.submit")); } } } diff --git a/pointercrate-user-pages/src/login.rs b/pointercrate-user-pages/src/login.rs index 038e1317d..b36c9e018 100644 --- a/pointercrate-user-pages/src/login.rs +++ b/pointercrate-user-pages/src/login.rs @@ -1,4 +1,8 @@ -use maud::{html, Markup}; +use maud::{html, Markup, PreEscaped}; +use pointercrate_core::{ + localization::{task_lang, tr}, + trp, +}; use pointercrate_core_pages::{head::HeadLike, PageFragment}; use pointercrate_user::config; @@ -21,17 +25,19 @@ pub fn login_page() -> PageFragment { } fn login_page_body() -> Markup { + let lang = task_lang(); + html! { div.center #login style="display: flex; align-items: center; justify-content: center; height: calc(100% - 70px)" { // 70px = height of nav bar div.flex.col style="align-items: center" { div.panel.fade { h1.underlined.pad { - "Sign In" + (tr("login")) } @if cfg!(feature = "oauth2") { p { - "If you have linked your pointercrate account with a Google account, you must sign in via Google oauth by clicking the button below:" + (tr("login.oauth-info")) } div #g_id_onload data-ux_mode="popup" @@ -40,33 +46,42 @@ fn login_page_body() -> Markup { data-client_id=(config::google_client_id()) data-callback="googleOauthCallback" {} - div .g_id_signin data-text="continue_with" style="margin: 10px 0px" {} + script src=(format!("https://accounts.google.com/gsi/client?hl={}", &lang)) async {} + div .g_id_signin data-text="continue_with" style="margin: 10px 0px" data-locale=(lang) {} p.error #g-signin-error style="text-align: left" {} - p.or style="text-size: small; margin: 0px" {"otherwise"} + p.or style="text-size: small; margin: 0px" { (tr("login.methods-separator")) } } p { - "Sign in using your username and password. Sign in attempts are limited to 3 per 30 minutes." + (tr("login.info")) } form.flex.col #login-form novalidate = "" { p.info-red.output {} span.form-input #login-username { - label for = "username" {"Username:"} + label for = "username" {(tr("auth-username")) } input required = "" type = "text" name = "username" minlength = "3"; p.error {} } span.form-input #login-password { - label for = "password" {"Password:"} + label for = "password" {(tr("auth-password")) } input required = "" type = "password" name = "password" minlength = "10"; p.error {} } - input.button.blue.hover type = "submit" style = "margin-top: 15px" value="Sign In"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = (tr("login.submit")); } } p style = "text-align: center; padding: 0px 10px" { - "Don't have a pointercrate account yet? " a.link href="/register" {"Sign up"} " for one!" + (PreEscaped(trp!( + "register.redirect", + ( + "redirect-link", + html! { + a.link href="/register" { (tr("register.redirect-link")) } + }.into_string() + ) + ))) } } } diff --git a/pointercrate-user-pages/src/register.rs b/pointercrate-user-pages/src/register.rs index d7334a448..c675acdda 100644 --- a/pointercrate-user-pages/src/register.rs +++ b/pointercrate-user-pages/src/register.rs @@ -1,4 +1,6 @@ -use maud::{html, Markup}; +use maud::{html, Markup, PreEscaped}; +use pointercrate_core::localization::task_lang; +use pointercrate_core::{localization::tr, trp}; use pointercrate_core_pages::head::HeadLike; use pointercrate_core_pages::PageFragment; use pointercrate_user::config; @@ -19,15 +21,17 @@ pub fn registration_page() -> PageFragment { } fn register_page_body() -> Markup { + let lang = task_lang(); + html! { div.center #register style="display: flex; align-items: center; justify-content: center; height: calc(100% - 70px)" { // 70px = height of nav bar div.flex.col style="align-items: center" { div.panel.fade { h1.underlined.pad { - "Sign Up" + (tr("register")) } p { - "Create a new account. Please note that the username cannot be changed after account creation, so choose wisely!" + (tr("register.info")) } @if cfg!(feature = "oauth2") { div #g_id_onload @@ -37,35 +41,44 @@ fn register_page_body() -> Markup { data-client_id=(config::google_client_id()) data-callback="googleOauthRegisterCallback" {} + script src=(format!("https://accounts.google.com/gsi/client?hl={}", &lang)) async {} div .g_id_signin data-text="signup_with" style="margin: 10px 0px" {} @if cfg!(feature = "legacy_accounts") { - p.or style="text-size: small; margin: 0px" {"or"} + p.or style="text-size: small; margin: 0px" { (tr("login.methods-separator")) } } } @if cfg!(feature = "legacy_accounts") { form.flex.col #register-form novalidate = "" { p.info-red.output {} span.form-input #register-username { - label for = "name" {"Username:"} + label for = "name" {(tr("auth-username")) } input required = "" type = "text" name = "name"; p.error {} } span.form-input #register-password { - label for = "password" {"Password:"} + label for = "password" {(tr("auth-password")) } input required = "" type = "password" name = "password" minlength = "10"; p.error {} } span.form-input #register-password-repeat { - label for = "password2" {"Repeat Password:"} + label for = "password2" {(tr("auth-repeatpassword")) } input required = "" type = "password" name = "password2" minlength = "10"; p.error {} } - input.button.blue.hover type = "submit" style = "margin-top: 15px" value = "Sign Up"; + input.button.blue.hover type = "submit" style = "margin-top: 15px" value = (tr("register.submit")); } } } p style = "text-align: center; padding: 0px 10px" { - "Already have a pointercrate account? " a.link href="/login" {"Sign in"} " instead." + (PreEscaped(trp!( + "login.redirect", + ( + "redirect-link", + html! { + a.link href="/login" { (tr("login.redirect-link")) } + }.into_string() + ) + ))) } } } @@ -81,16 +94,16 @@ fn oauth_registration_dialog() -> Markup { div.dialog #oauth-registration-pick-username style="width: 400px" { span.plus.cross.hover {} h2.underlined.pad { - "Pick your username:" + (tr("register-oauth")) } form.flex.col novalidate = "" { p.info-red.output {} span.form-input #oauth-username { - label for = "username" {"Username:"} + label for = "username" { (tr("auth-username")) } input type = "text" name = "username"; p.error {} } - input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value="Sign Up!"; + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value=(tr("register-oauth.submit")); } } } diff --git a/pointercrate-user-pages/static/ftl/en-us/error.ftl b/pointercrate-user-pages/static/ftl/en-us/error.ftl new file mode 100644 index 000000000..a82010ec0 --- /dev/null +++ b/pointercrate-user-pages/static/ftl/en-us/error.ftl @@ -0,0 +1,15 @@ +error-user-malformedchannelurl = Malformed channel URL +error-user-deleteself = You cannot delete your own account via this endpoint. Use DELETE /api/v1/auth/me/ +error-user-patchself = You cannot modify your own account via this endpoint. Use PATCH /api/v1/auth/me/ +error-user-permissionnotassignable = You cannot assign the following permissions: { $non-assignable } +error-user-usernotfound = No user with id { $user-id } found +error-user-usernotfoundname = No user with name { $user-name } found +error-user-nametaken = The chosen username is already taken +error-user-invalidusername = Invalid display or username! The name must be at least 3 characters long and not start/end with a space +error-user-invalidpassword = Invalid password! The password must be at least 10 characters long +error-user-notyoutube = The given URL is no YouTube URL +error-user-nonlegacyaccount = The given operation (change password) is invalid on non-legacy account, as password login is not supported for these + +error-user-ratelimit-registration = Too many registrations! +error-user-ratelimit-soft-registration = Too many failed registration attempts! +error-user-ratelimit-login = Too many login attempts! \ No newline at end of file diff --git a/pointercrate-user-pages/static/ftl/en-us/user.ftl b/pointercrate-user-pages/static/ftl/en-us/user.ftl new file mode 100644 index 000000000..909add72f --- /dev/null +++ b/pointercrate-user-pages/static/ftl/en-us/user.ftl @@ -0,0 +1,145 @@ +user-username = Username: +user-displayname = Display Name: + .none = None +user-id = User ID: + +user-permissions = Permissions: + .moderator = Moderator + .administrator = Administrator + + .list-helper = List Helper + .list-moderator = List Moderator + .list-administrator = List Administrator + +## Auth input fields +auth-username = Username: + .validator-valuemissing = Username required + .validator-tooshort = Username too short. It needs to be at least 3 characters long. + .error-alreadytaken = This username is already taken. Please choose another one +auth-password = Password: + .validator-valuemissing = Password required + .validator-tooshort = Password too short. It needs to be at least 10 characters long. +auth-repeatpassword = Repeat Password: + .validator-notmatching = Passwords don't match + +## Login/registration forms +# +# The .redirect-link attributes will be turned into +# clickable link, which will replace { $redirect-link } +# in the .redirect attributes +# +login = Sign In + .oauth-info = If you have linked your pointercrate account with a Google account, you must sign in via Google oauth by clicking the button below: + + .methods-separator = otherwise + + .info = Sign in using your username and password. Sign in attempts are limited to 3 per 30 minutes. + .submit = Sign In + + .error-invalidcredentials = Invalid credentials + + .redirect = Already have a pointercrate account? { $redirect-link } instead. + .redirect-link = Sign in + +register = Sign Up + .info = Create a new account. Please note that the username cannot be changed after account creation, so choose wisely! + .submit = Sign Up + + .redirect = Don't have a pointercrate account yet? { $redirect-link } for one! + .redirect-link = Sign up + +register-oauth = Pick your username: + .submit = Sign up! + +## Users tab +users = Users + +user-viewer = Pointercrate Account Manager + .welcome = Click on a user on the left to get started! + .delete-user = Delete user + .edit-user = Edit user + + .edit-success = Successfully modified user! + .edit-notmodified = No changes made! + .delete-success = Successfully deleted user! + + .own-account = This is your own account. You cannot modify your own account using this interface! + +user-listed = ID: { $user-id } + .displayname = Display name: + +user-idsearch-panel = Find users + .info = Users can be uniquely identified by name and ID. To modify a user's account, you need their ID. If you know neither, try looking in the list below + .id-field = User ID: + + .submit = Find by ID + + .id-validator-valuemissing = User ID required + +## Profile tab +profile = Profile + .header = Profile - {$username} + +profile-username = Username + .info = The name you registered under and which you use to log in to pointercrate. This name is unique to your account, and cannot be changed. + +profile-display-name = Display name + .info = If set, this name will be displayed instead of your username. Display names aren't unique and you cannot use your display name to login to your pointercrate account. + + .dialog-header = Edit Display Name + .dialog-newname = New display name: + + .dialog-submit = Edit + +profile-youtube = YouTube channel + .info = A link to your YouTube channel, if you have one. If set, all mentions of your name will turn into links to it. + + .dialog-header = Edit YouTube Channel Link + .dialog-newlink = New YouTube link: + + .dialog-submit = Edit + + .newlink-validator-typemismatch = Please enter a valid URL + +profile-permissions = Permissions + .info = The permissions you have on pointercrate. 'List ...' means you're a member of the demonlist team. 'Moderator' and 'Administrator' mean you're part of pointercrate's staff team. + +profile-delete-account = Delete My Account + .dialog-header = Delete Account + .dialog-info = Deletion of your account is irreversible! + .dialog-submit = Delete + +profile-change-password = Change Password + .dialog-header = Change Password + .dialog-info = To make profile related edits, re-entering your password below is required. Changing your password will log you out and redirect to the login page. It will further invalidate all access tokens to your account. + + .dialog-newpassword = New password: + .dialog-repeatnewpassword = Repeat new password: + .dialog-authenticate = Authenticate: + + .dialog-submit = Edit + + .authenticate-validator-valuemissing = Password required + .authenticate-validator-tooshort = Password too short. It needs to be at least 10 characters long. + + .newpassword-validator-tooshort = Password too short. It needs to be at least 10 characters long. + + .repeatnewpassword-validator-tooshort = Password too short. It needs to be at least 10 characters long. + .repeatnewpassword-validator-notmatching = Passwords don't match + +profile-logout = Logout + .info = Log out of your pointercrate account in this browser. + .button = Logout + +profile-get-token = Get access token + .info = Your pointercrate access token allows you, or programs authorized by you, to make API calls on your behalf. They do not allow modifications of your account however. + .button = Get access token + + .view-header = Your access token is + +profile-invalidate-tokens = Invalidate tokens + .info = If one of your access tokens ever got leaked, you can invalidate them here. Invalidating will cause all access tokens to your account to stop functioning. This includes the one stored inside the browser currently, meaning you'll have to log in again after this action! + .button = Invalidate all access tokens + +profile-oauth = Link With Google + .info = Enable signing in to your pointercrate account via Google oauth. More secure than password login, and avoids account lock-outs due to forgotten passwords. Linking a Google account is irreversible, and you cannot change the linked Google account later on! diff --git a/pointercrate-user-pages/static/ftl/ru-ru/error.ftl b/pointercrate-user-pages/static/ftl/ru-ru/error.ftl new file mode 100644 index 000000000..06603863c --- /dev/null +++ b/pointercrate-user-pages/static/ftl/ru-ru/error.ftl @@ -0,0 +1,15 @@ +error-user-malformedchannelurl = Неправильная ссылка на канал +error-user-deleteself = Вы не можете удалить свой аккаунт через этот эндпоинт. Используйте DELETE /api/v1/auth/me/ +error-user-patchself = Вы не можете изменять свой аккаунт через этот эндпоинт. Используйте PATCH /api/v1/auth/me/ +error-user-permissionnotassignable = Вы не можете назначать следующие права: { $non-assignable } +error-user-usernotfound = Пользователь с id { $user-id } не был найден +error-user-usernotfoundname = Пользователь с именем { $user-name } не был найден +error-user-nametaken = Выбранный никнейм уже занят +error-user-invalidusername = Неправильное отображаемое имя или логин! Имя должно быть как минимум 3 символа в длину и не начинаться либо заканчиваться пробелом +error-user-invalidpassword = Неверный пароль! Пароль должен быть минимум 10 символов в длину +error-user-notyoutube = Данная ссылка не является ссылкой YouTube +error-user-nonlegacyaccount = Данная операция (изменение пароля) не является валидной на новом типе аккаунтов, так как вход по паролю для них не поддерживается + +error-user-ratelimit-registration = Слишком много попыток регистрации! +error-user-ratelimit-soft-registration = Слишком много проваленных попыток регистрации! +error-user-ratelimit-login = Слишком много попыток входа! diff --git a/pointercrate-user-pages/static/ftl/ru-ru/user.ftl b/pointercrate-user-pages/static/ftl/ru-ru/user.ftl new file mode 100644 index 000000000..b185bd0a7 --- /dev/null +++ b/pointercrate-user-pages/static/ftl/ru-ru/user.ftl @@ -0,0 +1,145 @@ +user-username = Логин: +user-displayname = Отображаемый ник: + .none = Н/Д +user-id = ID пользователя: + +user-permissions = Полномочия: + .moderator = Модератор + .administrator = Администратор + + .list-helper = Помощник листа + .list-moderator = Модератор листа + .list-administrator = Лидер листа + +## Auth input fields +auth-username = Логин: + .validator-valuemissing = Требуется логин + .validator-tooshort = Логин слишком короткий. Он должен быть как минимум 3 символа в длину. + .error-alreadytaken = Этот логин уже занят. Пожалуйста, выберите другой +auth-password = Пароль: + .validator-valuemissing = Требуется пароль + .validator-tooshort = Пароль слишком короткий. Он должен быть как минимум 10 символов в длину. +auth-repeatpassword = Повторите пароль: + .validator-notmatching = Пароли не совпадают + +## Login/registration forms +# +# The .redirect-link attributes will be turned into +# clickable link, which will replace { $redirect-link } +# in the .redirect attributes +# +login = Вход + .oauth-info = Если вы связали свой аккаунт pointercrate с аккаунтом Google, вы должны войти через авторизацию Google по нажатию кнопки ниже: + + .methods-separator = либо + + .info = Войдите в аккаунт через свой логин и пароль. Попытки входа ограничены в качестве 3 попыток раз в 30 минут. + .submit = Войти + + .error-invalidcredentials = Неверные данные + + .redirect = Уже есть аккаунт pointercrate? { $redirect-link } в него. + .redirect-link = Войдите + +register = Регистрация + .info = Здесь проходит создание нового аккаунта. Учтите, что логин нельзя поменять после создания аккаунта, поэтому выбирайте его с умом! + .submit = Зарегистрироваться + + .redirect = Еще нет аккаунта pointercrate? { $redirect-link } его! + .redirect-link = Зарегистрируйте + +register-oauth = Выберите ник: + .submit = Зарегистрироваться! + +## Users tab +users = Пользователи + +user-viewer = Менеджер аккаунтов pointercrate + .welcome = Нажмите на пользователя слева для начала работы! + .delete-user = Удалить пользователя + .edit-user = Изменить пользователя + + .edit-success = Пользователь успешно изменен! + .edit-notmodified = Изменений не было сделано! + .delete-success = Пользователь успешно удален! + + .own-account = Это ваш аккаунт. Вы не можете менять свой аккаунт через этот интерфейс! + +user-listed = ID: { $user-id } + .displayname = Отображаемое имя: + +user-idsearch-panel = Найти пользователей + .info = Пользователей можно опознать по имени и ID. Для изменения аккаунта пользователя вам нужен их ID. Если вам ничего неизвестно о нем, попробуйте найти его в списке ниже + .id-field = ID пользователя: + + .submit = Найти по ID + + .id-validator-valuemissing = Требуется ID пользователя + +## Profile tab +profile = Профиль + .header = Профиль - {$username} + +profile-username = Логин + .info = Имя, под которым вы зарегистрировались, и которые вы используете для входа в pointercrate. Это имя уникально для вашего аккаунта и не может быть изменено. + +profile-display-name = Отображаемое имя + .info = При установлении это имя будет отображаться в панели с именами команды листа вместо вашего логина. Отображаемые имена не уникальны, и вы не можете использовать их для входа в аккаунт pointercrate. + + .dialog-header = Изменение отображаемого имени + .dialog-newname = Новое отображаемое имя: + + .dialog-submit = Изменить + +profile-youtube = Канал YouTube + .info = Ссылка на ваш YouTube-канал, если он у вас есть. При его установлении все упоминания вашего имени превратятся в гиперссылку с переходом на этот канал. + + .dialog-header = Изменение ссылки на YouTube-канал + .dialog-newlink = Новая ссылка на канал: + + .dialog-submit = Изменить + + .newlink-validator-typemismatch = Пожалуйста, введите правильную ссылку + +profile-permissions = Полномочия + .info = Полномочия, которые вы имеете в pointercrate. '... листа' означает, что вы член команды демонлиста. 'Модератор' и 'Администратор' причисляют вас к стаффу pointercrate. + +profile-delete-account = Удалить мой аккаунт + .dialog-header = Удаление аккаунта + .dialog-info = Удаление вашего аккаунта невозвратимо! + .dialog-submit = Удалить + +profile-change-password = Поменять пароль + .dialog-header = Изменение пароля + .dialog-info = Для всех связанных с профилем изменений нужно повторно ввести ваш пароль. Изменение пароля выкинет вас из аккаунта и осуществит переход на страницу входа. Это также отключит все токены доступа к вашему аккаунту. + + .dialog-newpassword = Новый пароль: + .dialog-repeatnewpassword = Повторите новый пароль: + .dialog-authenticate = Аутентификация: + + .dialog-submit = Изменить + + .authenticate-validator-valuemissing = Требуется пароль + .authenticate-validator-tooshort = Пароль слишком короткий. Он должен быть как минимум 10 символов в длину. + + .newpassword-validator-tooshort = Пароль слишком короткий. Он должен быть как минимум 10 символов в длину. + + .repeatnewpassword-validator-tooshort = Пароль слишком короткий. Он должен быть как минимум 10 символов в длину. + .repeatnewpassword-validator-notmatching = Пароли не совпадают + +profile-logout = Выход + .info = Выйти из своего аккаунта pointercrate в этом браузере. + .button = Выйти + +profile-get-token = Получение токена доступа + .info = Ваш токен доступа pointercrate позволяет вам либо авторизованным вами программам делать API-запросы от вашего имени. Через токен доступа при этом нельзя менять данные аккаунта. + .button = Получить токен доступа + + .view-header = Ваш токен доступа: + +profile-invalidate-tokens = Отключить токены + .info = Если один из ваших токенов доступа был слит в сеть, вы можете отключить их здесь. Отключение приведет к потере функциональности всех токенов доступа, связанных с вашим аккаунтом. Это включает в себя хранящийся в браузере на данный момент токен, что означает необходимость повторной авторизации после выполнения этого действия! + .button = Отключить все токены доступа + +profile-oauth = Связь с Google + .info = Здесь проходит включение возможности входа в ваш аккаунт pointercrate через Google. Эта опция более защищена, чем традиционный метод входа, и предотвращает потерю доступа к аккаунту из-за забытого пароля. Связь с аккаунтом Google отменить нельзя, и вы не можете поменять связанный аккаунт Google на другой! diff --git a/pointercrate-user-pages/static/js/account/profile.js b/pointercrate-user-pages/static/js/account/profile.js index 25e8af217..844a4266c 100644 --- a/pointercrate-user-pages/static/js/account/profile.js +++ b/pointercrate-user-pages/static/js/account/profile.js @@ -13,6 +13,7 @@ import { typeMismatch, valueMissing, } from "/static/core/js/modules/form.js"; +import { loadResource, tr } from "/static/core/js/modules/localization.js"; function setupGetAccessToken() { var getTokenForm = new Form(document.getElementById("get-token-form")); @@ -57,7 +58,7 @@ class ProfileEditorBackend extends EditorBackend { window.etag = response.headers["etag"]; window.username = response.data.data.name; - this._displayName.innerText = response.data.data.display_name || "None"; + this._displayName.innerText = response.data.data.display_name || "-"; this._youtube.removeChild(this._youtube.lastChild); // only ever has one child if (response.data.data.youtube_channel) { let a = document.createElement("a"); @@ -90,7 +91,7 @@ function setupEditAccount() { editYoutubeForm.addValidators({ "edit-yt": { - "Please enter a valid URL": typeMismatch, + [tr("user", "user", "profile-youtube.newlink-validator-typemismatch")]: typeMismatch, }, }); @@ -109,18 +110,18 @@ function setupEditAccount() { changePasswordForm.addValidators({ "auth-pw": { - "Password required": valueMissing, - "Password too short. It needs to be at least 10 characters long.": + [tr("user", "user", "profile-change-password.authenticate-validator-valuemissing")]: valueMissing, + [tr("user", "user", "profile-change-password.authenticate-validator-tooshort")]: tooShort, }, "edit-pw": { - "Password too short. It needs to be at least 10 characters long.": + [tr("user", "user", "profile-change-password.newpassword-validator-tooshort")]: tooShort, }, "edit-pw-repeat": { - "Password too short. It needs to be at least 10 characters long.": + [tr("user", "user", "profile-change-password.repeatnewpassword-validator-tooshort")]: tooShort, - "Passwords don't match": (rpp) => rpp.value == editPw.value, + [tr("user", "user", "profile-change-password.repeatnewpassword-validator-notmatching")]: (rpp) => rpp.value == editPw.value, }, }); diff --git a/pointercrate-user-pages/static/js/account/users.js b/pointercrate-user-pages/static/js/account/users.js index 88c42b30c..6ba5a6c28 100644 --- a/pointercrate-user-pages/static/js/account/users.js +++ b/pointercrate-user-pages/static/js/account/users.js @@ -9,6 +9,7 @@ import { Form, Viewer, } from "/static/core/js/modules/form.js"; +import { loadResource, tr, trp } from "/static/core/js/modules/localization.js"; let selectedUser; let userPaginator; @@ -33,9 +34,9 @@ function setupPatchUserPermissionsForm() { selectedUser = response.data.data; selectedUser.etag = response.headers["etag"]; - editForm.setSuccess("Successfully modified user!"); + editForm.setSuccess(tr("user", "user", "user-viewer.edit-success")); } else { - editForm.setSuccess("No changes made!"); + editForm.setSuccess(tr("user", "user", "user-viewer.edit-notmodified")); } }) .catch(displayError(editForm)); @@ -49,7 +50,7 @@ function setupPatchUserPermissionsForm() { del("/api/v1/users/" + selectedUser.id + "/", { "If-Match": selectedUser.etag, }) - .then(() => editForm.setSuccess("Successfully deleted user!")) + .then(() => editForm.setSuccess(tr("user", "user", "user-viewer.delete-success"))) .catch(displayError(editForm)); }); } @@ -59,7 +60,7 @@ function setupUserByIdForm() { var userByIdForm = new Form(document.getElementById("find-id-form")); var userId = userByIdForm.input("find-id"); - userId.addValidator(valueMissing, "User ID required"); + userId.addValidator(valueMissing, tr("user", "user", "user-idsearch-panel.id-validator-valuemissing")); userByIdForm.onSubmit(function () { userPaginator.selectArbitrary(userId.value).catch((response) => { @@ -82,12 +83,14 @@ function generateUser(userData) { b.appendChild(document.createTextNode(userData.name)); i.appendChild( document.createTextNode( - "Display name: " + (userData.display_name || "None") + tr("user", "user", "user-listed.displayname") + " " + (userData.display_name || tr("user", "user", "user-displayname.none")) ) ); li.appendChild(b); - li.appendChild(document.createTextNode(" (ID: " + userData.id + ")")); + li.appendChild(document.createTextNode(" (" + trp("user", "user", "user-listed", { + ["user-id"]: userData.id + }) + ")")); li.appendChild(document.createElement("br")); li.appendChild(i); @@ -114,7 +117,7 @@ class UserPaginator extends FilteredPaginator { if (selectedUser.name == window.username) { editForm.setError( - "This is your own account. You cannot modify your own account using this interface!" + tr("user", "user", "user-viewer.own-account") ); for (let btn of this.output.html.getElementsByTagName("input")) { btn.classList.add("disabled"); @@ -130,7 +133,7 @@ class UserPaginator extends FilteredPaginator { document.getElementById("user-user-name").innerText = selectedUser.name; document.getElementById("user-user-id").innerText = selectedUser.id; document.getElementById("user-display-name").innerText = - selectedUser.display_name || "None"; + selectedUser.display_name || tr("user", "user", "user-displayname.none"); let bitmask = selectedUser.permissions; diff --git a/pointercrate-user-pages/static/js/login.js b/pointercrate-user-pages/static/js/login.js index 8239a7ce7..36327cda7 100644 --- a/pointercrate-user-pages/static/js/login.js +++ b/pointercrate-user-pages/static/js/login.js @@ -4,6 +4,7 @@ import { tooShort, post, } from "/static/core/js/modules/form.js"; +import { tr } from "/static/core/js/modules/localization.js"; function initializeLoginForm() { var loginForm = new Form(document.getElementById("login-form")); @@ -11,17 +12,17 @@ function initializeLoginForm() { var loginUsername = loginForm.input("login-username"); var loginPassword = loginForm.input("login-password"); - loginUsername.addValidator(valueMissing, "Username required"); + loginUsername.addValidator(valueMissing, tr("user", "user", "auth-username.validator-valuemissing")); loginUsername.addValidator( tooShort, - "Username too short. It needs to be at least 3 characters long." + tr("user", "user", "auth-username.validator-tooshort") ); loginPassword.clearOnInvalid = true; - loginPassword.addValidator(valueMissing, "Password required"); + loginPassword.addValidator(valueMissing, tr("user", "user", "auth-password.validator-valuemissing")); loginPassword.addValidator( tooShort, - "Password too short. It needs to be at least 10 characters long." + tr("user", "user", "auth-password.validator-tooshort") ); loginForm.onSubmit(function (event) { @@ -35,7 +36,7 @@ function initializeLoginForm() { .catch((response) => { console.log(response); if (response.status === 401) { - loginPassword.errorText = "Invalid credentials"; + loginPassword.errorText = tr("user", "user", "login.error-invalidcredentials"); } else { loginForm.setError(response.data.message); } @@ -56,6 +57,6 @@ function googleOauthCallback(response) { window.googleOauthCallback = googleOauthCallback; -$(document).ready(function () { +$(window).on("load", function () { initializeLoginForm(); }); diff --git a/pointercrate-user-pages/static/js/register.js b/pointercrate-user-pages/static/js/register.js index 306df8d79..adaff2cb9 100644 --- a/pointercrate-user-pages/static/js/register.js +++ b/pointercrate-user-pages/static/js/register.js @@ -6,6 +6,7 @@ import { tooShort, valueMissing, } from "/static/core/js/modules/form.js"; +import { tr } from "/static/core/js/modules/localization.js"; function intializeRegisterForm() { var registerForm = new Form(document.getElementById("register-form")); @@ -14,26 +15,26 @@ function intializeRegisterForm() { var registerPassword = registerForm.input("register-password"); var registerPasswordRepeat = registerForm.input("register-password-repeat"); - registerUsername.addValidator(valueMissing, "Username required"); + registerUsername.addValidator(valueMissing, tr("user", "user", "auth-username.validator-valuemissing")); registerUsername.addValidator( tooShort, - "Username too short. It needs to be at least 3 characters long." + tr("user", "user", "auth-username.validator-tooshort") ); - registerPassword.addValidator(valueMissing, "Password required"); + registerPassword.addValidator(valueMissing, tr("user", "user", "auth-password.validator-valuemissing")); registerPassword.addValidator( tooShort, - "Password too short. It needs to be at least 10 characters long." + tr("user", "user", "auth-password.validator-tooshort") ); - registerPasswordRepeat.addValidator(valueMissing, "Password required"); + registerPasswordRepeat.addValidator(valueMissing, tr("user", "user", "auth-password.validator-valuemissing")); registerPasswordRepeat.addValidator( tooShort, - "Password too short. It needs to be at least 10 characters long." + tr("user", "user", "auth-password.validator-valuemissing") ); registerPasswordRepeat.addValidator( (rpp) => rpp.value == registerPassword.value, - "Passwords don't match" + tr("user", "user", "auth-repeatpassword.validator-notmatching") ); registerForm.onSubmit(function (event) { @@ -44,7 +45,7 @@ function intializeRegisterForm() { .catch((response) => { if (response.status === 409) { registerUsername.errorText = - "This username is already taken. Please choose another one"; + tr("user", "user", "auth-username.error-alreadytaken"); } else { registerForm.setError(response.data.message); } diff --git a/pointercrate-user/src/error.rs b/pointercrate-user/src/error.rs index 43cfa1c19..4a7b44d9e 100644 --- a/pointercrate-user/src/error.rs +++ b/pointercrate-user/src/error.rs @@ -1,69 +1,64 @@ -use derive_more::Display; - use pointercrate_core::{ error::{CoreError, PointercrateError}, + localization::tr, permission::Permission, + trp, }; use serde::Serialize; -use std::collections::HashSet; +use std::{collections::HashSet, fmt::Display}; pub type Result = std::result::Result; -#[derive(Debug, Display, Serialize, Eq, PartialEq, Clone)] +#[derive(Debug, Serialize, Eq, PartialEq, Clone)] pub enum UserError { - #[display("{}", _0)] Core(CoreError), - #[display("Malformed channel URL")] MalformedChannelUrl, /// `403 FORBIDDEN` error returned when a user attempts to delete his own account via the admin /// panel /// /// Error Code `40302` - #[display("You cannot delete your own account via this endpoint. Use DELETE /api/v1/auth/me/")] DeleteSelf, /// `403 FORBIDDEN` error returned when a user attempts to patch his own account via the admin /// panel /// /// Error Code `40303` - #[display("You cannot modify your own account via this endpoint. Use PATCH /api/v1/auth/me/")] PatchSelf, - #[display("You cannot assign the following permissions: {:?}", non_assignable)] - PermissionNotAssignable { non_assignable: HashSet }, + PermissionNotAssignable { + non_assignable: HashSet, + }, - #[display("No user with id {} found", user_id)] - UserNotFound { user_id: i32 }, + UserNotFound { + user_id: i32, + }, - #[display("No user with name {} found", user_name)] - UserNotFoundName { user_name: String }, + UserNotFoundName { + user_name: String, + }, /// `409 CONFLICT` error returned if a user tries to register with a name that's already taken /// /// Error Code `40902` - #[display("The chosen username is already taken")] NameTaken, /// `422 UNPROCESSABLE ENTITIY` variant returned if the username provided during registration /// is either shorter than 3 letters of contains trailing or leading whitespaces /// /// Error Code: `42202` - #[display("Invalid display- or username! The name must be at least 3 characters long and not start/end with a space")] InvalidUsername, /// `422 UNPROCESSABLE ENTITY` variant returned if the password provided during registration /// (or account update) is shorter than 10 characters /// /// Error Code `42204` - #[display("Invalid password! The password must be at least 10 characters long")] InvalidPassword, /// `422 UNPROCESSABLE ENTITY` variant /// /// Error Code `42226` - #[display("The given URL is no YouTube URL")] NotYouTube, /// `422 UNPROCESSABL ENTITY` variant indicating that the attempted operation can only be @@ -71,7 +66,6 @@ pub enum UserError { /// for a non-legacy account). /// /// Error Code `42234` - #[display("The given operation (change password) is invalid on non-legacy account, as password login is not supported for these")] NonLegacyAccount, } @@ -105,6 +99,41 @@ impl PointercrateError for UserError { } } +impl Display for UserError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + UserError::Core(core) => { + return core.fmt(f); + }, + UserError::MalformedChannelUrl => tr("error-user-malformedchannelurl"), + UserError::DeleteSelf => tr("error-user-deleteself"), + UserError::PatchSelf => tr("error-user-patchself"), + UserError::PermissionNotAssignable { non_assignable } => trp!( + "error-user-permissionnotassignable", + ( + "non-assignable", + non_assignable + .iter() + .map(|permission| tr(permission.text_id())) + .collect::>() + .join(", ") + ) + ), + UserError::UserNotFound { user_id } => trp!("error-user-usernotfound", ("user-id", user_id)), + UserError::UserNotFoundName { user_name } => trp!("error-user-usernotfoundname", ("user-name", user_name)), + UserError::NameTaken => tr("error-user-nametaken"), + UserError::InvalidUsername => tr("error-user-invalidusername"), + UserError::InvalidPassword => tr("error-user-invalidpassword"), + UserError::NotYouTube => tr("error-user-notyoutube"), + UserError::NonLegacyAccount => tr("error-user-nonlegacyaccount"), + } + ) + } +} + impl From for UserError { fn from(error: sqlx::Error) -> Self { UserError::Core(error.into()) diff --git a/pointercrate-user/src/lib.rs b/pointercrate-user/src/lib.rs index 366b0e743..63779aa3a 100644 --- a/pointercrate-user/src/lib.rs +++ b/pointercrate-user/src/lib.rs @@ -27,8 +27,8 @@ mod paginate; mod patch; mod video; -pub const ADMINISTRATOR: Permission = Permission::new("Administrator", 0x4000); -pub const MODERATOR: Permission = Permission::new("Moderator", 0x2000); +pub const ADMINISTRATOR: Permission = Permission::new("user-permissions.administrator", 0x4000); +pub const MODERATOR: Permission = Permission::new("user-permissions.moderator", 0x2000); pub fn default_permissions_manager() -> PermissionsManager { PermissionsManager::new(vec![ADMINISTRATOR, MODERATOR])