From 654122263b8b01b051baac58ea0258948f29714a Mon Sep 17 00:00:00 2001 From: Matthias Vogelgesang Date: Tue, 28 Apr 2026 15:37:56 +0200 Subject: [PATCH] Add i18n support and German translations Fixes #27. --- CHANGELOG.md | 4 + Cargo.lock | 1 + crates/wastebin_server/Cargo.toml | 1 + .../src/handlers/delete/form.rs | 4 +- .../wastebin_server/src/handlers/download.rs | 5 +- .../wastebin_server/src/handlers/extract.rs | 83 +++++++ .../wastebin_server/src/handlers/html/burn.rs | 6 +- .../src/handlers/html/index.rs | 4 + .../wastebin_server/src/handlers/html/mod.rs | 12 +- .../src/handlers/html/paste.rs | 8 +- .../wastebin_server/src/handlers/html/qr.rs | 6 +- .../src/handlers/html/rendered.rs | 10 +- .../src/handlers/insert/form.rs | 6 +- crates/wastebin_server/src/handlers/raw.rs | 5 +- crates/wastebin_server/src/i18n.rs | 216 ++++++++++++++++++ crates/wastebin_server/src/javascript/burn.js | 2 +- .../wastebin_server/src/javascript/index.js | 14 +- .../src/javascript/password-toggle.js | 2 +- .../wastebin_server/src/javascript/paste.js | 55 +---- crates/wastebin_server/src/main.rs | 5 + crates/wastebin_server/templates/base.html | 29 +-- .../templates/burn-confirmation.html | 9 +- crates/wastebin_server/templates/burn.html | 6 +- .../wastebin_server/templates/encrypted.html | 10 +- crates/wastebin_server/templates/error.html | 4 +- .../wastebin_server/templates/formatted.html | 3 +- crates/wastebin_server/templates/index.html | 74 +++--- crates/wastebin_server/templates/paste.html | 32 ++- crates/wastebin_server/templates/qr.html | 6 +- .../wastebin_server/templates/rendered.html | 2 +- 30 files changed, 486 insertions(+), 138 deletions(-) create mode 100644 crates/wastebin_server/src/i18n.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 96003e91..b32687d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Internationalization (i18n) support and initial German translation. + ## 3.6.2 diff --git a/Cargo.lock b/Cargo.lock index a8918875..8779b533 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2787,6 +2787,7 @@ dependencies = [ "hostname", "http", "mime", + "phf", "qrcodegen", "reqwest", "serde", diff --git a/crates/wastebin_server/Cargo.toml b/crates/wastebin_server/Cargo.toml index a0f32e9d..a2160f82 100644 --- a/crates/wastebin_server/Cargo.toml +++ b/crates/wastebin_server/Cargo.toml @@ -16,6 +16,7 @@ hex = "0.4" hostname = "0.4.0" http = "1.3" mime = "0.3" +phf = { version = "0.11", features = ["macros"] } qrcodegen = "1" sha2 = "0.11" serde = { workspace = true } diff --git a/crates/wastebin_server/src/handlers/delete/form.rs b/crates/wastebin_server/src/handlers/delete/form.rs index e1a4ad6d..f1898a9f 100644 --- a/crates/wastebin_server/src/handlers/delete/form.rs +++ b/crates/wastebin_server/src/handlers/delete/form.rs @@ -3,6 +3,7 @@ use axum::response::Redirect; use crate::handlers::extract::{Theme, Uid}; use crate::handlers::html::{ErrorResponse, make_error}; +use crate::i18n::Lang; use crate::{Database, Page}; pub async fn delete( @@ -11,6 +12,7 @@ pub async fn delete( State(page): State, Uid(uid): Uid, theme: Option, + lang: Lang, ) -> Result { async { let id = id.parse()?; @@ -18,7 +20,7 @@ pub async fn delete( Ok(Redirect::to("/")) } .await - .map_err(|err| make_error(err, page.clone(), theme)) + .map_err(|err| make_error(err, page.clone(), theme, lang)) } #[cfg(test)] diff --git a/crates/wastebin_server/src/handlers/download.rs b/crates/wastebin_server/src/handlers/download.rs index 664ff25f..02482777 100644 --- a/crates/wastebin_server/src/handlers/download.rs +++ b/crates/wastebin_server/src/handlers/download.rs @@ -9,6 +9,7 @@ use crate::Page; use crate::cache::Key; use crate::handlers::extract::{Password, Theme}; use crate::handlers::html::{ErrorResponse, PasswordInput, make_error}; +use crate::i18n::Lang; use wastebin_core::db::read::{Data, Entry}; use wastebin_core::db::{self, Database}; @@ -18,6 +19,7 @@ pub async fn get( State(db): State, State(page): State, theme: Option, + lang: Lang, password: Option, ) -> Result { async { @@ -31,6 +33,7 @@ pub async fn get( Err(db::Error::NoPassword) => Ok(PasswordInput { page: page.clone(), theme: theme.clone(), + lang, id: key.id.to_string(), } .into_response()), @@ -38,7 +41,7 @@ pub async fn get( } } .await - .map_err(|err| make_error(err, page, theme)) + .map_err(|err| make_error(err, page, theme, lang)) } fn make_content_disposition(filename: &str) -> HeaderValue { diff --git a/crates/wastebin_server/src/handlers/extract.rs b/crates/wastebin_server/src/handlers/extract.rs index 6ec23fcc..8ad74723 100644 --- a/crates/wastebin_server/src/handlers/extract.rs +++ b/crates/wastebin_server/src/handlers/extract.rs @@ -12,6 +12,8 @@ use serde::Deserialize; use wastebin_core::crypto; +use crate::i18n::Lang; + /// A safe redirect back to the referer. /// /// Extracts the `Referer` header and strips it down to just the path (and query string), @@ -192,3 +194,84 @@ where .map(|data| Password(data.password.as_bytes().to_vec().into()))) } } + +/// Map a single language tag (e.g. `en`, `de-AT`) to a supported [`Lang`]. +fn lang_from_tag(tag: &str) -> Option { + match tag.split('-').next()?.trim() { + "en" | "eN" | "En" | "EN" => Some(Lang::En), + "de" | "dE" | "De" | "DE" => Some(Lang::De), + _ => None, + } +} + +/// Pick the best supported language from an `Accept-Language` header value, +/// honoring `q=` weights. Falls back to the default language if nothing +/// matches. +fn lang_from_accept_language(header: &str) -> Lang { + header + .split(',') + .enumerate() + .filter_map(|(idx, entry)| { + let mut parts = entry.split(';'); + let tag = parts.next().map(str::trim).filter(|t| !t.is_empty())?; + let lang = lang_from_tag(tag)?; + + let q = parts + .find_map(|p| { + let p = p.trim(); + p.strip_prefix("q=").or_else(|| p.strip_prefix("Q=")) + }) + .and_then(|s| s.parse::().ok()) + .unwrap_or(1.0); + + // Use position as a tie-breaker so the first listed entry wins + // when weights are equal. + #[expect(clippy::cast_precision_loss)] + let weighted = q - (idx as f32) * 1e-6; + Some((weighted, lang)) + }) + .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)) + .map_or(Lang::default(), |(_, l)| l) +} + +impl FromRequestParts for Lang +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + Ok(parts + .headers + .get(http::header::ACCEPT_LANGUAGE) + .and_then(|v| v.to_str().ok()) + .map_or_else(Lang::default, lang_from_accept_language)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn picks_highest_q() { + assert_eq!(lang_from_accept_language("en;q=0.5,de;q=0.9"), Lang::De); + } + + #[test] + fn defaults_to_english_when_unsupported() { + assert_eq!(lang_from_accept_language("ja,fr;q=0.7"), Lang::En); + } + + #[test] + fn handles_region_subtags() { + assert_eq!(lang_from_accept_language("de-AT"), Lang::De); + } + + #[test] + fn first_listed_wins_on_tie() { + // Both implicit q=1.0; first listed should win. + assert_eq!(lang_from_accept_language("de,en"), Lang::De); + assert_eq!(lang_from_accept_language("en,de"), Lang::En); + } +} diff --git a/crates/wastebin_server/src/handlers/html/burn.rs b/crates/wastebin_server/src/handlers/html/burn.rs index 9ce235b4..fd425b5e 100644 --- a/crates/wastebin_server/src/handlers/html/burn.rs +++ b/crates/wastebin_server/src/handlers/html/burn.rs @@ -6,6 +6,7 @@ use crate::cache::Key; use crate::handlers::extract::Theme; use crate::handlers::html::qr::{code_from, dark_modules}; use crate::handlers::html::{ErrorResponse, make_error}; +use crate::i18n::Lang; use crate::{Error, Page}; /// GET handler for the burn page. @@ -13,6 +14,7 @@ pub async fn get( Path(id): Path, State(page): State, theme: Option, + lang: Lang, ) -> Result { async { let key: Key = id.parse()?; @@ -29,10 +31,11 @@ pub async fn get( key, code, theme: theme.clone(), + lang, }) } .await - .map_err(|err| make_error(err, page, theme)) + .map_err(|err| make_error(err, page, theme, lang)) } /// Burn page shown if "burn-after-reading" was selected during insertion. @@ -43,6 +46,7 @@ pub(crate) struct Burn { key: Key, code: qrcodegen::QrCode, theme: Option, + lang: Lang, } impl Burn { diff --git a/crates/wastebin_server/src/handlers/html/index.rs b/crates/wastebin_server/src/handlers/html/index.rs index 9df0a15a..82beb09b 100644 --- a/crates/wastebin_server/src/handlers/html/index.rs +++ b/crates/wastebin_server/src/handlers/html/index.rs @@ -2,6 +2,7 @@ use askama::Template; use askama_web::WebTemplate; use axum::extract::State; +use crate::i18n::Lang; use crate::{Highlighter, Page, handlers::extract::Theme}; /// GET handler for the index page. @@ -9,10 +10,12 @@ pub async fn get( State(page): State, State(highlighter): State, theme: Option, + lang: Lang, ) -> Index { Index { page, theme, + lang, highlighter, } } @@ -23,5 +26,6 @@ pub async fn get( pub(crate) struct Index { page: Page, theme: Option, + lang: Lang, highlighter: Highlighter, } diff --git a/crates/wastebin_server/src/handlers/html/mod.rs b/crates/wastebin_server/src/handlers/html/mod.rs index 6b5a6dad..e15d1a6a 100644 --- a/crates/wastebin_server/src/handlers/html/mod.rs +++ b/crates/wastebin_server/src/handlers/html/mod.rs @@ -10,6 +10,7 @@ use axum::http::StatusCode; use crate::Page; use crate::handlers::extract::Theme; +use crate::i18n::Lang; /// Error page showing a message. #[derive(Template, WebTemplate)] @@ -17,6 +18,7 @@ use crate::handlers::extract::Theme; pub(crate) struct Error { pub page: Page, pub theme: Option, + pub lang: Lang, pub description: String, } @@ -26,6 +28,7 @@ pub(crate) struct Error { pub(crate) struct PasswordInput { pub page: Page, pub theme: Option, + pub lang: Lang, pub id: String, } @@ -35,6 +38,7 @@ pub(crate) struct PasswordInput { pub(crate) struct BurnConfirmation { pub page: Page, pub theme: Option, + pub lang: Lang, pub id: String, pub title: Option, } @@ -44,13 +48,19 @@ pub(crate) type ErrorResponse = (StatusCode, Error); /// Create an error response from `error` consisting of [`StatusCode`] derive from `error` as well /// as a rendered page with a description. -pub fn make_error(error: crate::Error, page: Page, theme: Option) -> ErrorResponse { +pub fn make_error( + error: crate::Error, + page: Page, + theme: Option, + lang: Lang, +) -> ErrorResponse { let description = error.to_string(); ( error.into(), Error { page, theme, + lang, description, }, ) diff --git a/crates/wastebin_server/src/handlers/html/paste.rs b/crates/wastebin_server/src/handlers/html/paste.rs index a8aeae14..8f1da517 100644 --- a/crates/wastebin_server/src/handlers/html/paste.rs +++ b/crates/wastebin_server/src/handlers/html/paste.rs @@ -7,6 +7,7 @@ use serde::Deserialize; use crate::cache::{Key, Mode}; use crate::handlers::extract::{Theme, Uid}; use crate::handlers::html::{BurnConfirmation, ErrorResponse, PasswordInput, make_error}; +use crate::i18n::Lang; use crate::{Cache, Database, Highlighter, Page}; use wastebin_core::crypto::Password; use wastebin_core::db; @@ -33,6 +34,7 @@ pub(crate) struct Paste { page: Page, key: Key, theme: Option, + lang: Lang, can_delete: bool, /// If the paste still in the database and can be fetched with another request. is_available: bool, @@ -58,6 +60,7 @@ pub async fn get( Path(id): Path, uid: Option, theme: Option, + lang: Lang, form: Result, E>, ) -> Result { async { @@ -80,6 +83,7 @@ pub async fn get( return Ok(BurnConfirmation { page: page.clone(), theme: theme.clone(), + lang, id, title: metadata.title.clone(), } @@ -93,6 +97,7 @@ pub async fn get( return Ok(PasswordInput { page: page.clone(), theme: theme.clone(), + lang, id, } .into_response()); @@ -134,6 +139,7 @@ pub async fn get( page: page.clone(), key, theme: theme.clone(), + lang, can_delete, is_available, expiration, @@ -145,7 +151,7 @@ pub async fn get( Ok(paste.into_response()) } .await - .map_err(|err| make_error(err, page, theme)) + .map_err(|err| make_error(err, page, theme, lang)) } #[cfg(test)] diff --git a/crates/wastebin_server/src/handlers/html/qr.rs b/crates/wastebin_server/src/handlers/html/qr.rs index a5215f81..b2ebe8fa 100644 --- a/crates/wastebin_server/src/handlers/html/qr.rs +++ b/crates/wastebin_server/src/handlers/html/qr.rs @@ -8,6 +8,7 @@ use crate::cache::Key; use crate::handlers::extract::{Theme, Uid}; use crate::handlers::html::paste::is_markdown_ext; use crate::handlers::html::{ErrorResponse, make_error}; +use crate::i18n::Lang; use crate::{Error, Page}; use wastebin_core::db::Database; use wastebin_core::db::read::Metadata; @@ -20,6 +21,7 @@ pub async fn get( State(db): State, uid: Option, theme: Option, + lang: Lang, ) -> Result { async { let key: Key = id.parse()?; @@ -48,6 +50,7 @@ pub async fn get( Ok(Qr { page: page.clone(), theme: theme.clone(), + lang, key, can_delete, is_available: true, @@ -58,7 +61,7 @@ pub async fn get( }) } .await - .map_err(|err| make_error(err, page, theme)) + .map_err(|err| make_error(err, page, theme, lang)) } /// Paste view showing the formatted paste as well as a bunch of links. @@ -67,6 +70,7 @@ pub async fn get( pub(crate) struct Qr { page: Page, theme: Option, + lang: Lang, key: Key, can_delete: bool, is_available: bool, diff --git a/crates/wastebin_server/src/handlers/html/rendered.rs b/crates/wastebin_server/src/handlers/html/rendered.rs index fd2d2ad9..74002de5 100644 --- a/crates/wastebin_server/src/handlers/html/rendered.rs +++ b/crates/wastebin_server/src/handlers/html/rendered.rs @@ -7,6 +7,7 @@ use crate::cache::{Key, Mode}; use crate::handlers::extract::{Theme, Uid}; use crate::handlers::html::paste::PasswordForm; use crate::handlers::html::{ErrorResponse, PasswordInput, make_error}; +use crate::i18n::Lang; use crate::{Cache, Database, Highlighter, Page}; use wastebin_core::crypto::Password; use wastebin_core::db; @@ -21,8 +22,11 @@ pub(crate) struct Rendered { page: Page, key: Key, theme: Option, + lang: Lang, can_delete: bool, is_available: bool, + /// Always `true` for this view; needed by the inherited paste template. + is_markdown: bool, expiration: Option, html: String, title: Option, @@ -37,6 +41,7 @@ pub async fn get( Path(id): Path, uid: Option, theme: Option, + lang: Lang, form: Result, E>, ) -> Result { async { @@ -53,6 +58,7 @@ pub async fn get( return Ok(PasswordInput { page: page.clone(), theme: theme.clone(), + lang, id, } .into_response()); @@ -93,8 +99,10 @@ pub async fn get( page: page.clone(), key, theme: theme.clone(), + lang, can_delete, is_available, + is_markdown: true, expiration, html, title, @@ -103,7 +111,7 @@ pub async fn get( Ok(rendered.into_response()) } .await - .map_err(|err| make_error(err, page, theme)) + .map_err(|err| make_error(err, page, theme, lang)) } #[cfg(test)] diff --git a/crates/wastebin_server/src/handlers/insert/form.rs b/crates/wastebin_server/src/handlers/insert/form.rs index 4394fad8..207d3870 100644 --- a/crates/wastebin_server/src/handlers/insert/form.rs +++ b/crates/wastebin_server/src/handlers/insert/form.rs @@ -9,6 +9,7 @@ use crate::Page; use crate::handlers::cookie; use crate::handlers::extract::{Theme, Uid}; use crate::handlers::html::make_error; +use crate::i18n::Lang; use wastebin_core::db::{Database, write}; #[derive(Debug, Default, Serialize, Deserialize)] @@ -49,10 +50,11 @@ pub async fn post( jar: SignedCookieJar, uid: Option, theme: Option, + lang: Lang, entry: Result, E>, ) -> Result<(SignedCookieJar, Redirect), impl IntoResponse> { let Ok(Form(entry)) = entry else { - return Err(make_error(crate::Error::MalformedForm, page, theme)); + return Err(make_error(crate::Error::MalformedForm, page, theme, lang)); }; async { @@ -83,7 +85,7 @@ pub async fn post( Ok((jar.add(cookie), Redirect::to(&url))) } .await - .map_err(|err| make_error(err, page, theme)) + .map_err(|err| make_error(err, page, theme, lang)) } #[cfg(test)] diff --git a/crates/wastebin_server/src/handlers/raw.rs b/crates/wastebin_server/src/handlers/raw.rs index 6cc2db37..d1208d79 100644 --- a/crates/wastebin_server/src/handlers/raw.rs +++ b/crates/wastebin_server/src/handlers/raw.rs @@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response}; use crate::cache::Key; use crate::handlers::extract::{Password, Theme}; use crate::handlers::html::{ErrorResponse, PasswordInput, make_error}; +use crate::i18n::Lang; use crate::{Database, Page}; use wastebin_core::db; use wastebin_core::db::read::Entry; @@ -14,6 +15,7 @@ pub async fn get( State(db): State, State(page): State, theme: Option, + lang: Lang, password: Option, ) -> Result { async { @@ -25,6 +27,7 @@ pub async fn get( Err(db::Error::NoPassword) => Ok(PasswordInput { page: page.clone(), theme: theme.clone(), + lang, id: key.id.to_string(), } .into_response()), @@ -32,5 +35,5 @@ pub async fn get( } } .await - .map_err(|err| make_error(err, page, theme)) + .map_err(|err| make_error(err, page, theme, lang)) } diff --git a/crates/wastebin_server/src/i18n.rs b/crates/wastebin_server/src/i18n.rs new file mode 100644 index 00000000..5a2a20b2 --- /dev/null +++ b/crates/wastebin_server/src/i18n.rs @@ -0,0 +1,216 @@ +use phf::phf_map; + +/// Languages the UI is translated into. English is the default and the +/// fallback for any key missing from a non-English table. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum Lang { + #[default] + En, + De, +} + +impl Lang { + /// BCP-47 code suitable for the `lang` attribute on ``. + pub(crate) fn code(self) -> &'static str { + match self { + Lang::En => "en", + Lang::De => "de", + } + } + + /// Look up `key` in the current language, falling back to English and + /// finally to the key itself if the key is unknown everywhere. Keys are + /// expected to be string literals. + pub(crate) fn t(self, key: &'static str) -> &'static str { + let map: &phf::Map<&'static str, &'static str> = match self { + Lang::En => &EN, + Lang::De => &DE, + }; + + map.get(key).or_else(|| EN.get(key)).copied().unwrap_or(key) + } + + /// Look up `key` and substitute the `{0}` placeholder with `arg`'s + /// `Display` representation. + pub(crate) fn t_with(self, key: &'static str, arg: impl std::fmt::Display) -> String { + self.t(key).replace("{0}", &arg.to_string()) + } +} + +static EN: phf::Map<&'static str, &'static str> = phf_map! { + "nav.home" => "home", + "nav.upload" => "upload", + "nav.delete" => "delete paste", + "nav.download" => "download file", + "nav.raw" => "display raw file", + "nav.copy" => "copy to clipboard", + "nav.qr" => "qr code", + "nav.rendered" => "rendered view", + "nav.source" => "source view", + + "theme.dark" => "dark mode", + "theme.light" => "light mode", + "theme.auto" => "auto mode", + + "index.placeholder.paste" => "paste, type, or drop a file here …", + "index.drop" => "drop to load file", + "index.label.title" => "title", + "index.placeholder.title" => "untitled", + "index.label.language" => "language", + "index.aria.language" => "Language", + "index.placeholder.filter" => "filter …", + "index.label.expires" => "expires", + "index.label.options" => "options", + "index.toggle.burn" => "burn after reading", + "index.toggle.burn.hint" => "delete on first view", + "index.toggle.encrypt" => "encrypt", + "index.toggle.encrypt.hint" => "password-protect the paste", + "index.placeholder.password" => "password", + "index.stat.lines" => "lines", + "index.stat.chars" => "chars", + "index.stat.bytes" => "bytes", + "index.button.paste" => "Paste", + "index.button.paste.label" => "paste", + + "paste.expires_in" => "expires in", + "paste.toast.copied_content" => "Copied content", + "paste.toast.copied_url" => "Copied URL", + "paste.toast.burned" => "Content is burned and cannot be looked up again!", + "paste.help.go_home" => "Go home", + "paste.help.go_here" => "Go here", + "paste.help.copy_url" => "Copy URL", + "paste.help.copy_content" => "Copy content", + "paste.help.download" => "Download", + "paste.help.show_qr" => "Show QR code", + "paste.help.toggle_wrap" => "Toggle line wrapping", + "paste.help.toggle_rendered" => "Toggle rendered view", + "paste.help.toggle_help" => "Toggle help", + + "password.show" => "show password", + "password.hide" => "hide password", + + "stats.unit.kb" => "kb", + "stats.unit.mb" => "mb", + "stats.label.limit" => "limit", + + "burn.title" => "Burn after reading", + "burn.body" => "Copy and send this link. The recipient will be shown a confirmation prompt. The paste is deleted the moment they confirm.", + + "burn_confirm.body" => "This paste will be permanently deleted the moment it is revealed. You will not be able to view it again.", + "burn_confirm.cancel" => "cancel", + "burn_confirm.reveal" => "reveal", + + "encrypted.title" => "Encrypted paste", + "encrypted.placeholder" => "password …", + "encrypted.cancel" => "cancel", + "encrypted.decrypt" => "decrypt", + + "error.title" => "Error 😢", + "error.back" => "go back", + + "qr.label" => "qr code", +}; + +static DE: phf::Map<&'static str, &'static str> = phf_map! { + "nav.home" => "Start", + "nav.upload" => "Hochladen", + "nav.delete" => "Paste löschen", + "nav.download" => "Datei herunterladen", + "nav.raw" => "Rohansicht", + "nav.copy" => "In Zwischenablage kopieren", + "nav.qr" => "QR-Code", + "nav.rendered" => "Gerenderte Ansicht", + "nav.source" => "Quelltext-Ansicht", + + "theme.dark" => "Dunkler Modus", + "theme.light" => "Heller Modus", + "theme.auto" => "Automatisch", + + "index.placeholder.paste" => "Text einfügen, tippen oder Datei hierher ziehen …", + "index.drop" => "Datei hier ablegen", + "index.label.title" => "Titel", + "index.placeholder.title" => "ohne Titel", + "index.label.language" => "Sprache", + "index.aria.language" => "Sprache", + "index.placeholder.filter" => "filtern …", + "index.label.expires" => "Läuft ab", + "index.label.options" => "Optionen", + "index.toggle.burn" => "Nach Lesen vernichten", + "index.toggle.burn.hint" => "Nach erstem Aufruf löschen", + "index.toggle.encrypt" => "Verschlüsseln", + "index.toggle.encrypt.hint" => "Paste mit Passwort schützen", + "index.placeholder.password" => "Passwort", + "index.stat.lines" => "Zeilen", + "index.stat.chars" => "Zeichen", + "index.stat.bytes" => "Bytes", + "index.button.paste" => "Einfügen", + "index.button.paste.label" => "einfügen", + + "paste.expires_in" => "läuft ab in", + "paste.toast.copied_content" => "Inhalt kopiert", + "paste.toast.copied_url" => "URL kopiert", + "paste.toast.burned" => "Inhalt ist vernichtet und kann nicht mehr abgerufen werden!", + "paste.help.go_home" => "Zur Startseite", + "paste.help.go_here" => "Zu diesem Paste", + "paste.help.copy_url" => "URL kopieren", + "paste.help.copy_content" => "Inhalt kopieren", + "paste.help.download" => "Herunterladen", + "paste.help.show_qr" => "QR-Code anzeigen", + "paste.help.toggle_wrap" => "Zeilenumbruch umschalten", + "paste.help.toggle_rendered" => "Markdown Ansicht umschalten", + "paste.help.toggle_help" => "Hilfe umschalten", + + "password.show" => "Passwort anzeigen", + "password.hide" => "Passwort verbergen", + + "stats.unit.kb" => "kB", + "stats.unit.mb" => "MB", + "stats.label.limit" => "Limit", + + "burn.title" => "Nach Lesen vernichten", + "burn.body" => "Kopiere und schicke diesen Link. Dem Empfänger wird eine Bestätigungsaufforderung angezeigt und der Paste nach Bestätigung gelöscht.", + + "burn_confirm.body" => "Dieser Paste wird unwiderruflich gelöscht, sobald er angezeigt wird und kann danach nicht mehr eingesehen werden.", + "burn_confirm.cancel" => "Abbrechen", + "burn_confirm.reveal" => "Anzeigen", + + "encrypted.title" => "Verschlüsselter Paste", + "encrypted.placeholder" => "Passwort …", + "encrypted.cancel" => "Abbrechen", + "encrypted.decrypt" => "Entschlüsseln", + + "error.title" => "Fehler 😢", + "error.back" => "Zurück", + + "qr.label" => "QR-Code", +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn falls_back_to_english_for_missing_keys() { + assert_eq!(Lang::De.t("nav.home"), "Start"); + assert_eq!(Lang::En.t("nav.home"), "home"); + // Unknown key returns the key itself. + assert_eq!(Lang::De.t("does.not.exist"), "does.not.exist"); + } + + #[test] + fn t_with_substitutes_placeholder() { + let s = Lang::En.t_with("burn.body", "abc123"); + assert!(s.contains("href=\"/abc123\"")); + } + + #[test] + fn translations_intersect() { + for key in EN.keys() { + assert!(DE.contains_key(key)); + } + + for key in DE.keys() { + assert!(EN.contains_key(key)); + } + } +} diff --git a/crates/wastebin_server/src/javascript/burn.js b/crates/wastebin_server/src/javascript/burn.js index 54fbbd18..194ba7e7 100644 --- a/crates/wastebin_server/src/javascript/burn.js +++ b/crates/wastebin_server/src/javascript/burn.js @@ -1 +1 @@ -showToast("Content is burned and cannot be looked up again!", 3000); +showToast(document.getElementById("burn-message").dataset.message, 3000); diff --git a/crates/wastebin_server/src/javascript/index.js b/crates/wastebin_server/src/javascript/index.js index ead57a45..7ab4ec91 100644 --- a/crates/wastebin_server/src/javascript/index.js +++ b/crates/wastebin_server/src/javascript/index.js @@ -22,14 +22,18 @@ textarea.addEventListener("input", updateLineNumbers); textarea.addEventListener("scroll", syncScroll); updateLineNumbers(); -const MAX_BYTES = parseInt($("stats").dataset.maxBytes, 10) || 1024 * 1024; +const stats = $("stats"); +const MAX_BYTES = parseInt(stats.dataset.maxBytes, 10) || 1024 * 1024; +const UNIT_KB = stats.dataset.unitKb; +const UNIT_MB = stats.dataset.unitMb; +const LABEL_LIMIT = stats.dataset.labelLimit; function formatSize(bytes) { - if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " mb"; - return (bytes / 1024).toFixed(0) + " kb"; + if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " " + UNIT_MB; + return (bytes / 1024).toFixed(0) + " " + UNIT_KB; } -$("progress-limit").textContent = "limit " + formatSize(MAX_BYTES); +$("progress-limit").textContent = LABEL_LIMIT + " " + formatSize(MAX_BYTES); function updateStats() { const text = textarea.value; @@ -50,7 +54,7 @@ function updateStats() { } else { fill.classList.remove("warn"); } - $("progress-kb").textContent = (bytes / 1024).toFixed(1) + " kb"; + $("progress-kb").textContent = (bytes / 1024).toFixed(1) + " " + UNIT_KB; } textarea.addEventListener("input", updateStats); diff --git a/crates/wastebin_server/src/javascript/password-toggle.js b/crates/wastebin_server/src/javascript/password-toggle.js index 5084b87d..d2f56930 100644 --- a/crates/wastebin_server/src/javascript/password-toggle.js +++ b/crates/wastebin_server/src/javascript/password-toggle.js @@ -7,7 +7,7 @@ document.addEventListener("DOMContentLoaded", function() { const show = input.type === "password"; input.type = show ? "text" : "password"; toggle.classList.toggle("shown", show); - const label = show ? "hide password" : "show password"; + const label = show ? toggle.dataset.hideLabel : toggle.dataset.showLabel; toggle.title = label; toggle.setAttribute("aria-label", label); }); diff --git a/crates/wastebin_server/src/javascript/paste.js b/crates/wastebin_server/src/javascript/paste.js index feb39aff..b0818cfd 100644 --- a/crates/wastebin_server/src/javascript/paste.js +++ b/crates/wastebin_server/src/javascript/paste.js @@ -3,7 +3,10 @@ function $(id) { } document.addEventListener('keydown', onKey); -$("copy-button").addEventListener("click", copy); +const copyButton = $("copy-button"); +copyButton.addEventListener("click", copy); +const TOAST_CONTENT = copyButton.dataset.toastContent; +const TOAST_URL = copyButton.dataset.toastUrl; function highlightLines(scroll) { document.querySelectorAll('.line-highlight').forEach(el => { @@ -70,7 +73,7 @@ function copy() { navigator.clipboard.writeText(content) .then(() => { - showToast("Copied content", 1500); + showToast(TOAST_CONTENT, 1500); }, function(err) { console.error("failed to copy content", err); }); @@ -99,7 +102,7 @@ function onKey(e) { } else if (e.key == 'y') { navigator.clipboard.writeText(window.location.href); - showToast("Copied URL", 1500); + showToast(TOAST_URL, 1500); } else if (e.key == 'd' && pasteId) { window.location.href = "/dl/" + pasteId; @@ -125,46 +128,12 @@ function onKey(e) { } } -function buildOverlay() { - const rows = [ - ['n', 'Go home'], - ['p', 'Go here'], - ['y', 'Copy URL'], - ['c', 'Copy content'], - ['d', 'Download'], - ['q', 'Show QR code'], - ['w', 'Toggle line wrapping'], - ]; - if (document.getElementById('view-toggle')) { - rows.push(['m', 'Toggle rendered view']); - } - rows.push(['?', 'Toggle help']); - - const overlay = document.createElement('div'); - overlay.id = 'overlay'; - const content = document.createElement('div'); - content.id = 'overlay-content'; - const table = document.createElement('table'); - for (const [key, label] of rows) { - const tr = document.createElement('tr'); - const tdKey = document.createElement('td'); - const kbd = document.createElement('kbd'); - kbd.textContent = key; - tdKey.appendChild(kbd); - const tdLabel = document.createElement('td'); - tdLabel.textContent = label; - tr.appendChild(tdKey); - tr.appendChild(tdLabel); - table.appendChild(tr); - } - content.appendChild(table); - overlay.appendChild(content); - overlay.addEventListener('click', () => { overlay.style.display = 'none'; }); - document.body.appendChild(overlay); - return overlay; -} - function toggleOverlay() { - const overlay = document.getElementById('overlay') || buildOverlay(); + const overlay = document.getElementById('overlay'); + if (!overlay) return; overlay.style.display = overlay.style.display != 'block' ? 'block' : 'none'; + if (!overlay.dataset.bound) { + overlay.addEventListener('click', () => { overlay.style.display = 'none'; }); + overlay.dataset.bound = '1'; + } } diff --git a/crates/wastebin_server/src/main.rs b/crates/wastebin_server/src/main.rs index b1c58618..70fc6347 100644 --- a/crates/wastebin_server/src/main.rs +++ b/crates/wastebin_server/src/main.rs @@ -3,6 +3,7 @@ mod cache; mod env; mod errors; mod handlers; +mod i18n; mod page; #[cfg(test)] mod test_helpers; @@ -32,6 +33,7 @@ use crate::cache::Cache; use crate::errors::Error; use crate::handlers::extract::Theme; use crate::handlers::{delete, download, html, insert, raw, robots, theme}; +use crate::i18n::Lang; use wastebin_core::db::Database; /// Reference counted [`page::Page`] wrapper. @@ -113,6 +115,7 @@ async fn security_headers_layer(req: Request, next: Next) -> impl IntoResponse { async fn handle_service_errors( State(page): State, theme: Option, + lang: Lang, req: Request, next: Next, ) -> Response { @@ -124,6 +127,7 @@ async fn handle_service_errors( html::Error { page, theme, + lang, description: String::from("payload exceeded limit"), }, ) @@ -133,6 +137,7 @@ async fn handle_service_errors( html::Error { page, theme, + lang, description: String::from("unsupported media type"), }, ) diff --git a/crates/wastebin_server/templates/base.html b/crates/wastebin_server/templates/base.html index 36abe323..7dd4676d 100644 --- a/crates/wastebin_server/templates/base.html +++ b/crates/wastebin_server/templates/base.html @@ -1,5 +1,5 @@ - + @@ -27,7 +27,7 @@