From 73dfe12c951858886d93868802b91c5651330084 Mon Sep 17 00:00:00 2001 From: ci Date: Fri, 19 Jun 2026 13:05:35 +0800 Subject: [PATCH] =?UTF-8?q?add:=20=E6=B7=BB=E5=8A=A0=20i18n=20=E7=BF=BB?= =?UTF-8?q?=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- webgui/web/assets/js/app.js | 63 +++- webgui/web/assets/js/i18n.js | 387 ++++++++++++++++++++++++ webgui/web/assets/js/views/browser.js | 74 ++--- webgui/web/assets/js/views/configure.js | 95 +++--- webgui/web/assets/js/views/jobs.js | 98 +++--- webgui/web/assets/js/views/remotes.js | 28 +- webgui/web/assets/styles/components.css | 45 +++ webgui/web/index.html | 53 ++-- 8 files changed, 654 insertions(+), 189 deletions(-) create mode 100644 webgui/web/assets/js/i18n.js diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js index 377bae1..de93e34 100644 --- a/webgui/web/assets/js/app.js +++ b/webgui/web/assets/js/app.js @@ -1,7 +1,9 @@ -// app.js — entry point. Routes hash changes to view renderers and -// keeps the top-nav active state in sync. +// app.js — entry point. Routes hash changes to view renderers, keeps +// the top-nav active state in sync, and re-renders chrome strings when +// the locale changes. import { onRoute } from "./state.js"; +import { t, currentLocale, setLocale, onLocale } from "./i18n.js"; import { renderRemotes } from "./views/remotes.js"; import { renderBrowse } from "./views/browser.js"; import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js"; @@ -19,12 +21,13 @@ const views = { "configure-edit": renderConfigureEdit, }; +let currentRoute = { name: "remotes", params: {} }; + function setActiveNav(routeName) { const links = document.querySelectorAll("#nav-links a"); for (const a of links) { const target = a.dataset.route; let isActive = target === routeName; - // "Configure" is active on both configure-new and configure-edit if (target === "configure" && routeName.startsWith("configure-")) { isActive = true; } @@ -32,26 +35,66 @@ function setActiveNav(routeName) { } } -onRoute(async (route) => { +function applyChromeStrings() { + // nav links + document.querySelectorAll("#nav-links a").forEach((a) => { + const key = a.dataset.route === "configure" ? "nav.configure" : `nav.${a.dataset.route}`; + a.textContent = t(key); + }); + const newJobBtn = document.querySelector(".nav-new-job"); + if (newJobBtn) newJobBtn.textContent = t("nav.new_job"); + const lt = document.getElementById("loading-title"); + if (lt) lt.textContent = t("loading.title"); + const lb = document.getElementById("loading-body"); + if (lb) lb.innerHTML = t("loading.connecting"); + // Anything with data-i18n attribute (footer) + document.querySelectorAll("[data-i18n]").forEach((el) => { + el.textContent = t(el.dataset.i18n); + }); + // lang buttons + const locale = currentLocale(); + document.querySelectorAll(".lang-btn").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.lang === locale); + }); +} + +function wireLangSwitcher() { + document.querySelectorAll(".lang-btn").forEach((btn) => { + btn.addEventListener("click", () => setLocale(btn.dataset.lang)); + }); +} + +onRoute((route) => { + currentRoute = route; setActiveNav(route.name); - // Stop jobs polling when leaving the jobs view if (route.name !== "jobs") { stopJobPolling(); } const renderer = views[route.name] || renderRemotes; - try { - await renderer(route.params); - } catch (e) { + renderer(route.params).catch((e) => { console.error("view error", e); const app = document.getElementById("app"); if (app) { - app.innerHTML = `

Something went wrong

${escapeHtml(e.message)}

`; + app.innerHTML = `

${t("error.title")}

${escapeHtml(e.message)}

