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:
@@ -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 |
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>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, "'");
|
||||
}
|
||||
@@ -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>Couldn’t 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 can’t 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return escapeHtml(s);
|
||||
}
|
||||
@@ -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(`Couldn’t 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>Couldn’t 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -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>Couldn’t 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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…</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 — 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>
|
||||
Reference in New Issue
Block a user