-
+
- ${required.length > 0 ? `
Required
` : ""}
+ ${required.length > 0 ? `
${t("configure.section.required")}
` : ""}
${required.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
- ${basic.length > 0 ? `
Options
` : ""}
+ ${basic.length > 0 ? `
${t("configure.section.options")}
` : ""}
${basic.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
${advanced.length > 0 ? `
-
Advanced
+
${t("configure.section.advanced")}
${advanced.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
` : ""}
@@ -208,16 +207,16 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
${isEdit ? `
-
Delete this remote
-
Permanently remove ${escapeHtml(remoteName)} from rclone.conf.
-
+
${t("configure.delete_section_title")}
+
${t("configure.delete_section_body", escapeHtml(remoteName))}
+
` : ""}
@@ -235,27 +234,25 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
advToggle.addEventListener("click", () => {
const hidden = advSection.classList.toggle("hidden");
advToggle.textContent = hidden
- ? `Show advanced options (${advanced.length})`
- : `Hide advanced options (${advanced.length})`;
+ ? t("configure.show_advanced", advanced.length)
+ : t("configure.hide_advanced", advanced.length);
});
}
- // Delete button
const deleteBtn = document.getElementById("delete-btn");
if (deleteBtn) {
deleteBtn.addEventListener("click", async () => {
- if (!confirm(`Delete remote ${remoteName}? This cannot be undone.`)) return;
+ if (!confirm(t("remotes.delete_confirm", remoteName))) return;
try {
await post("config/delete", { name: remoteName });
- toast(`Deleted ${remoteName}`, "success");
+ toast(t("remotes.deleted", remoteName), "success");
location.hash = "#/remotes";
} catch (e) {
- toast(`Delete failed: ${e.message}`, "error");
+ toast(`${t("remotes.delete_failed")}: ${e.message}`, "error");
}
});
}
- // Submit
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (requiresOAuth) return;
@@ -275,10 +272,10 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
const endpoint = isEdit ? "config/update" : "config/create";
await post(endpoint, body);
- toast(`${isEdit ? "Updated" : "Created"} ${name}`, "success");
+ toast(isEdit ? t("configure.updated", name) : t("configure.created", name), "success");
location.hash = "#/remotes";
} catch (err) {
- toast(`Save failed: ${err.message}`, "error");
+ toast(`${t("configure.save_failed")}: ${err.message}`, "error");
}
});
}
@@ -289,11 +286,10 @@ function oauthBanner(providerName) {
return `
- ${escapeHtml(providerName)} requires OAuth authorization,
- which this web GUI can’t complete inside a container. Please configure it
- from a terminal first:
+ ${t("configure.oauth_title")}
+ ${t("configure.oauth_body", escapeHtml(providerName))}
rclone config
- Once the remote exists, you can edit its non-secret options here.
+ ${t("configure.oauth_hint")}
`;
@@ -327,7 +323,7 @@ function inputHtmlFor(opt, value, isEdit) {
// already obscured and resubmitting would double-obscure. Show empty
// with a "(unchanged)" placeholder.
if (opt.IsPassword && isEdit) {
- return `
`;
+ return `
`;
}
const name = `opt_${escapeHtml(opt.Name)}`;
@@ -343,12 +339,11 @@ function inputHtmlFor(opt, value, isEdit) {
}
if (opt.IsPassword) {
- return `
`;
+ return `
`;
}
- // Examples → select with custom override
if (opt.Examples && opt.Examples.length > 0) {
- const opts = ['
']
+ const opts = [`
`]
.concat(
opt.Examples.map(
(ex) =>
@@ -359,9 +354,9 @@ function inputHtmlFor(opt, value, isEdit) {
return `
-
+
`;
}
diff --git a/webgui/web/assets/js/views/jobs.js b/webgui/web/assets/js/views/jobs.js
index 681ec2b..f4423b0 100644
--- a/webgui/web/assets/js/views/jobs.js
+++ b/webgui/web/assets/js/views/jobs.js
@@ -11,6 +11,7 @@ import {
formatSpeed,
formatDuration,
} from "../state.js";
+import { t } from "../i18n.js";
let pollTimer = null;
@@ -19,13 +20,13 @@ export async function renderJobs() {
app.innerHTML = `
-
Loading jobs…
+
${t("loading.title")}…
`;
@@ -36,13 +37,12 @@ export async function renderJobs() {
export async function renderNewJob() {
const app = document.getElementById("app");
- // Pull remotes for the dropdowns
let remotes = [];
try {
const res = await post("config/listremotes");
remotes = (res && res.remotes) || [];
} catch (e) {
- toast(`Couldn’t load remotes: ${e.message}`, "error");
+ toast(`${t("error.couldnt_load_remotes")}: ${e.message}`, "error");
}
const remoteOpts = remotes
@@ -52,49 +52,48 @@ export async function renderNewJob() {
app.innerHTML = `
-
New Job
-
Copy, sync, or move between remotes.
+
${t("jobs.new_title")}
+
${t("jobs.new_subtitle")}
-
Cancel
+
${t("jobs.cancel")}
`;
- // Prefill first remote in both selectors for convenience
if (remotes.length > 0) {
app.querySelector('select[name="srcRemote"]').value = `${remotes[0]}:`;
app.querySelector('select[name="dstRemote"]').value = `${remotes[0]}:`;
@@ -109,7 +108,7 @@ export async function renderNewJob() {
const srcPath = form.elements.srcPath.value.trim().replace(/^\/+|\/+$/g, "");
const dstPath = form.elements.dstPath.value.trim().replace(/^\/+|\/+$/g, "");
if (!srcRemote || !dstRemote) {
- toast("Pick source and destination remotes", "error");
+ toast(t("error.no_remotes_selected"), "error");
return;
}
const src = srcPath ? `${srcRemote}${srcPath}` : srcRemote;
@@ -122,41 +121,39 @@ export async function renderNewJob() {
if (jobid != null) {
rememberJob(jobid, { action, src, dst });
}
- toast(`Started ${action} job #${jobid}`, "success");
+ toast(t("jobs.started", action, jobid), "success");
location.hash = "#/jobs";
} catch (e) {
- toast(`Job start failed: ${e.message}`, "error");
+ toast(`${t("jobs.start_failed")}: ${e.message}`, "error");
}
});
}
async function refreshJobs() {
const card = document.getElementById("jobs-card");
- if (!card) return; // user navigated away
+ if (!card) return;
let jobIds = [];
try {
const list = await post("job/list");
const running = (list && list.jobids) || [];
const finished = (list && list.finishedIds) || [];
- // Show both running and recently-finished. Sort happens in render.
jobIds = [...new Set([...running, ...finished])];
} catch (e) {
- card.innerHTML = `
Couldn’t load jobs
${escapeHtml(e.message)}
`;
+ card.innerHTML = `
${t("error.couldnt_load_jobs")}
${escapeHtml(e.message)}
`;
return;
}
if (jobIds.length === 0) {
card.innerHTML = `
-
No jobs yet
-
Use New Job to start a copy, sync, or move.
+
${t("jobs.empty_title")}
+
${t("jobs.empty_body").replace("New Job", `${t("jobs.new_btn")}`)}
`;
return;
}
- // Fetch each job's status in parallel
const statuses = await Promise.all(
jobIds.map((id) =>
post("job/status", { jobid: id }).catch((e) => ({
@@ -174,7 +171,6 @@ async function refreshJobs() {
}
function renderJobTable(statuses) {
- // Newest jobid first
statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0));
const rows = statuses.map(renderJobRow).join("");
@@ -183,15 +179,15 @@ function renderJobTable(statuses) {
- | # |
- Job |
- Status |
- Progress |
- Speed |
- ETA |
- Files |
- Errors |
- Action |
+ ${t("jobs.col.id")} |
+ ${t("jobs.col.job")} |
+ ${t("jobs.col.status")} |
+ ${t("jobs.col.progress")} |
+ ${t("jobs.col.speed")} |
+ ${t("jobs.col.eta")} |
+ ${t("jobs.col.files")} |
+ ${t("jobs.col.errors")} |
+ ${t("jobs.col.action")} |
@@ -210,13 +206,13 @@ function renderJobRow(s) {
let badge;
if (!finished) {
- badge = `running`;
+ badge = `${t("jobs.status.running")}`;
} else if (errored || (!success && errored)) {
- badge = `failed`;
+ badge = `${t("jobs.status.failed")}`;
} else if (success) {
- badge = `done`;
+ badge = `${t("jobs.status.done")}`;
} else {
- badge = `finished`;
+ badge = `${t("jobs.status.finished")}`;
}
const meta = getJobMeta(id);
@@ -230,7 +226,7 @@ function renderJobRow(s) {
${escapeHtml(meta.dst)}
`
- : `— submitted via CLI —`;
+ : `${t("jobs.submitted_cli")}`;
const pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0;
const progress = `
@@ -243,7 +239,7 @@ function renderJobRow(s) {
`;
const stopBtn = !finished
- ? ``
+ ? ``
: "";
return `
@@ -262,13 +258,13 @@ function renderJobRow(s) {
}
async function onStop(jobid) {
- if (!confirm(`Stop job #${jobid}?`)) return;
+ if (!confirm(t("jobs.stop_confirm", jobid))) return;
try {
await post("job/stop", { jobid });
- toast(`Stopped job #${jobid}`, "success");
+ toast(t("jobs.stopped", jobid), "success");
await refreshJobs();
} catch (e) {
- toast(`Stop failed: ${e.message}`, "error");
+ toast(`${t("jobs.stop_failed")}: ${e.message}`, "error");
}
}
diff --git a/webgui/web/assets/js/views/remotes.js b/webgui/web/assets/js/views/remotes.js
index 8175a7f..41d904c 100644
--- a/webgui/web/assets/js/views/remotes.js
+++ b/webgui/web/assets/js/views/remotes.js
@@ -2,6 +2,7 @@
import { post } from "../rc.js";
import { getState, setState, toast } from "../state.js";
+import { t } from "../i18n.js";
export async function renderRemotes() {
const app = document.getElementById("app");
@@ -10,13 +11,13 @@ export async function renderRemotes() {
app.innerHTML = `
-
Remotes
-
Configured cloud storage providers. Click a tile to browse, hover for edit/delete.
+
${t("remotes.title")}
+
${t("remotes.subtitle")}
-
New remote
+
${t("remotes.new_remote")}
-
Loading remotes…
+
${t("remotes.loading")}
`;
@@ -39,8 +40,8 @@ export async function renderRemotes() {
if (remotes.length === 0) {
grid.innerHTML = `
-
No remotes configured
-
Click New remote above to add one from this GUI.
+
${t("remotes.empty_title")}
+
${t("remotes.empty_body")}
`;
return;
@@ -53,15 +54,14 @@ export async function renderRemotes() {
${escapeHtml(r.name)}
${escapeHtml(r.type)}
-
-
+
+
`,
)
.join("");
- // Wire action buttons
grid.querySelectorAll("[data-edit]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
@@ -74,24 +74,24 @@ export async function renderRemotes() {
e.preventDefault();
e.stopPropagation();
const name = btn.dataset.delete;
- if (!confirm(`Delete remote ${name}? This cannot be undone.`)) return;
+ if (!confirm(t("remotes.delete_confirm", name))) return;
try {
await post("config/delete", { name });
- toast(`Deleted ${name}`, "success");
+ toast(t("remotes.deleted", name), "success");
await renderRemotes();
} catch (err) {
- toast(`Delete failed: ${err.message}`, "error");
+ toast(`${t("remotes.delete_failed")}: ${err.message}`, "error");
}
});
});
} catch (e) {
grid.innerHTML = `
-
Couldn’t reach rclone
+
${t("error.couldnt_reach")}
${escapeHtml(e.message)}
`;
- toast(`Failed to load remotes: ${e.message}`, "error");
+ toast(`${t("remotes.load_failed")}: ${e.message}`, "error");
}
}
diff --git a/webgui/web/assets/styles/components.css b/webgui/web/assets/styles/components.css
index f8c9f93..9eafddb 100644
--- a/webgui/web/assets/styles/components.css
+++ b/webgui/web/assets/styles/components.css
@@ -50,6 +50,51 @@
background-color: var(--color-surface-card);
}
+/* ---------- Nav-right cluster (lang switcher + New Job) ---------- */
+.nav-right {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ margin-left: auto;
+}
+
+.lang-switcher {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 32px;
+ padding: 0 8px;
+ border-radius: var(--radius-md);
+ background-color: var(--color-surface-soft);
+}
+
+.lang-btn {
+ background: transparent;
+ border: none;
+ padding: 4px 8px;
+ font: var(--typo-caption);
+ color: var(--color-muted);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ line-height: 1;
+}
+
+.lang-btn:hover {
+ color: var(--color-ink);
+}
+
+.lang-btn.active {
+ background-color: var(--color-canvas);
+ color: var(--color-ink);
+ box-shadow: 0 1px 2px rgba(20, 20, 19, 0.06);
+}
+
+.lang-sep {
+ color: var(--color-muted-soft);
+ font: var(--typo-caption);
+ user-select: none;
+}
+
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
diff --git a/webgui/web/index.html b/webgui/web/index.html
index eb35659..1f2efff 100644
--- a/webgui/web/index.html
+++ b/webgui/web/index.html
@@ -18,55 +18,62 @@
rclone
- New Job
+
-
Loading
-
Connecting to rclone RC…
+
+