`; } - } + }); }); +// Re-render everything on locale change so views pick up new strings. +onLocale(() => { + applyChromeStrings(); + const renderer = views[currentRoute.name] || renderRemotes; + renderer(currentRoute.params).catch((e) => { + console.error("view error on locale change", e); + }); +}); + +// Initial chrome paint + switcher wiring. +applyChromeStrings(); +wireLangSwitcher(); + function escapeHtml(s) { return String(s) .replace(/&/g, "&") diff --git a/webgui/web/assets/js/i18n.js b/webgui/web/assets/js/i18n.js new file mode 100644 index 0000000..47c1f90 --- /dev/null +++ b/webgui/web/assets/js/i18n.js @@ -0,0 +1,387 @@ +// i18n.js — minimal i18n. No framework, no build step. +// +// Two locales: "en" (default) and "zh". Locale is auto-detected from +// navigator.language on first load, then user-overridable via the +// switcher in the top nav. Persisted to localStorage. +// +// Usage: +// import { t, currentLocale, setLocale, onLocale } from "./i18n.js"; +// t("remotes.title") // → "Remotes" or "Remotes 远程存储" +// setLocale("zh") // switches + notifies subscribers +// onLocale(() => renderApp()) // re-render on locale change + +const STORAGE_KEY = "webgui:locale"; +const SUPPORTED = ["en", "zh"]; + +function detectInitial() { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved && SUPPORTED.includes(saved)) return saved; + if (navigator.language && navigator.language.toLowerCase().startsWith("zh")) { + return "zh"; + } + return "en"; +} + +let current = detectInitial(); +const listeners = new Set(); + +// --- string catalog --- +// Keep keys flat with dotted namespaces; English is the source. +const STRINGS = { + en: { + // nav + chrome + "nav.remotes": "Remotes", + "nav.configure": "Configure", + "nav.jobs": "Jobs", + "nav.new_job": "New Job", + "loading.title": "Loading", + "loading.connecting": "Connecting to rclone RC…", + "error.title": "Something went wrong", + "error.unknown_remote": "Unknown backend", + "error.no_such_provider": (p) => `No provider named "${p}".`, + "error.no_such_remote": (r) => `No remote named "${r}".`, + "error.couldnt_load": "Couldn't load", + "error.couldnt_load_backends": "Couldn't load backends", + "error.couldnt_reach": "Couldn't reach rclone", + "error.couldnt_load_remotes": "Couldn't load remotes", + "error.couldnt_list": "Couldn't list", + "error.couldnt_load_jobs": "Couldn't load jobs", + "error.no_remotes_selected": "Pick source and destination remotes", + "error.not_connected": "Couldn't connect to rclone", + + // footer + "footer.product": "Product", + "footer.docs": "Documentation", + "footer.community": "Community", + "footer.about": "About", + "footer.rclone_docs": "rclone Docs", + "footer.rcd_command": "rcd Command", + "footer.rc_api": "RC API", + "footer.forum": "Forum", + "footer.github": "GitHub", + "footer.issues": "Issues", + "footer.rclone_org": "rclone.org", + "footer.changelog": "Changelog", + "footer.faq": "FAQ", + "footer.colophon": "rclone — rsync for cloud storage.", + + // language switcher + "lang.label": "Language", + + // remotes view + "remotes.title": "Remotes", + "remotes.subtitle": + "Configured cloud storage providers. Click a tile to browse, hover for edit/delete.", + "remotes.new_remote": "New remote", + "remotes.loading": "Loading remotes…", + "remotes.empty_title": "No remotes configured", + "remotes.empty_body": "Click New remote above to add one from this GUI.", + "remotes.delete_confirm": (n) => + `Delete remote ${n}? This cannot be undone.`, + "remotes.deleted": (n) => `Deleted ${n}`, + "remotes.delete_failed": "Delete failed", + "remotes.load_failed": "Failed to load remotes", + + // browser view + "browser.browse_failed": "List failed", + "browser.empty_title": "Empty folder", + "browser.empty_body": + "No files here. Use Upload in the toolbar to add some.", + "browser.no_remote_title": "No remote selected", + "browser.no_remote_body": "Pick a remote to browse.", + "browser.pick_remote": "Pick a remote", + "browser.col.name": "Name", + "browser.col.size": "Size", + "browser.col.modified": "Modified", + "browser.col.actions": "Actions", + "browser.up": "../", + "browser.mkdir": "New folder", + "browser.upload": "Upload", + "browser.rename": "Rename", + "browser.delete": "Delete", + "browser.mkdir_title": "New folder", + "browser.mkdir_field": "Folder name", + "browser.mkdir_placeholder": "new-folder", + "browser.mkdir_created": (n) => `Created ${n}`, + "browser.mkdir_failed": "mkdir failed", + "browser.uploaded": (n) => `Uploaded ${n} file(s)`, + "browser.upload_failed": "Upload failed", + "browser.rename_title": (n) => `Rename ${n}`, + "browser.rename_field": "New name", + "browser.renamed": (n) => `Renamed to ${n}`, + "browser.rename_failed": "Rename failed", + "browser.delete_confirm": (p) => `Delete ${p}? This cannot be undone.`, + "browser.deleted": (p) => `Deleted ${p}`, + "browser.delete_file_failed": "Delete failed", + "browser.modal.cancel": "Cancel", + "browser.modal.ok": "OK", + + // configure view + "configure.new_title": "New remote", + "configure.new_subtitle": "Pick a storage backend to configure.", + "configure.edit_title": (t) => `Edit ${t} remote`, + "configure.create_title": (t) => `New ${t} remote`, + "configure.edit_subtitle": "", + "configure.cancel": "Cancel", + "configure.filter_placeholder": "Filter backends (e.g. s3, sftp, local)…", + "configure.no_match": "No backends match.", + "configure.name": "Remote name", + "configure.section.required": "Required", + "configure.section.options": "Options", + "configure.section.advanced": "Advanced", + "configure.show_advanced": (n) => `Show advanced options (${n})`, + "configure.hide_advanced": (n) => `Hide advanced options (${n})`, + "configure.save": "Save changes", + "configure.create": "Create remote", + "configure.created": (n) => `Created ${n}`, + "configure.updated": (n) => `Updated ${n}`, + "configure.save_failed": "Save failed", + "configure.oauth_title": "OAuth required", + "configure.oauth_body": (p) => + `${p} requires OAuth authorization, which this web GUI can't complete inside a container. Please configure it from a terminal first:`, + "configure.oauth_hint": "Once the remote exists, you can edit its non-secret options here.", + "configure.delete_section_title": "Delete this remote", + "configure.delete_section_body": (r) => + `Permanently remove ${r} from rclone.conf.`, + "configure.delete_btn": "Delete remote", + "configure.password_unchanged": "(unchanged)", + "configure.password_secret": "secret", + "configure.example_pick": "— pick —", + "configure.example_custom": "Custom…", + "configure.example_custom_placeholder": "custom value", + + // jobs view + "jobs.title": "Jobs", + "jobs.subtitle": "Running and recently completed transfers.", + "jobs.new_btn": "New Job", + "jobs.cancel": "Cancel", + "jobs.new_title": "New Job", + "jobs.new_subtitle": "Copy, sync, or move between remotes.", + "jobs.action": "Action", + "jobs.action_copy": "copy (mirror src → dst, keep both)", + "jobs.action_sync": "sync (mirror src → dst, delete extras on dst)", + "jobs.action_move": "move (mirror src → dst, delete src after)", + "jobs.source_remote": "Source remote", + "jobs.source_path": "Source path (optional)", + "jobs.dest_remote": "Destination remote", + "jobs.dest_path": "Destination path (optional)", + "jobs.path_placeholder": "folder/subfolder", + "jobs.start": "Start Job", + "jobs.no_remotes_option": "(no remotes)", + "jobs.empty_title": "No jobs yet", + "jobs.empty_body": "Use New Job to start a copy, sync, or move.", + "jobs.started": (a, id) => `Started ${a} job #${id}`, + "jobs.start_failed": "Job start failed", + "jobs.stop_confirm": (id) => `Stop job #${id}?`, + "jobs.stopped": (id) => `Stopped job #${id}`, + "jobs.stop_failed": "Stop failed", + "jobs.col.id": "#", + "jobs.col.job": "Job", + "jobs.col.status": "Status", + "jobs.col.progress": "Progress", + "jobs.col.speed": "Speed", + "jobs.col.eta": "ETA", + "jobs.col.files": "Files", + "jobs.col.errors": "Errors", + "jobs.col.action": "Action", + "jobs.status.running": "running", + "jobs.status.failed": "failed", + "jobs.status.done": "done", + "jobs.status.finished": "finished", + "jobs.stop": "Stop", + "jobs.submitted_cli": "— submitted via CLI —", + }, + + zh: { + "nav.remotes": "远程存储", + "nav.configure": "配置", + "nav.jobs": "任务", + "nav.new_job": "新建任务", + "loading.title": "加载中", + "loading.connecting": "正在连接 rclone RC…", + "error.title": "出错了", + "error.unknown_remote": "未知的存储后端", + "error.no_such_provider": (p) => `没有名为 "${p}" 的存储后端。`, + "error.no_such_remote": (r) => `没有名为 "${r}" 的远程存储。`, + "error.couldnt_load": "无法加载", + "error.couldnt_load_backends": "无法加载存储后端列表", + "error.couldnt_reach": "无法连接 rclone", + "error.couldnt_load_remotes": "无法加载远程存储列表", + "error.couldnt_list": "无法列出", + "error.couldnt_load_jobs": "无法加载任务列表", + "error.no_remotes_selected": "请选择源和目标远程存储", + "error.not_connected": "无法连接到 rclone", + + "footer.product": "产品", + "footer.docs": "文档", + "footer.community": "社区", + "footer.about": "关于", + "footer.rclone_docs": "rclone 文档", + "footer.rcd_command": "rcd 命令", + "footer.rc_api": "RC API", + "footer.forum": "论坛", + "footer.github": "GitHub", + "footer.issues": "Issue 列表", + "footer.rclone_org": "rclone.org", + "footer.changelog": "更新日志", + "footer.faq": "常见问题", + "footer.colophon": "rclone — 云存储的 rsync。", + + "lang.label": "语言", + + "remotes.title": "远程存储", + "remotes.subtitle": + "已配置的云存储。点击卡片浏览文件,悬停可编辑/删除。", + "remotes.new_remote": "新建远程存储", + "remotes.loading": "正在加载远程存储…", + "remotes.empty_title": "尚未配置任何远程存储", + "remotes.empty_body": "点击上方的「新建远程存储」从 GUI 添加一个。", + "remotes.delete_confirm": (n) => `删除远程存储 ${n}? 此操作不可撤销。`, + "remotes.deleted": (n) => `已删除 ${n}`, + "remotes.delete_failed": "删除失败", + "remotes.load_failed": "加载远程存储失败", + + "browser.browse_failed": "列出失败", + "browser.empty_title": "空文件夹", + "browser.empty_body": "这里没有文件。用工具栏里的「上传」添加一些。", + "browser.no_remote_title": "未选择远程存储", + "browser.no_remote_body": "请选择一个远程存储来浏览。", + "browser.pick_remote": "选择一个远程存储", + "browser.col.name": "名称", + "browser.col.size": "大小", + "browser.col.modified": "修改时间", + "browser.col.actions": "操作", + "browser.up": "返回上级", + "browser.mkdir": "新建文件夹", + "browser.upload": "上传", + "browser.rename": "重命名", + "browser.delete": "删除", + "browser.mkdir_title": "新建文件夹", + "browser.mkdir_field": "文件夹名称", + "browser.mkdir_placeholder": "新文件夹", + "browser.mkdir_created": (n) => `已创建 ${n}`, + "browser.mkdir_failed": "新建文件夹失败", + "browser.uploaded": (n) => `已上传 ${n} 个文件`, + "browser.upload_failed": "上传失败", + "browser.rename_title": (n) => `重命名 ${n}`, + "browser.rename_field": "新名称", + "browser.renamed": (n) => `已重命名为 ${n}`, + "browser.rename_failed": "重命名失败", + "browser.delete_confirm": (p) => `删除 ${p}? 此操作不可撤销。`, + "browser.deleted": (p) => `已删除 ${p}`, + "browser.delete_file_failed": "删除失败", + "browser.modal.cancel": "取消", + "browser.modal.ok": "确定", + + "configure.new_title": "新建远程存储", + "configure.new_subtitle": "选择一个存储后端进行配置。", + "configure.edit_title": (t) => `编辑 ${t} 远程存储`, + "configure.create_title": (t) => `新建 ${t} 远程存储`, + "configure.edit_subtitle": "", + "configure.cancel": "取消", + "configure.filter_placeholder": "筛选后端(如 s3、sftp、local)…", + "configure.no_match": "没有匹配的后端。", + "configure.name": "远程存储名称", + "configure.section.required": "必填项", + "configure.section.options": "选项", + "configure.section.advanced": "高级", + "configure.show_advanced": (n) => `显示高级选项 (${n})`, + "configure.hide_advanced": (n) => `隐藏高级选项 (${n})`, + "configure.save": "保存修改", + "configure.create": "创建远程存储", + "configure.created": (n) => `已创建 ${n}`, + "configure.updated": (n) => `已更新 ${n}`, + "configure.save_failed": "保存失败", + "configure.oauth_title": "需要 OAuth 授权", + "configure.oauth_body": (p) => + `${p} 需要 OAuth 授权,此 Web GUI 无法在容器内完成。请先在终端中配置:`, + "configure.oauth_hint": "远程存储创建后,可以在这里编辑其非机密选项。", + "configure.delete_section_title": "删除此远程存储", + "configure.delete_section_body": (r) => + `从 rclone.conf 永久移除 ${r}。`, + "configure.delete_btn": "删除远程存储", + "configure.password_unchanged": "(保持不变)", + "configure.password_secret": "密钥", + "configure.example_pick": "— 选择 —", + "configure.example_custom": "自定义…", + "configure.example_custom_placeholder": "自定义值", + + "jobs.title": "任务", + "jobs.subtitle": "正在运行和最近完成的传输。", + "jobs.new_btn": "新建任务", + "jobs.cancel": "取消", + "jobs.new_title": "新建任务", + "jobs.new_subtitle": "在远程存储之间复制、同步或移动。", + "jobs.action": "操作", + "jobs.action_copy": "copy(镜像 源 → 目标,两者都保留)", + "jobs.action_sync": "sync(镜像 源 → 目标,删除目标的额外文件)", + "jobs.action_move": "move(镜像 源 → 目标,完成后删除源)", + "jobs.source_remote": "源远程存储", + "jobs.source_path": "源路径(可选)", + "jobs.dest_remote": "目标远程存储", + "jobs.dest_path": "目标路径(可选)", + "jobs.path_placeholder": "文件夹/子文件夹", + "jobs.start": "开始任务", + "jobs.no_remotes_option": "(无远程存储)", + "jobs.empty_title": "尚无任务", + "jobs.empty_body": "用「新建任务」开始一个 copy、sync 或 move。", + "jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`, + "jobs.start_failed": "任务启动失败", + "jobs.stop_confirm": (id) => `停止任务 #${id}?`, + "jobs.stopped": (id) => `已停止任务 #${id}`, + "jobs.stop_failed": "停止失败", + "jobs.col.id": "#", + "jobs.col.job": "任务", + "jobs.col.status": "状态", + "jobs.col.progress": "进度", + "jobs.col.speed": "速度", + "jobs.col.eta": "预计剩余", + "jobs.col.files": "文件", + "jobs.col.errors": "错误", + "jobs.col.action": "操作", + "jobs.status.running": "运行中", + "jobs.status.failed": "失败", + "jobs.status.done": "完成", + "jobs.status.finished": "已结束", + "jobs.stop": "停止", + "jobs.submitted_cli": "— 通过命令行提交 —", + }, +}; + +export function t(key, ...args) { + const table = STRINGS[current] || STRINGS.en; + const val = table[key] ?? STRINGS.en[key] ?? key; + if (typeof val === "function") return val(...args); + return val; +} + +export function currentLocale() { + return current; +} + +export function setLocale(locale) { + if (!SUPPORTED.includes(locale)) return; + if (locale === current) return; + current = locale; + localStorage.setItem(STORAGE_KEY, locale); + document.documentElement.lang = locale === "zh" ? "zh-CN" : "en"; + for (const fn of listeners) { + try { + fn(current); + } catch (e) { + console.error("i18n listener error", e); + } + } +} + +export function onLocale(fn) { + listeners.add(fn); + return () => listeners.delete(fn); +} + +export function supportedLocales() { + return SUPPORTED; +} + +// Initialize on first load. +document.documentElement.lang = current === "zh" ? "zh-CN" : "en"; diff --git a/webgui/web/assets/js/views/browser.js b/webgui/web/assets/js/views/browser.js index 49c9566..7734d2c 100644 --- a/webgui/web/assets/js/views/browser.js +++ b/webgui/web/assets/js/views/browser.js @@ -2,11 +2,12 @@ import { post, uploadFile, downloadURL } from "../rc.js"; import { toast, formatBytes, formatTime } from "../state.js"; +import { t } from "../i18n.js"; export async function renderBrowse({ remote, path }) { const app = document.getElementById("app"); if (!remote) { - app.innerHTML = `

