Files
rclone_gui/webgui/web/assets/js/views/browser.js
T
ci eb32fd5e22 init: 初始化 rclone-webgui 项目结构
外层仓库管理 webgui 源码、Docker 编排与项目文档;rclone 作为
git submodule 锁定在上游 master HEAD(59c86b01b),不携带任何
我们的改动。

- webgui/: webgui 源码(原本位于 rclone/cmd/webgui/)
  - web/: 原生 HTML/CSS/JS 静态前端(Anthropic 设计语言)
  - webgui.go: Go 子命令源码,仅当自行构建 rclone 二进制时需要
  - rclone-cmd-all-add-webgui-import.patch: 把 webgui 注册进
    rclone 的 cmd/all/all.go 的补丁,留作 fork 时使用
- rclone/: submodule → github.com/rclone/rclone,纯净不改动
- Dockerfile.webgui: 基于 nginx:1.27-alpine,从 ./webgui/web/
  COPY 静态资源
- docker/nginx.conf: SPA 静态托管 + 反向代理 RC API
  (/config/、/operations/、/sync/、/job/ 等) 与文件下载
  (/<remote>:<path>) 到 rclone rcd 容器,前端同源访问无 CORS
- docker-compose.yml: rclone (官方镜像 + rcd --rc-no-auth
  --rc-serve) + gui (nginx) 双服务编排,config 走 bind mount
  持久化
- DESIGN.md / CLAUDE.md / README.md: 文档
- .gitignore / .dockerignore: 排除 rclone.conf 等敏感文件,
  Docker 构建上下文只剩 webgui/web/ + nginx 配置(几十 KB)
2026-06-19 12:42:35 +08:00

