UI: i18n (EN/DE/FR) via central registry; per-user language setting
Client-side localization with English, German and French: - web/src/i18n.rs: key constants (k::), static EN/DE/FR tables, t()/tr() (reactive), t_fmt() for one-argument strings, error_text() for server error codes; unit tests enforce key parity between languages - locale precedence: user profile setting, else browser language; <html lang> kept in sync - server errors carry stable codes (ApiError::localized) so the client can localize them; unknown codes fall back to the server message - language stored on the user (schema v4 migration); PUT /api/auth/me accepts language (null resets to auto), validated against a small tag grammar at the API boundary Settings UI polish from review: - language uses the same row styling as the single-click setting, with a custom square-cap chevron matching the sharp UI - checkboxes (single-click, writable shares) are square and sit on the right of their row - profile settings (language + single-click) save together via one Save-profile button instead of per-change round trips - non-admins no longer probe the admin settings endpoint (stray 403 toast); a few hardcoded strings that had slipped past the conversion are translated (share dialog, share list meta, setup intro, expiry presets) - topbar brand/logo is clickable and returns to the files root - small margin under the sidebar section headers Misc: t_fmt() replaces ad-hoc placeholder substitution, Theme::name_key() dedups the theme label matches, the app-level locale effect reads the current locale untracked to avoid re-triggering, and the api-types round-trip test was updated for the new UserInfo fields. Co-Authored-By: Qwen3.8 27b
Mapi-types/src/lib.rs
@@ -220,6 +220,9 @@ pub struct UserInfo {
/// Profile setting: single click opens entries (off = click selects,
/// double click opens).
pub single_click_open: bool,
/// Preferred UI language tag ("en", "de", "fr"); None = follow the
/// browser.
pub language: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
@@ -364,6 +367,8 @@ mod tests {
id: 1,
name: "admin".into(),
is_admin: true,
single_click_open: false,
language: None,
}),
roots: vec![RootInfo {
id: 1,
Mserver/src/api/admin.rs
@@ -59,7 +59,13 @@ async fn validate_roots(state: &AppState, roots: &[Root]) -> Result<Vec<(String,
let path2 = path.clone();
tokio::task::spawn_blocking(move || fs::resolve_root(&server_root, &path2))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?
.map_err(|e| {
ApiError::new(StatusCode::BAD_REQUEST, format!("root path '{path}': {e}"))
})?;
@@ -95,9 +101,10 @@ pub async fn create_user(
validate_account_name(&name)?;
validate_password(&body.password)?;
if state.db.find_user_by_name(&name).await?.is_some() {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::CONFLICT,
"a user with that name already exists",
"err_user_exists",
));
}
let roots = validate_roots(&state, &body.roots).await?;
@@ -118,24 +125,28 @@ pub async fn update_user(
AxumPath(id): AxumPath<i64>,
Json(body): Json<UpdateUser>,
) -> Result<Json<AdminUser>, ApiError> {
let target = state
.db
.find_user_by_id(id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
let target = state.db.find_user_by_id(id).await?.ok_or_else(|| {
ApiError::localized(
StatusCode::NOT_FOUND,
"user not found",
"err_user_not_found",
)
})?;
// Lockout guards: an admin cannot demote, disable, or delete themselves.
if id == admin.user.id {
if body.is_admin == Some(false) {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"you cannot remove your own admin rights",
"err_own_admin",
));
}
if body.active == Some(false) {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"you cannot disable your own account",
"err_own_account",
));
}
}
@@ -144,9 +155,10 @@ pub async fn update_user(
let disabling =
id != admin.user.id && body.active == Some(false) && target.active && target.is_admin;
if (demoting || disabling) && state.db.count_admins().await? <= 1 {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"cannot remove the last active admin",
"err_last_admin",
));
}
@@ -166,11 +178,13 @@ pub async fn update_user(
state.db.set_user_roots(id, &pairs).await?;
}
let updated = state
.db
.find_user_by_id(id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
let updated = state.db.find_user_by_id(id).await?.ok_or_else(|| {
ApiError::localized(
StatusCode::NOT_FOUND,
"user not found",
"err_user_not_found",
)
})?;
Ok(Json(user_info(&state.db, &state, &updated).await?))
}
@@ -181,24 +195,32 @@ pub async fn delete_user(
AxumPath(id): AxumPath<i64>,
) -> Result<Json<OkResp>, ApiError> {
if id == admin.user.id {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"you cannot delete your own account",
"err_own_delete",
));
}
let target = state
.db
.find_user_by_id(id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
let target = state.db.find_user_by_id(id).await?.ok_or_else(|| {
ApiError::localized(
StatusCode::NOT_FOUND,
"user not found",
"err_user_not_found",
)
})?;
if target.is_admin && target.active && state.db.count_admins().await? <= 1 {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"cannot delete the last active admin",
"err_last_admin_delete",
));
}
if !state.db.delete_user(id).await? {
return Err(ApiError::new(StatusCode::NOT_FOUND, "user not found"));
return Err(ApiError::localized(
StatusCode::NOT_FOUND,
"user not found",
"err_user_not_found",
));
}
Ok(Json(OkResp { ok: true }))
}
Mserver/src/api/auth.rs
@@ -37,12 +37,17 @@ pub async fn me(
/// The signed-in user behind the session cookie, or 401.
async fn require_user(state: &AppState, headers: &HeaderMap) -> Result<User, ApiError> {
let Some(token) = parse_session_cookie(headers) else {
return Err(ApiError::new(StatusCode::UNAUTHORIZED, "not signed in"));
return Err(ApiError::localized(
StatusCode::UNAUTHORIZED,
"not signed in",
"err_not_signed_in",
));
};
state.db.session_user(&token).await?.ok_or_else(|| {
ApiError::new(
ApiError::localized(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
"err_session_expired",
)
})
}
@@ -69,6 +74,7 @@ async fn me_for(state: &AppState, user: &User) -> Result<Me, ApiError> {
name: user.name.clone(),
is_admin: user.is_admin,
single_click_open: user.single_click,
language: user.language.clone(),
}),
roots,
allow_writable_shares: state.db.allow_writable_shares().await?,
@@ -77,11 +83,38 @@ async fn me_for(state: &AppState, user: &User) -> Result<Me, ApiError> {
/// PUT /api/auth/me
///
/// Update the signed-in user's profile settings. Returns the fresh `/me`
/// payload so clients can apply the change immediately.
/// Update the signed-in user's profile settings. Each field is optional;
/// omitted fields are left untouched. Returns the fresh `/me` payload so
/// clients can apply the change immediately.
#[derive(Deserialize)]
pub(crate) struct ProfilePatch {
pub single_click_open: bool,
#[serde(default)]
pub single_click_open: Option<bool>,
#[serde(default, deserialize_with = "language_field")]
pub language: Option<Option<String>>,
}
/// Deserializes `ProfilePatch::language` into the three-state patch value:
/// `"de"` → `Some(Some("de"))`, `null` → `Some(None)` (a missing field
/// never calls this and stays `None` via `#[serde(default)]`). The inner
/// `Option<String>` already maps `null` → `None` and `"de"` → `Some`, so
/// only the outer wrap is custom.
fn language_field<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
serde::Deserialize::deserialize(deserializer).map(Some)
}
/// A language tag we are willing to store: short, ASCII letters/digits and
/// `-`/`_` (BCP-47 style). Checked at the trust boundary so a raw API
/// client cannot write arbitrary blobs into the DB.
fn valid_language(tag: &str) -> bool {
!tag.is_empty()
&& tag.len() <= 12
&& tag
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
pub async fn update_profile(
@@ -90,11 +123,23 @@ pub async fn update_profile(
Json(body): Json<ProfilePatch>,
) -> Result<Json<Me>, ApiError> {
let mut user = require_user(&state, &headers).await?;
state
.db
.set_user_single_click(user.id, body.single_click_open)
.await?;
user.single_click = body.single_click_open;
if let Some(Some(ref tag)) = body.language
&& !valid_language(tag)
{
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid language tag",
"err_invalid_language",
));
}
if let Some(v) = body.single_click_open {
state.db.set_user_single_click(user.id, v).await?;
user.single_click = v;
}
if let Some(lang) = body.language {
state.db.set_user_language(user.id, lang.as_deref()).await?;
user.language = lang;
}
Ok(Json(me_for(&state, &user).await?))
}
@@ -108,9 +153,10 @@ pub async fn setup(
validate_account_name(name)?;
validate_password(&body.password)?;
if state.db.user_count().await? > 0 {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::CONFLICT,
"server is already set up",
"err_already_set_up",
));
}
@@ -134,9 +180,10 @@ pub async fn login(
Json(body): Json<Credentials>,
) -> Result<Response, ApiError> {
let Some(user) = state.db.verify_password(&body.name, &body.password).await? else {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::UNAUTHORIZED,
"invalid name or password",
"err_invalid_credentials",
));
};
Mserver/src/api/common.rs
@@ -50,8 +50,16 @@ where
share: Some(share),
})
}
Some(_) => Err(ApiError::new(StatusCode::GONE, "this share has expired")),
None => Err(ApiError::new(StatusCode::NOT_FOUND, "share not found")),
Some(_) => Err(ApiError::localized(
StatusCode::GONE,
"this share has expired",
"err_share_expired",
)),
None => Err(ApiError::localized(
StatusCode::NOT_FOUND,
"share not found",
"err_share_not_found",
)),
};
}
@@ -82,9 +90,10 @@ where
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
if share_token_from_request(parts).is_some() {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::FORBIDDEN,
"a share token cannot be used here; sign in instead",
"err_share_token_forbidden",
));
}
let (user, roots) = session_auth(parts, state.state()).await?;
@@ -95,12 +104,17 @@ where
/// Authenticate via the session cookie only, returning the user and roots.
async fn session_auth(parts: &Parts, state: &AppState) -> Result<(User, Vec<RootRow>), ApiError> {
let Some(token) = parse_session_cookie(&parts.headers) else {
return Err(ApiError::new(StatusCode::UNAUTHORIZED, "not signed in"));
return Err(ApiError::localized(
StatusCode::UNAUTHORIZED,
"not signed in",
"err_not_signed_in",
));
};
let Some(user) = state.db.session_user(&token).await? else {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
"err_session_expired",
));
};
let roots = state.db.user_roots(user.id).await?;
@@ -160,9 +174,10 @@ pub(crate) fn display_name(server_root: &std::path::Path, rel: &str) -> String {
pub(crate) fn validate_account_name(name: &str) -> Result<(), ApiError> {
let n = name.trim();
if n.is_empty() || n.len() > 64 {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"name must be 1–64 characters",
"err_name_length",
));
}
Ok(())
@@ -174,7 +189,13 @@ pub(crate) async fn hash_password(pw: &str) -> Result<String, ApiError> {
let pw = pw.to_string();
tokio::task::spawn_blocking(move || crate::auth::hash_password(&pw))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?
.map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -185,9 +206,10 @@ pub(crate) async fn hash_password(pw: &str) -> Result<String, ApiError> {
pub(crate) fn validate_password(pw: &str) -> Result<(), ApiError> {
if pw.len() < 8 {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"password must be at least 8 characters",
"err_password_short",
));
}
Ok(())
@@ -208,7 +230,11 @@ where
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let auth = SessionUser::from_request_parts(parts, state).await?;
if !auth.user.is_admin {
return Err(ApiError::new(StatusCode::FORBIDDEN, "admin only"));
return Err(ApiError::localized(
StatusCode::FORBIDDEN,
"admin only",
"err_admin_only",
));
}
Ok(AdminUser { user: auth.user })
}
Mserver/src/api/files.rs
@@ -114,11 +114,23 @@ async fn list_inner(
let full =
tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_rel, &req_rel))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
let entries = tokio::task::spawn_blocking(move || fs::list_dir(&full))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
Ok(Json(FilesResp { entries }))
}
@@ -157,7 +169,13 @@ async fn resolve_item(
Ok::<_, FsError>((full, name, meta.is_dir(), meta.len()))
})
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
Ok(inner?)
}
@@ -179,9 +197,10 @@ async fn download(
}
let fmt = format.and_then(ArchiveFormat::parse).ok_or_else(|| {
ApiError::new(
ApiError::localized(
StatusCode::BAD_REQUEST,
"format must be one of: zip, tar, tar.gz, tar.zst",
"err_bad_format",
)
})?;
let disp = format!(
@@ -214,7 +233,11 @@ async fn preview(
let (full, name, is_dir, size) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if is_dir {
return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"not a file",
"err_not_a_file",
));
}
file_response(&full, &name, size, true).await
}
@@ -231,20 +254,33 @@ async fn content(
let (full, _name, is_dir, size) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if is_dir {
return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"not a file",
"err_not_a_file",
));
}
if size > MAX_TEXT_BYTES {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::PAYLOAD_TOO_LARGE,
"file too large to preview",
"err_too_large_preview",
));
}
let bytes = tokio::fs::read(&full)
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
let meta = tokio::fs::metadata(&full)
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
let bytes = tokio::fs::read(&full).await.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
let meta = tokio::fs::metadata(&full).await.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
let mtime = fs::mtime_secs(&meta).unwrap_or(0);
Ok((
[
@@ -304,16 +340,18 @@ async fn put_inner(
body: axum::body::Bytes,
) -> Result<Json<SaveResp>, ApiError> {
if query.action.as_deref() != Some(api_types::ACTION_CONTENT) {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"expected action=content",
"err_bad_action",
));
}
let root = require_rw_root(&auth.roots, root_id)?;
if body.len() as u64 > MAX_TEXT_BYTES {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::PAYLOAD_TOO_LARGE,
"file too large to save",
"err_too_large_save",
));
}
let expected: Option<i64> = headers
@@ -333,7 +371,13 @@ async fn put_inner(
None => fs::save_file(&server_root, &root_rel, &rel, &content, expected),
})
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
Ok(Json(SaveResp { ok: true, mtime }))
}
@@ -520,9 +564,21 @@ async fn dispatch_inner(
if ct.starts_with("application/json") {
let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
.await
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?;
.map_err(|_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid request body",
"err_bad_body",
)
})?;
let body: Mutation = axum::Json::from_bytes(&bytes)
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?
.map_err(|_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid request body",
"err_bad_body",
)
})?
.0;
return Ok(mutation(state, auth, root_id, req_rel, body)
.await?
@@ -531,9 +587,10 @@ async fn dispatch_inner(
if action_param(req.uri()).as_deref() == Some(api_types::ACTION_MKDIR) {
return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response());
}
Err(ApiError::new(
Err(ApiError::localized(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
"err_bad_post",
))
}
@@ -549,15 +606,22 @@ async fn mkdir(
) -> Result<Json<OkResp>, ApiError> {
let root = require_rw_root(&auth.roots, root_id)?;
if req_rel.trim().is_empty() {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"a folder name is required",
"err_folder_name_required",
));
}
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
Ok(Json(OkResp { ok: true }))
}
@@ -577,7 +641,13 @@ async fn mutation(
let new_name = body
.new_name
.as_deref()
.ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "new_name is required"))?
.ok_or_else(|| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"new_name is required",
"err_new_name_required",
)
})?
.to_string();
let root = require_rw_root(&auth.roots, root_id)?;
let (server_root, root_rel, rel, overwrite) = (
@@ -590,13 +660,23 @@ async fn mutation(
fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)
})
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
Ok(Json(OkResp { ok: true }))
}
Op::Move | Op::Copy => {
let dst_root_id = body
.dst_root_id
.ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "dst_root_id is required"))?;
let dst_root_id = body.dst_root_id.ok_or_else(|| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"dst_root_id is required",
"err_dst_required",
)
})?;
let dst = body.dst.clone().unwrap_or_default();
// Moving or copying out of a folder requires rw there; copying
// *from* a read-only root is fine.
@@ -623,7 +703,13 @@ async fn mutation(
}
})
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
Ok(Json(OkResp { ok: true }))
}
}
@@ -641,16 +727,23 @@ pub async fn delete(
let (root_id, req_rel) = path.0;
let root = require_rw_root(&auth.roots, root_id)?;
if req_rel.trim().is_empty() {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"a path inside the folder is required",
"err_path_required",
));
}
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
let is_dir =
tokio::task::spawn_blocking(move || fs::remove_item(&server_root, &root_rel, &rel))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
Ok(Json(serde_json::json!({ "ok": true, "is_dir": is_dir })))
}
@@ -670,7 +763,13 @@ async fn upload(
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
tokio::task::spawn_blocking(move || fs::resolve_dir(&server_root, &root_rel, &rel))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??
};
let boundary = req
.headers()
@@ -678,9 +777,10 @@ async fn upload(
.and_then(|v| v.to_str().ok())
.and_then(parse_boundary)
.ok_or_else(|| {
ApiError::new(
ApiError::localized(
StatusCode::BAD_REQUEST,
"expected multipart/form-data with a boundary",
"err_bad_multipart",
)
})?;
let overwrite = parse_overwrite(req.uri());
@@ -690,17 +790,25 @@ async fn upload(
let mut uploaded: usize = 0;
let mut skipped: Vec<String> = Vec::new();
while let Some(mut field) = multipart
.next_field()
.await
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
{
while let Some(mut field) = multipart.next_field().await.map_err(|_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid upload data",
"err_bad_upload",
)
})? {
let part_name = field
.name()
.filter(|n| !n.is_empty())
.or_else(|| field.file_name())
.map(str::to_string)
.ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "part without a name"))?;
.ok_or_else(|| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"part without a name",
"err_part_no_name",
)
})?;
validate_rel_path(&part_name)?;
@@ -708,51 +816,73 @@ async fn upload(
let parent = target
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "invalid part name"))?;
.ok_or_else(|| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid part name",
"err_bad_part_name",
)
})?;
if !parent.is_dir() {
let p = parent.to_path_buf();
tokio::task::spawn_blocking(move || std::fs::create_dir_all(&p))
.await
.map_err(|_| {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
}
if target.exists() && !overwrite {
// Drain this part and report it as a conflict at the end.
while let Some(_chunk) = field
.chunk()
.await
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
{
}
while let Some(_chunk) = field.chunk().await.map_err(|_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid upload data",
"err_bad_upload",
)
})? {}
skipped.push(part_name);
continue;
}
if target.exists() {
if target.is_dir() {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::CONFLICT,
"a folder with this name already exists",
"err_folder_exists",
));
}
tokio::fs::remove_file(&target)
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
tokio::fs::remove_file(&target).await.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
}
// Stream to a temp file in the same directory, then rename into place.
let suffix = crate::auth::random_token();
let tmp = parent.join(format!(".upload-{suffix}"));
let mut tmp_file = tokio::fs::File::create(&tmp)
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
let mut tmp_file = tokio::fs::File::create(&tmp).await.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
let write_failed = loop {
match field
.chunk()
.await
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))
{
match field.chunk().await.map_err(|_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid upload data",
"err_bad_upload",
)
}) {
Ok(Some(chunk)) => {
if let Err(e) = tmp_file.write_all(&chunk).await {
tracing::warn!(error = %e, "write failed during upload");
@@ -765,35 +895,49 @@ async fn upload(
};
if write_failed {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"could not save the file",
"err_save_failed",
));
}
let tmp2 = tmp.clone();
let target2 = target.clone();
let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
renamed.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
tracing::warn!(error = %e, "rename failed during upload");
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
uploaded += 1;
}
if uploaded == 0 && skipped.is_empty() {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"no files were uploaded",
"err_no_files_uploaded",
));
}
if !skipped.is_empty() {
return Err(
ApiError::new(StatusCode::CONFLICT, "some files already exist")
.with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })),
);
return Err(ApiError::localized(
StatusCode::CONFLICT,
"some files already exist",
"err_files_exist",
)
.with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })));
}
Ok(Json(UploadResp { ok: true, uploaded }).into_response())
}
@@ -828,9 +972,10 @@ fn validate_rel_path(name: &str) -> Result<(), ApiError> {
match c {
Component::Normal(_) => {}
_ => {
return Err(ApiError::new(
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid file path in upload",
"err_bad_upload_path",
));
}
}
@@ -843,16 +988,23 @@ fn validate_rel_path(name: &str) -> Result<(), ApiError> {
// ---------------------------------------------------------------------------
fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
roots
.iter()
.find(|r| r.id == root_id)
.ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))
roots.iter().find(|r| r.id == root_id).ok_or_else(|| {
ApiError::localized(
StatusCode::FORBIDDEN,
"no such folder",
"err_no_such_folder",
)
})
}
fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
let root = find_root(roots, root_id)?;
if !root.mode.is_writable() {
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only folder"));
return Err(ApiError::localized(
StatusCode::FORBIDDEN,
"read-only folder",
"err_read_only_folder",
));
}
Ok(root)
}
Mserver/src/db.rs
@@ -5,7 +5,7 @@ pub use api_types::Mode;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
const SCHEMA_VERSION: i64 = 3;
const SCHEMA_VERSION: i64 = 4;
/// SQL adapter for [`Mode`]. A newtype is needed because both the rusqlite
/// traits and `Mode` are foreign to this crate.
@@ -37,6 +37,9 @@ pub struct User {
pub active: bool,
/// Profile setting: single click opens entries (off = click selects).
pub single_click: bool,
/// Preferred UI language tag ("en", "de", "fr"); None = follow the
/// browser.
pub language: Option<String>,
}
#[derive(Debug, Clone)]
@@ -142,6 +145,11 @@ impl Db {
"ALTER TABLE users ADD COLUMN single_click INTEGER NOT NULL DEFAULT 0",
)?;
}
if version < 4 {
// Per-user UI language preference; NULL means "follow the
// browser".
conn.execute_batch("ALTER TABLE users ADD COLUMN language TEXT")?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -178,6 +186,7 @@ impl Db {
is_admin: true,
active: true,
single_click: false,
language: None,
})
}
@@ -185,10 +194,12 @@ impl Db {
// The guard is scoped to the query alone. Argon2 below is slow by
// design; holding the single connection lock across it would make one
// login serialize every other database access.
let row: Option<(i64, String, bool, String, bool, bool)> = {
type UserRow = (i64, String, bool, String, bool, bool, Option<String>);
let row: Option<UserRow> = {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, pass_hash, active != 0, single_click != 0
"SELECT id, name, is_admin != 0, pass_hash, active != 0, single_click != 0,
language
FROM users WHERE name = ?1",
[name],
|r| {
@@ -199,12 +210,13 @@ impl Db {
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
))
},
)
.optional()?
};
let Some((id, name, is_admin, hash, active, single_click)) = row else {
let Some((id, name, is_admin, hash, active, single_click, language)) = row else {
return Ok(None);
};
if !active {
@@ -222,6 +234,7 @@ impl Db {
is_admin,
active,
single_click,
language,
}))
}
@@ -246,7 +259,7 @@ impl Db {
pub async fn session_user(&self, token: &str) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0, u.language
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ?1 AND u.active = 1",
[token],
@@ -276,7 +289,8 @@ impl Db {
pub async fn all_users(&self) -> DbResult<Vec<User>> {
let c = self.0.lock().await;
let mut stmt = c.prepare(
"SELECT id, name, is_admin != 0, active != 0, single_click != 0 FROM users ORDER BY id",
"SELECT id, name, is_admin != 0, active != 0, single_click != 0, language
FROM users ORDER BY id",
)?;
let rows = stmt.query_map([], map_user)?;
rows.collect()
@@ -285,7 +299,8 @@ impl Db {
pub async fn find_user_by_id(&self, id: i64) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0, single_click != 0 FROM users WHERE id = ?1",
"SELECT id, name, is_admin != 0, active != 0, single_click != 0, language
FROM users WHERE id = ?1",
[id],
map_user,
)
@@ -295,7 +310,8 @@ impl Db {
pub async fn find_user_by_name(&self, name: &str) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0, single_click != 0 FROM users WHERE name = ?1",
"SELECT id, name, is_admin != 0, active != 0, single_click != 0, language
FROM users WHERE name = ?1",
[name],
map_user,
)
@@ -340,6 +356,7 @@ impl Db {
is_admin,
active: true,
single_click: false,
language: None,
})
}
@@ -379,6 +396,15 @@ impl Db {
Ok(())
}
pub async fn set_user_language(&self, id: i64, language: Option<&str>) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET language = ?1 WHERE id = ?2",
params![language, id],
)?;
Ok(())
}
/// Delete a user. `false` means no row matched.
pub async fn delete_user(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
@@ -504,6 +530,7 @@ fn map_user(r: &rusqlite::Row) -> DbResult<User> {
is_admin: r.get(2)?,
active: r.get(3)?,
single_click: r.get(4)?,
language: r.get(5)?,
})
}
Mserver/src/error.rs
@@ -15,13 +15,27 @@ pub struct AppState {
}
/// API error. `extra` is optionally merged into the JSON body (e.g. a list of
/// conflicting file names on a 409). Rendered by `IntoResponse` below; the
/// error string itself is never displayed, so no `Display` impl is needed.
pub struct ApiError(pub StatusCode, pub String, pub Option<serde_json::Value>);
/// conflicting file names on a 409). `code`, when present, is a stable
/// machine-readable identifier the client maps to a localized message (the
/// English text stays for logs and non-localized clients). Rendered by
/// `IntoResponse` below; the error string itself is never displayed, so no
/// `Display` impl is needed.
pub struct ApiError(
pub StatusCode,
pub String,
pub Option<serde_json::Value>,
pub Option<&'static str>,
);
impl ApiError {
pub fn new(status: StatusCode, msg: impl Into<String>) -> Self {
Self(status, msg.into(), None)
Self(status, msg.into(), None, None)
}
/// A user-facing error with a stable `code` for client-side
/// localization (the code doubles as the i18n key).
pub fn localized(status: StatusCode, msg: impl Into<String>, code: &'static str) -> Self {
Self(status, msg.into(), None, Some(code))
}
pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
@@ -33,6 +47,9 @@ impl ApiError {
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let mut body = serde_json::json!({ "error": self.1 });
if let Some(code) = self.3 {
body["code"] = serde_json::json!(code);
}
if let Some(extra) = self.2
&& let (Some(obj), Some(extra)) = (body.as_object_mut(), extra.as_object())
{
@@ -47,14 +64,22 @@ impl IntoResponse for ApiError {
impl From<std::io::Error> for ApiError {
fn from(e: std::io::Error) -> Self {
tracing::error!(error = %e, "io error");
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
}
}
impl From<rusqlite::Error> for ApiError {
fn from(e: rusqlite::Error) -> Self {
tracing::error!(error = %e, "database error");
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
}
}
Mserver/src/fs.rs
@@ -31,15 +31,23 @@ pub enum FsError {
impl From<FsError> for ApiError {
fn from(e: FsError) -> Self {
use axum::http::StatusCode as S;
let status = match &e {
FsError::NotFound => S::NOT_FOUND,
FsError::NotADirectory => S::BAD_REQUEST,
FsError::Forbidden => S::FORBIDDEN,
FsError::RootMissing => S::NOT_FOUND,
FsError::Conflict => S::CONFLICT,
FsError::Invalid(_) => S::BAD_REQUEST,
};
ApiError::new(status, e.to_string())
match &e {
FsError::NotFound => {
ApiError::localized(S::NOT_FOUND, e.to_string(), "err_fs_not_found")
}
FsError::NotADirectory => {
ApiError::localized(S::BAD_REQUEST, e.to_string(), "err_fs_not_a_dir")
}
FsError::Forbidden => {
ApiError::localized(S::FORBIDDEN, e.to_string(), "err_fs_forbidden")
}
FsError::RootMissing => {
ApiError::localized(S::NOT_FOUND, e.to_string(), "err_fs_root_missing")
}
FsError::Conflict => ApiError::localized(S::CONFLICT, e.to_string(), "err_fs_conflict"),
// Dynamic message (e.g. an invalid name), not a fixed string.
FsError::Invalid(_) => ApiError::new(S::BAD_REQUEST, e.to_string()),
}
}
}
Mweb/app.css
@@ -15,6 +15,7 @@
--danger-border: #dc2626;
--border: #e5e7eb;
--topbar-h: 48px;
--select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2.5' stroke-linecap='square'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
}
:root.dark {
@@ -29,6 +30,7 @@
--danger: #f87171;
--danger-border: #fa8b8b;
--border: #2a2f3a;
--select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2.5' stroke-linecap='square'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
}
@media (prefers-color-scheme: dark) {
@@ -44,6 +46,7 @@
--danger: #f87171;
--danger-border: #fa8b8b;
--border: #2a2f3a;
--select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2.5' stroke-linecap='square'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
}
}
@@ -128,6 +131,39 @@ input:focus {
outline-offset: -1px;
}
/* Square checkboxes — native controls are rounded, the theme is sharp. */
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
margin: 0;
padding: 0;
flex: none;
border: 1px solid var(--border);
background: var(--panel);
cursor: pointer;
position: relative;
vertical-align: -3px;
}
input[type="checkbox"]:checked {
background: var(--accent);
border-color: var(--accent-border);
}
input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
button {
font: inherit;
cursor: pointer;
@@ -232,6 +268,13 @@ button:disabled {
gap: 10px;
font-weight: 700;
white-space: nowrap;
color: inherit;
text-decoration: none;
cursor: pointer;
}
.brand:hover {
opacity: 0.8;
}
.brand-name {
@@ -279,6 +322,7 @@ button:disabled {
align-items: center;
justify-content: space-between;
gap: 6px;
margin-bottom: 4px;
}
.nav-section-head .nav-section-label {
@@ -1382,7 +1426,7 @@ button:disabled {
.setting-row {
display: flex;
align-items: flex-start;
align-items: center;
gap: 10px;
padding: 14px;
border: 1px solid var(--border);
@@ -1390,8 +1434,7 @@ button:disabled {
}
.setting-row input {
width: auto;
margin-top: 2px;
margin-left: auto;
}
.setting-label {
@@ -1407,6 +1450,30 @@ button:disabled {
margin-top: 2px;
}
/* Setting row whose control is a select, pushed to the right. */
.setting-row-select {
cursor: default;
}
.setting-row-select > span {
flex: 1;
}
.setting-row-select select {
appearance: none;
margin-left: auto;
padding: 10px 42px 10px 12px;
border: 1px solid var(--border);
background-color: var(--panel);
background-image: var(--select-arrow);
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 14px 14px;
color: var(--text);
font: inherit;
max-width: 190px;
}
/* User create/edit form */
.user-form {
Mweb/src/api.rs
@@ -56,12 +56,16 @@ impl ApiError {
}
}
/// Server error body: `{"error": "...", "skipped": [...]?}`.
/// Server error body: `{"error": "...", "code": "..."?, "skipped": [...]?}`.
/// The message is already localized in [`fetch_checked`]; `code` carries the
/// machine-readable identifier the server sends for known failures.
#[derive(serde::Deserialize, Default)]
struct ErrBody {
#[serde(default)]
error: Option<String>,
#[serde(default)]
code: Option<String>,
#[serde(default)]
skipped: Option<Vec<String>>,
}
@@ -74,18 +78,27 @@ pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
}
/// PUT /api/auth/me — update the signed-in user's profile settings.
#[derive(Serialize)]
/// Omitted fields are left unchanged; `language: Some(None)` means "follow
/// the browser".
#[derive(Serialize, Default)]
struct ProfilePatch {
single_click_open: bool,
#[serde(skip_serializing_if = "Option::is_none")]
single_click_open: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
language: Option<Option<String>>,
}
pub fn update_profile(
single_click_open: bool,
single_click_open: Option<bool>,
language: Option<Option<String>>,
) -> impl std::future::Future<Output = Result<Me, ApiError>> {
request(
"PUT",
AUTH_ME.to_string(),
Some(ProfilePatch { single_click_open }),
Some(ProfilePatch {
single_click_open,
language,
}),
)
}
@@ -671,9 +684,15 @@ async fn fetch_checked(
let status = resp.status();
if !(200..300).contains(&status) {
let body = parse_error_body(&resp).await;
let fallback = body
.error
.clone()
.unwrap_or_else(|| fallback_msg.to_string());
return Err(ApiError::Http {
status,
message: body.error.unwrap_or_else(|| fallback_msg.to_string()),
// Known server errors carry a code the client maps to a
// localized message; unknown ones fall back to the raw text.
message: crate::i18n::error_text(body.code.as_deref(), &fallback),
skipped: body.skipped,
});
}
Mweb/src/app.rs
@@ -2,6 +2,7 @@ use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me};
use crate::i18n::{self, Locale};
use crate::router::{Location, parse_location};
#[derive(Clone, Copy, PartialEq, Debug)]
@@ -24,6 +25,23 @@ pub fn App() -> impl IntoView {
initial_theme.apply();
let (theme, set_theme) = signal(initial_theme);
// UI language: start from the browser's language; the signed-in user's
// profile setting (fetched below) overrides it via the effect.
i18n::init(Locale::from_browser().unwrap_or(Locale::En));
Effect::new(move |_| {
let lang = me.get().and_then(|m| m.user).and_then(|u| u.language);
let locale = match lang.as_deref().and_then(Locale::from_code) {
Some(l) => l,
// No stored preference (or an unsupported one): follow the browser.
None => Locale::from_browser().unwrap_or(Locale::En),
};
// Untracked read: comparing via `current()` would register a
// dependency on the signal we write below, retriggering the effect.
if i18n::current_untracked() != locale {
i18n::set_locale(locale);
}
});
// The URL hash is the source of truth for the location.
{
set_loc.set(parse_location());
@@ -62,7 +80,7 @@ pub fn App() -> impl IntoView {
match phase.get() {
AuthPhase::Loading => view! {
<div class="center-screen">
<p class="muted">"Loading…"</p>
<p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
</div>
}
.into_view()
Mweb/src/editor.rs
@@ -16,6 +16,7 @@ use crate::api::{self, ApiError};
use crate::cm;
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::i18n;
use crate::icons::IconName;
/// A text file the user wants to edit.
@@ -59,7 +60,7 @@ async fn do_save(
loaded_mtime.set_value(Some(new_mtime));
set_dirty.set(false);
set_conflict.set(false);
show(toast, "Saved");
show(toast, i18n::t(i18n::k::SAVED));
refresh.run(());
}
Err(ApiError::Conflict) => {
@@ -297,8 +298,8 @@ pub fn Editor(
<div class="cm-error-card">
<button
class="cm-error-close"
title="Dismiss"
aria-label="Dismiss error"
title={i18n::tr(i18n::k::DISMISS)}
aria-label={i18n::tr(i18n::k::DISMISS_ERROR)}
on:click=move |_| set_status.set(None)
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
@@ -321,16 +322,14 @@ pub fn Editor(
view! {
<div class="modal-overlay editor-sub-overlay" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
<div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
<h2 class="modal-title">"File changed on disk"</h2>
<p class="modal-message">
"This file was modified after you opened it. “Load latest” discards your unsaved changes; “Overwrite” keeps your version."
</p>
<h2 class="modal-title">{i18n::tr(i18n::k::FILE_CHANGED)}</h2>
<p class="modal-message">{i18n::tr(i18n::k::MODIFIED_AFTER)}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| load_latest_cb.run(())>
"Load latest"
{i18n::tr(i18n::k::LOAD_LATEST)}
</button>
<button class="btn btn-danger" on:click=move |_| overwrite_cb.run(())>
"Overwrite"
{i18n::tr(i18n::k::OVERWRITE)}
</button>
</div>
</div>
@@ -349,17 +348,17 @@ pub fn Editor(
view! {
<div class="modal-overlay editor-sub-overlay" on:click=move |_| set_confirm_discard.set(false)>
<div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
<h2 class="modal-title">"Unsaved changes"</h2>
<p class="modal-message">"Discard your changes and close the editor?"</p>
<h2 class="modal-title">{i18n::tr(i18n::k::UNSAVED_CHANGES)}</h2>
<p class="modal-message">{i18n::tr(i18n::k::DISCARD_QUESTION)}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| set_confirm_discard.set(false)>
"Keep editing"
{i18n::tr(i18n::k::KEEP_EDITING)}
</button>
<button class="btn btn-danger" on:click=move |_| {
set_confirm_discard.set(false);
close.run(());
}>
"Discard"
{i18n::tr(i18n::k::DISCARD)}
</button>
</div>
</div>
Aweb/src/i18n.rs
@@ -0,0 +1,1604 @@
//! Lightweight i18n: one central registry (in the spirit of `icons.rs`).
//!
//! `EN` is the canonical list of user-visible strings; it doubles as the key
//! table. `DE` and `FR` translate the same keys; a key missing from a
//! translation falls back to English.
//!
//! - `t(key)` resolves a key for the current locale. Non-reactive — for
//! toasts, event handlers, and one-shot constructions (dialogs).
//! - `tr(key)` is the same as a reactive closure, for `view!` children and
//! attributes, so switching the language re-renders in place.
//! - `error_text(code, fallback)` maps a server error *code* to a
//! translation; unknown or absent codes fall back to the server message.
//!
//! Parameterized strings carry exactly one `{}` placeholder; callers do
//! `format!(t(k::X), arg)`.
//!
//! Locale precedence: the signed-in user's profile setting (persisted
//! server-side), else the browser language. `init` + `set_locale` drive a
//! global signal so every `tr` subscriber updates.
use std::sync::OnceLock;
use leptos::prelude::*;
use wasm_bindgen::JsCast;
/// A supported UI language.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Locale {
En,
De,
Fr,
}
impl Locale {
pub const fn code(self) -> &'static str {
match self {
Locale::En => "en",
Locale::De => "de",
Locale::Fr => "fr",
}
}
/// Parse a BCP-47-ish tag ("de", "de-AT", "DE") into a supported locale.
pub fn from_code(code: &str) -> Option<Self> {
let lang = code.split(['-', '_']).next()?.to_ascii_lowercase();
match lang.as_str() {
"en" => Some(Locale::En),
"de" => Some(Locale::De),
"fr" => Some(Locale::Fr),
_ => None,
}
}
/// The browser's language, restricted to the supported locales.
pub fn from_browser() -> Option<Self> {
let lang = web_sys::window()?.navigator().language()?;
Self::from_code(&lang)
}
}
static READ: OnceLock<ReadSignal<Locale>> = OnceLock::new();
static WRITE: OnceLock<WriteSignal<Locale>> = OnceLock::new();
/// Called once during app setup, before the first UI render.
pub fn init(locale: Locale) {
let (read, write) = signal(locale);
let _ = READ.set(read);
let _ = WRITE.set(write);
apply_doc_lang(locale);
}
pub fn set_locale(locale: Locale) {
if let Some(w) = WRITE.get() {
w.set(locale);
}
apply_doc_lang(locale);
}
pub fn current() -> Locale {
READ.get().map(|r| r.get()).unwrap_or(Locale::En)
}
/// Like [`current`] but without registering a reactive dependency — for
/// code that reads the locale only to compare before writing (e.g. the
/// profile effect in `app.rs`), so the read cannot retrigger itself.
pub fn current_untracked() -> Locale {
READ.get().map(|r| r.get_untracked()).unwrap_or(Locale::En)
}
/// Keep `<html lang>` in sync for screen readers and the browser UI.
fn apply_doc_lang(locale: Locale) {
if let Some(html) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.document_element())
.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok())
{
let _ = html.set_attribute("lang", locale.code());
}
}
/// Resolve a key for the current locale (non-reactive).
pub fn t(key: &str) -> &'static str {
text(key, current())
}
/// Resolve a key for the current locale as a reactive closure, for use as a
/// `view!` child or attribute value.
pub fn tr(key: &str) -> impl Fn() -> String + Clone {
move || t(key).to_string()
}
/// Like [`t`], but substitutes a single `{}` placeholder in the translation
/// with `arg`.
pub fn t_fmt(key: &str, arg: &str) -> String {
t(key).replace("{}", arg)
}
/// Localize a server error: a known code maps to its translation, anything
/// else (no code, unknown code) falls back to the server's English message.
pub fn error_text(code: Option<&str>, fallback: &str) -> String {
let Some(code) = code.filter(|c| find(EN, c).is_some()) else {
return fallback.to_string();
};
t(code).to_string()
}
fn text(key: &str, locale: Locale) -> &'static str {
// Keys are compile-time constants from `k`; a miss is a bug (the unit
// tests pin the tables against each other).
match locale {
Locale::En => find(EN, key).expect("unknown i18n key"),
Locale::De => find(DE, key)
.or_else(|| find(EN, key))
.expect("unknown i18n key"),
Locale::Fr => find(FR, key)
.or_else(|| find(EN, key))
.expect("unknown i18n key"),
}
}
fn find(table: &'static [(&'static str, &'static str)], key: &str) -> Option<&'static str> {
table.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
}
/// Translation keys. English text is the value in `EN` below.
pub mod k {
// Empty states
pub const NO_FOLDERS_TITLE: &str = "no_folders_title";
pub const NO_FOLDERS_MSG: &str = "no_folders_msg";
pub const SELECT_FOLDER_TITLE: &str = "select_folder_title";
pub const SELECT_FOLDER_MSG: &str = "select_folder_msg";
pub const FOLDER_GONE_TITLE: &str = "folder_gone_title";
pub const FOLDER_GONE_MSG: &str = "folder_gone_msg";
pub const LOADING: &str = "loading";
pub const LOADING_SHARE: &str = "loading_share";
pub const RETRY: &str = "retry";
pub const EMPTY_FOLDER: &str = "empty_folder";
// Browser chrome
pub const REFRESH: &str = "refresh";
pub const GRID_VIEW: &str = "grid_view";
pub const LIST_VIEW: &str = "list_view";
// Entry actions
pub const OPEN: &str = "open";
pub const OPEN_IN_NEW_TAB: &str = "open_in_new_tab";
pub const DOWNLOAD: &str = "download";
pub const RENAME: &str = "rename";
pub const MOVE: &str = "move";
pub const COPY: &str = "copy";
pub const DONE: &str = "done";
pub const SHARE: &str = "share";
pub const INFO: &str = "info";
pub const DELETE: &str = "delete";
pub const NEW_FOLDER: &str = "new_folder";
pub const UPLOAD_FILES: &str = "upload_files";
pub const UPLOAD_FOLDER: &str = "upload_folder";
pub const READ_ONLY_HINT: &str = "read_only_hint";
pub const READ_ONLY_BADGE: &str = "read_only_badge";
pub const MORE_ACTIONS: &str = "more_actions";
// Dialogs: prompt / confirm
pub const FOLDER_NAME: &str = "folder_name";
pub const CREATE: &str = "create";
pub const RENAME_TITLE: &str = "rename_title";
pub const NEW_NAME: &str = "new_name";
pub const DELETE_FOLDER_MSG: &str = "delete_folder_msg";
pub const DELETE_MSG: &str = "delete_msg";
pub const CANCEL: &str = "cancel";
// Dialogs: conflict / picker / download format / info
pub const OVERWRITE_QUESTION: &str = "overwrite_question";
pub const OVERWRITE: &str = "overwrite";
pub const AND_MORE: &str = "and_more";
pub const NO_SUBFOLDERS: &str = "no_subfolders";
pub const MOVE_TO: &str = "move_to";
pub const COPY_TO: &str = "copy_to";
pub const READ_ONLY_ROOT: &str = "read_only_root";
pub const MOVE_HERE: &str = "move_here";
pub const COPY_HERE: &str = "copy_here";
pub const TOP: &str = "top";
pub const DOWNLOAD_TITLE: &str = "download_title";
pub const DOWNLOAD_FORMATS_MSG: &str = "download_formats_msg";
pub const FMT_ZIP: &str = "fmt_zip";
pub const FMT_TAR: &str = "fmt_tar";
pub const FMT_TARGZ: &str = "fmt_targz";
pub const FMT_TARZST: &str = "fmt_tarzst";
pub const FILE_INFO: &str = "file_info";
pub const NAME: &str = "name";
pub const TYPE: &str = "type";
pub const FOLDER: &str = "folder";
pub const FILE: &str = "file";
pub const SIZE: &str = "size";
pub const MODIFIED: &str = "modified";
pub const LOCATION: &str = "location";
pub const CLOSE: &str = "close";
// Toasts
pub const FOLDER_CREATED: &str = "folder_created";
pub const NAME_TAKEN: &str = "name_taken";
pub const RENAMED: &str = "renamed";
pub const DELETED: &str = "deleted";
pub const UPLOADING: &str = "uploading";
pub const SOME_FILES_EXIST: &str = "some_files_exist";
pub const UPLOAD_COMPLETE: &str = "upload_complete";
pub const MOVED: &str = "moved";
pub const COPIED: &str = "copied";
pub const DOWNLOAD_STARTED: &str = "download_started";
pub const SAVED: &str = "saved";
// Shell: sidebar, topbar, footer
pub const MENU: &str = "menu";
pub const OPEN_MENU: &str = "open_menu";
pub const REFRESH_FOLDERS: &str = "refresh_folders";
pub const THEME_TITLE: &str = "theme_title";
pub const LOG_OUT: &str = "log_out";
pub const FILES: &str = "files";
pub const MANAGE: &str = "manage";
pub const SHARES: &str = "shares";
pub const USERS: &str = "users";
pub const NO_USERS: &str = "no_users";
pub const SETTINGS: &str = "settings";
pub const THEME_AUTO: &str = "theme_auto";
pub const THEME_LIGHT: &str = "theme_light";
pub const THEME_DARK: &str = "theme_dark";
// Settings
pub const SAVING: &str = "saving";
pub const PROFILE: &str = "profile";
pub const SERVER: &str = "server";
pub const PROFILE_SAVED: &str = "profile_saved";
pub const PROFILE_SAVE_ERR: &str = "profile_save_err";
pub const SETTINGS_SAVED: &str = "settings_saved";
pub const SETTINGS_SAVE_ERR: &str = "settings_save_err";
pub const SINGLE_CLICK_LABEL: &str = "single_click_label";
pub const SINGLE_CLICK_DESC: &str = "single_click_desc";
pub const SAVE_PROFILE: &str = "save_profile";
pub const ALLOW_RW_SHARES: &str = "allow_rw_shares";
pub const ALLOW_RW_SHARES_DESC: &str = "allow_rw_shares_desc";
pub const SAVE_SETTINGS: &str = "save_settings";
pub const LANGUAGE: &str = "language";
pub const LANGUAGE_DESC: &str = "language_desc";
pub const LANG_AUTO: &str = "lang_auto";
pub const LANG_EN: &str = "lang_en";
pub const LANG_DE: &str = "lang_de";
pub const LANG_FR: &str = "lang_fr";
// Admin: users
pub const DELETE_USER_CONFIRM: &str = "delete_user_confirm";
pub const USER_DELETE_ERR: &str = "user_delete_err";
pub const NEW_USER: &str = "new_user";
pub const EDIT: &str = "edit";
pub const DELETE_USER: &str = "delete_user";
pub const CANT_DELETE_SELF: &str = "cant_delete_self";
pub const ROOT_DUP_ERR: &str = "root_dup_err";
pub const NAME_REQUIRED: &str = "name_required";
pub const PW_SHORT: &str = "pw_short";
pub const NEED_FOLDER: &str = "need_folder";
pub const USER_CREATED: &str = "user_created";
pub const USER_UPDATED: &str = "user_updated";
pub const USER_SAVE_ERR: &str = "user_save_err";
pub const PASSWORD: &str = "password";
pub const NEW_PASSWORD_KEEP: &str = "new_password_keep";
pub const ADMINISTRATOR: &str = "administrator";
pub const ACTIVE_CAN_SIGNIN: &str = "active_can_signin";
pub const REMOVE_FOLDER: &str = "remove_folder";
pub const CREATE_USER: &str = "create_user";
pub const EDIT_USER_TITLE: &str = "edit_user_title";
pub const FOLDERS: &str = "folders";
pub const FOLDERS_PATHS_HINT: &str = "folders_paths_hint";
pub const ADD: &str = "add";
pub const MODE_RW: &str = "mode_rw";
pub const EXPIRY_1H: &str = "expiry_1h";
pub const EXPIRY_1D: &str = "expiry_1d";
pub const EXPIRY_1W: &str = "expiry_1w";
pub const EXPIRY_1MO: &str = "expiry_1mo";
pub const META_EXPIRES: &str = "meta_expires";
pub const META_CREATED: &str = "meta_created";
pub const MODE_RO: &str = "mode_ro";
// Share page (public)
pub const SHARE_NOT_FOUND: &str = "share_not_found";
pub const SHARE_NOT_FOUND_MSG: &str = "share_not_found_msg";
pub const SHARE_EXPIRED: &str = "share_expired";
pub const SHARE_EXPIRED_MSG: &str = "share_expired_msg";
// Shares: dialog + management
pub const COPY_MANUAL: &str = "copy_manual";
pub const LINK_COPIED: &str = "link_copied";
pub const SHARE_CREATE_ERR: &str = "share_create_err";
pub const SHARE_LINK: &str = "share_link";
pub const SHARE_PERM_RW: &str = "share_perm_rw";
pub const SHARE_PERM_RO: &str = "share_perm_ro";
pub const SHARE_TITLE: &str = "share_title";
pub const SHARE_DIALOG_HINT: &str = "share_dialog_hint";
pub const EXPIRES: &str = "expires";
pub const NEVER: &str = "never";
pub const CUSTOM: &str = "custom";
pub const UNTIL: &str = "until";
pub const EXPIRES_AT: &str = "expires_at";
pub const DELETE_PARTIAL: &str = "delete_partial";
pub const NEVER_EXPIRES: &str = "never_expires";
pub const ALLOW_EDITING: &str = "allow_editing";
pub const RW_DISABLED: &str = "rw_disabled";
pub const RW_RO_FOLDER: &str = "rw_ro_folder";
pub const CREATING: &str = "creating";
pub const CREATE_LINK: &str = "create_link";
pub const YOUR_SHARES: &str = "your_shares";
pub const DELETED_N_SHARES: &str = "deleted_n_shares";
pub const SHARES_DELETE_ERR: &str = "shares_delete_err";
pub const DELETE_ALL: &str = "delete_all";
pub const NO_SHARES: &str = "no_shares";
pub const SHARES_VIEW_HINT: &str = "shares_view_hint";
pub const SHARES_EMPTY_HINT: &str = "shares_empty_hint";
pub const COPY_LINK: &str = "copy_link";
pub const DELETE_SHARE: &str = "delete_share";
pub const SHARE_DELETED: &str = "share_deleted";
pub const SHARE_DELETE_ERR: &str = "share_delete_err";
pub const DELETE_ALL_TITLE: &str = "delete_all_title";
pub const DELETE_ALL_MSG: &str = "delete_all_msg";
// Login / setup
pub const LOGIN_ERROR: &str = "login_error";
pub const LOGIN_SUBTITLE: &str = "login_subtitle";
pub const SIGN_IN: &str = "sign_in";
pub const SETUP_NAME_ERR: &str = "setup_name_err";
pub const SETUP_PW_MISMATCH: &str = "setup_pw_mismatch";
pub const REPEAT_PASSWORD: &str = "repeat_password";
pub const SETUP_INTRO: &str = "setup_intro";
pub const CREATE_ADMIN: &str = "create_admin";
// File view / editor
pub const SAVE: &str = "save";
pub const CLOSE_ESC: &str = "close_esc";
pub const NO_PREVIEW: &str = "no_preview";
pub const NO_PREVIEW_MSG: &str = "no_preview_msg";
pub const DISMISS: &str = "dismiss";
pub const DISMISS_ERROR: &str = "dismiss_error";
pub const FILE_CHANGED: &str = "file_changed";
pub const MODIFIED_AFTER: &str = "modified_after";
pub const LOAD_LATEST: &str = "load_latest";
pub const KEEP_EDITING: &str = "keep_editing";
pub const DISCARD: &str = "discard";
pub const UNSAVED_CHANGES: &str = "unsaved_changes";
pub const DISCARD_QUESTION: &str = "discard_question";
#[allow(dead_code)]
// Server error codes (see server/src for the constructors)
pub const ERR_INTERNAL: &str = "err_internal";
pub const ERR_NOT_SIGNED_IN: &str = "err_not_signed_in";
pub const ERR_SESSION_EXPIRED: &str = "err_session_expired";
pub const ERR_INVALID_CREDENTIALS: &str = "err_invalid_credentials";
pub const ERR_ALREADY_SET_UP: &str = "err_already_set_up";
pub const ERR_SHARE_NOT_FOUND: &str = "err_share_not_found";
pub const ERR_SHARE_EXPIRED: &str = "err_share_expired";
pub const ERR_SHARE_TOKEN_FORBIDDEN: &str = "err_share_token_forbidden";
pub const ERR_NAME_LENGTH: &str = "err_name_length";
pub const ERR_PASSWORD_SHORT: &str = "err_password_short";
pub const ERR_ADMIN_ONLY: &str = "err_admin_only";
pub const ERR_USER_EXISTS: &str = "err_user_exists";
pub const ERR_USER_NOT_FOUND: &str = "err_user_not_found";
pub const ERR_OWN_ADMIN: &str = "err_own_admin";
pub const ERR_OWN_ACCOUNT: &str = "err_own_account";
pub const ERR_LAST_ADMIN: &str = "err_last_admin";
pub const ERR_OWN_DELETE: &str = "err_own_delete";
pub const ERR_LAST_ADMIN_DELETE: &str = "err_last_admin_delete";
pub const ERR_BAD_FORMAT: &str = "err_bad_format";
pub const ERR_NOT_A_FILE: &str = "err_not_a_file";
pub const ERR_TOO_LARGE_PREVIEW: &str = "err_too_large_preview";
pub const ERR_BAD_ACTION: &str = "err_bad_action";
pub const ERR_TOO_LARGE_SAVE: &str = "err_too_large_save";
pub const ERR_BAD_BODY: &str = "err_bad_body";
pub const ERR_INVALID_LANGUAGE: &str = "err_invalid_language";
pub const ERR_BAD_POST: &str = "err_bad_post";
pub const ERR_FOLDER_NAME_REQUIRED: &str = "err_folder_name_required";
pub const ERR_NEW_NAME_REQUIRED: &str = "err_new_name_required";
pub const ERR_DST_REQUIRED: &str = "err_dst_required";
pub const ERR_PATH_REQUIRED: &str = "err_path_required";
pub const ERR_BAD_MULTIPART: &str = "err_bad_multipart";
pub const ERR_BAD_UPLOAD: &str = "err_bad_upload";
pub const ERR_PART_NO_NAME: &str = "err_part_no_name";
pub const ERR_BAD_PART_NAME: &str = "err_bad_part_name";
pub const ERR_FOLDER_EXISTS: &str = "err_folder_exists";
pub const ERR_SAVE_FAILED: &str = "err_save_failed";
pub const ERR_NO_FILES_UPLOADED: &str = "err_no_files_uploaded";
pub const ERR_FILES_EXIST: &str = "err_files_exist";
pub const ERR_BAD_UPLOAD_PATH: &str = "err_bad_upload_path";
pub const ERR_NO_SUCH_FOLDER: &str = "err_no_such_folder";
pub const ERR_READ_ONLY_FOLDER: &str = "err_read_only_folder";
pub const ERR_RW_SHARES_DISABLED: &str = "err_rw_shares_disabled";
pub const ERR_RW_RO_FOLDER: &str = "err_rw_ro_folder";
pub const ERR_FS_NOT_FOUND: &str = "err_fs_not_found";
pub const ERR_FS_NOT_A_DIR: &str = "err_fs_not_a_dir";
pub const ERR_FS_FORBIDDEN: &str = "err_fs_forbidden";
pub const ERR_FS_ROOT_MISSING: &str = "err_fs_root_missing";
pub const ERR_FS_CONFLICT: &str = "err_fs_conflict";
}
/// Canonical English strings (key → text). The source of truth for which
/// keys exist; DE and FR may be partial and fall back to English.
const EN: &[(&str, &str)] = &[
("no_folders_title", "No folders available"),
(
"no_folders_msg",
"An administrator hasn't assigned you any folders yet.",
),
("select_folder_title", "Select a folder"),
(
"select_folder_msg",
"Choose a folder from the sidebar to start browsing.",
),
("folder_gone_title", "Folder not available"),
(
"folder_gone_msg",
"This folder is no longer accessible. Choose another one from the sidebar.",
),
("loading", "Loading…"),
("loading_share", "Loading share…"),
("retry", "Retry"),
("empty_folder", "This folder is empty."),
("refresh", "Refresh"),
("grid_view", "Grid view"),
("list_view", "List view"),
("open", "Open"),
("open_in_new_tab", "Open in new tab"),
("download", "Download"),
("rename", "Rename"),
("move", "Move"),
("copy", "Copy"),
("share", "Share"),
("info", "Info"),
("delete", "Delete"),
("new_folder", "New folder"),
("upload_files", "Upload files"),
("upload_folder", "Upload folder"),
("read_only_hint", "Read-only folder"),
("read_only_badge", "Read-only"),
("more_actions", "More actions"),
("folder_name", "Folder name"),
("create", "Create"),
("rename_title", "Rename “{}”"),
("new_name", "New name"),
(
"delete_folder_msg",
"Delete folder “{}” and everything inside it?\nThis cannot be undone.",
),
("delete_msg", "Delete “{}”?\nThis cannot be undone."),
("cancel", "Cancel"),
(
"overwrite_question",
"The following files already exist. Overwrite them?",
),
("overwrite", "Overwrite"),
("and_more", "…and {} more"),
(
"no_subfolders",
"No subfolders — the file will be placed in the current folder.",
),
("move_to", "Move “{}” to…"),
("copy_to", "Copy “{}” to…"),
("read_only_root", "{} (read-only)"),
("move_here", "Move here"),
("copy_here", "Copy here"),
("top", "top"),
("download_title", "Download “{}”"),
(
"download_formats_msg",
"Choose an archive format for the folder.",
),
("fmt_zip", "Zip"),
("fmt_tar", "Tar"),
("fmt_targz", "Tar + gzip"),
("fmt_tarzst", "Tar + zstd"),
("file_info", "File info"),
("name", "Name"),
("type", "Type"),
("folder", "Folder"),
("file", "File"),
("size", "Size"),
("modified", "Modified"),
("location", "Location"),
("close", "Close"),
("folder_created", "Folder created"),
("name_taken", "Name already in use"),
("renamed", "Renamed"),
("deleted", "Deleted"),
("uploading", "Uploading {} files…"),
("some_files_exist", "Some files already exist"),
("upload_complete", "Upload complete"),
("moved", "Moved"),
("copied", "Copied"),
("download_started", "Download started"),
("saved", "Saved"),
("menu", "Menu"),
("open_menu", "Open menu"),
("refresh_folders", "Refresh your folders"),
("theme_title", "Theme: {} (click to switch)"),
("log_out", "Log out"),
("files", "Files"),
("manage", "Manage"),
("shares", "Shares"),
("users", "Users"),
("no_users", "No users."),
("settings", "Settings"),
("theme_auto", "Auto"),
("theme_light", "Light"),
("theme_dark", "Dark"),
("saving", "Saving…"),
("profile", "Profile"),
("server", "Server"),
("profile_saved", "Profile saved"),
("profile_save_err", "Could not save profile: {}"),
("settings_saved", "Settings saved"),
("settings_save_err", "Could not save settings: {}"),
("single_click_label", "Use single click to open"),
(
"single_click_desc",
"When on, a single click opens an entry and Ctrl+click selects it. When off, a single click selects and a double click opens.",
),
("save_profile", "Save profile"),
("allow_rw_shares", "Allow writable shares"),
(
"allow_rw_shares_desc",
"Let users create read-write share links. Off by default.",
),
("save_settings", "Save settings"),
("language", "Language"),
(
"language_desc",
"The interface language. Automatic uses your browser's language.",
),
("lang_auto", "Automatic"),
("lang_en", "English"),
("lang_de", "German"),
("lang_fr", "French"),
(
"delete_user_confirm",
"Delete user “{}”? This cannot be undone.",
),
("user_delete_err", "Could not delete user"),
("new_user", "New user"),
("edit", "Edit"),
("delete_user", "Delete user"),
("cant_delete_self", "You cannot delete yourself"),
("root_dup_err", "That folder is already in the list."),
("name_required", "A name is required."),
("pw_short", "Password must be at least 8 characters."),
("need_folder", "Give the user at least one folder."),
("user_created", "User created"),
("user_updated", "User updated"),
("user_save_err", "Could not save user: {}"),
("password", "Password"),
("new_password_keep", "New password (leave blank to keep)"),
("administrator", "Administrator"),
("active_can_signin", "Active (can sign in)"),
("remove_folder", "Remove folder"),
("create_user", "Create user"),
("save", "Save"),
("edit_user_title", "Edit “{}”"),
("folders", "Folders"),
(
"folders_paths_hint",
"Paths are relative to the server root. “.” is the whole root.",
),
("add", "Add"),
("mode_rw", "read-write"),
("expiry_1h", "1 hour"),
("expiry_1d", "1 day"),
("expiry_1w", "1 week"),
("expiry_1mo", "1 month"),
("meta_expires", "expires {}"),
("meta_created", "created {}"),
("mode_ro", "read-only"),
("share_not_found", "Share not found"),
(
"share_not_found_msg",
"This link is invalid or the share was removed.",
),
("share_expired", "Share expired"),
("share_expired_msg", "This share link is no longer active."),
("copy_manual", "Copy the link manually"),
("link_copied", "Link copied to clipboard"),
("share_create_err", "Could not create share: {}"),
("share_link", "Share link"),
(
"share_perm_rw",
"Anyone with this link can read and write “{}”.",
),
("share_perm_ro", "Anyone with this link can read “{}”."),
("share_title", "Share {}"),
(
"share_dialog_hint",
"Create a link for this item. Writable links let viewers upload and edit.",
),
("expires", "Expires"),
("never", "Never"),
("custom", "Custom…"),
("until", "Until"),
("expires_at", "Expires {}"),
("delete_partial", "{} of {} shares could not be deleted"),
("never_expires", "Never expires"),
("allow_editing", "Allow editing (read-write)"),
("rw_disabled", "Writable shares are disabled by the server."),
(
"rw_ro_folder",
"This folder is read-only, so the share is read-only too.",
),
("creating", "Creating…"),
("create_link", "Create link"),
("your_shares", "Your shares"),
("deleted_n_shares", "Deleted {} shares"),
("shares_delete_err", "Could not delete shares"),
("delete_all", "Delete all"),
("no_shares", "No shares yet"),
(
"shares_view_hint",
"Links you created. Right-click a file or folder and choose “Share” to add one.",
),
(
"shares_empty_hint",
"Right-click a file or folder and choose “Share” to create a link.",
),
("copy_link", "Copy link"),
("delete_share", "Delete share"),
("share_deleted", "Share deleted"),
("share_delete_err", "Could not delete share"),
("delete_all_title", "Delete all shares?"),
(
"delete_all_msg",
"Delete all {} share links? Anyone holding them loses access. This cannot be undone.",
),
("login_subtitle", "Sign in to continue."),
("login_error", "Please enter your name and password."),
("sign_in", "Sign in"),
("setup_name_err", "Please choose a name (1–64 characters)."),
("setup_pw_mismatch", "Passwords don't match."),
("repeat_password", "Repeat password"),
("create_admin", "Create admin account"),
(
"setup_intro",
"First boot: create the admin account. This user can manage other users and server settings.",
),
("close_esc", "Close (Esc)"),
("no_preview", "No preview available"),
("no_preview_msg", "The app can’t display “{}” here."),
("dismiss", "Dismiss"),
("dismiss_error", "Dismiss error"),
("file_changed", "File changed on disk"),
(
"modified_after",
"This file was modified after you opened it. “Load latest” discards your unsaved changes; “Overwrite” keeps your version.",
),
("load_latest", "Load latest"),
("keep_editing", "Keep editing"),
("discard", "Discard"),
("unsaved_changes", "Unsaved changes"),
(
"discard_question",
"Discard your changes and close the editor?",
),
(k::ERR_INTERNAL, "internal error"),
(k::ERR_NOT_SIGNED_IN, "not signed in"),
(
k::ERR_SESSION_EXPIRED,
"session expired, please sign in again",
),
(k::ERR_INVALID_CREDENTIALS, "invalid name or password"),
(k::ERR_ALREADY_SET_UP, "server is already set up"),
(k::ERR_SHARE_NOT_FOUND, "share not found"),
(k::ERR_SHARE_EXPIRED, "this share has expired"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"a share token cannot be used here; sign in instead",
),
(k::ERR_NAME_LENGTH, "name must be 1–64 characters"),
(
k::ERR_PASSWORD_SHORT,
"password must be at least 8 characters",
),
(k::ERR_ADMIN_ONLY, "admin only"),
(k::ERR_USER_EXISTS, "a user with that name already exists"),
(k::ERR_USER_NOT_FOUND, "user not found"),
(k::ERR_OWN_ADMIN, "you cannot remove your own admin rights"),
(k::ERR_OWN_ACCOUNT, "you cannot disable your own account"),
(k::ERR_LAST_ADMIN, "cannot remove the last active admin"),
(k::ERR_OWN_DELETE, "you cannot delete your own account"),
(
k::ERR_LAST_ADMIN_DELETE,
"cannot delete the last active admin",
),
(
k::ERR_BAD_FORMAT,
"format must be one of: zip, tar, tar.gz, tar.zst",
),
(k::ERR_NOT_A_FILE, "not a file"),
(k::ERR_TOO_LARGE_PREVIEW, "file too large to preview"),
(k::ERR_BAD_ACTION, "expected action=content"),
(k::ERR_TOO_LARGE_SAVE, "file too large to save"),
(k::ERR_BAD_BODY, "invalid request body"),
(k::ERR_INVALID_LANGUAGE, "invalid language tag"),
(
k::ERR_BAD_POST,
"POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
),
(k::ERR_FOLDER_NAME_REQUIRED, "a folder name is required"),
(k::ERR_NEW_NAME_REQUIRED, "new_name is required"),
(k::ERR_DST_REQUIRED, "dst_root_id is required"),
(k::ERR_PATH_REQUIRED, "a path inside the folder is required"),
(
k::ERR_BAD_MULTIPART,
"expected multipart/form-data with a boundary",
),
(k::ERR_BAD_UPLOAD, "invalid upload data"),
(k::ERR_PART_NO_NAME, "part without a name"),
(k::ERR_BAD_PART_NAME, "invalid part name"),
(
k::ERR_FOLDER_EXISTS,
"a folder with this name already exists",
),
(k::ERR_SAVE_FAILED, "could not save the file"),
(k::ERR_NO_FILES_UPLOADED, "no files were uploaded"),
(k::ERR_FILES_EXIST, "some files already exist"),
(k::ERR_BAD_UPLOAD_PATH, "invalid file path in upload"),
(k::ERR_NO_SUCH_FOLDER, "no such folder"),
(k::ERR_READ_ONLY_FOLDER, "read-only folder"),
(k::ERR_RW_SHARES_DISABLED, "writable shares are disabled"),
(
k::ERR_RW_RO_FOLDER,
"this folder is read-only for you, so it cannot be shared writably",
),
(k::ERR_FS_NOT_FOUND, "folder not found"),
(k::ERR_FS_NOT_A_DIR, "not a folder"),
(k::ERR_FS_FORBIDDEN, "access denied"),
(
k::ERR_FS_ROOT_MISSING,
"the configured folder no longer exists",
),
(k::ERR_FS_CONFLICT, "already exists"),
];
/// German translations.
const DE: &[(&str, &str)] = &[
("no_folders_title", "Keine Ordner verfügbar"),
(
"no_folders_msg",
"Ein Administrator hat Ihnen noch keine Ordner zugewiesen.",
),
("select_folder_title", "Ordner auswählen"),
(
"select_folder_msg",
"Wählen Sie links einen Ordner aus, um mit dem Durchsuchen zu beginnen.",
),
("folder_gone_title", "Ordner nicht verfügbar"),
(
"folder_gone_msg",
"Dieser Ordner ist nicht mehr verfügbar. Wählen Sie links einen anderen aus.",
),
("loading", "Lade…"),
("loading_share", "Freigabe wird geladen…"),
("retry", "Erneut versuchen"),
("empty_folder", "Dieser Ordner ist leer."),
("refresh", "Aktualisieren"),
("grid_view", "Kachelansicht"),
("list_view", "Liste"),
("open", "Öffnen"),
("open_in_new_tab", "In neuem Tab öffnen"),
("download", "Herunterladen"),
("rename", "Umbenennen"),
("move", "Verschieben"),
("copy", "Kopieren"),
("share", "Teilen"),
("info", "Info"),
("delete", "Löschen"),
("new_folder", "Neuer Ordner"),
("upload_files", "Dateien hochladen"),
("upload_folder", "Ordner hochladen"),
("read_only_hint", "Schreibgeschützter Ordner"),
("read_only_badge", "Schreibgeschützt"),
("more_actions", "Weitere Aktionen"),
("folder_name", "Ordnername"),
("create", "Erstellen"),
("rename_title", "„{}“ umbenennen"),
("new_name", "Neuer Name"),
(
"delete_folder_msg",
"Ordner „{}“ mit allem Inhalt löschen?\nDas kann nicht rückgängig gemacht werden.",
),
(
"delete_msg",
"Lose „{}“ löschen?\nDas kann nicht rückgängig gemacht werden.",
),
("cancel", "Abbrechen"),
(
"overwrite_question",
"Die folgenden Dateien existieren bereits. Überschreiben?",
),
("overwrite", "Überschreiben"),
("and_more", "…und {} weitere"),
(
"no_subfolders",
"Keine Unterordner – die Datei wird im aktuellen Ordner abgelegt.",
),
("move_to", "„{}“ verschieben nach…"),
("copy_to", "„{}“ kopieren nach…"),
("read_only_root", "{} (nur lesen)"),
("move_here", "Hierher verschieben"),
("copy_here", "Hierher kopieren"),
("top", "oben"),
("download_title", "„{}“ herunterladen"),
(
"download_formats_msg",
"Archivformat für den Ordner wählen.",
),
("fmt_zip", "Zip"),
("fmt_tar", "Tar"),
("fmt_targz", "Tar + gzip"),
("fmt_tarzst", "Tar + zstd"),
("file_info", "Datei-Info"),
("name", "Name"),
("type", "Typ"),
("folder", "Ordner"),
("file", "Datei"),
("size", "Größe"),
("modified", "Geändert"),
("location", "Ort"),
("close", "Schließen"),
("folder_created", "Ordner erstellt"),
("name_taken", "Name wird bereits verwendet"),
("renamed", "Umbenannt"),
("deleted", "Gelöscht"),
("uploading", "{} Datei(en) werden hochgeladen…"),
("some_files_exist", "Einige Dateien existieren bereits"),
("upload_complete", "Hochladen abgeschlossen"),
("moved", "Verschoben"),
("copied", "Kopiert"),
("download_started", "Download gestartet"),
("saved", "Gespeichert"),
("menu", "Menü"),
("open_menu", "Menü öffnen"),
("refresh_folders", "Ordner aktualisieren"),
("theme_title", "Design: {} (klicken zum Wechseln)"),
("log_out", "Abmelden"),
("files", "Dateien"),
("manage", "Verwalten"),
("shares", "Teilen"),
("users", "Benutzer"),
("no_users", "Keine Benutzer."),
("settings", "Einstellungen"),
("theme_auto", "Auto"),
("theme_light", "Hell"),
("theme_dark", "Dunkel"),
("saving", "Wird gespeichert…"),
("profile", "Profil"),
("server", "Server"),
("profile_saved", "Profil gespeichert"),
(
"profile_save_err",
"Profil konnte nicht gespeichert werden: {}",
),
("settings_saved", "Einstellungen gespeichert"),
(
"settings_save_err",
"Einstellungen konnten nicht gespeichert werden: {}",
),
("single_click_label", "Einzelklick öffnet Einträge"),
(
"single_click_desc",
"Wenn aktiv, öffnet ein einfacher Klick einen Eintrag und Strg+Klick wählt ihn aus. Wenn inaktiv, wählt ein einfacher Klick aus und ein Doppelklick öffnet.",
),
("save_profile", "Profil speichern"),
("allow_rw_shares", "Schreibbare Links erlauben"),
(
"allow_rw_shares_desc",
"Benutzern erlauben, schreibbare Freigabelinks zu erstellen. Standardmäßig aus.",
),
("save_settings", "Einstellungen speichern"),
("language", "Sprache"),
(
"language_desc",
"Die Sprache der Benutzeroberfläche. „Automatisch“ verwendet die Sprache Ihres Browsers.",
),
("lang_auto", "Automatisch"),
("lang_en", "Englisch"),
("lang_de", "Deutsch"),
("lang_fr", "Französisch"),
(
"delete_user_confirm",
"Benutzer „{}“ löschen? Das kann nicht rückgängig gemacht werden.",
),
("user_delete_err", "Benutzer konnte nicht gelöscht werden"),
("new_user", "Neuer Benutzer"),
("edit", "Bearbeiten"),
("delete_user", "Benutzer löschen"),
("cant_delete_self", "Sie können sich nicht selbst löschen"),
("root_dup_err", "Dieser Ordner ist bereits in der Liste."),
("name_required", "Ein Name ist erforderlich."),
(
"pw_short",
"Das Passwort muss mindestens 8 Zeichen lang sein.",
),
(
"need_folder",
"Dem Benutzer muss mindestens ein Ordner zugewiesen werden.",
),
("user_created", "Benutzer erstellt"),
("user_updated", "Benutzer aktualisiert"),
(
"user_save_err",
"Benutzer konnte nicht gespeichert werden: {}",
),
("password", "Passwort"),
(
"new_password_keep",
"Neues Passwort (leer lassen, um beizubehalten)",
),
("administrator", "Administrator"),
("active_can_signin", "Aktiv (kann sich anmelden)"),
("remove_folder", "Ordner entfernen"),
("create_user", "Benutzer erstellen"),
("save", "Speichern"),
("edit_user_title", "„{}“ bearbeiten"),
("folders", "Ordner"),
(
"folders_paths_hint",
"Pfade sind relativ zur Serverwurzel. „.“ ist der gesamte Wurzelordner.",
),
("add", "Hinzufügen"),
("mode_rw", "lesen/schreiben"),
("expiry_1h", "1 Stunde"),
("expiry_1d", "1 Tag"),
("expiry_1w", "1 Woche"),
("expiry_1mo", "1 Monat"),
("meta_expires", "läuft ab am {}"),
("meta_created", "erstellt am {}"),
("mode_ro", "nur lesen"),
("share_not_found", "Link nicht gefunden"),
(
"share_not_found_msg",
"Dieser Link ist ungültig oder die Freigabe wurde entfernt.",
),
("share_expired", "Link abgelaufen"),
("share_expired_msg", "Dieser Link ist nicht mehr aktiv."),
("copy_manual", "Link manuell kopieren"),
("link_copied", "Link in die Zwischenablage kopiert"),
("share_create_err", "Link konnte nicht erstellt werden: {}"),
("share_link", "Teil-Link"),
(
"share_perm_rw",
"Jeder mit diesem Link kann „{}“ lesen und schreiben.",
),
("share_perm_ro", "Jeder mit diesem Link kann „{}“ lesen."),
("share_title", "{} teilen"),
(
"share_dialog_hint",
"Erstellt einen Link für diesen Eintrag. Mit schreibbaren Links können Empfänger Dateien hochladen und bearbeiten.",
),
("expires", "Läuft ab"),
("never", "Nie"),
("custom", "Eigentlich…"),
("until", "Bis"),
("expires_at", "Läuft {} ab"),
(
"delete_partial",
"{} von {} Links konnten nicht gelöscht werden",
),
("never_expires", "Läuft nie ab"),
("allow_editing", "Bearbeitung erlauben (lesen/schreiben)"),
(
"rw_disabled",
"Schreibbare Freigaben sind vom Server deaktiviert.",
),
(
"rw_ro_folder",
"Dieser Ordner ist schreibgeschützt, daher ist die Freigabe auch schreibgeschützt.",
),
("creating", "Wird erstellt…"),
("create_link", "Link erstellen"),
("your_shares", "Ihre Links"),
("deleted_n_shares", "{} Link(s) gelöscht"),
("shares_delete_err", "Links konnten nicht gelöscht werden"),
("delete_all", "Alle löschen"),
("no_shares", "Noch keine Links"),
(
"shares_view_hint",
"Von Ihnen erstellte Links. Klicken Sie mit der rechten Maustaste auf eine Datei oder einen Ordner und wählen Sie „Teilen“, um einen hinzuzufügen.",
),
(
"shares_empty_hint",
"Klicken Sie mit der rechten Maustaste auf eine Datei oder einen Ordner und wählen Sie „Teilen“, um einen Link zu erstellen.",
),
("copy_link", "Link kopieren"),
("delete_share", "Link löschen"),
("share_deleted", "Link gelöscht"),
("share_delete_err", "Link konnte nicht gelöscht werden"),
("delete_all_title", "Alle Links löschen?"),
(
"delete_all_msg",
"Alle {} Freigabelinks löschen? Jeder, der sie besitzt, verliert den Zugriff. Das kann nicht rückgängig gemacht werden.",
),
("login_subtitle", "Anmelden, um fortzufahren."),
("login_error", "Bitte Name und Passwort eingeben."),
("sign_in", "Anmelden"),
(
"setup_name_err",
"Bitte wählen Sie einen Namen (1–64 Zeichen).",
),
("setup_pw_mismatch", "Die Passwörter stimmen nicht überein."),
("repeat_password", "Passwort wiederholen"),
("create_admin", "Administrator-Konto erstellen"),
(
"setup_intro",
"Erster Start: Erstellen Sie das Administrator-Konto. Dieser Benutzer kann andere Benutzer und die Servereinstellungen verwalten.",
),
("close_esc", "Schließen (Esc)"),
("no_preview", "Keine Vorschau verfügbar"),
("no_preview_msg", "Die App kann „{}“ hier nicht anzeigen."),
("dismiss", "Schließen"),
("dismiss_error", "Fehler schließen"),
("file_changed", "Datei auf der Festplatte geändert"),
(
"modified_after",
"Diese Datei wurde nach dem Öffnen geändert. „Neueste laden“ verwirft Ihre ungespeicherten Änderungen; „Überschreiben“ behält Ihre Version bei.",
),
("load_latest", "Neueste Version laden"),
("keep_editing", "Weiter bearbeiten"),
("discard", "Verwerfen"),
("unsaved_changes", "Ungespeicherte Änderungen"),
(
"discard_question",
"Änderungen verwerfen und den Editor schließen?",
),
(k::ERR_INTERNAL, "interner Fehler"),
(k::ERR_NOT_SIGNED_IN, "nicht angemeldet"),
(
k::ERR_SESSION_EXPIRED,
"Sitzung abgelaufen, bitte erneut anmelden",
),
(k::ERR_INVALID_CREDENTIALS, "Name oder Passwort ungültig"),
(k::ERR_ALREADY_SET_UP, "Server ist bereits eingerichtet"),
(k::ERR_SHARE_NOT_FOUND, "Link nicht gefunden"),
(k::ERR_SHARE_EXPIRED, "dieser Link ist abgelaufen"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"ein Freigabetoken kann hier nicht verwendet werden; bitte anmelden",
),
(k::ERR_NAME_LENGTH, "der Name muss 1–64 Zeichen haben"),
(
k::ERR_PASSWORD_SHORT,
"das Passwort muss mindestens 8 Zeichen lang sein",
),
(k::ERR_ADMIN_ONLY, "nur für Administratoren"),
(
k::ERR_USER_EXISTS,
"ein Benutzer mit diesem Namen existiert bereits",
),
(k::ERR_USER_NOT_FOUND, "Benutzer nicht gefunden"),
(
k::ERR_OWN_ADMIN,
"Sie können Ihre eigenen Administratorrechte nicht entfernen",
),
(
k::ERR_OWN_ACCOUNT,
"Sie können Ihr eigenes Konto nicht deaktivieren",
),
(
k::ERR_LAST_ADMIN,
"der letzte aktive Administrator kann nicht entfernt werden",
),
(
k::ERR_OWN_DELETE,
"Sie können Ihr eigenes Konto nicht löschen",
),
(
k::ERR_LAST_ADMIN_DELETE,
"der letzte aktive Administrator kann nicht gelöscht werden",
),
(
k::ERR_BAD_FORMAT,
"das Format muss eines der folgenden sein: zip, tar, tar.gz, tar.zst",
),
(k::ERR_NOT_A_FILE, "keine Datei"),
(k::ERR_TOO_LARGE_PREVIEW, "Datei zu groß für eine Vorschau"),
(k::ERR_BAD_ACTION, "action=content erwartet"),
(k::ERR_TOO_LARGE_SAVE, "Datei zu groß zum Speichern"),
(k::ERR_INVALID_LANGUAGE, "ungültiges Sprachkürzel"),
(k::ERR_BAD_BODY, "ungültiger Anfrageinhalt"),
(
k::ERR_BAD_POST,
"POST erwartet einen Multipart-Upload, eine JSON-Mutation oder ?action=mkdir",
),
(
k::ERR_FOLDER_NAME_REQUIRED,
"ein Ordnernamen ist erforderlich",
),
(k::ERR_NEW_NAME_REQUIRED, "new_name ist erforderlich"),
(k::ERR_DST_REQUIRED, "dst_root_id ist erforderlich"),
(
k::ERR_PATH_REQUIRED,
"ein Pfad innerhalb des Ordners ist erforderlich",
),
(
k::ERR_BAD_MULTIPART,
"multipart/form-data mit Boundary erwartet",
),
(k::ERR_BAD_UPLOAD, "ungültige Upload-Daten"),
(k::ERR_PART_NO_NAME, "Part ohne Namen"),
(k::ERR_BAD_PART_NAME, "ungültiger Part-Name"),
(
k::ERR_FOLDER_EXISTS,
"ein Ordner mit diesem Namen existiert bereits",
),
(
k::ERR_SAVE_FAILED,
"die Datei konnte nicht gespeichert werden",
),
(k::ERR_NO_FILES_UPLOADED, "keine Dateien hochgeladen"),
(k::ERR_FILES_EXIST, "einige Dateien existieren bereits"),
(k::ERR_BAD_UPLOAD_PATH, "ungültiger Dateipfad im Upload"),
(k::ERR_NO_SUCH_FOLDER, "kein solcher Ordner"),
(k::ERR_READ_ONLY_FOLDER, "schreibgeschützter Ordner"),
(
k::ERR_RW_SHARES_DISABLED,
"schreibbare Freigaben sind deaktiviert",
),
(
k::ERR_RW_RO_FOLDER,
"dieser Ordner ist für Sie schreibgeschützt und kann nicht schreibbar freigegeben werden",
),
(k::ERR_FS_NOT_FOUND, "Ordner nicht gefunden"),
(k::ERR_FS_NOT_A_DIR, "kein Ordner"),
(k::ERR_FS_FORBIDDEN, "Zugriff verweigert"),
(
k::ERR_FS_ROOT_MISSING,
"der konfigurierte Ordner existiert nicht mehr",
),
(k::ERR_FS_CONFLICT, "existiert bereits"),
];
/// French translations.
const FR: &[(&str, &str)] = &[
("no_folders_title", "Aucun dossier disponible"),
(
"no_folders_msg",
"Un administrateur ne vous a pas encore affecté de dossiers.",
),
("select_folder_title", "Choisir un dossier"),
(
"select_folder_msg",
"Choisissez un dossier dans le panneau latéral pour commencer à naviguer.",
),
("folder_gone_title", "Dossier indisponible"),
(
"folder_gone_msg",
"Ce dossier n'est plus accessible. Choisissez-en un autre dans le panneau latéral.",
),
("loading", "Chargement…"),
("loading_share", "Chargement du partage…"),
("retry", "Réessayer"),
("empty_folder", "Ce dossier est vide."),
("refresh", "Actualiser"),
("grid_view", "Vue en grille"),
("list_view", "Vue en liste"),
("open", "Ouvrir"),
("open_in_new_tab", "Ouvrir dans un nouvel onglet"),
("download", "Télécharger"),
("rename", "Renommer"),
("move", "Déplacer"),
("copy", "Copier"),
("share", "Partager"),
("info", "Infos"),
("delete", "Supprimer"),
("new_folder", "Nouveau dossier"),
("upload_files", "Téléverser des fichiers"),
("upload_folder", "Téléverser un dossier"),
("read_only_hint", "Dossier en lecture seule"),
("read_only_badge", "Lecture seule"),
("more_actions", "Plus d'actions"),
("folder_name", "Nom du dossier"),
("create", "Créer"),
("rename_title", "Renommer « {} »"),
("new_name", "Nouveau nom"),
(
"delete_folder_msg",
"Supprimer le dossier « {} » et tout son contenu ?\nCette action est irréversible.",
),
(
"delete_msg",
"Supprimer « {} » ?\nCette action est irréversible.",
),
("cancel", "Annuler"),
(
"overwrite_question",
"Les fichiers suivants existent déjà. Les écraser ?",
),
("overwrite", "Écraser"),
("and_more", "…et {} de plus"),
(
"no_subfolders",
"Aucun sous-dossier – le fichier sera placé dans le dossier actuel.",
),
("move_to", "Déplacer « {} » vers…"),
("copy_to", "Copier « {} » vers…"),
("read_only_root", "{} (lecture seule)"),
("move_here", "Déplacer ici"),
("copy_here", "Copier ici"),
("top", "racine"),
("download_title", "Télécharger « {} »"),
(
"download_formats_msg",
"Choisissez un format d'archivage pour le dossier.",
),
("fmt_zip", "Zip"),
("fmt_tar", "Tar"),
("fmt_targz", "Tar + gzip"),
("fmt_tarzst", "Tar + zstd"),
("file_info", "Informations"),
("name", "Nom"),
("type", "Type"),
("folder", "Dossier"),
("file", "Fichier"),
("size", "Taille"),
("modified", "Modifié"),
("location", "Emplacement"),
("close", "Fermer"),
("folder_created", "Dossier créé"),
("name_taken", "Ce nom est déjà utilisé"),
("renamed", "Renommé"),
("deleted", "Supprimé"),
("uploading", "Téléversement de {} fichier(s)…"),
("some_files_exist", "Certains fichiers existent déjà"),
("upload_complete", "Téléversement terminé"),
("moved", "Déplacé"),
("copied", "Copié"),
("download_started", "Téléchargement démarré"),
("saved", "Enregistré"),
("menu", "Menu"),
("open_menu", "Ouvrir le menu"),
("refresh_folders", "Actualiser vos dossiers"),
("theme_title", "Thème : {} (cliquer pour changer)"),
("log_out", "Se déconnecter"),
("files", "Fichiers"),
("manage", "Gérer"),
("shares", "Partages"),
("users", "Utilisateurs"),
("no_users", "Aucun utilisateur."),
("settings", "Paramètres"),
("theme_auto", "Auto"),
("theme_light", "Clair"),
("theme_dark", "Sombre"),
("saving", "Enregistrement…"),
("profile", "Profil"),
("server", "Serveur"),
("profile_saved", "Profil enregistré"),
(
"profile_save_err",
"Impossible d'enregistrer le profil : {}",
),
("settings_saved", "Paramètres enregistrés"),
(
"settings_save_err",
"Impossible d'enregistrer les paramètres : {}",
),
("single_click_label", "Ouvrir au clic simple"),
(
"single_click_desc",
"Quand activé, un simple clic ouvre une entrée et Ctrl+clic la sélectionne. Quand désactivé, un simple clic sélectionne et un double-clic ouvre.",
),
("save_profile", "Enregistrer le profil"),
("allow_rw_shares", "Autoriser les partages modifiables"),
(
"allow_rw_shares_desc",
"Autoriser les utilisateurs à créer des liens de partage en lecture-écriture. Désactivé par défaut.",
),
("save_settings", "Enregistrer les paramètres"),
("language", "Langue"),
(
"language_desc",
"La langue de l'interface. « Automatique » utilise la langue de votre navigateur.",
),
("lang_auto", "Automatique"),
("lang_en", "Anglais"),
("lang_de", "Allemand"),
("lang_fr", "Français"),
(
"delete_user_confirm",
"Supprimer l'utilisateur « {} » ? Cette action est irréversible.",
),
("user_delete_err", "Impossible de supprimer l'utilisateur"),
("new_user", "Nouvel utilisateur"),
("edit", "Modifier"),
("delete_user", "Supprimer l'utilisateur"),
(
"cant_delete_self",
"Vous ne pouvez pas supprimer votre propre compte",
),
("root_dup_err", "Ce dossier est déjà dans la liste."),
("name_required", "Un nom est requis."),
(
"pw_short",
"Le mot de passe doit contenir au moins 8 caractères.",
),
("need_folder", "Donnez au moins un dossier à l'utilisateur."),
("user_created", "Utilisateur créé"),
("user_updated", "Utilisateur mis à jour"),
(
"user_save_err",
"Impossible d'enregistrer l'utilisateur : {}",
),
("password", "Mot de passe"),
(
"new_password_keep",
"Nouveau mot de passe (laisser vide pour conserver)",
),
("administrator", "Administrateur"),
("active_can_signin", "Actif (peut se connecter)"),
("remove_folder", "Retirer le dossier"),
("create_user", "Créer l'utilisateur"),
("save", "Enregistrer"),
("edit_user_title", "Modifier « {} »"),
("folders", "Dossiers"),
(
"folders_paths_hint",
"Les chemins sont relatifs à la racine du serveur. « . » désigne toute la racine.",
),
("add", "Ajouter"),
("mode_rw", "lecture-écriture"),
("expiry_1h", "1 heure"),
("expiry_1d", "1 jour"),
("expiry_1w", "1 semaine"),
("expiry_1mo", "1 mois"),
("meta_expires", "expire le {}"),
("meta_created", "créé le {}"),
("mode_ro", "lecture seule"),
("share_not_found", "Partage introuvable"),
(
"share_not_found_msg",
"Ce lien est invalide ou le partage a été supprimé.",
),
("share_expired", "Partage expiré"),
("share_expired_msg", "Ce lien de partage n'est plus actif."),
("copy_manual", "Copiez le lien manuellement"),
("link_copied", "Lien copié dans le presse-papiers"),
("share_create_err", "Impossible de créer le partage : {}"),
("share_link", "Lien de partage"),
(
"share_perm_rw",
"Toute personne possédant ce lien peut lire et modifier « {} ».",
),
(
"share_perm_ro",
"Toute personne possédant ce lien peut lire « {} ».",
),
("share_title", "Partager {}"),
(
"share_dialog_hint",
"Crée un lien pour cet élément. Les liens modifiables permettent aux destinataires de téléverser et de modifier.",
),
("expires", "Expire"),
("never", "Jamais"),
("custom", "Personnalisé…"),
("until", "Jusqu'au"),
("expires_at", "Expire le {}"),
(
"delete_partial",
"{} des {} liens n'ont pas pu être supprimés",
),
("never_expires", "N'expire jamais"),
(
"allow_editing",
"Autoriser la modification (lecture-écriture)",
),
(
"rw_disabled",
"Les partages modifiables sont désactivés par le serveur.",
),
(
"rw_ro_folder",
"Ce dossier est en lecture seule, le partage l'est donc aussi.",
),
("creating", "Création…"),
("create_link", "Créer le lien"),
("your_shares", "Vos partages"),
("deleted_n_shares", "{} partage(s) supprimé(s)"),
("shares_delete_err", "Impossible de supprimer les partages"),
("delete_all", "Tout supprimer"),
("no_shares", "Aucun partage pour l'instant"),
(
"shares_view_hint",
"Liens que vous avez créés. Cliquez du bouton droit sur un fichier ou un dossier et choisissez « Partager » pour en ajouter un.",
),
(
"shares_empty_hint",
"Cliquez du bouton droit sur un fichier ou un dossier et choisissez « Partager » pour créer un lien.",
),
("copy_link", "Copier le lien"),
("delete_share", "Supprimer le partage"),
("share_deleted", "Partage supprimé"),
("share_delete_err", "Impossible de supprimer le partage"),
("delete_all_title", "Tout supprimer ?"),
(
"delete_all_msg",
"Supprimer les {} liens de partage ? Toute personne les possédant perdra l'accès. Cette action est irréversible.",
),
("login_subtitle", "Connectez-vous pour continuer."),
(
"login_error",
"Veuillez saisir votre nom et votre mot de passe.",
),
("sign_in", "Se connecter"),
(
"setup_name_err",
"Veuillez choisir un nom (1 à 64 caractères).",
),
(
"setup_pw_mismatch",
"Les mots de passe ne correspondent pas.",
),
("repeat_password", "Répéter le mot de passe"),
("create_admin", "Créer le compte administrateur"),
(
"setup_intro",
"Premier démarrage : créez le compte administrateur. Cet utilisateur peut gérer les autres utilisateurs et les paramètres du serveur.",
),
("close_esc", "Fermer (Échap)"),
("no_preview", "Aperçu non disponible"),
(
"no_preview_msg",
"L’application ne peut pas afficher « {} » ici.",
),
("dismiss", "Fermer"),
("dismiss_error", "Fermer l'erreur"),
("file_changed", "Fichier modifié sur disque"),
(
"modified_after",
"Ce fichier a été modifié après son ouverture. « Charger la version la plus récente » abandonne vos modifications non enregistrées ; « Écraser » conserve votre version.",
),
("load_latest", "Charger la version récente"),
("keep_editing", "Continuer à modifier"),
("discard", "Abandonner"),
("unsaved_changes", "Modifications non enregistrées"),
(
"discard_question",
"Abandonner vos modifications et fermer l'éditeur ?",
),
(k::ERR_INTERNAL, "erreur interne"),
(k::ERR_NOT_SIGNED_IN, "non connecté"),
(
k::ERR_SESSION_EXPIRED,
"session expirée, veuillez vous connecter à nouveau",
),
(k::ERR_INVALID_CREDENTIALS, "nom ou mot de passe incorrect"),
(k::ERR_ALREADY_SET_UP, "le serveur est déjà configuré"),
(k::ERR_SHARE_NOT_FOUND, "partage introuvable"),
(k::ERR_SHARE_EXPIRED, "ce partage a expiré"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"un jeton de partage ne peut pas être utilisé ici ; connectez-vous à la place",
),
(k::ERR_NAME_LENGTH, "le nom doit contenir 1 à 64 caractères"),
(
k::ERR_PASSWORD_SHORT,
"le mot de passe doit contenir au moins 8 caractères",
),
(k::ERR_ADMIN_ONLY, "réservé aux administrateurs"),
(k::ERR_USER_EXISTS, "un utilisateur avec ce nom existe déjà"),
(k::ERR_USER_NOT_FOUND, "utilisateur introuvable"),
(
k::ERR_OWN_ADMIN,
"vous ne pouvez pas retirer vos propres droits d'administrateur",
),
(
k::ERR_OWN_ACCOUNT,
"vous ne pouvez pas désactiver votre propre compte",
),
(
k::ERR_LAST_ADMIN,
"impossible de supprimer le dernier administrateur actif",
),
(
k::ERR_OWN_DELETE,
"vous ne pouvez pas supprimer votre propre compte",
),
(
k::ERR_LAST_ADMIN_DELETE,
"impossible de supprimer le dernier administrateur actif",
),
(
k::ERR_BAD_FORMAT,
"le format doit être l'un de : zip, tar, tar.gz, tar.zst",
),
(k::ERR_NOT_A_FILE, "ce n'est pas un fichier"),
(
k::ERR_TOO_LARGE_PREVIEW,
"fichier trop volumineux pour l'aperçu",
),
(k::ERR_BAD_ACTION, "action=content attendu"),
(
k::ERR_TOO_LARGE_SAVE,
"fichier trop volumineux pour être enregistré",
),
(k::ERR_BAD_BODY, "corps de requête invalide"),
(k::ERR_INVALID_LANGUAGE, "balise de langue invalide"),
(
k::ERR_BAD_POST,
"POST attend un téléversement multipart, une mutation JSON ou ?action=mkdir",
),
(k::ERR_FOLDER_NAME_REQUIRED, "un nom de dossier est requis"),
(k::ERR_NEW_NAME_REQUIRED, "new_name est requis"),
(k::ERR_DST_REQUIRED, "dst_root_id est requis"),
(k::ERR_PATH_REQUIRED, "un chemin dans le dossier est requis"),
(
k::ERR_BAD_MULTIPART,
"multipart/form-data avec boundary attendu",
),
(k::ERR_BAD_UPLOAD, "données de téléversement invalides"),
(k::ERR_PART_NO_NAME, "partie sans nom"),
(k::ERR_BAD_PART_NAME, "nom de partie invalide"),
(k::ERR_FOLDER_EXISTS, "un dossier avec ce nom existe déjà"),
(k::ERR_SAVE_FAILED, "impossible d'enregistrer le fichier"),
(k::ERR_NO_FILES_UPLOADED, "aucun fichier n'a été téléversé"),
(k::ERR_FILES_EXIST, "certains fichiers existent déjà"),
(
k::ERR_BAD_UPLOAD_PATH,
"chemin de fichier invalide dans le téléversement",
),
(k::ERR_NO_SUCH_FOLDER, "aucun tel dossier"),
(k::ERR_READ_ONLY_FOLDER, "dossier en lecture seule"),
(
k::ERR_RW_SHARES_DISABLED,
"les partages modifiables sont désactivés",
),
(
k::ERR_RW_RO_FOLDER,
"ce dossier est en lecture seule pour vous, il ne peut donc pas être partagé en écriture",
),
(k::ERR_FS_NOT_FOUND, "dossier introuvable"),
(k::ERR_FS_NOT_A_DIR, "ce n'est pas un dossier"),
(k::ERR_FS_FORBIDDEN, "accès refusé"),
(k::ERR_FS_ROOT_MISSING, "le dossier configuré n'existe plus"),
(k::ERR_FS_CONFLICT, "existe déjà"),
];
#[cfg(test)]
mod tests {
use super::*;
fn keys<'a>(table: &'a [(&'a str, &'a str)]) -> Vec<&'a str> {
table.iter().map(|(k, _)| *k).collect()
}
#[test]
fn no_duplicate_keys() {
for (name, table) in [("EN", EN), ("DE", DE), ("FR", FR)] {
let mut ks = keys(table);
ks.sort_unstable();
let dups = ks
.windows(2)
.filter(|w| w[0] == w[1])
.map(|w| w[0])
.collect::<Vec<_>>();
assert!(dups.is_empty(), "{name} duplicates: {dups:?}");
}
}
#[test]
fn translations_cover_all_keys() {
// Full parity: DE and FR must translate every EN key (both
// directions), so no string can silently fall back to English.
let en = keys(EN);
for (name, table) in [("DE", DE), ("FR", FR)] {
let other = keys(table);
assert_eq!(
en.len(),
other.len(),
"{name} has {} keys, EN has {}",
other.len(),
en.len()
);
for k in &other {
assert!(en.contains(k), "{name} key not in EN: {k}");
}
}
}
}
Mweb/src/main.rs
@@ -7,6 +7,7 @@ mod app;
mod cm;
mod components;
mod editor;
mod i18n;
mod icons;
mod preview;
mod router;
Mweb/src/theme.rs
@@ -20,6 +20,15 @@ pub enum Theme {
}
impl Theme {
/// The i18n key of this state's display name.
pub const fn name_key(self) -> &'static str {
match self {
Theme::Auto => crate::i18n::k::THEME_AUTO,
Theme::Light => crate::i18n::k::THEME_LIGHT,
Theme::Dark => crate::i18n::k::THEME_DARK,
}
}
/// The next state in the toggle cycle: auto → light → dark → auto.
pub const fn next(self) -> Theme {
match self {
@@ -38,15 +47,6 @@ impl Theme {
}
}
/// Display name for the UI ("Auto", "Light", "Dark").
pub const fn name(self) -> &'static str {
match self {
Theme::Auto => "Auto",
Theme::Light => "Light",
Theme::Dark => "Dark",
}
}
/// The glyph the toggle button shows for this state.
pub const fn icon(self) -> IconName {
match self {
Mweb/src/views/admin.rs
@@ -1,8 +1,16 @@
//! Admin UI (milestone 7): user management and server settings.
//! Rendered as full views from the shell's "manage" section.
use crate::i18n;
use leptos::prelude::*;
use wasm_bindgen::JsCast;
/// The value of a `<select>` behind an event, when the target is one.
fn select_value(ev: &web_sys::Event) -> Option<String> {
ev.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
.map(|s| s.value())
}
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AdminUser, Me, Mode};
@@ -62,12 +70,16 @@ fn SettingToggle(
view! {
{move || match value.get() {
None => view! {
<p class="muted">"Loading…"</p>
<p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
Some(v) => view! {
<label class="setting-row">
<span>
<span class="setting-label">{label}</span>
<span class="setting-desc">{desc}</span>
</span>
<input
type="checkbox"
checked=v
@@ -80,10 +92,6 @@ fn SettingToggle(
}
}
/>
<span>
<span class="setting-label">{label}</span>
<span class="setting-desc">{desc}</span>
</span>
</label>
<div class="modal-actions">
<button
@@ -91,7 +99,7 @@ fn SettingToggle(
disabled=move || busy.get()
on:click=move |_| save.run(())
>
{move || if busy.get() { "Saving…" } else { save_label }}
{move || if busy.get() { i18n::t(i18n::k::SAVING).to_string() } else { save_label.to_string() }}
</button>
</div>
}
@@ -116,33 +124,44 @@ pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
// --- profile: single-click-to-open ------------------------------------
let (single_click, set_single_click) = signal(Option::<bool>::None);
let (profile_busy, set_profile_busy) = signal(false);
// Profile language: Some(None) = auto (follow the browser).
let (language, set_language) = signal(Option::<Option<String>>::None);
Effect::new(move |_| {
if let Some(v) = me.get().and_then(|m| m.user).map(|u| u.single_click_open) {
set_single_click.set(Some(v));
if let Some(u) = me.get().and_then(|m| m.user) {
set_single_click.set(Some(u.single_click_open));
set_language.set(Some(u.language.clone()));
}
});
let save_profile = move |_| {
// Saves both profile settings in one call.
let save_profile = Callback::new(move |_| {
if profile_busy.get() {
return;
}
let Some(v) = single_click.get() else { return };
let Some(lang) = language.get() else { return };
set_profile_busy.set(true);
let toast2 = toast;
let set_me2 = set_me;
let set_val = set_single_click;
let set_lang = set_language;
spawn_local(async move {
match api::update_profile(v).await {
match api::update_profile(Some(v), Some(lang.clone())).await {
Ok(m) => {
show(toast2, "Profile saved".to_string());
// The response already carries the fresh /me.
show(toast2, i18n::t(i18n::k::PROFILE_SAVED).to_string());
// The response already carries the fresh /me (which also
// flips the UI language via the app-level effect).
set_me2.set(Some(m));
set_val.set(Some(v));
set_lang.set(Some(lang));
}
Err(e) => show(toast2, format!("Could not save profile: {e}")),
Err(e) => show(
toast2,
i18n::t_fmt(i18n::k::PROFILE_SAVE_ERR, &e.to_string()),
),
}
set_profile_busy.set(false);
});
};
});
// --- server: allow writable shares (admin only) ------------------------
let (value, set_value) = signal(Option::<bool>::None);
@@ -151,6 +170,11 @@ pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
let set = set_value;
let toast2 = toast;
spawn_local(async move {
// Non-admins don't have a Server tab; don't even probe the
// endpoint (it would 403 and toast).
if !is_admin() {
return;
}
match api::get_admin_settings().await {
Ok(s) => set.set(Some(s.allow_writable_shares)),
Err(e) => show(toast2, e.to_string()),
@@ -168,14 +192,17 @@ pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
spawn_local(async move {
match api::update_admin_settings(v).await {
Ok(_) => {
show(toast2, "Settings saved".to_string());
show(toast2, i18n::t(i18n::k::SETTINGS_SAVED).to_string());
// Re-fetch this session's /me so the share dialog (and
// anything else) sees the new setting without a reload.
if let Ok(m) = api::me().await {
set_me2.set(Some(m));
}
}
Err(e) => show(toast2, format!("Could not save settings: {e}")),
Err(e) => show(
toast2,
i18n::t_fmt(i18n::k::SETTINGS_SAVE_ERR, &e.to_string()),
),
}
set_busy.set(false);
});
@@ -189,7 +216,7 @@ pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
class:active=move || tab.get() == SettingsTab::Profile
on:click=move |_| pick_tab(SettingsTab::Profile)
>
"Profile"
{i18n::tr(i18n::k::PROFILE)}
</button>
<Show when=move || is_admin()>
<button
@@ -197,31 +224,89 @@ pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
class:active=move || tab.get() == SettingsTab::Server
on:click=move |_| pick_tab(SettingsTab::Server)
>
"Server"
{i18n::tr(i18n::k::SERVER)}
</button>
</Show>
</div>
<Show when=move || tab.get() == SettingsTab::Profile>
<SettingToggle
label="Use single click to open"
desc="When on, a single click opens an entry and Ctrl+click selects it. \
When off, a single click selects and a double click opens."
value=single_click
set_value=set_single_click
busy=profile_busy
save=Callback::new(save_profile)
save_label="Save profile"
/>
{move || match (single_click.get(), language.get()) {
(Some(sc), Some(lang)) => {
let lang_val = lang.clone().unwrap_or_default();
view! {
<label class="setting-row">
<span>
<span class="setting-label">{i18n::t(i18n::k::SINGLE_CLICK_LABEL)}</span>
<span class="setting-desc">{i18n::t(i18n::k::SINGLE_CLICK_DESC)}</span>
</span>
<input
type="checkbox"
checked=sc
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_single_click.set(Some(t.checked()));
}
}
/>
</label>
<div class="setting-row setting-row-select">
<span>
<span class="setting-label">{i18n::t(i18n::k::LANGUAGE)}</span>
<span class="setting-desc">{i18n::t(i18n::k::LANGUAGE_DESC)}</span>
</span>
<select
on:change=move |ev: web_sys::Event| {
let Some(tag) = select_value(&ev) else {
return;
};
set_language.set(Some(if tag.is_empty() {
None
} else {
Some(tag)
}));
}
>
<option value="" selected=lang_val.is_empty()>{i18n::t(i18n::k::LANG_AUTO)}</option>
<option value="en" selected=lang_val == "en">{i18n::t(i18n::k::LANG_EN)}</option>
<option value="de" selected=lang_val == "de">{i18n::t(i18n::k::LANG_DE)}</option>
<option value="fr" selected=lang_val == "fr">{i18n::t(i18n::k::LANG_FR)}</option>
</select>
</div>
<div class="modal-actions">
<button
class="btn btn-primary"
disabled=move || profile_busy.get()
on:click=move |_| save_profile.run(())
>
{move || if profile_busy.get() {
i18n::t(i18n::k::SAVING).to_string()
} else {
i18n::t(i18n::k::SAVE_PROFILE).to_string()
}}
</button>
</div>
}
.into_view()
.into_any()
}
_ => view! {
<p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
}}
</Show>
<Show when=move || tab.get() == SettingsTab::Server && is_admin()>
<SettingToggle
label="Allow writable shares"
desc="Let users create read-write share links. Off by default."
label=i18n::t(i18n::k::ALLOW_RW_SHARES)
desc=i18n::t(i18n::k::ALLOW_RW_SHARES_DESC)
value=value
set_value=set_value
busy=busy
save=Callback::new(save)
save_label="Save settings"
save_label=i18n::t(i18n::k::SAVE_SETTINGS)
/>
</Show>
</div>
@@ -275,7 +360,7 @@ pub fn UsersView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) ->
}
let Some(win) = web_sys::window() else { return };
let ok = win
.confirm_with_message(&format!("Delete user “{}”? This cannot be undone.", u.name))
.confirm_with_message(&i18n::t_fmt(i18n::k::DELETE_USER_CONFIRM, &u.name))
.unwrap_or(false);
if !ok {
return;
@@ -291,7 +376,7 @@ pub fn UsersView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) ->
write.set(Some(cur));
}
} else {
show(toast2, "Could not delete user".to_string());
show(toast2, i18n::t(i18n::k::USER_DELETE_ERR).to_string());
}
});
};
@@ -299,21 +384,20 @@ pub fn UsersView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) ->
view! {
<div class="users-tab">
<div class="users-header">
<h3 class="admin-section-title">"Users"</h3>
<h3 class="admin-section-title">{i18n::tr(i18n::k::USERS)}</h3>
<button class="btn btn-primary" on:click=move |_| set_editing.set(Some(Editing::New))>
<Icon name=IconName::Add class="ic-btn".to_string()/>
"New user"
{i18n::tr(i18n::k::NEW_USER)}
</button>
</div>
{move || match users.get() {
None => view! {
<p class="muted">"Loading…"</p>
<p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
Some(ref list) if list.is_empty() => view! {
<p class="muted">"No users."
</p>
<p class="muted">{i18n::tr(i18n::k::NO_USERS)}</p>
}
.into_view()
.into_any(),
@@ -367,15 +451,19 @@ pub fn UsersView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) ->
class="btn btn-sm"
on:click=move |_| set_edit.set(Some(Editing::Edit(u_edit.clone())))
>
"Edit"
{i18n::tr(i18n::k::EDIT)}
</button>
<button
class="btn btn-sm btn-danger"
disabled=is_self
title=if is_self { "You cannot delete yourself" } else { "Delete user" }
title=if is_self {
i18n::tr(i18n::k::CANT_DELETE_SELF)
} else {
i18n::tr(i18n::k::DELETE_USER)
}
on:click=move |_| del(u_del.clone())
>
"Delete"
{i18n::tr(i18n::k::DELETE)}
</button>
</div>
</div>
@@ -483,7 +571,7 @@ fn UserForm(
let mut v = roots.get();
// Avoid duplicate paths.
if v.iter().any(|r| r.path == path) {
set_error.set(Some("That folder is already in the list.".to_string()));
set_error.set(Some(i18n::t(i18n::k::ROOT_DUP_ERR).to_string()));
return;
}
v.push(RootDraft {
@@ -510,21 +598,21 @@ fn UserForm(
.collect();
// Client-side validation.
if n.is_empty() {
set_error.set(Some("A name is required.".to_string()));
set_error.set(Some(i18n::t(i18n::k::NAME_REQUIRED).to_string()));
return;
}
if is_new && pw.len() < 8 {
set_error.set(Some("Password must be at least 8 characters.".to_string()));
set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string()));
return;
}
if !is_new && pw.is_empty() {
// keep password unchanged
} else if pw.len() < 8 {
set_error.set(Some("Password must be at least 8 characters.".to_string()));
set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string()));
return;
}
if pairs.is_empty() {
set_error.set(Some("Give the user at least one folder.".to_string()));
set_error.set(Some(i18n::t(i18n::k::NEED_FOLDER).to_string()));
return;
}
@@ -551,16 +639,16 @@ fn UserForm(
show(
toast2,
if is_new {
"User created".to_string()
i18n::t(i18n::k::USER_CREATED).to_string()
} else {
"User updated".to_string()
i18n::t(i18n::k::USER_UPDATED).to_string()
},
);
on_saved2.run(existing_id);
close2.run(());
}
Err(e) => {
show(toast2, format!("Could not save user: {e}"));
show(toast2, i18n::t_fmt(i18n::k::USER_SAVE_ERR, &e.to_string()));
set_busy.set(false);
}
}
@@ -573,12 +661,18 @@ fn UserForm(
class="modal user-form"
on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
>
<h2 class="modal-title">{if is_new { "New user".to_string() } else { format!("Edit “{}”", original_name) }}</h2>
<h2 class="modal-title">{move || {
if is_new {
i18n::t(i18n::k::NEW_USER).to_string()
} else {
i18n::t_fmt(i18n::k::EDIT_USER_TITLE, &original_name)
}
}}</h2>
{move || {
let n0 = name.get();
view! {
<label class="field">
<span class="field-label">"Name"</span>
<span class="field-label">{i18n::tr(i18n::k::NAME)}</span>
<input
class="field-input"
value=n0.clone()
@@ -598,7 +692,11 @@ fn UserForm(
}}
<label class="field">
<span class="field-label">
{if is_new { "Password" } else { "New password (leave blank to keep)" }}
{if is_new {
i18n::t(i18n::k::PASSWORD)
} else {
i18n::t(i18n::k::NEW_PASSWORD_KEEP)
}}
</span>
<input
class="field-input"
@@ -629,7 +727,7 @@ fn UserForm(
}
}
/>
"Administrator"
{i18n::tr(i18n::k::ADMINISTRATOR)}
</label>
{move || if !is_new {
view! {
@@ -647,7 +745,7 @@ fn UserForm(
}
}
/>
"Active (can sign in)"
{i18n::tr(i18n::k::ACTIVE_CAN_SIGNIN)}
</label>
}
.into_view()
@@ -656,8 +754,8 @@ fn UserForm(
view! {}.into_any()
}}
<div class="field">
<span class="field-label">"Folders"</span>
<p class="muted field-hint">"Paths are relative to the server root. “.” is the whole root.”"</p>
<span class="field-label">{i18n::tr(i18n::k::FOLDERS)}</span>
<p class="muted field-hint">{i18n::tr(i18n::k::FOLDERS_PATHS_HINT)}</p>
<div class="roots-editor">
{move || {
let set_roots_c = set_roots;
@@ -691,12 +789,12 @@ fn UserForm(
}
}
>
<option value=Mode::Rw.as_str() selected=rw_selected>"read-write"</option>
<option value=Mode::Ro.as_str() selected=ro_selected>"read-only"</option>
<option value=Mode::Rw.as_str() selected=rw_selected>{i18n::t(i18n::k::MODE_RW)}</option>
<option value=Mode::Ro.as_str() selected=ro_selected>{i18n::t(i18n::k::MODE_RO)}</option>
</select>
<button
class="icon-btn icon-btn-sm"
title="Remove folder"
title={i18n::tr(i18n::k::REMOVE_FOLDER)}
on:click=move |_| {
let mut v = roots_c.get();
if i < v.len() {
@@ -726,7 +824,7 @@ fn UserForm(
}
}
/>
<button class="btn" on:click=add_root>"Add"</button>
<button class="btn" on:click=add_root>{i18n::tr(i18n::k::ADD)}</button>
</div>
</div>
</div>
@@ -741,13 +839,21 @@ fn UserForm(
},
}}
<div class="modal-actions">
<button class="btn" on:click=move |_| close.run(())>"Cancel"</button>
<button class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
<button
class="btn btn-primary"
disabled=move || busy.get()
on:click=save
>
{move || if busy.get() { "Saving…" } else { if is_new { "Create user" } else { "Save" } }}
{move || {
if busy.get() {
i18n::t(i18n::k::SAVING).to_string()
} else if is_new {
i18n::t(i18n::k::CREATE_USER).to_string()
} else {
i18n::t(i18n::k::SAVE).to_string()
}
}}
</button>
</div>
</div>
Mweb/src/views/browser.rs
@@ -10,6 +10,7 @@ use crate::api::{self, Entry, Me, RootInfo};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::editor::EditTarget;
use crate::i18n;
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{Location, Section, navigate};
@@ -209,18 +210,18 @@ pub fn Browser(
fn no_folder_view(roots: &[RootInfo], loc: &Location) -> impl IntoView {
let (title, message) = if roots.is_empty() {
(
"No folders available",
"An administrator hasn't assigned you any folders yet.",
i18n::t(i18n::k::NO_FOLDERS_TITLE),
i18n::t(i18n::k::NO_FOLDERS_MSG),
)
} else if loc.root_id.is_none() {
(
"Select a folder",
"Choose a folder from the sidebar to start browsing.",
i18n::t(i18n::k::SELECT_FOLDER_TITLE),
i18n::t(i18n::k::SELECT_FOLDER_MSG),
)
} else {
(
"Folder not available",
"This folder is no longer accessible. Choose another one from the sidebar.",
i18n::t(i18n::k::FOLDER_GONE_TITLE),
i18n::t(i18n::k::FOLDER_GONE_MSG),
)
};
view! {
@@ -313,8 +314,8 @@ fn file_browser(
<div class="toolbar">
<button
class="icon-btn"
title="Refresh"
aria-label="Refresh"
title=i18n::tr(i18n::k::REFRESH)
aria-label=i18n::tr(i18n::k::REFRESH)
on:click=move |_| fetch.run(())
>
<Icon name=IconName::Refresh class="ic-btn".to_string()/>
@@ -328,7 +329,7 @@ fn file_browser(
"icon-btn".to_string()
}
}
title="Grid view"
title=i18n::tr(i18n::k::GRID_VIEW)
on:click=move |_| {
set_view_mode.set(ViewMode::Grid);
ViewMode::Grid.save();
@@ -344,7 +345,7 @@ fn file_browser(
"icon-btn".to_string()
}
}
title="List view"
title=i18n::tr(i18n::k::LIST_VIEW)
on:click=move |_| {
set_view_mode.set(ViewMode::List);
ViewMode::List.save();
@@ -374,7 +375,7 @@ fn file_browser(
{move || {
match list_state.get() {
ListState::Loading => view! {
<p class="muted center-note">"Loading…"</p>
<p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
@@ -382,7 +383,7 @@ fn file_browser(
<div class="card error-card">
<span>{msg.clone()}</span>
<button class="btn" on:click=move |_| fetch.run(())>
"Retry"
{i18n::tr(i18n::k::RETRY)}
</button>
</div>
}
@@ -390,7 +391,7 @@ fn file_browser(
.into_any(),
ListState::Entries(entries) => {
if entries.is_empty() {
view! { <p class="muted center-note">"This folder is empty."</p> }
view! { <p class="muted center-note">{i18n::tr(i18n::k::EMPTY_FOLDER)}</p> }
.into_view()
.into_any()
} else if view_mode.get() == ViewMode::Grid {
@@ -709,7 +710,7 @@ fn menu_items(
} else {
IconName::Open
},
"Open",
i18n::t(i18n::k::OPEN),
open_entry(e, rid, loc, is_rw, open_file),
));
}
@@ -719,24 +720,24 @@ fn menu_items(
// you share a small site or an HTML report.
v.push(MenuItem::new(
IconName::OpenInNew,
"Open in new tab",
i18n::t(i18n::k::OPEN_IN_NEW_TAB),
action_open_tab(e, root_id, loc, own()),
));
}
v.push(MenuItem::new(
IconName::Download,
"Download",
i18n::t(i18n::k::DOWNLOAD),
action_download(e, root_id, loc, set_dialog, toast, own()),
));
v.push(MenuItem::rw(
IconName::Rename,
"Rename",
i18n::t(i18n::k::RENAME),
is_rw,
action_rename(e, root_id, loc, refresh, toast, set_dialog, own()),
));
v.push(MenuItem::rw(
IconName::Move,
"Move",
i18n::t(i18n::k::MOVE),
is_rw,
action_move_copy(
Op::Move,
@@ -754,7 +755,7 @@ fn menu_items(
// folder is fine.
v.push(MenuItem::new(
IconName::Copy,
"Copy",
i18n::t(i18n::k::COPY),
action_move_copy(
Op::Copy,
e,
@@ -770,7 +771,7 @@ fn menu_items(
if loc_now.share_token.is_none() {
v.push(MenuItem::new(
IconName::Share,
"Share",
i18n::t(i18n::k::SHARE),
action_share(
e,
root_id,
@@ -784,12 +785,12 @@ fn menu_items(
}
v.push(MenuItem::new(
IconName::Info,
"Info",
i18n::t(i18n::k::INFO),
action_info(e, loc, root_name, set_dialog, own()),
));
v.push(MenuItem::rw(
IconName::Delete,
"Delete",
i18n::t(i18n::k::DELETE),
is_rw,
action_delete(e, root_id, loc, refresh, toast, set_dialog, own()),
));
@@ -798,19 +799,19 @@ fn menu_items(
None => vec![
MenuItem::rw(
IconName::NewFolder,
"New folder",
i18n::t(i18n::k::NEW_FOLDER),
is_rw,
action_new_folder(root_id, loc, refresh, toast, set_dialog, own()),
),
MenuItem::rw(
IconName::Upload,
"Upload files",
i18n::t(i18n::k::UPLOAD_FILES),
is_rw,
action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
),
MenuItem::rw(
IconName::UploadFolder,
"Upload folder",
i18n::t(i18n::k::UPLOAD_FOLDER),
is_rw,
action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
),
@@ -915,7 +916,7 @@ impl MenuItem {
label,
icon,
action: is_rw.then_some(action),
hint: Some("Read-only folder"),
hint: Some(i18n::t(i18n::k::READ_ONLY_HINT)),
}
}
}
@@ -1075,8 +1076,8 @@ pub fn SelectionActions(
<div class="sel-more-wrap">
<button
class="icon-btn"
title="More actions"
aria-label="More actions"
title=i18n::tr(i18n::k::MORE_ACTIONS)
aria-label=move || i18n::t(i18n::k::MORE_ACTIONS).to_string()
on:click=move |ev: MouseEvent| {
ev.stop_propagation();
set_more_open.set(!more_open.get());
@@ -1211,10 +1212,10 @@ fn action_new_folder(
let loc = loc.get();
let dir = loc.path.clone();
set_dialog.set(Some(Dialog::Prompt {
title: "New folder".into(),
label: "Folder name".into(),
title: i18n::t(i18n::k::NEW_FOLDER).to_string(),
label: i18n::t(i18n::k::FOLDER_NAME).to_string(),
initial: String::new(),
submit: "Create".into(),
submit: i18n::t(i18n::k::CREATE).to_string(),
on_submit: owner.with(|| {
Callback::new(move |name: String| {
let name = name.trim().to_string();
@@ -1226,7 +1227,7 @@ fn action_new_folder(
spawn_local(async move {
match api::mkdir(root_id, &full).await {
Ok(_) => {
show(toast, "Folder created");
show(toast, i18n::t(i18n::k::FOLDER_CREATED));
refresh.run(());
}
Err(e) => show(toast, e.to_string()),
@@ -1254,10 +1255,10 @@ fn action_rename(
let dir = loc.path.clone();
let full = join_path(&dir, &name);
set_dialog.set(Some(Dialog::Prompt {
title: format!("Rename \u{201c}{name}\u{201d}"),
label: "New name".into(),
title: i18n::t_fmt(i18n::k::RENAME_TITLE, &name),
label: i18n::t(i18n::k::NEW_NAME).to_string(),
initial: name.clone(),
submit: "Rename".into(),
submit: i18n::t(i18n::k::RENAME).to_string(),
on_submit: owner.with(|| {
Callback::new(move |new_name: String| {
let new_name = new_name.trim().to_string();
@@ -1276,9 +1277,9 @@ fn action_rename(
.map(|_| ())
})
},
"Name already in use",
i18n::t(i18n::k::NAME_TAKEN),
vec![target],
"Renamed",
i18n::t(i18n::k::RENAMED),
refresh,
toast,
set_dialog,
@@ -1306,16 +1307,14 @@ fn action_delete(
let dir = loc.path.clone();
let full = join_path(&dir, &name);
let message = if is_dir {
format!(
"Delete folder \u{201c}{name}\u{201d} and everything inside it?\nThis cannot be undone."
)
i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, &name)
} else {
format!("Delete \u{201c}{name}\u{201d}?\nThis cannot be undone.")
i18n::t_fmt(i18n::k::DELETE_MSG, &name)
};
set_dialog.set(Some(Dialog::Confirm {
title: "Delete".into(),
title: i18n::t(i18n::k::DELETE).to_string(),
message,
submit: "Delete".into(),
submit: i18n::t(i18n::k::DELETE).to_string(),
danger: true,
on_submit: owner.with(|| {
Callback::new(move |_| {
@@ -1324,7 +1323,7 @@ fn action_delete(
spawn_local(async move {
match api::delete_item(root_id, &f).await {
Ok(_) => {
show(toast, "Deleted");
show(toast, i18n::t(i18n::k::DELETED));
refresh.run(());
}
Err(e) => show(toast, e.to_string()),
@@ -1353,22 +1352,16 @@ fn action_upload(
api::pick_files(multiple, directory, move |files| {
let n = files.len();
let dir = dir1.clone();
show(
toast,
format!(
"Uploading {n} file{}\u{2026}",
if n == 1 { "" } else { "s" }
),
);
show(toast, i18n::t_fmt(i18n::k::UPLOADING, &n.to_string()));
run_with_overwrite_retry(
move |overwrite| {
let (dir, files) = (dir.clone(), files.clone());
Box::pin(async move { api::upload(root_id, &dir, overwrite, files).await })
},
"Some files already exist",
i18n::t(i18n::k::SOME_FILES_EXIST),
// Unused: an upload conflict always names the skipped files.
Vec::new(),
"Upload complete",
i18n::t(i18n::k::UPLOAD_COMPLETE),
refresh,
toast,
set_dialog,
@@ -1426,9 +1419,13 @@ fn action_move_copy(
res.map(|_| ())
})
},
"Name already in use",
i18n::t(i18n::k::NAME_TAKEN),
vec![name3.clone()],
if op == Op::Move { "Moved" } else { "Copied" },
if op == Op::Move {
i18n::t(i18n::k::MOVED)
} else {
i18n::t(i18n::k::COPIED)
},
refresh,
toast,
set_dialog,
@@ -1463,7 +1460,7 @@ fn action_download(
} else {
let url = api::download_url(root_id, &full, None);
api::trigger_download(&url, &name);
show(toast, "Download started");
show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
}
})
})
Mweb/src/views/dialogs.rs
@@ -8,6 +8,7 @@ use web_sys::MouseEvent;
use crate::api::{self, Entry, RootInfo};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::i18n;
use crate::icons::IconName;
/// Which mutating operation the destination picker is for.
@@ -18,10 +19,19 @@ pub enum Op {
}
impl Op {
pub fn verb(self) -> &'static str {
/// The translated title prefix for the destination picker ("Move … to…").
pub fn title_key(self) -> &'static str {
match self {
Op::Move => "Move",
Op::Copy => "Copy",
Op::Move => crate::i18n::k::MOVE_TO,
Op::Copy => crate::i18n::k::COPY_TO,
}
}
/// The translated "… here" confirm button label.
pub fn here_key(self) -> &'static str {
match self {
Op::Move => crate::i18n::k::MOVE_HERE,
Op::Copy => crate::i18n::k::COPY_HERE,
}
}
}
@@ -339,7 +349,7 @@ fn PromptDialog(
/>
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
"Cancel"
{i18n::tr(i18n::k::CANCEL)}
</button>
<button
class="btn btn-primary"
@@ -373,7 +383,7 @@ fn ConfirmDialog(
<p class="modal-message">{message}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
"Cancel"
{i18n::tr(i18n::k::CANCEL)}
</button>
<button
class=move || {
@@ -414,7 +424,7 @@ fn ConflictDialog(
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<h2 class="modal-title">{title}</h2>
<p class="modal-message">
"The following files already exist. Overwrite them?"
{i18n::tr(i18n::k::OVERWRITE_QUESTION)}
</p>
<ul class="conflict-list">
{shown.iter().map(|f| {
@@ -423,7 +433,7 @@ fn ConflictDialog(
}).collect::<Vec<_>>()}
{move || {
if extra > 0 {
view! { <li class="muted">format!("…and {extra} more") </li> }
view! { <li class="muted">{move || i18n::t_fmt(i18n::k::AND_MORE, &extra.to_string())}</li> }
.into_view()
.into_any()
} else {
@@ -433,7 +443,7 @@ fn ConflictDialog(
</ul>
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
"Cancel"
{i18n::tr(i18n::k::CANCEL)}
</button>
<button
class="btn btn-danger"
@@ -442,7 +452,7 @@ fn ConflictDialog(
on_close.run(());
}
>
"Overwrite"
{i18n::tr(i18n::k::OVERWRITE)}
</button>
</div>
</div>
@@ -494,7 +504,7 @@ fn PickerDialog(
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card picker" on:click=move |e: MouseEvent| e.stop_propagation()>
<h2 class="modal-title">
{move || format!("{} “{}” to…", op.verb(), entry.name)}
{move || i18n::t_fmt(op.title_key(), &entry.name)}
</h2>
<div class="picker-roots">
@@ -502,7 +512,7 @@ fn PickerDialog(
let rid = r.id;
let ro = !r.mode.is_writable();
let name = if ro {
format!("{} (read-only)", r.name)
i18n::t_fmt(i18n::k::READ_ONLY_ROOT, &r.name)
} else {
r.name.clone()
};
@@ -549,7 +559,7 @@ fn PickerDialog(
class="crumb"
on:click=move |_| set_dir.set(String::new())
>
"top"
{i18n::tr(i18n::k::TOP)}
</span>
{move || {
let segs = dir_segs(&sel_dir2.get());
@@ -586,7 +596,7 @@ fn PickerDialog(
if es.is_empty() {
view! {
<p class="muted center-note">
"No subfolders — the file will be placed in the current folder."
{i18n::tr(i18n::k::NO_SUBFOLDERS)}
</p>
}
.into_view()
@@ -623,7 +633,7 @@ fn PickerDialog(
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
"Cancel"
{i18n::tr(i18n::k::CANCEL)}
</button>
<button
class="btn btn-primary"
@@ -638,7 +648,7 @@ fn PickerDialog(
on_close.run(());
}
>
{move || format!("{} here", op.verb())}
{i18n::tr(op.here_key())}
</button>
</div>
</div>
@@ -658,17 +668,20 @@ fn DownloadFormatDialog(
on_close: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let name_title = name.clone();
let formats: [(&str, &str, &str); 4] = [
("zip", "Zip", ".zip"),
("tar", "Tar", ".tar"),
("tar.gz", "Tar + gzip", ".tar.gz"),
("tar.zst", "Tar + zstd", ".tar.zst"),
("zip", i18n::t(i18n::k::FMT_ZIP), ".zip"),
("tar", i18n::t(i18n::k::FMT_TAR), ".tar"),
("tar.gz", i18n::t(i18n::k::FMT_TARGZ), ".tar.gz"),
("tar.zst", i18n::t(i18n::k::FMT_TARZST), ".tar.zst"),
];
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<h2 class="modal-title">{format!("Download “{name}”")}</h2>
<p class="modal-message">"Choose an archive format for the folder."</p>
<h2 class="modal-title">
{move || i18n::t_fmt(i18n::k::DOWNLOAD_TITLE, &name_title)}
</h2>
<p class="modal-message">{i18n::tr(i18n::k::DOWNLOAD_FORMATS_MSG)}</p>
<div class="dl-formats">
{formats.iter().map(|(id, label, ext)| {
let (id, label, ext) = (*id, *label, *ext);
@@ -681,7 +694,7 @@ fn DownloadFormatDialog(
let url = api::download_url(root_id, &path, Some(id));
let fname = format!("{name}{ext}");
api::trigger_download(&url, &fname);
show(toast, "Download started");
show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
on_close.run(());
}
>
@@ -692,7 +705,7 @@ fn DownloadFormatDialog(
</div>
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
"Cancel"
{i18n::tr(i18n::k::CANCEL)}
</button>
</div>
</div>
@@ -728,22 +741,22 @@ fn InfoDialog(
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<h2 class="modal-title">"File info"</h2>
<h2 class="modal-title">{i18n::tr(i18n::k::FILE_INFO)}</h2>
<dl class="info-list">
<dt>"Name"</dt>
<dt>{i18n::tr(i18n::k::NAME)}</dt>
<dd>{entry.name.clone()}</dd>
<dt>"Type"</dt>
<dd>{if is_dir { "Folder".to_string() } else { "File".to_string() }}</dd>
<dt>"Size"</dt>
<dt>{i18n::tr(i18n::k::TYPE)}</dt>
<dd>{if is_dir { i18n::t(i18n::k::FOLDER).to_string() } else { i18n::t(i18n::k::FILE).to_string() }}</dd>
<dt>{i18n::tr(i18n::k::SIZE)}</dt>
<dd>{size}</dd>
<dt>"Modified"</dt>
<dt>{i18n::tr(i18n::k::MODIFIED)}</dt>
<dd>{modified}</dd>
<dt>"Location"</dt>
<dt>{i18n::tr(i18n::k::LOCATION)}</dt>
<dd>{location}</dd>
</dl>
<div class="modal-actions">
<button class="btn btn-primary" on:click=move |_| on_close.run(())>
"Close"
{i18n::tr(i18n::k::CLOSE)}
</button>
</div>
</div>
Mweb/src/views/file_view.rs
@@ -4,6 +4,7 @@
//! was, with save/close on the right. Used by both the signed-in shell and
//! the public share page.
use crate::i18n;
use api_types::FileKind;
use leptos::prelude::*;
@@ -86,7 +87,7 @@ pub fn FileViewTitle(view: FileView, dirty: ReadSignal<bool>) -> impl IntoView {
</span>
{move || {
if readonly {
view! { <span class="badge">"Read-only"</span> }.into_view().into_any()
view! { <span class="badge">{i18n::t(i18n::k::READ_ONLY_BADGE)}</span> }.into_view().into_any()
} else {
view! {}.into_any()
}
@@ -118,7 +119,13 @@ pub fn FileViewActions(
disabled=move || !dirty.get() || saving.get()
on:click=move |_| cb.run(())
>
{move || if saving.get() { "Saving…".to_string() } else { "Save".to_string() }}
{move || {
if saving.get() {
i18n::t(i18n::k::SAVING).to_string()
} else {
i18n::t(i18n::k::SAVE).to_string()
}
}}
</button>
}
.into_view()
@@ -128,8 +135,8 @@ pub fn FileViewActions(
<Show when=move || show_close>
<button
class="icon-btn file-close"
title="Close (Esc)"
aria-label="Close"
title={i18n::tr(i18n::k::CLOSE_ESC)}
aria-label={i18n::tr(i18n::k::CLOSE)}
on:click=move |_| {
if let Some(cb) = close_cb.get() {
cb.run(());
@@ -204,8 +211,8 @@ fn NoPreview(target: UnsupportedTarget) -> impl IntoView {
<div class="file-nopreview">
<div class="empty-state">
<Icon name=icon class="empty-glyph".to_string()/>
<h3>"No preview available"</h3>
<p class="muted">{format!("The app can't display “{name}” here.")}</p>
<h3>{i18n::tr(i18n::k::NO_PREVIEW)}</h3>
<p class="muted">{i18n::t_fmt(i18n::k::NO_PREVIEW_MSG, &name)}</p>
<button
class="btn btn-primary"
on:click=move |_| {
@@ -214,7 +221,7 @@ fn NoPreview(target: UnsupportedTarget) -> impl IntoView {
}
>
<Icon name=IconName::Download class="ic-btn".to_string()/>
"Download"
{i18n::tr(i18n::k::DOWNLOAD)}
</button>
</div>
</div>
Mweb/src/views/login.rs
@@ -1,3 +1,4 @@
use crate::i18n;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
@@ -18,7 +19,7 @@ pub fn LoginView(
let name = input_value("login-name");
let pass = input_value("login-pass");
if name.trim().is_empty() || pass.is_empty() {
set_error.set("Please enter your name and password.".into());
set_error.set(i18n::t(i18n::k::LOGIN_ERROR).into());
return;
}
set_error.set(String::new());
@@ -52,10 +53,10 @@ pub fn LoginView(
<div class="card auth-card">
<Logo class="auth-logo".to_string()/>
<h1>"filebrowser-ng"</h1>
<p class="muted">"Sign in to continue."</p>
<p class="muted">{i18n::tr(i18n::k::LOGIN_SUBTITLE)}</p>
<form on:submit=on_submit>
<label class="field">
<span>"Name"</span>
<span>{i18n::tr(i18n::k::NAME)}</span>
<input
id="login-name"
type="text"
@@ -64,7 +65,7 @@ pub fn LoginView(
/>
</label>
<label class="field">
<span>"Password"</span>
<span>{i18n::tr(i18n::k::PASSWORD)}</span>
<input
id="login-pass"
type="password"
@@ -73,7 +74,7 @@ pub fn LoginView(
</label>
<p class="error">{move || error.get()}</p>
<button class="btn-primary" type="submit" disabled=move || busy.get()>
"Sign in"
{i18n::tr(i18n::k::SIGN_IN)}
</button>
</form>
</div>
Mweb/src/views/setup.rs
@@ -1,3 +1,4 @@
use crate::i18n;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
@@ -22,15 +23,15 @@ pub fn SetupView(
let name = name.trim();
if name.is_empty() || name.len() > 64 {
set_error.set("Please choose a name (1–64 characters).".into());
set_error.set(i18n::t(i18n::k::SETUP_NAME_ERR).into());
return;
}
if pass.len() < 8 {
set_error.set("Password must be at least 8 characters.".into());
set_error.set(i18n::t(i18n::k::PW_SHORT).into());
return;
}
if pass != confirm {
set_error.set("Passwords don't match.".into());
set_error.set(i18n::t(i18n::k::SETUP_PW_MISMATCH).into());
return;
}
@@ -63,13 +64,10 @@ pub fn SetupView(
<div class="card auth-card">
<Logo class="auth-logo".to_string()/>
<h1>"filebrowser-ng"</h1>
<p class="muted">
"First boot: create the admin account. This user can manage
other users and server settings."
</p>
<p class="muted">{i18n::tr(i18n::k::SETUP_INTRO)}</p>
<form on:submit=on_submit>
<label class="field">
<span>"Name"</span>
<span>{i18n::tr(i18n::k::NAME)}</span>
<input
id="setup-name"
type="text"
@@ -78,7 +76,7 @@ pub fn SetupView(
/>
</label>
<label class="field">
<span>"Password"</span>
<span>{i18n::tr(i18n::k::PASSWORD)}</span>
<input
id="setup-pass"
type="password"
@@ -86,7 +84,7 @@ pub fn SetupView(
/>
</label>
<label class="field">
<span>"Repeat password"</span>
<span>{i18n::tr(i18n::k::REPEAT_PASSWORD)}</span>
<input
id="setup-confirm"
type="password"
@@ -95,7 +93,7 @@ pub fn SetupView(
</label>
<p class="error">{move || error.get()}</p>
<button class="btn-primary" type="submit" disabled=move || busy.get()>
"Create admin account"
{i18n::tr(i18n::k::CREATE_ADMIN)}
</button>
</form>
</div>
Mweb/src/views/shell.rs
@@ -1,5 +1,6 @@
//! The signed-in shell: left sidebar (folders + manage tabs) and the topbar.
use crate::i18n;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
@@ -160,8 +161,8 @@ pub fn ShellView(
<div class="topbar-left">
<button
class="icon-btn nav-toggle"
title="Menu"
aria-label="Open menu"
title={i18n::tr(i18n::k::MENU)}
aria-label={i18n::tr(i18n::k::OPEN_MENU)}
on:click=move |_| set_nav_open.update(|v| *v = !*v)
>
<Icon name=IconName::Menu class="ic-btn".to_string()/>
@@ -173,10 +174,7 @@ pub fn ShellView(
.into_view()
.into_any(),
None => view! {
<div class="brand">
<Logo/>
<span class="brand-name">"filebrowser-ng"</span>
</div>
<a class="brand" href="#/"><Logo/><span class="brand-name">"filebrowser-ng"</span></a>
}
.into_view()
.into_any(),
@@ -236,13 +234,8 @@ pub fn ShellView(
if file_view.get().is_none() {
view! {
<aside
class=move || {
if nav_open.get() {
"sidebar open".to_string()
} else {
"sidebar".to_string()
}
}
class="sidebar"
class:open=move || nav_open.get()
>
<nav class="nav">
{move || {
@@ -259,10 +252,10 @@ pub fn ShellView(
}}
<div class="nav-section">
<div class="nav-section-head">
<div class="nav-section-label">"Files"</div>
<div class="nav-section-label">{i18n::tr(i18n::k::FILES)}</div>
<button
class="icon-btn icon-btn-sm nav-section-refresh"
title="Refresh your folders"
title={i18n::tr(i18n::k::REFRESH_FOLDERS)}
on:click=move |_| refresh_me(set_me, toast)
>
<Icon name=IconName::Refresh class="ic-btn".to_string()/>
@@ -326,14 +319,14 @@ pub fn ShellView(
}}
</div>
<div class="nav-section">
<div class="nav-section-label">"Manage"</div>
<div class="nav-section-label">{i18n::tr(i18n::k::MANAGE)}</div>
<button
class="nav-tab"
class:active=move || loc.get().section == Section::Shares
on:click=move |_| open_section(Section::Shares)
>
<Icon name=IconName::Share class="ic-tab".to_string()/>
<span class="nav-tab-name">"Shares"</span>
<span class="nav-tab-name">{i18n::tr(i18n::k::SHARES)}</span>
</button>
<Show when=move || is_admin_of(&me.get())>
<button
@@ -342,7 +335,7 @@ pub fn ShellView(
on:click=move |_| open_section(Section::Users)
>
<Icon name=IconName::User class="ic-tab".to_string()/>
<span class="nav-tab-name">"Users"</span>
<span class="nav-tab-name">{i18n::tr(i18n::k::USERS)}</span>
</button>
</Show>
<button
@@ -351,14 +344,14 @@ pub fn ShellView(
on:click=move |_| open_section(Section::Settings)
>
<Icon name=IconName::Settings class="ic-tab".to_string()/>
<span class="nav-tab-name">"Settings"</span>
<span class="nav-tab-name">{i18n::tr(i18n::k::SETTINGS)}</span>
</button>
</div>
</nav>
<div class="sidebar-footer">
<button
class="nav-tab sidebar-action"
title=move || format!("Theme: {} (click to switch)", theme.get().label())
title=move || i18n::t_fmt(i18n::k::THEME_TITLE, i18n::t(theme.get().name_key()))
on:click=move |_| {
let next = theme.get().next();
set_theme.set(next);
@@ -371,15 +364,15 @@ pub fn ShellView(
<Icon name=theme.get().icon() class="ic-tab".to_string()/>
}
}}
<span class="nav-tab-name">{move || theme.get().name().to_string()}</span>
<span class="nav-tab-name">{move || i18n::t(theme.get().name_key()).to_string()}</span>
</button>
<button
class="nav-tab sidebar-action logout"
title="Log out"
title={i18n::tr(i18n::k::LOG_OUT)}
on:click=move |_| on_logout(())
>
<Icon name=IconName::Logout class="ic-tab".to_string()/>
<span class="nav-tab-name">"Log out"</span>
<span class="nav-tab-name">{i18n::tr(i18n::k::LOG_OUT)}</span>
</button>
<div class="sidebar-version">
{format!("filebrowser-ng v{}", env!("CARGO_PKG_VERSION"))}