No remote selected

Pick a remote to browse.

`; + app.innerHTML = `

${t("browser.no_remote_title")}

${t("browser.pick_remote")} ${t("browser.no_remote_body")}

`; return; } @@ -20,13 +21,13 @@ export async function renderBrowse({ remote, path }) {
- - + +
-

Loading…

+

${t("loading.title")}…

`; @@ -45,24 +46,23 @@ export async function renderBrowse({ remote, path }) { const items = (res && res.list) || []; renderTable(card, fs, path, items); } catch (e) { - card.innerHTML = `

Couldn’t list

${escapeHtml(e.message)}

`; - toast(`List failed: ${e.message}`, "error"); + card.innerHTML = `

${t("error.couldnt_list")}

${escapeHtml(e.message)}

`; + toast(`${t("browser.browse_failed")}: ${e.message}`, "error"); } - // --- Toolbar handlers --- app.querySelector('[data-action="mkdir"]').addEventListener("click", () => { openModal( - "New folder", + t("browser.mkdir_title"), [ - { name: "name", label: "Folder name", type: "text", placeholder: "new-folder" }, + { name: "name", label: t("browser.mkdir_field"), type: "text", placeholder: t("browser.mkdir_placeholder") }, ], async ({ name }) => { if (!name) return; const target = path ? `${path}/${name}` : name; await post("operations/mkdir", { fs, remote: target }); - toast(`Created ${name}`, "success"); + toast(t("browser.mkdir_created", name), "success"); }, - ).catch((e) => toast(`mkdir failed: ${e.message}`, "error")); + ).catch((e) => toast(`${t("browser.mkdir_failed")}: ${e.message}`, "error")); }); app.querySelector('[data-action="upload"]').addEventListener("click", () => { @@ -75,18 +75,17 @@ export async function renderBrowse({ remote, path }) { if (files.length === 0) return; try { await uploadFile(fs, path || "", files); - toast(`Uploaded ${files.length} file(s)`, "success"); - // Re-render list + toast(t("browser.uploaded", files.length), "success"); location.reload(); } catch (e) { - toast(`Upload failed: ${e.message}`, "error"); + toast(`${t("browser.upload_failed")}: ${e.message}`, "error"); } }); } function renderBreadcrumbs(el, remote, path) { const segments = (path || "").split("/").filter(Boolean); - let html = `Remotes/`; + let html = `${t("nav.remotes")}/`; html += `${escapeHtml(remote)}`; let acc = ""; for (let i = 0; i < segments.length; i++) { @@ -107,14 +106,13 @@ function renderTable(card, fs, path, items) { if (!items || items.length === 0) { card.innerHTML = `
-