309 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
import { post, uploadFile, downloadURL } from "../rc.js";
import { toast, formatBytes, formatTime } from "../state.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>`;
return;
}
const fs = `${remote}:`;
app.innerHTML = `
<div class="section-head">
<div>
<h2>${escapeHtml(remote)}</h2>
<p class="subtitle">${escapeHtml(path || "(root)")}</p>
</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>
</div>
</div>
<div id="breadcrumbs" class="breadcrumbs"></div>
<div id="browser-card" class="card-outline">
<p class="empty">Loading…</p>
</div>
`;
const breadcrumbsEl = document.getElementById("breadcrumbs");
renderBreadcrumbs(breadcrumbsEl, remote, path);
const card = document.getElementById("browser-card");
const fileInput = document.getElementById("upload-input");
try {
const res = await post("operations/list", {
fs,
remote: path || "",
opt: { noModTime: false, noMimeType: true, showHash: false },
});
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");
}
// --- Toolbar handlers ---
app.querySelector('[data-action="mkdir"]').addEventListener("click", () => {
openModal(
"New folder",
[
{ name: "name", label: "Folder name", type: "text", placeholder: "new-folder" },
],
async ({ name }) => {
if (!name) return;
const target = path ? `${path}/${name}` : name;
await post("operations/mkdir", { fs, remote: target });
toast(`Created ${name}`, "success");
},
).catch((e) => toast(`mkdir failed: ${e.message}`, "error"));
});
app.querySelector('[data-action="upload"]').addEventListener("click", () => {
fileInput.value = "";
fileInput.click();
});
fileInput.addEventListener("change", async () => {
const files = Array.from(fileInput.files || []);
if (files.length === 0) return;
try {
await uploadFile(fs, path || "", files);
toast(`Uploaded ${files.length} file(s)`, "success");
// Re-render list
location.reload();
} catch (e) {
toast(`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>`;
html += `<a href="#/browse/${encodeURIComponent(remote)}">${escapeHtml(remote)}</a>`;
let acc = "";
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
acc = acc ? `${acc}/${seg}` : seg;
const isLast = i === segments.length - 1;
html += `<span class="sep">/</span>`;
if (isLast) {
html += `<span class="current">${escapeHtml(seg)}</span>`;
} else {
html += `<a href="#/browse/${encodeURIComponent(remote)}/${acc.split("/").map(encodeURIComponent).join("/")}">${escapeHtml(seg)}</a>`;
}
}
el.innerHTML = html;
}
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>
</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);
});
const parentHref = parentLink(fs, path);
const rows = items.map((item) => row(fs, path, item)).join("");
card.innerHTML = `
<table class="table">
<thead>
<tr>
<th>Name</th>
<th class="col-num">Size</th>
<th>Modified</th>
<th class="col-num">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>` : ""}
${rows}
</tbody>
</table>
`;
// Wire row action buttons
card.querySelectorAll("[data-delete]").forEach((btn) => {
btn.addEventListener("click", () => onDelete(fs, path, btn.dataset.delete));
});
card.querySelectorAll("[data-rename]").forEach((btn) => {
btn.addEventListener("click", () => onRename(fs, path, btn.dataset.rename));
});
}
function row(fs, path, item) {
const itemPath = path ? `${path}/${item.Name}` : item.Name;
if (item.IsDir) {
const href = `#/browse/${fs.replace(/:$/, "")}/${itemPath.split("/").map(encodeURIComponent).join("/")}`;
return `
<tr class="row-dir">
<td class="col-name"><a href="${href}">${icon("dir")} ${escapeHtml(item.Name)}/</a></td>
<td class="col-num col-mono">—</td>
<td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td>
<td class="col-num"></td>
</tr>
`;
}
return `
<tr>
<td class="col-name">
<a href="${downloadURL(fs, path, item.Name)}" download="${escapeHtml(item.Name)}">${icon("file")} ${escapeHtml(item.Name)}</a>
</td>
<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>
</td>
</tr>
`;
}
function icon(kind) {
if (kind === "dir") {
return `<span style="display:inline-block;width:1em;color:var(--color-primary)">▸</span>`;
}
return `<span style="display:inline-block;width:1em;color:var(--color-muted-soft)">📄</span>`;
}
function parentLink(fs, path) {
if (!path) return "";
const segments = path.split("/").filter(Boolean);
segments.pop();
const parent = segments.join("/");
const remote = fs.replace(/:$/, "");
if (!parent) {
return `#/browse/${encodeURIComponent(remote)}`;
}
return `#/browse/${encodeURIComponent(remote)}/${parent.split("/").map(encodeURIComponent).join("/")}`;
}
async function onDelete(fs, path, itemPath) {
// itemPath is relative to fs root
if (!confirm(`Delete ${itemPath}? This cannot be undone.`)) return;
try {
await post("operations/deletefile", { fs, remote: itemPath });
toast(`Deleted ${itemPath}`, "success");
location.reload();
} catch (e) {
toast(`Delete failed: ${e.message}`, "error");
}
}
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 }],
async ({ name }) => {
if (!name || name === oldName) return;
const dir = segments.join("/");
const dst = dir ? `${dir}/${name}` : name;
await post("operations/movefile", {
srcFs: fs,
srcRemote: itemPath,
dstFs: fs,
dstRemote: dst,
});
toast(`Renamed to ${name}`, "success");
},
);
// Modal succeeded → reload to refresh list
location.reload();
} catch (e) {
toast(`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");
const formHtml = fields
.map(
(f) => `
<div class="field">
<label>${escapeHtml(f.label)}</label>
<input class="input" name="${escapeHtml(f.name)}" type="${escapeHtml(f.type || "text")}" value="${escapeHtml(f.value || "")}" placeholder="${escapeHtml(f.placeholder || "")}">
</div>`,
)
.join("");
root.innerHTML = `
<div class="modal-overlay">
<form class="modal">
<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>
</div>
</form>
</div>
`;
const overlay = root.querySelector(".modal-overlay");
const form = root.querySelector("form");
const close = () => {
root.innerHTML = "";
};
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
form.querySelector("[data-cancel]").addEventListener("click", () => {
close();
resolve(null);
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const data = {};
for (const f of fields) {
data[f.name] = form.elements[f.name].value;
}
try {
await onSubmit(data);
close();
resolve(data);
} catch (err) {
toast(err.message, "error");
}
});
// Focus first input
const first = form.elements[fields[0].name];
if (first) first.focus();
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}