eb32fd5e22
外层仓库管理 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)
309 lines
9.7 KiB
JavaScript
309 lines
9.7 KiB
JavaScript
// 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>Couldn’t 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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|