Empty folder

-

No files here. Use Upload in the toolbar to add some.

+

${t("browser.empty_title")}

+

${t("browser.empty_body")}

`; return; } - // Sort: directories first, then files; alphabetical within each group. items.sort((a, b) => { if (a.IsDir !== b.IsDir) return a.IsDir ? -1 : 1; return a.Name.localeCompare(b.Name); @@ -127,20 +125,19 @@ function renderTable(card, fs, path, items) { - - - - + + + + - ${parentHref ? `` : ""} + ${parentHref ? `` : ""} ${rows}
NameSizeModifiedActions${t("browser.col.name")}${t("browser.col.size")}${t("browser.col.modified")}${t("browser.col.actions")}
../
${t("browser.up")}
`; - // Wire row action buttons card.querySelectorAll("[data-delete]").forEach((btn) => { btn.addEventListener("click", () => onDelete(fs, path, btn.dataset.delete)); }); @@ -170,8 +167,8 @@ function row(fs, path, item) { ${escapeHtml(formatBytes(item.Size))} ${escapeHtml(formatTime(item.ModTime))} - - + + `; @@ -197,14 +194,13 @@ function parentLink(fs, path) { } async function onDelete(fs, path, itemPath) { - // itemPath is relative to fs root - if (!confirm(`Delete ${itemPath}? This cannot be undone.`)) return; + if (!confirm(t("browser.delete_confirm", itemPath))) return; try { await post("operations/deletefile", { fs, remote: itemPath }); - toast(`Deleted ${itemPath}`, "success"); + toast(t("browser.deleted", itemPath), "success"); location.reload(); } catch (e) { - toast(`Delete failed: ${e.message}`, "error"); + toast(`${t("browser.delete_file_failed")}: ${e.message}`, "error"); } } @@ -212,9 +208,9 @@ async function onRename(fs, path, itemPath) { const segments = itemPath.split("/"); const oldName = segments.pop(); try { - const result = await openModal( - `Rename ${oldName}`, - [{ name: "name", label: "New name", type: "text", value: oldName }], + await openModal( + t("browser.rename_title", oldName), + [{ name: "name", label: t("browser.rename_field"), type: "text", value: oldName }], async ({ name }) => { if (!name || name === oldName) return; const dir = segments.join("/"); @@ -225,19 +221,16 @@ async function onRename(fs, path, itemPath) { dstFs: fs, dstRemote: dst, }); - toast(`Renamed to ${name}`, "success"); + toast(t("browser.renamed", name), "success"); }, ); - // Modal succeeded → reload to refresh list location.reload(); } catch (e) { - toast(`Rename failed: ${e.message}`, "error"); + toast(`${t("browser.rename_failed")}: ${e.message}`, "error"); } } // --- Modal helper --- -let modalResolver = null; - export function openModal(title, fields, onSubmit) { return new Promise((resolve) => { const root = document.getElementById("modal-root"); @@ -257,8 +250,8 @@ export function openModal(title, fields, onSubmit) {

