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)
This commit is contained in:
ci
2026-06-19 12:35:12 +08:00
commit eb32fd5e22
25 changed files with 3872 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="#141413">
<path d="M8 0 L9.2 6.8 L16 8 L9.2 9.2 L8 16 L6.8 9.2 L0 8 L6.8 6.8 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 157 B

+62
View File
@@ -0,0 +1,62 @@
// app.js — entry point. Routes hash changes to view renderers and
// keeps the top-nav active state in sync.
import { onRoute } from "./state.js";
import { renderRemotes } from "./views/remotes.js";
import { renderBrowse } from "./views/browser.js";
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js";
import {
renderConfigureNew,
renderConfigureEdit,
} from "./views/configure.js";
const views = {
remotes: renderRemotes,
browse: renderBrowse,
jobs: renderJobs,
"jobs-new": renderNewJob,
"configure-new": renderConfigureNew,
"configure-edit": renderConfigureEdit,
};
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;
}
a.classList.toggle("active", isActive);
}
}
onRoute(async (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) {
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>`;
}
}
});
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+105
View File
@@ -0,0 +1,105 @@
// rc.js — minimal client for rclone's RC HTTP API.
// The RC base URL is discovered from the ?url= query param that the
// webgui command appends to the GUI URL on launch. If absent (e.g. when
// developing the SPA from another server), fall back to same-origin.
const params = new URLSearchParams(location.search);
const QUERY_URL = params.get("url");
const RC_BASE = QUERY_URL
? QUERY_URL.replace(/\/$/, "")
: location.origin;
const AUTH_USER = params.get("user");
const AUTH_PASS = params.get("pass");
let authHeader = null;
if (AUTH_USER && AUTH_PASS) {
authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`);
}
export function rcURL() {
return RC_BASE;
}
// Are we running with basic auth configured from the launch URL?
export function hasAuth() {
return authHeader !== null;
}
export function isNoAuth() {
return !authHeader;
}
// POST JSON to an RC endpoint. Returns the parsed JSON response, or throws.
export async function post(path, body = {}) {
const headers = { "Content-Type": "application/json" };
if (authHeader) headers["Authorization"] = authHeader;
const res = await fetch(RC_BASE + "/" + path.replace(/^\//, ""), {
method: "POST",
headers,
body: JSON.stringify(body),
});
return parseResponse(res, path);
}
// POST JSON and request an async job. Returns { jobid, executeId }.
export async function postAsync(path, body = {}) {
return post(path, { ...body, _async: true });
}
// Upload one or more files via multipart form-data.
// Matches operations/uploadfile: form fields `fs`, `remote`, and one
// file part per uploaded file. The server uses the part's filename.
export async function uploadFile(fs, remote, files) {
const form = new FormData();
form.set("fs", fs);
form.set("remote", remote);
for (const file of files) {
form.append("file", file, file.name);
}
const headers = {};
if (authHeader) headers["Authorization"] = authHeader;
// Do NOT set Content-Type — the browser sets multipart boundary.
const res = await fetch(RC_BASE + "/operations/uploadfile", {
method: "POST",
headers,
body: form,
});
return parseResponse(res, "/operations/uploadfile");
}
// Build a download URL for a file. Requires opt.Serve = true on the rc server.
export function downloadURL(remoteFs, remotePath, fileName) {
const base = RC_BASE.replace(/\/$/, "");
// rc server serves remote files at /<remote>:<path>
const trimmed = (remotePath || "").replace(/^\/+|\/+$/g, "");
const path = trimmed ? `${remoteFs}/${trimmed}/${fileName}` : `${remoteFs}/${fileName}`;
let url = `${base}/${path}`;
if (authHeader) {
// Embed basic auth into the URL so the browser can fetch it directly.
url = url.replace(/^(https?:\/\/)/, `$1${encodeURIComponent(AUTH_USER)}:${encodeURIComponent(AUTH_PASS)}@`);
}
return url;
}
async function parseResponse(res, path) {
let body = null;
const ct = res.headers.get("Content-Type") || "";
if (ct.includes("application/json")) {
body = await res.json();
} else {
const text = await res.text();
body = text ? { raw: text } : {};
}
if (!res.ok) {
const msg = (body && (body.error || body.message)) || `HTTP ${res.status}`;
const err = new Error(`${path}: ${msg}`);
err.status = res.status;
err.body = body;
throw err;
}
if (body && body.error) {
throw new Error(`${path}: ${body.error}`);
}
return body;
}
+216
View File
@@ -0,0 +1,216 @@
// state.js — minimal pub/sub store + hash router + localStorage-backed
// job metadata cache. Vanilla, no framework.
const JOB_META_KEY = "webgui:jobMeta";
const JOB_META_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const listeners = new Set();
const state = {
remotes: [], // [{ name, type }]
jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst }
providers: null, // cached /config/providers response
// jobMeta is persisted to localStorage so the jobs view can show
// src/dst for jobs after a page reload. Keyed by jobid (number).
jobMeta: loadJobMeta(),
};
function loadJobMeta() {
try {
const raw = localStorage.getItem(JOB_META_KEY);
if (!raw) return new Map();
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return new Map();
const now = Date.now();
const map = new Map();
for (const [id, meta] of arr) {
if (meta && typeof meta.submittedAt === "number") {
if (now - meta.submittedAt < JOB_META_TTL_MS) {
map.set(Number(id), meta);
}
}
}
return map;
} catch {
return new Map();
}
}
function saveJobMeta() {
try {
const arr = Array.from(state.jobMeta.entries());
localStorage.setItem(JOB_META_KEY, JSON.stringify(arr));
} catch {
// localStorage might be unavailable (private mode, quota); ignore.
}
}
export function getState() {
return state;
}
export function setState(patch) {
Object.assign(state, patch);
for (const l of listeners) {
try {
l(state);
} catch (e) {
console.error("state listener error", e);
}
}
}
export function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
// --- Job metadata API ---
export function rememberJob(jobid, { action, src, dst }) {
state.jobMeta.set(Number(jobid), {
action,
src,
dst,
submittedAt: Date.now(),
});
saveJobMeta();
}
export function getJobMeta(jobid) {
return state.jobMeta.get(Number(jobid));
}
export function forgetJob(jobid) {
state.jobMeta.delete(Number(jobid));
saveJobMeta();
}
// --- Router ---
// Hash-based routes:
// #/remotes
// #/browse/<remote>/<path...>
// #/jobs
// #/jobs/new
// #/configure/new
// #/configure/new/<providerName>
// #/configure/edit/<remoteName>
const routeListeners = new Set();
export function onRoute(fn) {
routeListeners.add(fn);
return () => routeListeners.delete(fn);
}
function parseHash(hash) {
const raw = hash.replace(/^#\/?/, "");
const parts = raw.split("/").filter(Boolean);
if (parts.length === 0) {
return { name: "remotes", params: {} };
}
switch (parts[0]) {
case "remotes":
return { name: "remotes", params: {} };
case "browse":
return {
name: "browse",
params: {
remote: decodeURIComponent(parts[1] || ""),
path: parts.slice(2).map(decodeURIComponent).join("/"),
},
};
case "jobs":
if (parts[1] === "new") {
return { name: "jobs-new", params: {} };
}
return { name: "jobs", params: {} };
case "configure":
if (parts[1] === "new") {
return {
name: "configure-new",
params: { provider: parts[2] ? decodeURIComponent(parts[2]) : "" },
};
}
if (parts[1] === "edit") {
return {
name: "configure-edit",
params: { remote: decodeURIComponent(parts[2] || "") },
};
}
return { name: "configure-new", params: { provider: "" } };
default:
return { name: "remotes", params: {} };
}
}
export function navigate(hash) {
if (location.hash !== hash) {
location.hash = hash;
} else {
dispatch();
}
}
function dispatch() {
const route = parseHash(location.hash);
for (const l of routeListeners) {
try {
l(route);
} catch (e) {
console.error("route listener error", e);
}
}
}
window.addEventListener("hashchange", dispatch);
window.addEventListener("load", dispatch);
// --- Toast ---
export function toast(message, kind = "default", ttl = 4000) {
const stack = document.getElementById("toast-stack");
if (!stack) return;
const el = document.createElement("div");
el.className = `toast toast-${kind}`;
el.textContent = message;
stack.appendChild(el);
setTimeout(() => el.remove(), ttl);
}
// --- Format helpers ---
export function formatBytes(n) {
if (n == null || isNaN(n)) return "—";
if (n < 1024) return `${n} B`;
const units = ["KB", "MB", "GB", "TB", "PB"];
let v = n / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(1)} ${units[i]}`;
}
export function formatSpeed(bytesPerSec) {
return formatBytes(bytesPerSec) + "/s";
}
export function formatDuration(seconds) {
if (seconds == null || !isFinite(seconds)) return "—";
const s = Math.round(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
export function formatTime(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
return d.toLocaleString();
} catch {
return iso;
}
}
+308
View File
@@ -0,0 +1,308 @@
// 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;");
}
+445
View File
@@ -0,0 +1,445 @@
// views/configure.js — dynamic remote form builder.
//
// Two modes:
// #/configure/new → provider picker (searchable grid)
// #/configure/new/<provider> → form for that backend
// #/configure/edit/<remote> → form pre-filled from /config/get
//
// Pulls backend metadata from /config/providers (cached in app state).
// OAuth backends (option named "token" with IsPassword) get a banner
// and disabled submit — user must run `rclone config` in a terminal.
import { post } from "../rc.js";
import { getState, setState, toast } from "../state.js";
// --- Route entrypoints ---
export async function renderConfigureNew({ provider = "" }) {
if (!provider) {
return renderProviderPicker();
}
const providers = await ensureProviders();
const info = providers.find((p) => p.Name === 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>
</div>`;
return;
}
return renderForm({ provider: info, mode: "create" });
}
export async function renderConfigureEdit({ remote }) {
if (!remote) {
location.hash = "#/remotes";
return;
}
const app = document.getElementById("app");
app.innerHTML = `<div class="empty"><p>Loading remote <code>${escapeHtml(remote)}</code>…</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");
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>
</div>`;
return;
}
const providers = await ensureProviders();
const info = providers.find((p) => p.Name === typeName);
if (!info) {
toast(`Backend ${typeName} not found in registry`, "error");
return;
}
let currentValues = {};
try {
currentValues = await post("config/get", { name: remote });
} catch (e) {
toast(`Couldn't read config: ${e.message}`, "error");
}
return renderForm({
provider: info,
mode: "edit",
remoteName: remote,
currentValues,
});
}
// --- Provider picker ---
async function renderProviderPicker() {
const app = document.getElementById("app");
app.innerHTML = `
<div class="section-head">
<div>
<h2>New remote</h2>
<p class="subtitle">Pick a storage backend to configure.</p>
</div>
<a class="btn btn-secondary btn-sm" href="#/remotes">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">
</div>
<div id="provider-grid" class="connector-grid">
<p class="empty" style="grid-column:1/-1">Loading backends…</p>
</div>
`;
const grid = document.getElementById("provider-grid");
const filter = document.getElementById("provider-filter");
let providers;
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>`;
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>`;
return;
}
grid.innerHTML = list
.map(
(p) => `
<a class="connector-tile" href="#/configure/new/${encodeURIComponent(p.Name)}">
<span class="tile-name">${escapeHtml(p.Name)}</span>
<span class="tile-type">${escapeHtml(p.Description || "")}</span>
</a>`,
)
.join("");
}
paint(visible);
filter.addEventListener("input", () => {
const q = filter.value.trim().toLowerCase();
if (!q) return paint(visible);
paint(
visible.filter((p) =>
(p.Name + " " + (p.Description || "")).toLowerCase().includes(q),
),
);
});
filter.focus();
}
// --- Dynamic form ---
async function renderForm({ provider, mode, remoteName = "", currentValues = {} }) {
const app = document.getElementById("app");
const isEdit = mode === "edit";
const requiresOAuth = provider.Options.some(
(o) => o.Name === "token" && (o.IsPassword || o.Sensitive),
);
// Partition options into required, optional-basic, optional-advanced.
const required = provider.Options.filter((o) => o.Required && !o.Hide);
const basic = provider.Options.filter((o) => !o.Required && !o.Advanced && !o.Hide);
const advanced = provider.Options.filter((o) => o.Advanced && !o.Hide);
app.innerHTML = `
<div class="section-head">
<div>
<h2>${isEdit ? "Edit" : "New"} ${escapeHtml(provider.Name)} remote</h2>
<p class="subtitle">${escapeHtml(provider.Description || "")}</p>
</div>
<a class="btn btn-secondary btn-sm" href="#/remotes">Cancel</a>
</div>
<form id="remote-form" class="card-outline" style="display:flex;flex-direction:column;gap:16px;max-width:760px">
${requiresOAuth ? oauthBanner(provider.Name) : ""}
<div class="form-grid">
<div class="field field-full">
<label>Remote name <span class="field-required">*</span></label>
<input
class="input"
name="_remote_name"
type="text"
required
${isEdit ? `value="${escapeHtml(remoteName)}" readonly` : 'placeholder="my-remote"'}
autocomplete="off"
>
</div>
${required.length > 0 ? `<div class="form-section-title">Required</div>` : ""}
${required.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
${basic.length > 0 ? `<div class="form-section-title">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})
</button>
</div>
<div id="advanced-section" class="advanced-section hidden">
<div class="form-section-title">Advanced</div>
${advanced.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
</div>
` : ""}
</div>
<div class="toolbar">
<button type="submit" class="btn btn-primary" ${requiresOAuth ? "disabled" : ""}>
${isEdit ? "Save changes" : "Create remote"}
</button>
<a class="btn btn-secondary" href="#/remotes">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>
</div>
` : ""}
</form>
`;
const form = document.getElementById("remote-form");
// Wire up "Custom…" reveal on <select> fields that have it.
wireCustomSelects(form);
// Advanced toggle
const advToggle = document.getElementById("advanced-toggle");
const advSection = document.getElementById("advanced-section");
if (advToggle && advSection) {
advToggle.addEventListener("click", () => {
const hidden = advSection.classList.toggle("hidden");
advToggle.textContent = hidden
? `Show advanced options (${advanced.length})`
: `Hide advanced options (${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;
try {
await post("config/delete", { name: remoteName });
toast(`Deleted ${remoteName}`, "success");
location.hash = "#/remotes";
} catch (e) {
toast(`Delete failed: ${e.message}`, "error");
}
});
}
// Submit
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (requiresOAuth) return;
const name = form.elements._remote_name.value.trim();
if (!name) return;
const parameters = collectParameters(form, provider.Options, isEdit);
try {
const body = {
name,
parameters,
opt: { nonInteractive: true },
};
if (!isEdit) body.type = provider.Name;
const endpoint = isEdit ? "config/update" : "config/create";
await post(endpoint, body);
toast(`${isEdit ? "Updated" : "Created"} ${name}`, "success");
location.hash = "#/remotes";
} catch (err) {
toast(`Save failed: ${err.message}`, "error");
}
});
}
// --- Helpers ---
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:
<code>rclone config</code>
Once the remote exists, you can edit its non-secret options here.
</div>
</div>
`;
}
function fieldHtml(opt, currentValues, isEdit) {
const isFull = opt.Type === "bool";
const classes = ["field"];
if (isFull) classes.push("field-bool");
if (opt.Examples && opt.Examples.length > 0 && opt.Type !== "bool") {
// Examples go full-width to fit the dropdown + custom override
classes.push("field-full");
} else if (opt.Help && opt.Help.length > 60) {
classes.push("field-full");
}
const labelHtml = `${escapeHtml(opt.Name)}${opt.Required ? '<span class="field-required">*</span>' : ""}`;
const helpHtml = opt.Help ? `<span class="field-help">${escapeHtml(opt.Help)}</span>` : "";
const inputHtml = inputHtmlFor(opt, currentValues[opt.Name], isEdit);
return `
<div class="${classes.join(" ")}">
<label>${labelHtml}</label>
${inputHtml}
${helpHtml}
</div>
`;
}
function inputHtmlFor(opt, value, isEdit) {
// In edit mode, never pre-fill password fields — server returns them
// 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)">`;
}
const name = `opt_${escapeHtml(opt.Name)}`;
const currentValue = value != null ? String(value) : "";
if (opt.Type === "bool") {
const checked = value === true || value === "true" ? "checked" : "";
return `<input type="checkbox" name="${name}" ${checked}>`;
}
if (opt.Type === "int" || opt.Type === "int64" || opt.Type === "Duration") {
return `<input class="input" name="${name}" type="number" value="${escapeAttr(currentValue)}" autocomplete="off">`;
}
if (opt.IsPassword) {
return `<input class="input" name="${name}" type="password" autocomplete="off" placeholder="secret">`;
}
// Examples → select with custom override
if (opt.Examples && opt.Examples.length > 0) {
const opts = ['<option value="">— pick —</option>']
.concat(
opt.Examples.map(
(ex) =>
`<option value="${escapeAttr(ex.Value)}"${ex.Value === currentValue ? " selected" : ""}>${escapeHtml(ex.Help || ex.Value)}${ex.Provider ? ` (${escapeHtml(ex.Provider)})` : ""}</option>`,
),
)
.join("");
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>
</select>
<input class="input" name="${name}__custom" type="text" value="${escapeAttr(currentValue)}" style="margin-top:8px;display:none" placeholder="custom value" autocomplete="off">
`;
}
return `<input class="input" name="${name}" type="text" value="${escapeAttr(currentValue)}" autocomplete="off">`;
}
function collectParameters(form, options, isEdit) {
const params = {};
for (const opt of options) {
if (opt.Hide) continue;
const baseName = `opt_${opt.Name}`;
const el = form.elements[baseName];
if (!el) continue;
let val;
if (opt.Type === "bool") {
val = el.checked ? "true" : "false";
} else if (el.dataset && el.dataset.hasCustom === "1" || el.getAttribute("data-has-custom") === "1") {
// It's a <select> with a sibling text override
if (el.value === "__custom__") {
const customEl = form.elements[`${baseName}__custom`];
val = customEl ? customEl.value.trim() : "";
} else {
val = el.value;
}
} else {
val = el.value.trim();
}
// In edit mode, skip empty password fields (leave unchanged).
if (isEdit && opt.IsPassword && val === "") continue;
// Skip empty non-required fields.
if (val === "" && !opt.Required) continue;
params[opt.Name] = val;
}
return params;
}
// Wire up the "Custom…" reveal on selects with data-has-custom.
// Called from app.js after every form render via a MutationObserver-
// free approach: we attach the listener at form-render time inside
// renderForm. Use event delegation for simplicity.
export function wireCustomSelects(container) {
container.querySelectorAll('select[data-has-custom="1"]').forEach((sel) => {
const customName = sel.name + "__custom";
const customEl = container.elements
? container.elements[customName]
: container.querySelector(`[name="${CSS.escape(customName)}"]`);
if (!customEl) return;
const sync = () => {
customEl.style.display = sel.value === "__custom__" ? "block" : "none";
};
sync();
sel.addEventListener("change", sync);
});
}
async function ensureProviders() {
const cached = getState().providers;
if (cached) return cached;
const res = await post("config/providers");
const providers = (res && res.providers) || [];
setState({ providers });
return providers;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeAttr(s) {
return escapeHtml(s);
}
+304
View File
@@ -0,0 +1,304 @@
// views/jobs.js — submit sync/copy/move jobs and poll their progress.
// Job src/dst metadata is persisted to localStorage (via state.rememberJob)
// so the table can show what each job is doing even after a page reload.
import { post, postAsync } from "../rc.js";
import {
toast,
rememberJob,
getJobMeta,
formatBytes,
formatSpeed,
formatDuration,
} from "../state.js";
let pollTimer = null;
export async function renderJobs() {
const app = document.getElementById("app");
app.innerHTML = `
<div class="section-head">
<div>
<h2>Jobs</h2>
<p class="subtitle">Running and recently completed transfers.</p>
</div>
<a class="btn btn-primary btn-sm" href="#/jobs/new">New Job</a>
</div>
<div id="jobs-card" class="card-outline">
<p class="empty">Loading jobs…</p>
</div>
`;
await refreshJobs();
startPolling();
}
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");
}
const remoteOpts = remotes
.map((r) => `<option value="${escapeHtml(r)}:">${escapeHtml(r)}</option>`)
.join("");
app.innerHTML = `
<div class="section-head">
<div>
<h2>New Job</h2>
<p class="subtitle">Copy, sync, or move between remotes.</p>
</div>
<a class="btn btn-secondary btn-sm" href="#/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>
<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>
</select>
</div>
<div class="form-grid">
<div class="field">
<label>Source remote</label>
<select name="srcRemote" class="select">
${remoteOpts || '<option value="">(no remotes)</option>'}
</select>
</div>
<div class="field">
<label>Source path (optional)</label>
<input class="input" name="srcPath" placeholder="folder/subfolder" autocomplete="off">
</div>
<div class="field">
<label>Destination remote</label>
<select name="dstRemote" class="select">
${remoteOpts || '<option value="">(no remotes)</option>'}
</select>
</div>
<div class="field">
<label>Destination path (optional)</label>
<input class="input" name="dstPath" placeholder="folder/subfolder" autocomplete="off">
</div>
</div>
<div class="toolbar">
<button type="submit" class="btn btn-primary">Start Job</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]}:`;
}
const form = document.getElementById("new-job-form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const action = form.elements.action.value;
const srcRemote = form.elements.srcRemote.value;
const dstRemote = form.elements.dstRemote.value;
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");
return;
}
const src = srcPath ? `${srcRemote}${srcPath}` : srcRemote;
const dst = dstPath ? `${dstRemote}${dstPath}` : dstRemote;
try {
const body = { srcFs: src, dstFs: dst };
if (action === "move") body.deleteEmptySrcDirs = true;
const res = await postAsync(`sync/${action}`, body);
const jobid = res && res.jobid;
if (jobid != null) {
rememberJob(jobid, { action, src, dst });
}
toast(`Started ${action} job #${jobid}`, "success");
location.hash = "#/jobs";
} catch (e) {
toast(`Job start failed: ${e.message}`, "error");
}
});
}
async function refreshJobs() {
const card = document.getElementById("jobs-card");
if (!card) return; // user navigated away
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>`;
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>
</div>
`;
return;
}
// Fetch each job's status in parallel
const statuses = await Promise.all(
jobIds.map((id) =>
post("job/status", { jobid: id }).catch((e) => ({
jobid: id,
error: e.message,
finished: true,
})),
),
);
card.innerHTML = renderJobTable(statuses);
card.querySelectorAll("[data-stop]").forEach((btn) => {
btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10)));
});
}
function renderJobTable(statuses) {
// Newest jobid first
statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0));
const rows = statuses.map(renderJobRow).join("");
return `
<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>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
function renderJobRow(s) {
const p = s.progress || {};
const id = s.jobid;
const finished = s.finished;
const success = s.success;
const errored = !!s.error;
let badge;
if (!finished) {
badge = `<span class="badge">running</span>`;
} else if (errored || (!success && errored)) {
badge = `<span class="badge badge-error">failed</span>`;
} else if (success) {
badge = `<span class="badge badge-success">done</span>`;
} else {
badge = `<span class="badge badge-warning">finished</span>`;
}
const meta = getJobMeta(id);
const jobCell = meta
? `
<div class="job-cell">
<span class="job-action">${escapeHtml(meta.action)}</span>
<span class="job-paths">
<code>${escapeHtml(meta.src)}</code>
<span class="arrow">→</span>
<code>${escapeHtml(meta.dst)}</code>
</span>
</div>`
: `<span class="col-mono" style="color:var(--color-muted-soft)">— submitted via CLI —</span>`;
const pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0;
const progress = `
<div style="display:flex;flex-direction:column;gap:4px;min-width:120px">
<div class="progress"><span style="width:${pct.toFixed(1)}%"></span></div>
<span class="col-mono" style="font-size:11px;color:var(--color-muted)">
${formatBytes(p.bytes)} / ${formatBytes(p.totalBytes)}
</span>
</div>
`;
const stopBtn = !finished
? `<button class="btn btn-danger btn-sm" data-stop="${id}">Stop</button>`
: "";
return `
<tr>
<td class="col-mono">${id}</td>
<td>${jobCell}</td>
<td>${badge}</td>
<td class="col-num">${progress}</td>
<td class="col-num col-mono">${finished ? "—" : escapeHtml(formatSpeed(p.speed || 0))}</td>
<td class="col-num col-mono">${finished ? "—" : escapeHtml(formatDuration(p.eta || 0))}</td>
<td class="col-num col-mono">${p.transfers ?? 0} / ${p.totalTransfers ?? 0}</td>
<td class="col-num col-mono">${(p.errors && p.errors.length) || 0}</td>
<td class="col-num">${stopBtn}</td>
</tr>
`;
}
async function onStop(jobid) {
if (!confirm(`Stop job #${jobid}?`)) return;
try {
await post("job/stop", { jobid });
toast(`Stopped job #${jobid}`, "success");
await refreshJobs();
} catch (e) {
toast(`Stop failed: ${e.message}`, "error");
}
}
function startPolling() {
stopPolling();
pollTimer = setInterval(async () => {
if (!document.getElementById("jobs-card")) {
stopPolling();
return;
}
await refreshJobs();
}, 1500);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
export function stopJobPolling() {
stopPolling();
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+105
View File
@@ -0,0 +1,105 @@
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
import { post } from "../rc.js";
import { getState, setState, toast } from "../state.js";
export async function renderRemotes() {
const app = document.getElementById("app");
if (!app) return;
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>
</div>
<a class="btn btn-primary btn-sm" href="#/configure/new">New remote</a>
</div>
<div id="remote-grid" class="connector-grid">
<p class="empty" style="grid-column:1/-1">Loading remotes…</p>
</div>
`;
const grid = document.getElementById("remote-grid");
try {
const [listRes, dumpRes] = await Promise.all([
post("config/listremotes"),
post("config/dump"),
]);
const names = (listRes && listRes.remotes) || [];
const dump = dumpRes || {};
const remotes = names.map((name) => ({
name,
type: (dump[name] && dump[name].type) || "unknown",
}));
setState({ remotes });
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>
</div>
`;
return;
}
grid.innerHTML = remotes
.map(
(r) => `
<a class="connector-tile tile-with-actions" href="#/browse/${encodeURIComponent(r.name)}">
<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>
</div>
</a>
`,
)
.join("");
// Wire action buttons
grid.querySelectorAll("[data-edit]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
location.hash = `#/configure/edit/${encodeURIComponent(btn.dataset.edit)}`;
});
});
grid.querySelectorAll("[data-delete]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const name = btn.dataset.delete;
if (!confirm(`Delete remote ${name}? This cannot be undone.`)) return;
try {
await post("config/delete", { name });
toast(`Deleted ${name}`, "success");
await renderRemotes();
} catch (err) {
toast(`Delete failed: ${err.message}`, "error");
}
});
});
} catch (e) {
grid.innerHTML = `
<div class="empty" style="grid-column:1/-1">
<h3>Couldnt reach rclone</h3>
<p>${escapeHtml(e.message)}</p>
</div>
`;
toast(`Failed to load remotes: ${e.message}`, "error");
}
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+85
View File
@@ -0,0 +1,85 @@
/* Base reset, font loading, and document defaults. */
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400&display=swap");
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background-color: var(--color-canvas);
color: var(--color-body);
font: var(--typo-body-md);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1 0 auto;
width: 100%;
max-width: var(--content-max);
margin: 0 auto;
padding: var(--space-lg) var(--space-xl);
}
h1, h2, h3, h4 {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-weight: 400;
}
h1 {
font: var(--typo-display-lg);
letter-spacing: var(--typo-display-lg-tracking);
}
h2 {
font: var(--typo-display-md);
letter-spacing: var(--typo-display-md-tracking);
}
h3 {
font: var(--typo-display-sm);
letter-spacing: var(--typo-display-sm-tracking);
}
p {
margin: 0;
}
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
font-family: inherit;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
code,
pre {
font: var(--typo-code);
font-family: var(--font-mono);
}
+758
View File
@@ -0,0 +1,758 @@
/* Component library — all UI primitives map to DESIGN.md tokens. */
/* ---------- Top Navigation ---------- */
.top-nav {
display: flex;
align-items: center;
gap: var(--space-lg);
height: var(--nav-height);
padding: 0 var(--space-xl);
background-color: var(--color-canvas);
border-bottom: 1px solid var(--color-hairline);
}
.top-nav .wordmark {
display: flex;
align-items: center;
gap: var(--space-xs);
font: var(--typo-title-md);
font-weight: 500;
color: var(--color-ink);
}
.top-nav .wordmark .spike {
width: 16px;
height: 16px;
color: var(--color-ink);
}
.top-nav nav {
display: flex;
gap: var(--space-md);
flex: 1;
}
.top-nav nav a {
padding: var(--space-xs) var(--space-sm);
color: var(--color-muted);
font: var(--typo-nav-link);
border-radius: var(--radius-md);
text-decoration: none;
}
.top-nav nav a:hover {
color: var(--color-ink);
background-color: var(--color-surface-soft);
}
.top-nav nav a.active {
color: var(--color-ink);
background-color: var(--color-surface-card);
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
height: 40px;
padding: 12px 20px;
font: var(--typo-button);
border-radius: var(--radius-md);
border: 1px solid transparent;
cursor: pointer;
text-decoration: none;
transition: background-color 120ms ease;
user-select: none;
}
.btn-primary {
background-color: var(--color-primary);
color: var(--color-on-primary);
}
.btn-primary:hover {
background-color: var(--color-primary-active);
text-decoration: none;
}
.btn-primary:disabled {
background-color: var(--color-primary-disabled);
color: var(--color-muted);
cursor: not-allowed;
}
.btn-secondary {
background-color: var(--color-canvas);
color: var(--color-ink);
border-color: var(--color-hairline);
}
.btn-secondary:hover {
background-color: var(--color-surface-soft);
text-decoration: none;
}
.btn-danger {
background-color: transparent;
color: var(--color-error);
border-color: var(--color-hairline);
}
.btn-danger:hover {
background-color: var(--color-error);
color: var(--color-on-primary);
border-color: var(--color-error);
}
.btn-icon {
width: 36px;
height: 36px;
padding: 0;
border-radius: var(--radius-full);
background-color: var(--color-canvas);
color: var(--color-ink);
border: 1px solid var(--color-hairline);
font-size: 16px;
}
.btn-icon:hover {
background-color: var(--color-surface-soft);
}
.btn-sm {
height: 32px;
padding: 8px 12px;
font-size: 13px;
}
/* ---------- Cards ---------- */
.card {
background-color: var(--color-surface-card);
border-radius: var(--radius-lg);
padding: var(--space-xl);
}
.card-outline {
background-color: var(--color-canvas);
border: 1px solid var(--color-hairline);
border-radius: var(--radius-lg);
padding: var(--space-xl);
}
.card-dark {
background-color: var(--color-surface-dark);
color: var(--color-on-dark);
border-radius: var(--radius-lg);
padding: var(--space-xl);
}
/* ---------- Connector tiles (remote cards) ---------- */
.connector-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-md);
}
.connector-tile {
background-color: var(--color-canvas);
border: 1px solid var(--color-hairline);
border-radius: var(--radius-lg);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-xs);
cursor: pointer;
text-decoration: none;
color: inherit;
}
.connector-tile:hover {
background-color: var(--color-surface-soft);
border-color: var(--color-surface-cream-strong);
}
.connector-tile .tile-name {
font: var(--typo-title-sm);
color: var(--color-ink);
}
.connector-tile .tile-type {
font: var(--typo-caption);
color: var(--color-muted);
}
/* Tile with action overlay (edit/delete buttons in top-right corner) */
.connector-tile.tile-with-actions {
position: relative;
}
.connector-tile.tile-with-actions .tile-actions {
position: absolute;
top: var(--space-xs);
right: var(--space-xs);
display: flex;
gap: var(--space-xxs);
opacity: 0;
transition: opacity 120ms ease;
}
.connector-tile.tile-with-actions:hover .tile-actions {
opacity: 1;
}
.connector-tile.tile-with-actions .tile-actions button {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--color-hairline);
border-radius: var(--radius-sm);
background-color: var(--color-canvas);
color: var(--color-muted);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.connector-tile.tile-with-actions .tile-actions button:hover {
background-color: var(--color-surface-card);
color: var(--color-ink);
}
.connector-tile.tile-with-actions .tile-actions button.danger:hover {
background-color: var(--color-error);
color: var(--color-on-primary);
border-color: var(--color-error);
}
/* ---------- Inputs ---------- */
.input,
.select {
width: 100%;
height: 40px;
padding: 10px 14px;
font: var(--typo-body-md);
background-color: var(--color-canvas);
color: var(--color-ink);
border: 1px solid var(--color-hairline);
border-radius: var(--radius-md);
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus,
.select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-15);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.field label {
font: var(--typo-caption);
color: var(--color-muted);
}
/* ---------- Badges ---------- */
.badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
font: var(--typo-caption);
border-radius: var(--radius-pill);
background-color: var(--color-surface-card);
color: var(--color-ink);
}
.badge-coral {
background-color: var(--color-primary);
color: var(--color-on-primary);
font: var(--typo-caption-uppercase);
letter-spacing: var(--typo-caption-uppercase-tracking);
}
.badge-success {
background-color: var(--color-success);
color: var(--color-on-primary);
}
.badge-warning {
background-color: var(--color-warning);
color: var(--color-on-primary);
}
.badge-error {
background-color: var(--color-error);
color: var(--color-on-primary);
}
/* ---------- Breadcrumbs ---------- */
.breadcrumbs {
display: flex;
align-items: center;
gap: var(--space-xxs);
flex-wrap: wrap;
}
.breadcrumbs a,
.breadcrumbs span {
padding: var(--space-xs) calc(var(--space-sm) + 2px);
font: var(--typo-nav-link);
color: var(--color-muted);
border-radius: var(--radius-md);
text-decoration: none;
}
.breadcrumbs a:hover {
color: var(--color-ink);
background-color: var(--color-surface-soft);
}
.breadcrumbs .current {
color: var(--color-ink);
background-color: var(--color-surface-card);
}
.breadcrumbs .sep {
padding: 0 var(--space-xxs);
color: var(--color-muted-soft);
}
/* ---------- Tables ---------- */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
padding: var(--space-sm) var(--space-md);
font: var(--typo-body-sm);
color: var(--color-body);
}
.table thead th {
font: var(--typo-caption);
color: var(--color-muted);
border-bottom: 1px solid var(--color-hairline);
}
.table tbody tr {
border-bottom: 1px solid var(--color-hairline-soft);
}
.table tbody tr:last-child {
border-bottom: none;
}
.table tbody tr:hover {
background-color: var(--color-surface-soft);
}
.table .col-name {
font: var(--typo-body-md);
color: var(--color-ink);
}
.table .col-name a {
color: var(--color-ink);
text-decoration: none;
}
.table .col-name a:hover {
color: var(--color-primary);
text-decoration: underline;
}
.table .col-mono {
font: var(--typo-code);
color: var(--color-muted);
}
.table .col-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.table .row-dir .col-name a {
color: var(--color-primary);
}
/* ---------- Progress bar ---------- */
.progress {
width: 100%;
height: 4px;
background-color: var(--color-surface-card);
border-radius: var(--radius-pill);
overflow: hidden;
}
.progress > span {
display: block;
height: 100%;
background-color: var(--color-primary);
transition: width 200ms ease;
}
/* ---------- Toolbar ---------- */
.toolbar {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.toolbar .spacer {
flex: 1;
}
/* ---------- Section header ---------- */
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.section-head h2 {
font: var(--typo-display-md);
letter-spacing: var(--typo-display-md-tracking);
}
.section-head .subtitle {
color: var(--color-muted);
font: var(--typo-body-sm);
}
/* ---------- Empty state ---------- */
.empty {
padding: var(--space-section) var(--space-xl);
text-align: center;
color: var(--color-muted);
}
.empty h3 {
margin-bottom: var(--space-sm);
font: var(--typo-display-sm);
letter-spacing: var(--typo-display-sm-tracking);
color: var(--color-ink);
}
/* ---------- Toast ---------- */
.toast-stack {
position: fixed;
bottom: var(--space-lg);
right: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-sm);
z-index: 100;
}
.toast {
padding: var(--space-sm) var(--space-lg);
background-color: var(--color-surface-dark);
color: var(--color-on-dark);
border-radius: var(--radius-lg);
font: var(--typo-body-sm);
max-width: 360px;
box-shadow: var(--shadow-hover);
}
.toast-error {
background-color: var(--color-error);
color: var(--color-on-primary);
}
.toast-success {
background-color: var(--color-success);
color: var(--color-on-primary);
}
/* ---------- Modal ---------- */
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(20, 20, 19, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--space-lg);
}
.modal {
background-color: var(--color-surface-dark);
color: var(--color-on-dark);
border-radius: var(--radius-lg);
padding: var(--space-lg);
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.modal h3 {
font: var(--typo-title-lg);
color: var(--color-on-dark);
margin: 0;
}
.modal label {
color: var(--color-on-dark-soft);
font: var(--typo-caption);
}
.modal .input,
.modal .select {
background-color: var(--color-surface-dark-elevated);
color: var(--color-on-dark);
border-color: var(--color-surface-dark-elevated);
}
.modal .input:focus,
.modal .select:focus {
border-color: var(--color-primary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
}
.modal .btn-secondary {
background-color: var(--color-surface-dark-elevated);
color: var(--color-on-dark);
border-color: transparent;
}
/* ---------- Footer ---------- */
.footer {
background-color: var(--color-surface-dark);
color: var(--color-on-dark-soft);
padding: var(--space-xxl) var(--space-xl);
margin-top: var(--space-section);
flex-shrink: 0;
}
.footer-inner {
max-width: var(--content-max);
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: var(--space-xl);
}
.footer h4 {
color: var(--color-on-dark);
font: var(--typo-title-sm);
font-family: var(--font-body);
font-weight: 500;
margin-bottom: var(--space-sm);
}
.footer a {
color: var(--color-on-dark-soft);
font: var(--typo-body-sm);
text-decoration: none;
}
.footer a:hover {
color: var(--color-on-dark);
}
.footer .colophon {
grid-column: 1 / -1;
margin-top: var(--space-lg);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-surface-dark-elevated);
color: var(--color-on-dark-soft);
font: var(--typo-caption);
}
/* ---------- Configure: provider picker + dynamic form ---------- */
.provider-search {
margin-bottom: var(--space-lg);
}
.provider-search input {
max-width: 360px;
}
.banner {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
border-radius: var(--radius-md);
background-color: var(--color-surface-card);
color: var(--color-body-strong);
font: var(--typo-body-sm);
margin-bottom: var(--space-lg);
}
.banner-warning {
background-color: color-mix(in srgb, var(--color-warning) 18%, var(--color-canvas));
border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent);
}
.banner code {
display: block;
margin-top: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background-color: var(--color-surface-dark);
color: var(--color-on-dark);
border-radius: var(--radius-sm);
font: var(--typo-code);
white-space: pre-wrap;
user-select: all;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md) var(--space-lg);
}
.form-grid .field-full {
grid-column: 1 / -1;
}
.form-grid .field-bool {
flex-direction: row;
align-items: center;
gap: var(--space-sm);
}
.form-grid .field-bool input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.field .field-help {
font: var(--typo-body-sm);
color: var(--color-muted);
line-height: 1.4;
}
.field .field-required {
color: var(--color-error);
margin-left: 2px;
}
.form-section-title {
grid-column: 1 / -1;
font: var(--typo-caption-uppercase);
letter-spacing: var(--typo-caption-uppercase-tracking);
color: var(--color-muted-soft);
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--color-hairline-soft);
}
.form-section-title:first-child {
margin-top: 0;
border-top: none;
padding-top: 0;
}
.advanced-toggle-wrap {
grid-column: 1 / -1;
margin-top: var(--space-sm);
}
.advanced-section {
grid-column: 1 / -1;
display: contents;
}
.advanced-section.hidden {
display: none;
}
.danger-zone {
border: 1px solid var(--color-error);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-top: var(--space-xl);
background-color: color-mix(in srgb, var(--color-error) 5%, var(--color-canvas));
}
.danger-zone h4 {
font: var(--typo-title-sm);
font-family: var(--font-body);
color: var(--color-error);
margin: 0 0 var(--space-xs);
}
.danger-zone p {
font: var(--typo-body-sm);
color: var(--color-muted);
margin-bottom: var(--space-md);
}
/* ---------- Job cell with action + src→dst ---------- */
.job-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 200px;
}
.job-cell .job-action {
font: var(--typo-caption-uppercase);
letter-spacing: var(--typo-caption-uppercase-tracking);
color: var(--color-muted);
}
.job-cell .job-paths {
font: var(--typo-body-sm);
color: var(--color-body);
word-break: break-all;
}
.job-cell .job-paths .arrow {
color: var(--color-muted-soft);
margin: 0 var(--space-xxs);
}
/* ---------- Responsive ---------- */
@media (max-width: 1024px) {
.connector-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.footer-inner {
grid-template-columns: 1fr 1fr;
}
.form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
main {
padding: var(--space-md);
}
.connector-grid {
grid-template-columns: 1fr;
}
.footer-inner {
grid-template-columns: 1fr;
}
.top-nav {
padding: 0 var(--space-md);
}
.top-nav nav {
display: none;
}
}
+93
View File
@@ -0,0 +1,93 @@
/* DESIGN.md tokens — Anthropic/Claude design system
Source of truth: every color/size/radius used in the app is defined here. */
:root {
/* --- Brand & Accent --- */
--color-primary: #cc785c;
--color-primary-active: #a9583e;
--color-primary-disabled: #e6dfd8;
--color-primary-15: rgba(204, 120, 92, 0.15);
--color-accent-teal: #5db8a6;
--color-accent-amber: #e8a55a;
/* --- Surfaces --- */
--color-canvas: #faf9f5;
--color-surface-soft: #f5f0e8;
--color-surface-card: #efe9de;
--color-surface-cream-strong: #e8e0d2;
--color-surface-dark: #181715;
--color-surface-dark-elevated: #252320;
--color-surface-dark-soft: #1f1e1b;
--color-hairline: #e6dfd8;
--color-hairline-soft: #ebe6df;
/* --- Text --- */
--color-ink: #141413;
--color-body-strong: #252523;
--color-body: #3d3d3a;
--color-muted: #6c6a64;
--color-muted-soft: #8e8b82;
--color-on-primary: #ffffff;
--color-on-dark: #faf9f5;
--color-on-dark-soft: #a09d96;
/* --- Semantic --- */
--color-success: #5db872;
--color-warning: #d4a017;
--color-error: #c64545;
/* --- Typography families --- */
--font-display: "Cormorant Garamond", "Tiempos Headline", "EB Garamond", Garamond, "Times New Roman", serif;
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
/* --- Typography scale (matches DESIGN.md 1:1) --- */
--typo-display-xl: 400 64px/1.05 var(--font-display);
--typo-display-xl-tracking: -1.5px;
--typo-display-lg: 400 48px/1.1 var(--font-display);
--typo-display-lg-tracking: -1px;
--typo-display-md: 400 36px/1.15 var(--font-display);
--typo-display-md-tracking: -0.5px;
--typo-display-sm: 400 28px/1.2 var(--font-display);
--typo-display-sm-tracking: -0.3px;
--typo-title-lg: 500 22px/1.3 var(--font-body);
--typo-title-md: 500 18px/1.4 var(--font-body);
--typo-title-sm: 500 16px/1.4 var(--font-body);
--typo-body-md: 400 16px/1.55 var(--font-body);
--typo-body-sm: 400 14px/1.55 var(--font-body);
--typo-caption: 500 13px/1.4 var(--font-body);
--typo-caption-uppercase: 500 12px/1.4 var(--font-body);
--typo-caption-uppercase-tracking: 1.5px;
--typo-code: 400 14px/1.6 var(--font-mono);
--typo-button: 500 14px/1 var(--font-body);
--typo-nav-link: 500 14px/1.4 var(--font-body);
/* --- Spacing scale --- */
--space-xxs: 4px;
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-xxl: 48px;
--space-section: 96px;
/* --- Radii --- */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-pill: 9999px;
--radius-full: 9999px;
/* --- Layout --- */
--content-max: 1200px;
--nav-height: 64px;
/* --- Shadows (used sparingly per DESIGN.md elevation philosophy) --- */
--shadow-hover: 0 1px 3px rgba(20, 20, 19, 0.08);
}
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rclone</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="stylesheet" href="/assets/styles/tokens.css">
<link rel="stylesheet" href="/assets/styles/base.css">
<link rel="stylesheet" href="/assets/styles/components.css">
</head>
<body>
<header class="top-nav">
<a class="wordmark" href="#/remotes">
<svg class="spike" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0 L9.2 6.8 L16 8 L9.2 9.2 L8 16 L6.8 9.2 L0 8 L6.8 6.8 Z"/>
</svg>
<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>
</nav>
<a class="btn btn-primary btn-sm" href="#/jobs/new">New Job</a>
</header>
<main id="app">
<div class="empty">
<h3>Loading</h3>
<p>Connecting to rclone RC&hellip;</p>
</div>
</main>
<footer class="footer">
<div class="footer-inner">
<div>
<h4>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>
</ul>
</div>
<div>
<h4>Documentation</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>
</ul>
</div>
<div>
<h4>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>
</ul>
</div>
<div>
<h4>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>
</ul>
</div>
<p class="colophon">rclone &mdash; rsync for cloud storage.</p>
</div>
</footer>
<div id="toast-stack" class="toast-stack"></div>
<div id="modal-root"></div>
<script type="module" src="/assets/js/app.js"></script>
</body>
</html>