add: 添加 i18n 翻译

This commit is contained in:
ci
2026-06-19 13:05:35 +08:00
parent 5ebe562a62
commit 73dfe12c95
8 changed files with 654 additions and 189 deletions
+33 -41
View File
@@ -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>Couldnt 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();
});
+45 -50
View File
@@ -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>Couldnt 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 cant 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">
`;
}
+47 -51
View File
@@ -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(`Couldnt 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>Couldnt 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");
}
}
+14 -14
View File
@@ -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>Couldnt 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");
}
}