${escapeHtml(title)}

${formHtml} @@ -292,7 +285,6 @@ export function openModal(title, fields, onSubmit) { toast(err.message, "error"); } }); - // Focus first input const first = form.elements[fields[0].name]; if (first) first.focus(); }); diff --git a/webgui/web/assets/js/views/configure.js b/webgui/web/assets/js/views/configure.js index 72dd402..0a7c1cf 100644 --- a/webgui/web/assets/js/views/configure.js +++ b/webgui/web/assets/js/views/configure.js @@ -11,6 +11,7 @@ import { post } from "../rc.js"; import { getState, setState, toast } from "../state.js"; +import { t } from "../i18n.js"; // --- Route entrypoints --- @@ -23,9 +24,9 @@ export async function renderConfigureNew({ provider = "" }) { if (!info) { document.getElementById("app").innerHTML = `
-

Unknown backend

-

No provider named ${escapeHtml(provider)}.

-

← Back to provider picker

+

${t("error.unknown_remote")}

+

${t("error.no_such_provider", provider)}

+

← ${t("configure.new_title")}

`; return; } @@ -38,23 +39,22 @@ export async function renderConfigureEdit({ remote }) { return; } const app = document.getElementById("app"); - app.innerHTML = `

Loading remote ${escapeHtml(remote)}

