Translate the web UI to German
The browser language picks German or English. English texts are the
keys; a {} in a key matches any value, so server messages with numbers
or names translate too. Server errors are translated in one place, in
the HTTP helper. Leaflet's zoom and layer titles follow the language.
The mobile header buttons get less padding, so the German labels fit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Mweb/Cargo.toml
@@ -13,4 +13,4 @@ serde.workspace = true
serde_json.workspace = true
wasm-bindgen = "0.2.129"
wasm-bindgen-futures = "0.4.79"
web-sys = { version = "0.3.106", features = ["Document", "Element", "HtmlElement", "Location", "Storage", "UrlSearchParams", "Window"] }
web-sys = { version = "0.3.106", features = ["Document", "Element", "HtmlElement", "Location", "Navigator", "Storage", "UrlSearchParams", "Window"] }
Mweb/src/app.rs
@@ -5,6 +5,7 @@ use leptos::prelude::*;
use leptos::task::spawn_local;
use wasm_bindgen::prelude::*;
use crate::i18n::tr;
use crate::{http, map};
#[wasm_bindgen(inline_js = r#"
@@ -25,7 +26,9 @@ pub fn DevicePage() -> impl IntoView {
view! { <main class="device"><map::MapPage source=map::Source::Device(token) /></main> }
.into_any()
}
None => view! { <p class="login">"This page is for the opentracker app."</p> }.into_any(),
None => {
view! { <p class="login">{tr("This page is for the opentracker app.")}</p> }.into_any()
}
}
}
@@ -71,19 +74,19 @@ pub fn PairPage() -> impl IntoView {
view! {
<form class="login" on:submit=connect>
<h1>"Connect the app"</h1>
<h1>{tr("Connect the app")}</h1>
{move || match (has_challenge, done.get()) {
(false, _) => view! { <p class="error">"This link is incomplete. Start again in the app."</p> }.into_any(),
(false, _) => view! { <p class="error">{tr("This link is incomplete. Start again in the app.")}</p> }.into_any(),
// Browsers may block opening the app without a tap, so the link stays as a fallback.
(true, Some(url)) => view! {
<p>"Done. Return to the app to finish."</p>
<a class="button primary" href=url>"Open the app"</a>
<p>{tr("Done. Return to the app to finish.")}</p>
<a class="button primary" href=url>{tr("Open the app")}</a>
}
.into_any(),
(true, None) => view! {
<p>"The opentracker app asks to upload positions to your account as a new device."</p>
<label>"Device name" <input required maxlength="100" bind:value=name /></label>
<button class="primary" disabled=busy>"Connect"</button>
<p>{tr("The opentracker app asks to upload positions to your account as a new device.")}</p>
<label>{tr("Device name")} <input required maxlength="100" bind:value=name /></label>
<button class="primary" disabled=busy>{tr("Connect")}</button>
}
.into_any(),
}}
Mweb/src/guest.rs
@@ -4,6 +4,7 @@ use api::{GuestAuth, GuestKey, GuestUnlock, GuestView};
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::i18n::tr;
use crate::{ThemeToggle, http, map};
#[derive(Clone, PartialEq)]
@@ -61,8 +62,8 @@ pub fn GuestPage(token: String) -> impl IntoView {
check();
}
Err(e) => error.set(Some(match e.status {
401 => "Wrong password.".into(),
429 => "Too many failed attempts. Try again in 15 minutes.".into(),
401 => tr("Wrong password."),
429 => tr("Too many failed attempts. Try again in 15 minutes."),
_ => e.to_string(),
})),
}
@@ -70,8 +71,8 @@ pub fn GuestPage(token: String) -> impl IntoView {
};
let title = move || match state.get() {
State::Open(name) => format!("Location of {name}"),
_ => "Shared location".into(),
State::Open(name) => tr(&format!("Location of {name}")),
_ => tr("Shared location"),
};
view! {
<header>
@@ -84,12 +85,12 @@ pub fn GuestPage(token: String) -> impl IntoView {
State::Locked => {
view! {
<form class="login" on:submit=unlock.clone()>
<p>"This shared location is protected with a password."</p>
<p>{tr("This shared location is protected with a password.")}</p>
<label>
"Password"
{tr("Password")}
<input type="password" autocomplete="current-password" required bind:value=password />
</label>
<button class="primary">"Show location"</button>
<button class="primary">{tr("Show location")}</button>
<p class="error">{move || error.get()}</p>
</form>
}
@@ -97,7 +98,7 @@ pub fn GuestPage(token: String) -> impl IntoView {
}
State::Open(_) => view! { <main><map::MapPage source=map::Source::Guest(auth()) /></main> }.into_any(),
State::Gone => {
view! { <p class="login">"This link is no longer valid. It may have expired or been deleted."</p> }
view! { <p class="login">{tr("This link is no longer valid. It may have expired or been deleted.")}</p> }
.into_any()
}
State::Failed(msg) => view! { <p class="login error">{msg}</p> }.into_any(),
Mweb/src/http.rs
@@ -2,6 +2,8 @@ use std::fmt;
use gloo_net::http::{Request, RequestBuilder, Response};
use serde::Serialize;
use crate::i18n::tr;
use serde::de::DeserializeOwned;
#[derive(Clone)]
@@ -59,7 +61,7 @@ async fn read<T: DeserializeOwned>(
) -> Result<T, Error> {
let res = res.map_err(|e| Error {
status: 0,
msg: format!("network error: {e}"),
msg: tr(&format!("network error: {e}")),
})?;
// The session has ended. A reload shows the login form.
if res.status() == 401
@@ -70,10 +72,11 @@ async fn read<T: DeserializeOwned>(
}
if !res.ok() {
let body = res.text().await.unwrap_or_default();
// The server writes its messages in English. They are translated here, in one place.
let msg = if body.is_empty() {
res.status_text()
} else {
body
tr(&body)
};
return Err(Error {
status: res.status(),
Aweb/src/i18n.rs
@@ -0,0 +1,592 @@
//! German texts. English is the source language and the key: `tr("Log out")`.
//! A `{}` in a key matches any text, so texts with values translate too, also the server's messages.
//! A German text uses `{}` in the same order, or `{0}`, `{1}` to reorder.
use std::sync::OnceLock;
fn german() -> bool {
static GERMAN: OnceLock<bool> = OnceLock::new();
*GERMAN.get_or_init(|| {
web_sys::window()
.and_then(|w| w.navigator().language())
.is_some_and(|l| l.to_lowercase().starts_with("de"))
})
}
/// Lets the browser pick German hyphenation, spell checking and date inputs.
pub fn set_document_language() {
if let Some(root) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.document_element())
{
let _ = root.set_attribute("lang", if german() { "de" } else { "en" });
}
}
/// The text in the user's language. Falls back to English.
pub fn tr(en: &str) -> String {
if !german() {
return en.to_owned();
}
DE.iter()
.find_map(|(key, de)| matches(key, en).map(|values| fill(de, &values)))
.unwrap_or_else(|| en.to_owned())
}
/// The texts that the `{}` in `key` stand for, if `text` fits the key.
fn matches<'a>(key: &str, text: &'a str) -> Option<Vec<&'a str>> {
let parts: Vec<&str> = key.split("{}").collect();
let mut rest = text.strip_prefix(parts[0])?;
let mut values = Vec::new();
for (i, part) in parts.iter().enumerate().skip(1) {
let end = if i == parts.len() - 1 {
rest.strip_suffix(part).map(str::len)?
} else {
rest.find(part)?
};
values.push(&rest[..end]);
rest = &rest[end + part.len()..];
}
rest.is_empty().then_some(values)
}
fn fill(de: &str, values: &[&str]) -> String {
let mut out = de.to_owned();
for (i, v) in values.iter().enumerate() {
out = out.replace(&format!("{{{i}}}"), v);
}
for v in values {
out = out.replacen("{}", v, 1);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_with_values_match() {
assert_eq!(matches("{} points.", "12 points."), Some(vec!["12"]));
assert_eq!(
matches(
"Theme: {}. Click to change.",
"Theme: dark. Click to change."
),
Some(vec!["dark"])
);
assert_eq!(matches("{} points.", "12 points!"), None);
assert_eq!(matches("Log out", "Log out"), Some(vec![]));
assert_eq!(fill("{1}, dann {0}", &["a", "b"]), "b, dann a");
assert_eq!(fill("{} Punkte", &["3"]), "3 Punkte");
}
#[test]
fn german_texts_keep_every_value() {
for (en, de) in DE {
let values = en.matches("{}").count();
let used = de.matches("{}").count()
+ (0..values)
.filter(|i| de.contains(&format!("{{{i}}}")))
.count();
assert_eq!(values, used, "{en}");
}
}
}
/// English key, German text.
const DE: &[(&str, &str)] = &[
("Theme: light", "Design: hell"),
("Theme: dark", "Design: dunkel"),
("Theme: auto", "Design: automatisch"),
(
"Theme: light. Click to change.",
"Design: hell. Klicken zum Wechseln.",
),
(
"Theme: dark. Click to change.",
"Design: dunkel. Klicken zum Wechseln.",
),
(
"Theme: auto. Click to change.",
"Design: automatisch. Klicken zum Wechseln.",
),
("1 day", "1 Tag"),
("1 hour", "1 Stunde"),
("1 month", "1 Monat"),
("1 point.", "1 Punkt."),
("1 week", "1 Woche"),
("8 hours", "8 Stunden"),
("Add", "Hinzufügen"),
("Add a device", "Gerät hinzufügen"),
("Add a user", "Benutzer hinzufügen"),
("Add passkey", "Passkey hinzufügen"),
("Added", "Hinzugefügt"),
("Added \"{}\".", "\"{}\" hinzugefügt."),
("Admin", "Admin"),
(
"All devices, also ones I add later",
"Alle Geräte, auch später hinzugefügte",
),
(
"Anyone with the link sees your location, without an account.",
"Jeder mit dem Link sieht deinen Standort, ohne Konto.",
),
("Connect", "Verbinden"),
("Connect the app", "App verbinden"),
("Change", "Ändern"),
("Copy", "Kopieren"),
("Copy this link:", "Diesen Link kopieren:"),
("Create a guest link", "Gastlink erstellen"),
("Create admin account", "Admin-Konto erstellen"),
("Create link", "Link erstellen"),
("Create token", "Token erstellen"),
("Created", "Erstellt"),
("Created {}.", "{} erstellt."),
("Current", "Aktuell"),
("Cycling", "Fahrrad"),
("Day", "Tag"),
("Delete", "Löschen"),
(
"Delete this link? Everyone who has it loses access.",
"Diesen Link löschen? Alle, die ihn haben, verlieren den Zugriff.",
),
(
"Delete {} and all their data?",
"{} und alle zugehörigen Daten löschen?",
),
(
"Delete the location history of the web app? It starts again when a browser sends a position.",
"Den Standortverlauf der Web-App löschen? Er beginnt neu, sobald ein Browser einen Standort sendet.",
),
("Device", "Gerät"),
("Device name", "Gerätename"),
("Devices", "Geräte"),
(
"Done. Return to the app to finish.",
"Fertig. Kehre zur App zurück, um abzuschließen.",
),
("Enter a number of days.", "Gib eine Anzahl Tage ein."),
("Expires", "Läuft ab"),
("For", "Dauer"),
("From", "Von"),
("Guest links", "Gastlinks"),
("History", "Verlauf"),
("Humanitarian", "Humanitär"),
("I share my location with", "Ich teile meinen Standort mit"),
(
"Keep my points for (days)",
"Meine Punkte aufbewahren (Tage)",
),
("Last 24 hours", "Letzte 24 Stunden"),
("Last sent at {}.", "Zuletzt gesendet um {}."),
("Last upload", "Letzter Upload"),
("Last used", "Zuletzt verwendet"),
(
"Leave the field empty to use the server setting.",
"Lass das Feld leer, um die Servereinstellung zu verwenden.",
),
("Link copied.", "Link kopiert."),
("Link created and copied.", "Link erstellt und kopiert."),
("Link created.", "Link erstellt."),
("Location history", "Standortverlauf"),
("Location of {}", "Standort von {}"),
("Log in", "Anmelden"),
("Log out", "Abmelden"),
("Map", "Karte"),
("Map layers", "Kartenebenen"),
("Name", "Name"),
(
"Needs a passkey, and two-factor sign-in turned off.",
"Braucht einen Passkey und ausgeschaltete Zwei-Faktor-Anmeldung.",
),
("New", "Neu"),
(
"New password for {}. This also removes their passkeys, disconnects their devices, turns off two-factor sign-in and logs them out.",
"Neues Passwort für {}. Dabei werden auch die Passkeys entfernt, die Geräte getrennt, die Zwei-Faktor-Anmeldung ausgeschaltet und alle Sitzungen abgemeldet.",
),
("No devices yet.", "Noch keine Geräte."),
("No links yet.", "Noch keine Links."),
("No passkeys yet.", "Noch keine Passkeys."),
("Nobody.", "Niemand."),
(
"None, current position only",
"Keine, nur aktueller Standort",
),
("One day", "Ein Tag"),
("Only these devices:", "Nur diese Geräte:"),
("Open the app", "App öffnen"),
("Passkeys", "Passkeys"),
("Password", "Passwort"),
("Password (optional)", "Passwort (optional)"),
(
"Password and passkey (two-factor)",
"Passwort und Passkey (Zwei-Faktor)",
),
("Password or passkey", "Passwort oder Passkey"),
("Password removed.", "Passwort entfernt."),
("Password reset for {}.", "Passwort für {} zurückgesetzt."),
(
"Password saved. Your other sessions are logged out.",
"Passwort gespeichert. Deine anderen Sitzungen sind abgemeldet.",
),
(
"Pick a start and an end.",
"Wähle einen Anfang und ein Ende.",
),
(
"Pick when the trail starts.",
"Wähle, wann die Spur beginnt.",
),
(
"Points older than this are deleted now. Continue?",
"Ältere Punkte werden jetzt gelöscht. Fortfahren?",
),
("Precision", "Genauigkeit"),
("Remove", "Entfernen"),
(
"Remove device \"{}\" and delete its location history? It can no longer upload positions.",
"Gerät \"{}\" entfernen und seinen Standortverlauf löschen? Es kann dann keine Standorte mehr senden.",
),
("Remove password", "Passwort entfernen"),
(
"Remove the passkey \"{}\"?",
"Den Passkey \"{}\" entfernen?",
),
(
"Remove your password? You then sign in with a passkey only.",
"Dein Passwort entfernen? Du meldest dich dann nur noch mit einem Passkey an.",
),
("Repeat new", "Neues wiederholen"),
("Repeat password", "Passwort wiederholen"),
("Reset password", "Passwort zurücksetzen"),
("Role", "Rolle"),
("Satellite", "Satellit"),
("Save", "Speichern"),
("Saved.", "Gespeichert."),
("Set password", "Passwort setzen"),
("Seconds between sends", "Sekunden zwischen zwei Sendungen"),
("Security", "Sicherheit"),
(
"Select a person to show their trail.",
"Wähle eine Person, um ihre Spur zu zeigen.",
),
("Send every", "Senden alle"),
("Send my location now", "Meinen Standort jetzt senden"),
("Settings", "Einstellungen"),
("Share", "Teilen"),
("Share from this browser", "Aus diesem Browser teilen"),
("Share my location", "Meinen Standort teilen"),
("Shared location", "Geteilter Standort"),
("Shared with me", "Mit mir geteilt"),
("Sharing", "Teilen"),
(
"Sharing again with the same person replaces the settings.",
"Erneutes Teilen mit derselben Person ersetzt die Einstellungen.",
),
("Show location", "Standort anzeigen"),
("Shows", "Zeigt"),
("Sign in with a passkey", "Mit Passkey anmelden"),
("Sign-in", "Anmeldung"),
("Sign-in failed.", "Anmeldung fehlgeschlagen."),
("Since", "Seit"),
("Stop", "Beenden"),
(
"Stop seeing the location of {}?",
"Den Standort von {} nicht mehr sehen?",
),
(
"Stop sharing your location with {}?",
"Deinen Standort nicht mehr mit {} teilen?",
),
(
"The Android app needs no token. Enter this address in the app and sign in:",
"Die Android-App braucht kein Token. Gib diese Adresse in der App ein und melde dich an:",
),
(
"The new passwords do not match.",
"Die neuen Passwörter stimmen nicht überein.",
),
(
"The opentracker app asks to upload positions to your account as a new device.",
"Die opentracker-App möchte als neues Gerät Standorte an dein Konto senden.",
),
(
"The passkey was not used ({}).",
"Der Passkey wurde nicht verwendet ({}).",
),
(
"The passkey was not used.",
"Der Passkey wurde nicht verwendet.",
),
(
"The passwords do not match.",
"Die Passwörter stimmen nicht überein.",
),
(
"The range can be at most 31 days.",
"Der Zeitraum darf höchstens 31 Tage lang sein.",
),
(
"The server keeps points for {} days. You can choose a shorter time.",
"Der Server bewahrt Punkte {} Tage auf. Du kannst eine kürzere Zeit wählen.",
),
(
"The server keeps points forever. You can choose a limit.",
"Der Server bewahrt Punkte unbegrenzt auf. Du kannst eine Grenze wählen.",
),
(
"The start must be before the end.",
"Der Anfang muss vor dem Ende liegen.",
),
(
"The token is shown only once. Set up the device with:",
"Das Token wird nur einmal angezeigt. Richte das Gerät so ein:",
),
(
"The web app, in any browser",
"Die Web-App, in jedem Browser",
),
(
"This account also needs its password.",
"Dieses Konto braucht auch sein Passwort.",
),
(
"This browser cannot share its location.",
"Dieser Browser kann seinen Standort nicht teilen.",
),
(
"This device was removed. Connect the app again.",
"Dieses Gerät wurde entfernt. Verbinde die App erneut.",
),
(
"This link is incomplete. Start again in the app.",
"Dieser Link ist unvollständig. Beginne erneut in der App.",
),
(
"This link is no longer valid.",
"Dieser Link ist nicht mehr gültig.",
),
(
"This link is no longer valid. It may have expired or been deleted.",
"Dieser Link ist nicht mehr gültig. Er ist vielleicht abgelaufen oder wurde gelöscht.",
),
("This link works until {}.", "Dieser Link gilt bis {}."),
(
"This page is for the opentracker app.",
"Diese Seite ist für die opentracker-App.",
),
(
"This shared location is protected with a password.",
"Dieser geteilte Standort ist mit einem Passwort geschützt.",
),
(
"The location is not available.",
"Der Standort ist nicht verfügbar.",
),
(
"User denied Geolocation",
"Der Standortzugriff wurde abgelehnt.",
),
("Time range", "Zeitraum"),
("To", "Bis"),
(
"Too many failed attempts. Try again in 15 minutes.",
"Zu viele Fehlversuche. Versuche es in 15 Minuten erneut.",
),
("Topographic", "Topografisch"),
("Trail", "Spur"),
(
"Two-factor sign-in needs a password and at least one passkey.",
"Die Zwei-Faktor-Anmeldung braucht ein Passwort und mindestens einen Passkey.",
),
("User", "Benutzer"),
("Username", "Benutzername"),
("Users", "Benutzer"),
(
"Welcome. Create the admin account to finish the setup.",
"Willkommen. Erstelle das Admin-Konto, um die Einrichtung abzuschließen.",
),
("With", "Mit"),
("Wrong password.", "Falsches Passwort."),
(
"Wrong username or password.",
"Falscher Benutzername oder falsches Passwort.",
),
("Zoom in", "Vergrößern"),
("Zoom out", "Verkleinern"),
("about 1 km", "etwa 1 km"),
("about 10 km", "etwa 10 km"),
("about 100 m", "etwa 100 m"),
("admin", "Admin"),
("all devices", "alle Geräte"),
("current position only", "nur aktueller Standort"),
("exact", "genau"),
("for Anna", "für Anna"),
("forever", "unbegrenzt"),
("from now on", "ab jetzt"),
("full history", "gesamter Verlauf"),
("full trail", "ganze Spur"),
("laptop, phone, key", "Laptop, Handy, Schlüssel"),
("never", "nie"),
("no position yet", "noch kein Standort"),
(
"none, current position only",
"keine, nur aktueller Standort",
),
("password", "Passwort"),
("phone", "Handy"),
("seconds", "Sekunden"),
("since a time", "seit einem Zeitpunkt"),
("some devices", "einige Geräte"),
("unnamed", "ohne Namen"),
("until I stop it", "bis ich es beende"),
("user", "Benutzer"),
("username", "Benutzername"),
("{} is now an admin.", "{} ist jetzt Admin."),
("{} is now a user.", "{} ist jetzt Benutzer."),
("Unauthorized", "Nicht angemeldet"),
("Forbidden", "Nicht erlaubt"),
("Not Found", "Nicht gefunden"),
(
"Too Many Requests",
"Zu viele Anfragen. Versuche es später erneut.",
),
("Internal Server Error", "Interner Serverfehler"),
(
"add a passkey before removing your password",
"füge einen Passkey hinzu, bevor du dein Passwort entfernst",
),
(
"add a passkey before turning on two-factor sign-in",
"füge einen Passkey hinzu, bevor du die Zwei-Faktor-Anmeldung einschaltest",
),
(
"challenge must be a SHA-256 in hex",
"die Challenge muss ein SHA-256 in Hex sein",
),
(
"device name must have 1 to 100 characters",
"der Gerätename muss 1 bis 100 Zeichen haben",
),
(
"expiry must be in the future",
"der Ablauf muss in der Zukunft liegen",
),
("no Host header", "kein Host-Header"),
("no such device", "dieses Gerät gibt es nicht"),
("no such user", "diesen Benutzer gibt es nicht"),
(
"password needs at least 8 characters",
"das Passwort braucht mindestens 8 Zeichen",
),
(
"passkeys need a domain name, not an IP address",
"Passkeys brauchen einen Domainnamen, keine IP-Adresse",
),
(
"range must be 0 to 31 days",
"der Zeitraum muss 0 bis 31 Tage lang sein",
),
(
"retention must be at least 1 days",
"die Aufbewahrung muss mindestens 1 Tag betragen",
),
("select at least one device", "wähle mindestens ein Gerät"),
(
"set a password before removing your last passkey",
"setze ein Passwort, bevor du deinen letzten Passkey entfernst",
),
(
"set a password before turning on two-factor sign-in",
"setze ein Passwort, bevor du die Zwei-Faktor-Anmeldung einschaltest",
),
(
"that passkey could not be used",
"dieser Passkey konnte nicht verwendet werden",
),
(
"that passkey is already registered",
"dieser Passkey ist schon registriert",
),
(
"that took too long, please try again",
"das hat zu lange gedauert, bitte versuche es erneut",
),
("that username is taken", "dieser Benutzername ist vergeben"),
(
"the name can have at most 100 characters",
"der Name darf höchstens 100 Zeichen haben",
),
(
"the server is already set up",
"der Server ist schon eingerichtet",
),
("this link has no password", "dieser Link hat kein Passwort"),
(
"turn off two-factor sign-in before removing your last passkey",
"schalte die Zwei-Faktor-Anmeldung aus, bevor du deinen letzten Passkey entfernst",
),
(
"turn off two-factor sign-in before removing your password",
"schalte die Zwei-Faktor-Anmeldung aus, bevor du dein Passwort entfernst",
),
("unreadable credential", "unlesbare Anmeldedaten"),
(
"username must have 1 to 64 characters",
"der Benutzername muss 1 bis 64 Zeichen haben",
),
("wrong current password", "falsches aktuelles Passwort"),
(
"you cannot change your own role",
"du kannst deine eigene Rolle nicht ändern",
),
(
"you cannot delete your own account",
"du kannst dein eigenes Konto nicht löschen",
),
(
"you cannot share with yourself",
"du kannst nicht mit dir selbst teilen",
),
("about {} km", "etwa {} km"),
("about {} m", "etwa {} m"),
("approximate, {} km", "ungefähr, {} km"),
("approximate, {} m", "ungefähr, {} m"),
("battery {}%", "Akku {}%"),
("network error: {}", "Netzwerkfehler: {}"),
("trail since {}", "Spur seit {}"),
(
"retention must be 1 to {} days",
"die Aufbewahrung muss 1 bis {} Tage betragen",
),
(
"at most {} points per request",
"höchstens {} Punkte pro Anfrage",
),
(
"precision must be at most {} metres",
"die Genauigkeit darf höchstens {} Meter betragen",
),
(
"you can have at most {} passkeys",
"du kannst höchstens {} Passkeys haben",
),
(
"passkeys are set up for {}, but this page is {}. Set --public-url to the address you use.",
"Passkeys sind für {} eingerichtet, diese Seite ist aber {}. Setze --public-url auf die Adresse, die du verwendest.",
),
("{} (you)", "{} (du)"),
("{} h ago", "vor {} h"),
("{} min ago", "vor {} min"),
("{} points.", "{} Punkte."),
("{} s ago", "vor {} s"),
(
"{} shares only their current position.",
"{} teilt nur den aktuellen Standort.",
),
(
"{} shares the trail since {}.",
"{} teilt die Spur seit {}.",
),
];
Mweb/src/locate.rs
@@ -8,6 +8,7 @@ use leptos::task::spawn_local;
use wasm_bindgen::prelude::*;
use crate::http;
use crate::i18n::tr;
#[wasm_bindgen(inline_js = r#"
export function locate() {
@@ -116,8 +117,9 @@ impl Locator {
spawn_local(async move {
let result = async {
let json = locate().await.map_err(|e| {
e.as_string()
.unwrap_or_else(|| "The location is not available.".into())
tr(&e
.as_string()
.unwrap_or_else(|| "The location is not available.".into()))
})?;
let point: Point = serde_json::from_str(&json.as_string().unwrap_or_default())
.map_err(|e| e.to_string())?;
@@ -132,7 +134,8 @@ impl Locator {
let time: String = js_sys::Date::new(&JsValue::from_f64(p.ts as f64 * 1000.0))
.to_locale_time_string("default")
.into();
format!("Last sent at {time}{acc}.")
let sent = format!("{time}{acc}");
tr(&format!("Last sent at {sent}."))
})));
self.busy.set(false);
});
@@ -144,17 +147,17 @@ pub fn LocationControls() -> impl IntoView {
let locator = expect_context::<Locator>();
view! {
<div class="send-location">
<h3>"Share from this browser"</h3>
<h3>{tr("Share from this browser")}</h3>
<button on:click=move |_| locator.send() disabled=move || locator.busy.get()>
"Send my location now"
{tr("Send my location now")}
</button>
<div class="auto-send">
<label class="radio">
<input type="checkbox" bind:checked=locator.auto />
"Send every"
{tr("Send every")}
</label>
<input type="number" min=MIN_SECS aria-label="Seconds between sends" bind:value=locator.secs />
"seconds"
<input type="number" min=MIN_SECS aria-label=tr("Seconds between sends") bind:value=locator.secs />
{tr("seconds")}
</div>
{move || {
locator.status.get().map(|s| match s {
Mweb/src/main.rs
@@ -1,6 +1,7 @@
mod app;
mod guest;
mod http;
mod i18n;
mod locate;
mod map;
mod passkey;
@@ -11,8 +12,11 @@ use leptos::prelude::*;
use leptos::task::spawn_local;
use wasm_bindgen::JsValue;
use i18n::tr;
fn main() {
console_error_panic_hook::set_once();
i18n::set_document_language();
leptos::mount::mount_to_body(App);
}
@@ -107,7 +111,7 @@ fn SetupForm() -> impl IntoView {
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if password.get_untracked() != repeat.get_untracked() {
error.set(Some("The passwords do not match.".into()));
error.set(Some(tr("The passwords do not match.")));
return;
}
let body = Credentials {
@@ -124,18 +128,18 @@ fn SetupForm() -> impl IntoView {
view! {
<form class="login" on:submit=submit>
<h1>"opentracker"</h1>
<p>"Welcome. Create the admin account to finish the setup."</p>
<label>"Username" <input autocomplete="username" required bind:value=username /></label>
<h1>{tr("opentracker")}</h1>
<p>{tr("Welcome. Create the admin account to finish the setup.")}</p>
<label>{tr("Username")} <input autocomplete="username" required bind:value=username /></label>
<label>
"Password"
{tr("Password")}
<input type="password" autocomplete="new-password" minlength="8" required bind:value=password />
</label>
<label>
"Repeat password"
{tr("Repeat password")}
<input type="password" autocomplete="new-password" minlength="8" required bind:value=repeat />
</label>
<button class="primary">"Create admin account"</button>
<button class="primary">{tr("Create admin account")}</button>
<p class="error">{move || error.get()}</p>
</form>
}
@@ -185,7 +189,7 @@ fn LoginForm() -> impl IntoView {
password_step.set(Some(state_id));
error.set(None);
}
Ok(_) => error.set(Some("Sign-in failed.".into())),
Ok(_) => error.set(Some(tr("Sign-in failed."))),
Err(e) => error.set(Some(e)),
}
busy.set(false);
@@ -204,8 +208,8 @@ fn LoginForm() -> impl IntoView {
let result = http::post::<LoginResult>("/api/login", &body)
.await
.map_err(|e| match e.status {
401 => "Wrong username or password.".to_string(),
429 => "Too many failed attempts. Try again in 15 minutes.".to_string(),
401 => tr("Wrong username or password."),
429 => tr("Too many failed attempts. Try again in 15 minutes."),
_ => e.to_string(),
});
handle(result);
@@ -225,20 +229,20 @@ fn LoginForm() -> impl IntoView {
view! {
<form class="login" on:submit=submit>
<h1>"opentracker"</h1>
<h1>{tr("opentracker")}</h1>
<Show
when=move || password_step.get().is_none()
fallback=move || view! { <p>"This account also needs its password."</p> }
>
<label>"Username" <input autocomplete="username webauthn" required bind:value=username /></label>
<label>{tr("Username")} <input autocomplete="username webauthn" required bind:value=username /></label>
</Show>
<label>
"Password"
{tr("Password")}
<input type="password" autocomplete="current-password" required bind:value=password />
</label>
<button class="primary" disabled=busy>"Log in"</button>
<button class="primary" disabled=busy>{tr("Log in")}</button>
<Show when=move || passkey::supported() && password_step.get().is_none()>
<button type="button" disabled=busy on:click=with_passkey>"Sign in with a passkey"</button>
<button type="button" disabled=busy on:click=with_passkey>{tr("Sign in with a passkey")}</button>
</Show>
<p class="error">{move || error.get()}</p>
</form>
@@ -267,18 +271,18 @@ fn Shell() -> impl IntoView {
<img class="brand" src="/icon.svg" alt="opentracker" width="28" height="28" />
<nav>
<button class:active=move || tab.get() == Tab::Map on:click=move |_| tab.set(Tab::Map)>
"Map"
{tr("Map")}
</button>
<button
class:active=move || tab.get() == Tab::Settings
on:click=move |_| tab.set(Tab::Settings)
>
"Settings"
{tr("Settings")}
</button>
</nav>
<ThemeToggle />
<span class="user">{move || account.me().map(|m| m.username)}</span>
<button on:click=logout>"Log out"</button>
<button on:click=logout>{tr("Log out")}</button>
</header>
<main>
{move || match tab.get() {
@@ -325,8 +329,8 @@ pub fn ThemeToggle() -> impl IntoView {
view! {
<button
class="theme"
title=move || format!("Theme: {}", label())
aria-label=move || format!("Theme: {}. Click to change.", label())
title=move || tr(&format!("Theme: {}", label()))
aria-label=move || tr(&format!("Theme: {}. Click to change.", label()))
on:click=move |_| choose(next())
inner_html=move || {
icon(match theme.get().as_str() {
@@ -363,9 +367,9 @@ pub fn fmt_time(ts: i64) -> String {
pub fn ago(ts: i64) -> String {
match (now_secs() - ts).max(0) {
d @ 0..60 => format!("{d} s ago"),
d @ 60..3600 => format!("{} min ago", d / 60),
d @ 3600..86400 => format!("{} h ago", d / 3600),
d @ 0..60 => tr(&format!("{d} s ago")),
d @ 60..3600 => tr(&format!("{} min ago", d / 60)),
d @ 3600..86400 => tr(&format!("{} h ago", d / 3600)),
_ => fmt_time(ts),
}
}
Mweb/src/map.rs
@@ -8,6 +8,7 @@ use leptos::task::spawn_local;
use wasm_bindgen::JsValue;
use wasm_bindgen::prelude::*;
use crate::i18n::tr;
use crate::{ago, fmt_time, http, now_secs};
const OSM: &str =
@@ -27,7 +28,7 @@ extern "C" {
type Layer;
#[wasm_bindgen(js_namespace = L, js_name = map)]
fn leaflet_map(el: &web_sys::HtmlElement) -> LMap;
fn leaflet_map(el: &web_sys::HtmlElement, opts: &JsValue) -> LMap;
#[wasm_bindgen(method, js_name = setView)]
fn set_view(this: &LMap, center: &Array, zoom: f64);
#[wasm_bindgen(method, js_name = fitBounds)]
@@ -45,6 +46,10 @@ extern "C" {
fn layers_control(base: &JsValue, overlays: &JsValue, opts: &JsValue) -> Control;
#[wasm_bindgen(method, js_name = addTo)]
fn add_control_to(this: &Control, map: &LMap);
#[wasm_bindgen(method, js_name = getContainer)]
fn container(this: &Control) -> web_sys::HtmlElement;
#[wasm_bindgen(js_namespace = ["L", "control"], js_name = zoom)]
fn zoom_control(opts: &JsValue) -> Control;
#[wasm_bindgen(js_namespace = L, js_name = tileLayer)]
fn tile_layer(url: &str, opts: &JsValue) -> Layer;
@@ -99,7 +104,7 @@ fn add_base_maps(m: &LMap) {
.and_then(|s| s.get_item(LAYER_KEY).ok().flatten());
let bases = [
(
"OpenStreetMap",
"OpenStreetMap".to_string(),
tiles(
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
19,
@@ -108,7 +113,7 @@ fn add_base_maps(m: &LMap) {
),
),
(
"Humanitarian",
tr("Humanitarian"),
tiles(
"https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png",
19,
@@ -119,7 +124,7 @@ fn add_base_maps(m: &LMap) {
),
),
(
"Cycling",
tr("Cycling"),
tiles(
"https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png",
20,
@@ -128,7 +133,7 @@ fn add_base_maps(m: &LMap) {
),
),
(
"Topographic",
tr("Topographic"),
tiles(
"https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
17,
@@ -139,7 +144,7 @@ fn add_base_maps(m: &LMap) {
),
),
(
"Satellite",
tr("Satellite"),
tiles(
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
19,
@@ -150,14 +155,22 @@ fn add_base_maps(m: &LMap) {
];
let index = bases
.iter()
.position(|(name, _)| chosen.as_deref() == Some(*name))
.position(|(name, _)| chosen.as_deref() == Some(name.as_str()))
.unwrap_or(0);
bases[index].1.add_to(m);
let control = Object::new();
for (name, layer) in &bases {
Reflect::set(&control, &(*name).into(), layer).unwrap();
Reflect::set(&control, &name.into(), layer).unwrap();
}
let layers = layers_control(&control, &JsValue::UNDEFINED, &JsValue::UNDEFINED);
layers.add_control_to(m);
// Leaflet has no option for this title.
if let Ok(Some(toggle)) = layers
.container()
.query_selector(".leaflet-control-layers-toggle")
{
let _ = toggle.set_attribute("title", &tr("Map layers"));
}
layers_control(&control, &JsValue::UNDEFINED, &JsValue::UNDEFINED).add_control_to(m);
let save = Closure::<dyn Fn(JsValue)>::new(move |e: JsValue| {
let name = Reflect::get(&e, &"name".into())
.ok()
@@ -377,11 +390,11 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
error.set(None);
}
Err(e) if e.status == 404 && is_guest => {
error.set(Some("This link is no longer valid.".into()))
error.set(Some(tr("This link is no longer valid.")))
}
Err(e) if e.status == 401 && is_device => {
error.set(Some(tr("This device was removed. Connect the app again.")))
}
Err(e) if e.status == 401 && is_device => error.set(Some(
"This device was removed. Connect the app again.".into(),
)),
Err(e) => error.set(Some(e.to_string())),
}
})
@@ -406,7 +419,13 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
if ready.get_untracked() {
return;
}
let m = leaflet_map(&div);
// Our own zoom control, for translated button titles.
let m = leaflet_map(&div, &obj(&[("zoomControl", false.into())]));
zoom_control(&obj(&[
("zoomInTitle", tr("Zoom in").into()),
("zoomOutTitle", tr("Zoom out").into()),
]))
.add_control_to(&m);
m.set_view(&latlng(50.0, 10.0), 4.0);
add_base_maps(&m);
map.set_value(Some(m));
@@ -563,7 +582,7 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
.map(|(i, p)| {
let id = p.id;
let detail = describe(&p);
let name = if i == 0 && !is_guest { format!("{} (you)", p.username) } else { p.username.clone() };
let name = if i == 0 && !is_guest { tr(&format!("{} (you)", p.username)) } else { p.username.clone() };
view! {
<li class:selected=move || selected.get() == Some(id) on:click=move |_| select(&p)>
<span class="dot" style:background=color(id)></span>
@@ -580,7 +599,7 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
let current = device();
Some(view! {
<label>
"Device"
{tr("Device")}
<select on:change:target=move |ev| trail_device.set(ev.target().value().parse().ok())>
{p
.devices
@@ -593,35 +612,35 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
}}
<Show when=move || person().is_none_or(|p| p.trail_since.is_some())>
<label>
"Trail"
{tr("Trail")}
<select bind:value=span>
<option value="none">"None, current position only"</option>
<option value="24h">"Last 24 hours"</option>
<option value="day">"One day"</option>
<option value="range">"Time range"</option>
<option value="none">{tr("None, current position only")}</option>
<option value="24h">{tr("Last 24 hours")}</option>
<option value="day">{tr("One day")}</option>
<option value="range">{tr("Time range")}</option>
</select>
</label>
<Show when=move || span.get() == "day">
<label>"Day" <input type="date" bind:value=day /></label>
<label>{tr("Day")} <input type="date" bind:value=day /></label>
</Show>
<Show when=move || span.get() == "range">
<label>"From" <input type="datetime-local" bind:value=from /></label>
<label>"To" <input type="datetime-local" bind:value=to /></label>
<label>{tr("From")} <input type="datetime-local" bind:value=from /></label>
<label>{tr("To")} <input type="datetime-local" bind:value=to /></label>
</Show>
</Show>
<p class="hint">
{move || match (person(), range()) {
(_, Err(msg)) => msg.to_string(),
(None, _) => "Select a person to show their trail.".to_string(),
(Some(p), _) if p.trail_since.is_none() => format!("{} shares only their current position.", p.username),
(_, Err(msg)) => tr(msg),
(None, _) => tr("Select a person to show their trail."),
(Some(p), _) if p.trail_since.is_none() => tr(&format!("{} shares only their current position.", p.username)),
_ if span.get() == "none" => String::new(),
(Some(Person { trail_since: Some(t @ 1..), username, .. }), _) => {
format!("{} {username} shares the trail since {}.", count(track.get().len()), fmt_time(t))
format!("{} {}", count(track.get().len()), tr(&format!("{username} shares the trail since {}.", fmt_time(t))))
}
(Some(_), _) => count(track.get().len()),
}}
</p>
{move || expires.get().map(|t| view! { <p class="hint">"This link works until " {fmt_time(t)} "."</p> })}
{move || expires.get().map(|t| view! { <p class="hint">{tr(&format!("This link works until {}.", fmt_time(t)))}</p> })}
{is_account.then(|| view! { <crate::locate::LocationControls /> })}
</aside>
<div class="map" node_ref=el></div>
@@ -631,8 +650,8 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
fn count(n: usize) -> String {
match n {
1 => "1 point.".into(),
n => format!("{n} points."),
1 => tr("1 point."),
n => tr(&format!("{n} points.")),
}
}
@@ -645,7 +664,7 @@ fn fit_opts() -> JsValue {
fn describe(p: &Person) -> String {
let Some(d) = p.last() else {
return "no position yet".into();
return tr("no position yet");
};
let l = &d.last;
let mut parts = vec![ago(l.ts)];
@@ -655,14 +674,14 @@ fn describe(p: &Person) -> String {
match (p.precision_m, l.acc) {
(0, Some(acc)) => parts.push(format!("±{acc:.0} m")),
(0, None) => {}
(m @ ..1000, _) => parts.push(format!("approximate, {m} m")),
(m, _) => parts.push(format!("approximate, {} km", m / 1000)),
(m @ ..1000, _) => parts.push(tr(&format!("approximate, {m} m"))),
(m, _) => parts.push(tr(&format!("approximate, {} km", m / 1000))),
}
if let Some(speed) = l.speed {
parts.push(format!("{:.0} km/h", speed * 3.6));
}
if let Some(b) = l.battery {
parts.push(format!("battery {b}%"));
parts.push(tr(&format!("battery {b}%")));
}
parts.join(" · ")
}
Mweb/src/passkey.rs
@@ -59,7 +59,9 @@ fn error_text(e: JsValue) -> String {
.ok()
.and_then(|v| v.as_string())
{
Some(name) if name != "NotAllowedError" => format!("The passkey was not used ({name})."),
_ => "The passkey was not used.".into(),
Some(name) if name != "NotAllowedError" => {
crate::i18n::tr(&format!("The passkey was not used ({name})."))
}
_ => crate::i18n::tr("The passkey was not used."),
}
}
Mweb/src/settings.rs
@@ -7,6 +7,7 @@ use leptos::prelude::*;
use leptos::task::spawn_local;
use wasm_bindgen::prelude::*;
use crate::i18n::tr;
use crate::{Account, ago, fmt_time, http, map, now_secs, passkey};
#[derive(Clone, Copy, PartialEq)]
@@ -25,6 +26,7 @@ pub fn Settings() -> impl IntoView {
let tab = RwSignal::new(Tab::Sharing);
let is_admin = move || account.me().is_some_and(|m| m.is_admin);
let tab_button = move |t: Tab, label: &'static str| {
let label = tr(label);
view! { <button class:active=move || tab.get() == t on:click=move |_| tab.set(t)>{label}</button> }
};
view! {
@@ -50,12 +52,12 @@ pub fn Settings() -> impl IntoView {
}
fn confirm(msg: &str) -> bool {
window().confirm_with_message(msg).unwrap_or(false)
window().confirm_with_message(&tr(msg)).unwrap_or(false)
}
fn expiry(e: Option<i64>) -> String {
match e {
None => "never".into(),
None => tr("never"),
Some(t) => fmt_time(t),
}
}
@@ -134,36 +136,36 @@ fn Devices() -> impl IntoView {
let token_url = url.clone();
view! {
<section>
<h2>"Add a device"</h2>
<h2>{tr("Add a device")}</h2>
<form on:submit=add>
<label>"Name" <input placeholder="phone" required bind:value=name /></label>
<button class="primary">"Create token"</button>
<label>{tr("Name")} <input placeholder=tr("phone") required bind:value=name /></label>
<button class="primary">{tr("Create token")}</button>
</form>
{move || {
new_token
.get()
.map(|token| {
view! {
<p>"The token is shown only once. Set up the device with:"</p>
<p>{tr("The token is shown only once. Set up the device with:")}</p>
<p><code class="copy">{format!("ot use-token {token_url} {token}")}</code></p>
}
})
}}
<p class="hint">"The Android app needs no token. Enter " <code>{url}</code> " in the app and sign in."</p>
<p class="hint">{tr("The Android app needs no token. Enter this address in the app and sign in:")} " " <code>{url}</code></p>
<p class="error">{move || error.get()}</p>
</section>
<section>
<h2>"Devices"</h2>
<h2>{tr("Devices")}</h2>
{move || {
devices
.get()
.map(|r| match r {
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">"No devices yet."</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">{tr("No devices yet.")}</p> }.into_any(),
Ok(list) => {
view! {
<table>
<tr><th>"Name"</th><th>"Added"</th><th>"Last upload"</th><th></th></tr>
<tr><th>{tr("Name")}</th><th>{tr("Added")}</th><th>{tr("Last upload")}</th><th></th></tr>
{list
.into_iter()
.map(|d| {
@@ -171,11 +173,11 @@ fn Devices() -> impl IntoView {
<tr>
<td>
{d.name.clone()}
{d.web.then(|| view! { <div class="hint">"The web app, in any browser"</div> })}
{d.web.then(|| view! { <div class="hint">{tr("The web app, in any browser")}</div> })}
</td>
<td>{fmt_time(d.created_at)}</td>
<td>{d.last_seen_at.map_or("never".into(), ago)}</td>
<td class="actions"><button on:click=move |_| remove(&d)>"Remove"</button></td>
<td>{d.last_seen_at.map_or(tr("never"), ago)}</td>
<td class="actions"><button on:click=move |_| remove(&d)>{tr("Remove")}</button></td>
</tr>
}
})
@@ -225,7 +227,7 @@ impl ShareOptions {
"now" => Some(now_secs()),
"since" => Some(
map::local_time(&self.since.get_untracked())
.ok_or("Pick when the trail starts.")?,
.ok_or_else(|| tr("Pick when the trail starts."))?,
),
_ => Some(0),
};
@@ -252,46 +254,46 @@ fn ShareOptionsFields(
} = opts;
view! {
<label>
"For"
{tr("For")}
<select bind:value=duration>
<option value="0">"until I stop it"</option>
<option value="3600">"1 hour"</option>
<option value="28800">"8 hours"</option>
<option value="86400">"1 day"</option>
<option value="604800">"1 week"</option>
<option value="2592000">"1 month"</option>
<option value="0">{tr("until I stop it")}</option>
<option value="3600">{tr("1 hour")}</option>
<option value="28800">{tr("8 hours")}</option>
<option value="86400">{tr("1 day")}</option>
<option value="604800">{tr("1 week")}</option>
<option value="2592000">{tr("1 month")}</option>
</select>
</label>
<label>
"Precision"
{tr("Precision")}
<select bind:value=precision>
<option value="0">"exact"</option>
<option value="100">"about 100 m"</option>
<option value="1000">"about 1 km"</option>
<option value="10000">"about 10 km"</option>
<option value="0">{tr("exact")}</option>
<option value="100">{tr("about 100 m")}</option>
<option value="1000">{tr("about 1 km")}</option>
<option value="10000">{tr("about 10 km")}</option>
</select>
</label>
<label>
"Trail"
{tr("Trail")}
<select bind:value=history>
<option value="none">"none, current position only"</option>
<option value="now">"from now on"</option>
<option value="since">"since a time"</option>
<option value="all">"full history"</option>
<option value="none">{tr("none, current position only")}</option>
<option value="now">{tr("from now on")}</option>
<option value="since">{tr("since a time")}</option>
<option value="all">{tr("full history")}</option>
</select>
</label>
<Show when=move || history.get() == "since">
<label>"Since" <input type="datetime-local" bind:value=since /></label>
<label>{tr("Since")} <input type="datetime-local" bind:value=since /></label>
</Show>
<fieldset>
<legend>"Devices"</legend>
<legend>{tr("Devices")}</legend>
<label class="radio">
<input type="radio" name="share-devices" prop:checked=all_devices on:change=move |_| all_devices.set(true) />
"All devices, also ones I add later"
{tr("All devices, also ones I add later")}
</label>
<label class="radio">
<input type="radio" name="share-devices" prop:checked=move || !all_devices.get() on:change=move |_| all_devices.set(false) />
"Only these devices:"
{tr("Only these devices:")}
</label>
<div class="device-choice">
{move || {
@@ -330,23 +332,23 @@ fn ShareOptionsFields(
/// A short summary of what a share shows. Device names are known only for the caller's own devices.
fn describe(s: &ShareSettings, mine: Option<&[Device]>) -> String {
let devices = match (&s.devices, mine) {
(None, _) => "all devices".to_string(),
(None, _) => tr("all devices"),
(Some(ids), Some(mine)) => ids
.iter()
.filter_map(|id| mine.iter().find(|d| d.id == *id).map(|d| d.name.clone()))
.collect::<Vec<_>>()
.join(", "),
(Some(_), None) => "some devices".to_string(),
(Some(_), None) => tr("some devices"),
};
let trail = match s.trail_since {
None => "current position only".to_string(),
Some(0) => "full trail".to_string(),
Some(t) => format!("trail since {}", fmt_time(t)),
None => tr("current position only"),
Some(0) => tr("full trail"),
Some(t) => tr(&format!("trail since {}", fmt_time(t))),
};
let precision = match s.precision_m {
0 => "exact".to_string(),
m @ ..1000 => format!("about {m} m"),
m => format!("about {} km", m / 1000),
0 => tr("exact"),
m @ ..1000 => tr(&format!("about {m} m")),
m => tr(&format!("about {} km", m / 1000)),
};
format!("{devices} · {trail} · {precision}")
}
@@ -407,19 +409,19 @@ fn SharesSection() -> impl IntoView {
let table = move |list: Vec<Share>, outgoing: bool| {
if list.is_empty() {
return view! { <p class="hint">"Nobody."</p> }.into_any();
return view! { <p class="hint">{tr("Nobody.")}</p> }.into_any();
}
let mine = devices.get();
view! {
<table>
<tr><th>"User"</th><th>"Shows"</th><th>"Expires"</th><th></th></tr>
<tr><th>{tr("User")}</th><th>{tr("Shows")}</th><th>{tr("Expires")}</th><th></th></tr>
{list
.into_iter()
.map(|s| {
let (label, question) = if outgoing {
("Stop", format!("Stop sharing your location with {}?", s.username))
(tr("Stop"), format!("Stop sharing your location with {}?", s.username))
} else {
("Remove", format!("Stop seeing the location of {}?", s.username))
(tr("Remove"), format!("Stop seeing the location of {}?", s.username))
};
view! {
<tr>
@@ -438,11 +440,11 @@ fn SharesSection() -> impl IntoView {
view! {
<section>
<h2>"Share my location"</h2>
<h2>{tr("Share my location")}</h2>
<form on:submit=submit>
<label>
"With"
<input placeholder="username" list="usernames" autocomplete="off" required bind:value=viewer />
{tr("With")}
<input placeholder=tr("username") list="usernames" autocomplete="off" required bind:value=viewer />
</label>
<datalist id="usernames">
{move || {
@@ -456,9 +458,9 @@ fn SharesSection() -> impl IntoView {
}}
</datalist>
<ShareOptionsFields opts devices />
<button class="primary">"Share"</button>
<button class="primary">{tr("Share")}</button>
</form>
<p class="hint">"Sharing again with the same person replaces the settings."</p>
<p class="hint">{tr("Sharing again with the same person replaces the settings.")}</p>
<p class="error">{move || error.get()}</p>
</section>
{move || {
@@ -469,11 +471,11 @@ fn SharesSection() -> impl IntoView {
Ok(s) => {
view! {
<section>
<h2>"I share my location with"</h2>
<h2>{tr("I share my location with")}</h2>
{table(s.outgoing, true)}
</section>
<section>
<h2>"Shared with me"</h2>
<h2>{tr("Shared with me")}</h2>
{table(s.incoming, false)}
</section>
}
@@ -499,7 +501,7 @@ extern "C" {
async fn copy(text: String) -> bool {
let copied = copy_text(&text).await.as_bool() == Some(true);
if !copied {
let _ = window().prompt_with_message_and_default("Copy this link:", &text);
let _ = window().prompt_with_message_and_default(&tr("Copy this link:"), &text);
}
copied
}
@@ -540,9 +542,9 @@ fn Links() -> impl IntoView {
password.set(String::new());
let copied = copy(url(&link.token)).await;
message.set(Some(Ok(if copied {
"Link created and copied.".into()
tr("Link created and copied.")
} else {
"Link created.".into()
tr("Link created.")
})));
}
Err(e) => message.set(Some(Err(e.to_string()))),
@@ -566,43 +568,43 @@ fn Links() -> impl IntoView {
view! {
<section>
<h2>"Create a guest link"</h2>
<p class="hint">"Anyone with the link sees your location, without an account."</p>
<h2>{tr("Create a guest link")}</h2>
<p class="hint">{tr("Anyone with the link sees your location, without an account.")}</p>
<form on:submit=create>
<label>"Name" <input placeholder="for Anna" maxlength="100" bind:value=name /></label>
<label>{tr("Name")} <input placeholder=tr("for Anna") maxlength="100" bind:value=name /></label>
<ShareOptionsFields opts devices />
<label>
"Password (optional)"
{tr("Password (optional)")}
<input type="password" autocomplete="new-password" minlength="8" bind:value=password />
</label>
<button class="primary">"Create link"</button>
<button class="primary">{tr("Create link")}</button>
</form>
{status(message)}
</section>
<section>
<h2>"Guest links"</h2>
<h2>{tr("Guest links")}</h2>
{move || {
links
.get()
.map(|r| match r {
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">"No links yet."</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">{tr("No links yet.")}</p> }.into_any(),
Ok(list) => {
let mine = devices.get();
view! {
<table>
<tr><th>"Name"</th><th>"Shows"</th><th>"Expires"</th><th></th></tr>
<tr><th>{tr("Name")}</th><th>{tr("Shows")}</th><th>{tr("Expires")}</th><th></th></tr>
{list
.into_iter()
.map(|l| {
let link = url(&l.token);
let mut shows = describe(&l.settings, Some(&mine));
if l.has_password {
shows.push_str(" · password");
shows = format!("{shows} · {}", tr("password"));
}
view! {
<tr>
<td>{if l.name.is_empty() { "unnamed".to_string() } else { l.name.clone() }}</td>
<td>{if l.name.is_empty() { tr("unnamed") } else { l.name.clone() }}</td>
<td>{shows}</td>
<td>{expiry(l.expires_at)}</td>
<td class="actions">
@@ -610,12 +612,12 @@ fn Links() -> impl IntoView {
let link = link.clone();
spawn_local(async move {
if copy(link).await {
message.set(Some(Ok("Link copied.".into())));
message.set(Some(Ok(tr("Link copied."))));
}
});
}>"Copy"</button>
}>{tr("Copy")}</button>
" "
<button on:click=move |_| remove(&l)>"Delete"</button>
<button on:click=move |_| remove(&l)>{tr("Delete")}</button>
</td>
</tr>
}
@@ -647,7 +649,7 @@ fn History() -> impl IntoView {
t => match t.parse::<i64>() {
Ok(d) => Some(d),
Err(_) => {
message.set(Some(Err("Enter a number of days.".into())));
message.set(Some(Err(tr("Enter a number of days."))));
return;
}
},
@@ -663,7 +665,7 @@ fn History() -> impl IntoView {
spawn_local(async move {
match http::put::<()>("/api/me/retention", &SetRetention { days: value }).await {
Ok(()) => {
message.set(Some(Ok("Saved.".into())));
message.set(Some(Ok(tr("Saved."))));
account.reload();
}
Err(e) => message.set(Some(Err(e.to_string()))),
@@ -672,22 +674,24 @@ fn History() -> impl IntoView {
};
let server_limit = match max {
Some(d) => format!("The server keeps points for {d} days. You can choose a shorter time."),
None => "The server keeps points forever. You can choose a limit.".into(),
Some(d) => tr(&format!(
"The server keeps points for {d} days. You can choose a shorter time."
)),
None => tr("The server keeps points forever. You can choose a limit."),
};
let placeholder = max.map_or("forever".into(), |d| d.to_string());
let placeholder = max.map_or(tr("forever"), |d| d.to_string());
view! {
<section>
<h2>"Location history"</h2>
<h2>{tr("Location history")}</h2>
<p class="hint">{server_limit}</p>
<form on:submit=submit>
<label>
"Keep my points for (days)"
{tr("Keep my points for (days)")}
<input type="number" min="1" max=max placeholder=placeholder bind:value=days />
</label>
<button class="primary">"Save"</button>
<button class="primary">{tr("Save")}</button>
</form>
<p class="hint">"Leave the field empty to use the server setting."</p>
<p class="hint">{tr("Leave the field empty to use the server setting.")}</p>
{status(message)}
</section>
}
@@ -717,7 +721,7 @@ fn Security() -> impl IntoView {
let change_password = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if new.get_untracked() != repeat.get_untracked() {
pw_message.set(Some(Err("The new passwords do not match.".into())));
pw_message.set(Some(Err(tr("The new passwords do not match."))));
return;
}
let body = ChangePassword {
@@ -730,9 +734,9 @@ fn Security() -> impl IntoView {
for s in [old, new, repeat] {
s.set(String::new());
}
pw_message.set(Some(Ok(
"Password saved. Your other sessions are logged out.".into(),
)));
pw_message.set(Some(Ok(tr(
"Password saved. Your other sessions are logged out.",
))));
changed();
}
Err(e) => pw_message.set(Some(Err(e.to_string()))),
@@ -746,7 +750,7 @@ fn Security() -> impl IntoView {
spawn_local(async move {
match http::delete("/api/me/password").await {
Ok(()) => {
pw_message.set(Some(Ok("Password removed.".into())));
pw_message.set(Some(Ok(tr("Password removed."))));
changed();
}
Err(e) => pw_message.set(Some(Err(e.to_string()))),
@@ -777,7 +781,7 @@ fn Security() -> impl IntoView {
match result {
Ok(k) => {
key_name.set(String::new());
key_message.set(Some(Ok(format!("Added \"{}\".", k.name))));
key_message.set(Some(Ok(tr(&format!("Added \"{}\".", k.name)))));
changed();
}
Err(e) => key_message.set(Some(Err(e))),
@@ -811,23 +815,23 @@ fn Security() -> impl IntoView {
view! {
<section>
<h2>"Password"</h2>
<h2>{tr("Password")}</h2>
<form on:submit=change_password>
<Show when=has_password>
<label>
"Current"
{tr("Current")}
<input type="password" autocomplete="current-password" required bind:value=old />
</label>
</Show>
<label>
"New"
{tr("New")}
<input type="password" autocomplete="new-password" minlength="8" required bind:value=new />
</label>
<label>
"Repeat new"
{tr("Repeat new")}
<input type="password" autocomplete="new-password" minlength="8" required bind:value=repeat />
</label>
<button class="primary">{move || if has_password() { "Change" } else { "Set password" }}</button>
<button class="primary">{move || tr(if has_password() { "Change" } else { "Set password" })}</button>
</form>
<Show
when=has_password
@@ -838,26 +842,26 @@ fn Security() -> impl IntoView {
disabled=move || passkey_count() == 0 || two_factor()
on:click=remove_password
>
"Remove password"
{tr("Remove password")}
</button>
" "
<span class="hint">"Needs a passkey, and two-factor sign-in turned off."</span>
<span class="hint">{tr("Needs a passkey, and two-factor sign-in turned off.")}</span>
</p>
</Show>
{status(pw_message)}
</section>
<section>
<h2>"Passkeys"</h2>
<h2>{tr("Passkeys")}</h2>
{move || {
passkeys
.get()
.map(|r| match r {
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">"No passkeys yet."</p> }.into_any(),
Ok(list) if list.is_empty() => view! { <p class="hint">{tr("No passkeys yet.")}</p> }.into_any(),
Ok(list) => {
view! {
<table>
<tr><th>"Name"</th><th>"Added"</th><th>"Last used"</th><th></th></tr>
<tr><th>{tr("Name")}</th><th>{tr("Added")}</th><th>{tr("Last used")}</th><th></th></tr>
{list
.into_iter()
.map(|k| {
@@ -865,8 +869,8 @@ fn Security() -> impl IntoView {
<tr>
<td>{k.name.clone()}</td>
<td>{fmt_time(k.created_at)}</td>
<td>{k.last_used_at.map_or("never".into(), ago)}</td>
<td class="actions"><button on:click=move |_| remove_passkey(&k)>"Remove"</button></td>
<td>{k.last_used_at.map_or(tr("never"), ago)}</td>
<td class="actions"><button on:click=move |_| remove_passkey(&k)>{tr("Remove")}</button></td>
</tr>
}
})
@@ -882,17 +886,17 @@ fn Security() -> impl IntoView {
fallback=|| view! { <p class="hint">"This browser does not support passkeys."</p> }
>
<form on:submit=add_passkey>
<label>"Name" <input placeholder="laptop, phone, key" bind:value=key_name /></label>
<button class="primary">"Add passkey"</button>
<label>{tr("Name")} <input placeholder=tr("laptop, phone, key") bind:value=key_name /></label>
<button class="primary">{tr("Add passkey")}</button>
</form>
</Show>
{status(key_message)}
</section>
<section>
<h2>"Sign-in"</h2>
<h2>{tr("Sign-in")}</h2>
<label class="radio">
<input type="radio" name="mode" prop:checked=move || !two_factor() on:change=move |_| set_mode(false) />
"Password or passkey"
{tr("Password or passkey")}
</label>
<label class="radio">
<input
@@ -902,9 +906,9 @@ fn Security() -> impl IntoView {
disabled=move || !has_password() || passkey_count() == 0
on:change=move |_| set_mode(true)
/>
"Password and passkey (two-factor)"
{tr("Password and passkey (two-factor)")}
</label>
<p class="hint">"Two-factor sign-in needs a password and at least one passkey."</p>
<p class="hint">{tr("Two-factor sign-in needs a password and at least one passkey.")}</p>
{status(mode_message)}
</section>
}
@@ -937,7 +941,7 @@ fn Users() -> impl IntoView {
username.set(String::new());
password.set(String::new());
is_admin.set(false);
message.set(Some(Ok(format!("Created {}.", u.username))));
message.set(Some(Ok(tr(&format!("Created {}.", u.username)))));
}
Err(e) => message.set(Some(Err(e.to_string()))),
}
@@ -950,7 +954,7 @@ fn Users() -> impl IntoView {
"New password for {}. This also removes their passkeys, disconnects their devices, turns off two-factor sign-in and logs them out.",
u.username
);
let Ok(Some(password)) = window().prompt_with_message(&prompt) else {
let Ok(Some(password)) = window().prompt_with_message(&tr(&prompt)) else {
return;
};
let (id, name) = (u.id, u.username.clone());
@@ -961,7 +965,7 @@ fn Users() -> impl IntoView {
)
.await
{
Ok(()) => message.set(Some(Ok(format!("Password reset for {name}.")))),
Ok(()) => message.set(Some(Ok(tr(&format!("Password reset for {name}."))))),
Err(e) => message.set(Some(Err(e.to_string()))),
}
});
@@ -971,10 +975,11 @@ fn Users() -> impl IntoView {
let (id, name) = (u.id, u.username.clone());
spawn_local(async move {
match http::put::<()>(&format!("/api/users/{id}/role"), &SetRole { is_admin }).await {
Ok(()) => message.set(Some(Ok(format!(
"{name} is now {}.",
if is_admin { "an admin" } else { "a user" }
)))),
Ok(()) => message.set(Some(Ok(tr(&if is_admin {
format!("{name} is now an admin.")
} else {
format!("{name} is now a user.")
})))),
Err(e) => message.set(Some(Err(e.to_string()))),
}
version.update(|v| *v += 1);
@@ -996,20 +1001,20 @@ fn Users() -> impl IntoView {
view! {
<section>
<h2>"Add a user"</h2>
<h2>{tr("Add a user")}</h2>
<form on:submit=create>
<label>"Username" <input autocomplete="off" required bind:value=username /></label>
<label>{tr("Username")} <input autocomplete="off" required bind:value=username /></label>
<label>
"Password"
{tr("Password")}
<input type="password" autocomplete="new-password" minlength="8" required bind:value=password />
</label>
<label class="radio"><input type="checkbox" bind:checked=is_admin />"Admin"</label>
<button class="primary">"Add"</button>
<label class="radio"><input type="checkbox" bind:checked=is_admin />{tr("Admin")}</label>
<button class="primary">{tr("Add")}</button>
</form>
{status(message)}
</section>
<section>
<h2>"Users"</h2>
<h2>{tr("Users")}</h2>
{move || {
users
.get()
@@ -1018,7 +1023,7 @@ fn Users() -> impl IntoView {
Ok(list) => {
view! {
<table>
<tr><th>"Username"</th><th>"Role"</th><th>"Created"</th><th></th></tr>
<tr><th>{tr("Username")}</th><th>{tr("Role")}</th><th>{tr("Created")}</th><th></th></tr>
{list
.into_iter()
.map(|u| {
@@ -1030,19 +1035,19 @@ fn Users() -> impl IntoView {
<td>{u.username.clone()}</td>
<td>
<select
aria-label="Role"
aria-label=tr("Role")
disabled=me
on:change:target=move |ev| set_role(&u3, ev.target().value() == "admin")
>
<option value="user" selected=!u.is_admin>"user"</option>
<option value="admin" selected=u.is_admin>"admin"</option>
<option value="user" selected=!u.is_admin>{tr("user")}</option>
<option value="admin" selected=u.is_admin>{tr("admin")}</option>
</select>
</td>
<td>{fmt_time(u.created_at)}</td>
<td class="actions">
<button on:click=move |_| reset(&u2)>"Reset password"</button>
<button on:click=move |_| reset(&u2)>{tr("Reset password")}</button>
" "
<button disabled=me on:click=move |_| remove(&u)>"Delete"</button>
<button disabled=me on:click=move |_| remove(&u)>{tr("Delete")}</button>
</td>
</tr>
}
Mweb/style.css
@@ -110,6 +110,8 @@ main { flex: 1; min-height: 0; display: flex; }
.map-page aside { width: auto; max-height: 35vh; border-right: 0; border-bottom: 1px solid var(--line); }
header { gap: 0.5em; padding: 0.5em; }
header .user { display: none; }
header nav button, header > button:not(.theme) { padding: 0.35em 0.55em; }
header .brand { margin-right: 0; }
}
.settings .tabs {