add: 添加 i18n 翻译
This commit is contained in:
+53
-10
@@ -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 = `<div class="empty"><h3>Something went wrong</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||
app.innerHTML = `<div class="empty"><h3>${t("error.title")}</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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, "&")
|
||||
|
||||
@@ -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 <html lang> on first load.
|
||||
document.documentElement.lang = current === "zh" ? "zh-CN" : "en";
|
||||
@@ -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 = `<div class="empty"><h3>No remote selected</h3><p><a href="#/remotes">Pick a remote</a> to browse.</p></div>`;
|
||||
app.innerHTML = `<div class="empty"><h3>${t("browser.no_remote_title")}</h3><p><a href="#/remotes">${t("browser.pick_remote")}</a> ${t("browser.no_remote_body")}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,13 +21,13 @@ export async function renderBrowse({ remote, path }) {
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input type="file" id="upload-input" multiple style="display:none">
|
||||
<button class="btn btn-secondary btn-sm" data-action="mkdir">New folder</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="upload">Upload</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="mkdir">${t("browser.mkdir")}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="upload">${t("browser.upload")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="breadcrumbs" class="breadcrumbs"></div>
|
||||
<div id="browser-card" class="card-outline">
|
||||
<p class="empty">Loading…</p>
|
||||
<p class="empty">${t("loading.title")}…</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -45,24 +46,23 @@ export async function renderBrowse({ remote, path }) {
|
||||
const items = (res && res.list) || [];
|
||||
renderTable(card, fs, path, items);
|
||||
} catch (e) {
|
||||
card.innerHTML = `<div class="empty"><h3>Couldn’t list</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||
toast(`List failed: ${e.message}`, "error");
|
||||
card.innerHTML = `<div class="empty"><h3>${t("error.couldnt_list")}</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||
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 = `<a href="#/remotes">Remotes</a><span class="sep">/</span>`;
|
||||
let html = `<a href="#/remotes">${t("nav.remotes")}</a><span class="sep">/</span>`;
|
||||
html += `<a href="#/browse/${encodeURIComponent(remote)}">${escapeHtml(remote)}</a>`;
|
||||
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 = `
|
||||
<div class="empty">
|
||||
<h3>Empty folder</h3>
|
||||
<p>No files here. Use <strong>Upload</strong> in the toolbar to add some.</p>
|
||||
<h3>${t("browser.empty_title")}</h3>
|
||||
<p>${t("browser.empty_body")}</p>
|
||||
</div>
|
||||
`;
|
||||
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) {
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="col-num">Size</th>
|
||||
<th>Modified</th>
|
||||
<th class="col-num">Actions</th>
|
||||
<th>${t("browser.col.name")}</th>
|
||||
<th class="col-num">${t("browser.col.size")}</th>
|
||||
<th>${t("browser.col.modified")}</th>
|
||||
<th class="col-num">${t("browser.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${parentHref ? `<tr class="row-dir"><td class="col-name"><a href="${parentHref}">../</a></td><td></td><td></td><td></td></tr>` : ""}
|
||||
${parentHref ? `<tr class="row-dir"><td class="col-name"><a href="${parentHref}">${t("browser.up")}</a></td><td></td><td></td><td></td></tr>` : ""}
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
// 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) {
|
||||
<td class="col-num col-mono">${escapeHtml(formatBytes(item.Size))}</td>
|
||||
<td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td>
|
||||
<td class="col-num">
|
||||
<button class="btn btn-secondary btn-sm" data-rename="${escapeHtml(itemPath)}">Rename</button>
|
||||
<button class="btn btn-danger btn-sm" data-delete="${escapeHtml(itemPath)}">Delete</button>
|
||||
<button class="btn btn-secondary btn-sm" data-rename="${escapeHtml(itemPath)}">${t("browser.rename")}</button>
|
||||
<button class="btn btn-danger btn-sm" data-delete="${escapeHtml(itemPath)}">${t("browser.delete")}</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -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) {
|
||||
<h3>${escapeHtml(title)}</h3>
|
||||
${formHtml}
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" data-cancel>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">OK</button>
|
||||
<button type="button" class="btn btn-secondary" data-cancel>${t("browser.modal.cancel")}</button>
|
||||
<button type="submit" class="btn btn-primary">${t("browser.modal.ok")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 = `
|
||||
<div class="empty">
|
||||
<h3>Unknown backend</h3>
|
||||
<p>No provider named <code>${escapeHtml(provider)}</code>.</p>
|
||||
<p><a href="#/configure/new">← Back to provider picker</a></p>
|
||||
<h3>${t("error.unknown_remote")}</h3>
|
||||
<p>${t("error.no_such_provider", provider)}</p>
|
||||
<p><a href="#/configure/new">← ${t("configure.new_title")}</a></p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
@@ -38,23 +39,22 @@ export async function renderConfigureEdit({ remote }) {
|
||||
return;
|
||||
}
|
||||
const app = document.getElementById("app");
|
||||
app.innerHTML = `<div class="empty"><p>Loading remote <code>${escapeHtml(remote)}</code>…</p></div>`;
|
||||
app.innerHTML = `<div class="empty"><p>${t("loading.title")}…</p></div>`;
|
||||
|
||||
// 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 = `
|
||||
<div class="empty">
|
||||
<h3>Remote not found</h3>
|
||||
<p>No remote named <code>${escapeHtml(remote)}</code>.</p>
|
||||
<p><a href="#/remotes">← Back to remotes</a></p>
|
||||
<h3>${t("error.unknown_remote")}</h3>
|
||||
<p>${t("error.no_such_remote", remote)}</p>
|
||||
<p><a href="#/remotes">← ${t("nav.remotes")}</a></p>
|
||||
</div>`;
|
||||
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 = `
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>New remote</h2>
|
||||
<p class="subtitle">Pick a storage backend to configure.</p>
|
||||
<h2>${t("configure.new_title")}</h2>
|
||||
<p class="subtitle">${t("configure.new_subtitle")}</p>
|
||||
</div>
|
||||
<a class="btn btn-secondary btn-sm" href="#/remotes">Cancel</a>
|
||||
<a class="btn btn-secondary btn-sm" href="#/remotes">${t("configure.cancel")}</a>
|
||||
</div>
|
||||
<div class="provider-search">
|
||||
<input id="provider-filter" class="input" type="search" placeholder="Filter backends (e.g. s3, sftp, local)…" autocomplete="off">
|
||||
<input id="provider-filter" class="input" type="search" placeholder="${t("configure.filter_placeholder")}" autocomplete="off">
|
||||
</div>
|
||||
<div id="provider-grid" class="connector-grid">
|
||||
<p class="empty" style="grid-column:1/-1">Loading backends…</p>
|
||||
<p class="empty" style="grid-column:1/-1">${t("loading.title")}…</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -108,18 +108,17 @@ async function renderProviderPicker() {
|
||||
try {
|
||||
providers = await ensureProviders();
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div class="empty" style="grid-column:1/-1"><h3>Couldn’t load backends</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||
grid.innerHTML = `<div class="empty" style="grid-column:1/-1"><h3>${t("error.couldnt_load_backends")}</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||
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 = `<p class="empty" style="grid-column:1/-1">No backends match.</p>`;
|
||||
grid.innerHTML = `<p class="empty" style="grid-column:1/-1">${t("configure.no_match")}</p>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = list
|
||||
@@ -165,10 +164,10 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
|
||||
app.innerHTML = `
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>${isEdit ? "Edit" : "New"} ${escapeHtml(provider.Name)} remote</h2>
|
||||
<h2>${isEdit ? t("configure.edit_title", provider.Name) : t("configure.create_title", provider.Name)}</h2>
|
||||
<p class="subtitle">${escapeHtml(provider.Description || "")}</p>
|
||||
</div>
|
||||
<a class="btn btn-secondary btn-sm" href="#/remotes">Cancel</a>
|
||||
<a class="btn btn-secondary btn-sm" href="#/remotes">${t("configure.cancel")}</a>
|
||||
</div>
|
||||
|
||||
<form id="remote-form" class="card-outline" style="display:flex;flex-direction:column;gap:16px;max-width:760px">
|
||||
@@ -176,7 +175,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field field-full">
|
||||
<label>Remote name <span class="field-required">*</span></label>
|
||||
<label>${t("configure.name")} <span class="field-required">*</span></label>
|
||||
<input
|
||||
class="input"
|
||||
name="_remote_name"
|
||||
@@ -187,20 +186,20 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
|
||||
>
|
||||
</div>
|
||||
|
||||
${required.length > 0 ? `<div class="form-section-title">Required</div>` : ""}
|
||||
${required.length > 0 ? `<div class="form-section-title">${t("configure.section.required")}</div>` : ""}
|
||||
${required.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
|
||||
|
||||
${basic.length > 0 ? `<div class="form-section-title">Options</div>` : ""}
|
||||
${basic.length > 0 ? `<div class="form-section-title">${t("configure.section.options")}</div>` : ""}
|
||||
${basic.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
|
||||
|
||||
${advanced.length > 0 ? `
|
||||
<div class="advanced-toggle-wrap">
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="advanced-toggle">
|
||||
Show advanced options (${advanced.length})
|
||||
${t("configure.show_advanced", advanced.length)}
|
||||
</button>
|
||||
</div>
|
||||
<div id="advanced-section" class="advanced-section hidden">
|
||||
<div class="form-section-title">Advanced</div>
|
||||
<div class="form-section-title">${t("configure.section.advanced")}</div>
|
||||
${advanced.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
|
||||
</div>
|
||||
` : ""}
|
||||
@@ -208,16 +207,16 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
|
||||
|
||||
<div class="toolbar">
|
||||
<button type="submit" class="btn btn-primary" ${requiresOAuth ? "disabled" : ""}>
|
||||
${isEdit ? "Save changes" : "Create remote"}
|
||||
${isEdit ? t("configure.save") : t("configure.create")}
|
||||
</button>
|
||||
<a class="btn btn-secondary" href="#/remotes">Cancel</a>
|
||||
<a class="btn btn-secondary" href="#/remotes">${t("configure.cancel")}</a>
|
||||
</div>
|
||||
|
||||
${isEdit ? `
|
||||
<div class="danger-zone">
|
||||
<h4>Delete this remote</h4>
|
||||
<p>Permanently remove <code>${escapeHtml(remoteName)}</code> from rclone.conf.</p>
|
||||
<button type="button" class="btn btn-danger" id="delete-btn">Delete remote</button>
|
||||
<h4>${t("configure.delete_section_title")}</h4>
|
||||
<p>${t("configure.delete_section_body", escapeHtml(remoteName))}</p>
|
||||
<button type="button" class="btn btn-danger" id="delete-btn">${t("configure.delete_btn")}</button>
|
||||
</div>
|
||||
` : ""}
|
||||
</form>
|
||||
@@ -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 `
|
||||
<div class="banner banner-warning">
|
||||
<div>
|
||||
<strong>${escapeHtml(providerName)}</strong> requires OAuth authorization,
|
||||
which this web GUI can’t complete inside a container. Please configure it
|
||||
from a terminal first:
|
||||
<strong>${t("configure.oauth_title")}</strong>
|
||||
${t("configure.oauth_body", escapeHtml(providerName))}
|
||||
<code>rclone config</code>
|
||||
Once the remote exists, you can edit its non-secret options here.
|
||||
${t("configure.oauth_hint")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -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 `<input class="input" name="opt_${escapeHtml(opt.Name)}" type="password" autocomplete="off" placeholder="(unchanged)">`;
|
||||
return `<input class="input" name="opt_${escapeHtml(opt.Name)}" type="password" autocomplete="off" placeholder="${t("configure.password_unchanged")}">`;
|
||||
}
|
||||
|
||||
const name = `opt_${escapeHtml(opt.Name)}`;
|
||||
@@ -343,12 +339,11 @@ function inputHtmlFor(opt, value, isEdit) {
|
||||
}
|
||||
|
||||
if (opt.IsPassword) {
|
||||
return `<input class="input" name="${name}" type="password" autocomplete="off" placeholder="secret">`;
|
||||
return `<input class="input" name="${name}" type="password" autocomplete="off" placeholder="${t("configure.password_secret")}">`;
|
||||
}
|
||||
|
||||
// Examples → select with custom override
|
||||
if (opt.Examples && opt.Examples.length > 0) {
|
||||
const opts = ['<option value="">— pick —</option>']
|
||||
const opts = [`<option value="">${t("configure.example_pick")}</option>`]
|
||||
.concat(
|
||||
opt.Examples.map(
|
||||
(ex) =>
|
||||
@@ -359,9 +354,9 @@ function inputHtmlFor(opt, value, isEdit) {
|
||||
return `
|
||||
<select class="select" name="${name}" data-has-custom="1">
|
||||
${opts}
|
||||
<option value="__custom__"${currentValue && !opt.Examples.some((e) => e.Value === currentValue) ? " selected" : ""}>Custom…</option>
|
||||
<option value="__custom__"${currentValue && !opt.Examples.some((e) => e.Value === currentValue) ? " selected" : ""}>${t("configure.example_custom")}</option>
|
||||
</select>
|
||||
<input class="input" name="${name}__custom" type="text" value="${escapeAttr(currentValue)}" style="margin-top:8px;display:none" placeholder="custom value" autocomplete="off">
|
||||
<input class="input" name="${name}__custom" type="text" value="${escapeAttr(currentValue)}" style="margin-top:8px;display:none" placeholder="${t("configure.example_custom_placeholder")}" autocomplete="off">
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>Jobs</h2>
|
||||
<p class="subtitle">Running and recently completed transfers.</p>
|
||||
<h2>${t("jobs.title")}</h2>
|
||||
<p class="subtitle">${t("jobs.subtitle")}</p>
|
||||
</div>
|
||||
<a class="btn btn-primary btn-sm" href="#/jobs/new">New Job</a>
|
||||
<a class="btn btn-primary btn-sm" href="#/jobs/new">${t("jobs.new_btn")}</a>
|
||||
</div>
|
||||
<div id="jobs-card" class="card-outline">
|
||||
<p class="empty">Loading jobs…</p>
|
||||
<p class="empty">${t("loading.title")}…</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -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 = `
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>New Job</h2>
|
||||
<p class="subtitle">Copy, sync, or move between remotes.</p>
|
||||
<h2>${t("jobs.new_title")}</h2>
|
||||
<p class="subtitle">${t("jobs.new_subtitle")}</p>
|
||||
</div>
|
||||
<a class="btn btn-secondary btn-sm" href="#/jobs">Cancel</a>
|
||||
<a class="btn btn-secondary btn-sm" href="#/jobs">${t("jobs.cancel")}</a>
|
||||
</div>
|
||||
<form id="new-job-form" class="card-outline" style="display:grid;gap:16px;max-width:640px">
|
||||
<div class="field">
|
||||
<label>Action</label>
|
||||
<label>${t("jobs.action")}</label>
|
||||
<select name="action" class="select">
|
||||
<option value="copy">copy (mirror src → dst, keep both)</option>
|
||||
<option value="sync">sync (mirror src → dst, delete extras on dst)</option>
|
||||
<option value="move">move (mirror src → dst, delete src after)</option>
|
||||
<option value="copy">${t("jobs.action_copy")}</option>
|
||||
<option value="sync">${t("jobs.action_sync")}</option>
|
||||
<option value="move">${t("jobs.action_move")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>Source remote</label>
|
||||
<label>${t("jobs.source_remote")}</label>
|
||||
<select name="srcRemote" class="select">
|
||||
${remoteOpts || '<option value="">(no remotes)</option>'}
|
||||
${remoteOpts || `<option value="">${t("jobs.no_remotes_option")}</option>`}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Source path (optional)</label>
|
||||
<input class="input" name="srcPath" placeholder="folder/subfolder" autocomplete="off">
|
||||
<label>${t("jobs.source_path")}</label>
|
||||
<input class="input" name="srcPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Destination remote</label>
|
||||
<label>${t("jobs.dest_remote")}</label>
|
||||
<select name="dstRemote" class="select">
|
||||
${remoteOpts || '<option value="">(no remotes)</option>'}
|
||||
${remoteOpts || `<option value="">${t("jobs.no_remotes_option")}</option>`}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Destination path (optional)</label>
|
||||
<input class="input" name="dstPath" placeholder="folder/subfolder" autocomplete="off">
|
||||
<label>${t("jobs.dest_path")}</label>
|
||||
<input class="input" name="dstPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button type="submit" class="btn btn-primary">Start Job</button>
|
||||
<button type="submit" class="btn btn-primary">${t("jobs.start")}</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
// 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 = `<div class="empty"><h3>Couldn’t load jobs</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||
card.innerHTML = `<div class="empty"><h3>${t("error.couldnt_load_jobs")}</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (jobIds.length === 0) {
|
||||
card.innerHTML = `
|
||||
<div class="empty">
|
||||
<h3>No jobs yet</h3>
|
||||
<p>Use <a href="#/jobs/new">New Job</a> to start a copy, sync, or move.</p>
|
||||
<h3>${t("jobs.empty_title")}</h3>
|
||||
<p>${t("jobs.empty_body").replace("New Job", `<a href="#/jobs/new">${t("jobs.new_btn")}</a>`)}</p>
|
||||
</div>
|
||||
`;
|
||||
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) {
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Job</th>
|
||||
<th>Status</th>
|
||||
<th class="col-num">Progress</th>
|
||||
<th class="col-num">Speed</th>
|
||||
<th class="col-num">ETA</th>
|
||||
<th class="col-num">Files</th>
|
||||
<th class="col-num">Errors</th>
|
||||
<th class="col-num">Action</th>
|
||||
<th>${t("jobs.col.id")}</th>
|
||||
<th>${t("jobs.col.job")}</th>
|
||||
<th>${t("jobs.col.status")}</th>
|
||||
<th class="col-num">${t("jobs.col.progress")}</th>
|
||||
<th class="col-num">${t("jobs.col.speed")}</th>
|
||||
<th class="col-num">${t("jobs.col.eta")}</th>
|
||||
<th class="col-num">${t("jobs.col.files")}</th>
|
||||
<th class="col-num">${t("jobs.col.errors")}</th>
|
||||
<th class="col-num">${t("jobs.col.action")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -210,13 +206,13 @@ function renderJobRow(s) {
|
||||
|
||||
let badge;
|
||||
if (!finished) {
|
||||
badge = `<span class="badge">running</span>`;
|
||||
badge = `<span class="badge">${t("jobs.status.running")}</span>`;
|
||||
} else if (errored || (!success && errored)) {
|
||||
badge = `<span class="badge badge-error">failed</span>`;
|
||||
badge = `<span class="badge badge-error">${t("jobs.status.failed")}</span>`;
|
||||
} else if (success) {
|
||||
badge = `<span class="badge badge-success">done</span>`;
|
||||
badge = `<span class="badge badge-success">${t("jobs.status.done")}</span>`;
|
||||
} else {
|
||||
badge = `<span class="badge badge-warning">finished</span>`;
|
||||
badge = `<span class="badge badge-warning">${t("jobs.status.finished")}</span>`;
|
||||
}
|
||||
|
||||
const meta = getJobMeta(id);
|
||||
@@ -230,7 +226,7 @@ function renderJobRow(s) {
|
||||
<code>${escapeHtml(meta.dst)}</code>
|
||||
</span>
|
||||
</div>`
|
||||
: `<span class="col-mono" style="color:var(--color-muted-soft)">— submitted via CLI —</span>`;
|
||||
: `<span class="col-mono" style="color:var(--color-muted-soft)">${t("jobs.submitted_cli")}</span>`;
|
||||
|
||||
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
|
||||
? `<button class="btn btn-danger btn-sm" data-stop="${id}">Stop</button>`
|
||||
? `<button class="btn btn-danger btn-sm" data-stop="${id}">${t("jobs.stop")}</button>`
|
||||
: "";
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>Remotes</h2>
|
||||
<p class="subtitle">Configured cloud storage providers. Click a tile to browse, hover for edit/delete.</p>
|
||||
<h2>${t("remotes.title")}</h2>
|
||||
<p class="subtitle">${t("remotes.subtitle")}</p>
|
||||
</div>
|
||||
<a class="btn btn-primary btn-sm" href="#/configure/new">New remote</a>
|
||||
<a class="btn btn-primary btn-sm" href="#/configure/new">${t("remotes.new_remote")}</a>
|
||||
</div>
|
||||
<div id="remote-grid" class="connector-grid">
|
||||
<p class="empty" style="grid-column:1/-1">Loading remotes…</p>
|
||||
<p class="empty" style="grid-column:1/-1">${t("remotes.loading")}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -39,8 +40,8 @@ export async function renderRemotes() {
|
||||
if (remotes.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty" style="grid-column:1/-1">
|
||||
<h3>No remotes configured</h3>
|
||||
<p>Click <strong>New remote</strong> above to add one from this GUI.</p>
|
||||
<h3>${t("remotes.empty_title")}</h3>
|
||||
<p>${t("remotes.empty_body")}</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
@@ -53,15 +54,14 @@ export async function renderRemotes() {
|
||||
<span class="tile-name">${escapeHtml(r.name)}</span>
|
||||
<span class="badge">${escapeHtml(r.type)}</span>
|
||||
<div class="tile-actions">
|
||||
<button type="button" title="Edit" data-edit="${escapeHtml(r.name)}">✎</button>
|
||||
<button type="button" title="Delete" class="danger" data-delete="${escapeHtml(r.name)}">🗑</button>
|
||||
<button type="button" title="${t("browser.rename")}" data-edit="${escapeHtml(r.name)}">✎</button>
|
||||
<button type="button" title="${t("browser.delete")}" class="danger" data-delete="${escapeHtml(r.name)}">🗑</button>
|
||||
</div>
|
||||
</a>
|
||||
`,
|
||||
)
|
||||
.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 = `
|
||||
<div class="empty" style="grid-column:1/-1">
|
||||
<h3>Couldn’t reach rclone</h3>
|
||||
<h3>${t("error.couldnt_reach")}</h3>
|
||||
<p>${escapeHtml(e.message)}</p>
|
||||
</div>
|
||||
`;
|
||||
toast(`Failed to load remotes: ${e.message}`, "error");
|
||||
toast(`${t("remotes.load_failed")}: ${e.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+30
-23
@@ -18,55 +18,62 @@
|
||||
<span>rclone</span>
|
||||
</a>
|
||||
<nav id="nav-links">
|
||||
<a href="#/remotes" data-route="remotes">Remotes</a>
|
||||
<a href="#/configure/new" data-route="configure">Configure</a>
|
||||
<a href="#/jobs" data-route="jobs">Jobs</a>
|
||||
<a href="#/remotes" data-route="remotes"></a>
|
||||
<a href="#/configure/new" data-route="configure"></a>
|
||||
<a href="#/jobs" data-route="jobs"></a>
|
||||
</nav>
|
||||
<a class="btn btn-primary btn-sm" href="#/jobs/new">New Job</a>
|
||||
<div class="nav-right">
|
||||
<div class="lang-switcher" id="lang-switcher">
|
||||
<button type="button" class="lang-btn" data-lang="en" aria-label="English">EN</button>
|
||||
<span class="lang-sep">/</span>
|
||||
<button type="button" class="lang-btn" data-lang="zh" aria-label="中文">中</button>
|
||||
</div>
|
||||
<a class="btn btn-primary btn-sm nav-new-job" href="#/jobs/new"></a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="app">
|
||||
<div class="empty">
|
||||
<h3>Loading</h3>
|
||||
<p>Connecting to rclone RC…</p>
|
||||
<h3 id="loading-title"></h3>
|
||||
<p id="loading-body"></p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h4>Product</h4>
|
||||
<h4 data-i18n="footer.product"></h4>
|
||||
<ul>
|
||||
<li><a href="#/remotes">Remotes</a></li>
|
||||
<li><a href="#/jobs">Jobs</a></li>
|
||||
<li><a href="#/jobs/new">New Job</a></li>
|
||||
<li><a href="#/remotes" data-i18n="nav.remotes"></a></li>
|
||||
<li><a href="#/jobs" data-i18n="nav.jobs"></a></li>
|
||||
<li><a href="#/jobs/new" data-i18n="nav.new_job"></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Documentation</h4>
|
||||
<h4 data-i18n="footer.docs"></h4>
|
||||
<ul>
|
||||
<li><a href="https://rclone.org/docs/" target="_blank" rel="noopener">rclone Docs</a></li>
|
||||
<li><a href="https://rclone.org/commands/rclone_rcd/" target="_blank" rel="noopener">rcd Command</a></li>
|
||||
<li><a href="https://rclone.org/rc/" target="_blank" rel="noopener">RC API</a></li>
|
||||
<li><a href="https://rclone.org/docs/" target="_blank" rel="noopener" data-i18n="footer.rclone_docs"></a></li>
|
||||
<li><a href="https://rclone.org/commands/rclone_rcd/" target="_blank" rel="noopener" data-i18n="footer.rcd_command"></a></li>
|
||||
<li><a href="https://rclone.org/rc/" target="_blank" rel="noopener" data-i18n="footer.rc_api"></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Community</h4>
|
||||
<h4 data-i18n="footer.community"></h4>
|
||||
<ul>
|
||||
<li><a href="https://forum.rclone.org/" target="_blank" rel="noopener">Forum</a></li>
|
||||
<li><a href="https://github.com/rclone/rclone" target="_blank" rel="noopener">GitHub</a></li>
|
||||
<li><a href="https://github.com/rclone/rclone/issues" target="_blank" rel="noopener">Issues</a></li>
|
||||
<li><a href="https://forum.rclone.org/" target="_blank" rel="noopener" data-i18n="footer.forum"></a></li>
|
||||
<li><a href="https://github.com/rclone/rclone" target="_blank" rel="noopener" data-i18n="footer.github"></a></li>
|
||||
<li><a href="https://github.com/rclone/rclone/issues" target="_blank" rel="noopener" data-i18n="footer.issues"></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>About</h4>
|
||||
<h4 data-i18n="footer.about"></h4>
|
||||
<ul>
|
||||
<li><a href="https://rclone.org/" target="_blank" rel="noopener">rclone.org</a></li>
|
||||
<li><a href="https://rclone.org/changelog/" target="_blank" rel="noopener">Changelog</a></li>
|
||||
<li><a href="https://rclone.org/faq/" target="_blank" rel="noopener">FAQ</a></li>
|
||||
<li><a href="https://rclone.org/" target="_blank" rel="noopener" data-i18n="footer.rclone_org"></a></li>
|
||||
<li><a href="https://rclone.org/changelog/" target="_blank" rel="noopener" data-i18n="footer.changelog"></a></li>
|
||||
<li><a href="https://rclone.org/faq/" target="_blank" rel="noopener" data-i18n="footer.faq"></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="colophon">rclone — rsync for cloud storage.</p>
|
||||
<p class="colophon" data-i18n="footer.colophon"></p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user