`; + app.innerHTML = `

${t("loading.title")}…

`; - // Find the remote's type from /config/dump let typeName; try { const dump = await post("config/dump"); typeName = dump && dump[remote] && dump[remote].type; } catch (e) { - toast(`Couldn't read remote: ${e.message}`, "error"); + toast(`${t("error.couldnt_load")}: ${e.message}`, "error"); return; } if (!typeName) { app.innerHTML = `
-

Remote not found

-

No remote named ${escapeHtml(remote)}.

-

← Back to remotes

+

${t("error.unknown_remote")}

+

${t("error.no_such_remote", remote)}

+

← ${t("nav.remotes")}

`; return; } @@ -62,7 +62,7 @@ export async function renderConfigureEdit({ remote }) { const providers = await ensureProviders(); const info = providers.find((p) => p.Name === typeName); if (!info) { - toast(`Backend ${typeName} not found in registry`, "error"); + toast(`${t("error.no_such_provider", typeName)}`, "error"); return; } @@ -70,7 +70,7 @@ export async function renderConfigureEdit({ remote }) { try { currentValues = await post("config/get", { name: remote }); } catch (e) { - toast(`Couldn't read config: ${e.message}`, "error"); + toast(`${t("error.couldnt_load")}: ${e.message}`, "error"); } return renderForm({ @@ -88,16 +88,16 @@ async function renderProviderPicker() { app.innerHTML = `
-

New remote

-

Pick a storage backend to configure.

+

${t("configure.new_title")}

+

${t("configure.new_subtitle")}

- Cancel + ${t("configure.cancel")}
-

Loading backends…

+

${t("loading.title")}…

`; @@ -108,18 +108,17 @@ async function renderProviderPicker() { try { providers = await ensureProviders(); } catch (e) { - grid.innerHTML = `

Couldn’t load backends

${escapeHtml(e.message)}

`; + grid.innerHTML = `

${t("error.couldnt_load_backends")}

${escapeHtml(e.message)}

`; return; } - // Skip hidden + alias/all wrapper backends const visible = providers.filter( (p) => !p.Hide && p.Name !== "all" && p.Name !== "alias", ); function paint(list) { if (list.length === 0) { - grid.innerHTML = `

No backends match.

`; + grid.innerHTML = `

${t("configure.no_match")}

`; return; } grid.innerHTML = list @@ -165,10 +164,10 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {} app.innerHTML = `
-

${isEdit ? "Edit" : "New"} ${escapeHtml(provider.Name)} remote

+

${isEdit ? t("configure.edit_title", provider.Name) : t("configure.create_title", provider.Name)}

${escapeHtml(provider.Description || "")}

- Cancel + ${t("configure.cancel")}
@@ -176,7 +175,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
- +
- ${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 ? `
` : ""} @@ -208,16 +207,16 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
- Cancel + ${t("configure.cancel")}
${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 ` `; @@ -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 = `
-

Jobs

-

Running and recently completed transfers.

+

${t("jobs.title")}

+

${t("jobs.subtitle")}

- New Job + ${t("jobs.new_btn")}
-

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) { - - - - - - - - - + + + + + + + + + @@ -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…

+

+

#JobStatusProgressSpeedETAFilesErrorsAction${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")}