commit eb32fd5e22c81ca3852c4a6cd59921e677625256 Author: ci Date: Fri Jun 19 12:35:12 2026 +0800 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/ 等) 与文件下载 (/:) 到 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) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..deca834 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +# Keep the frontend image build context small. +# We only need: +# - webgui/web/ (the static frontend bundle) +# - docker/nginx.conf (the reverse-proxy config) +# - Dockerfile.webgui + +# VCS metadata +.git +.gitignore +.gitmodules +.github + +# The Go-side webgui command is not needed for the frontend image — +# the official rclone/rclone container handles the backend. +webgui/webgui.go +webgui/*.patch + +# Submodule source — the frontend image doesn't need rclone itself. +rclone + +# Local rclone config (contains secrets) +config/ + +# Editor / OS cruft +.idea +.vscode +.history +.devcontainer +*~ +_junk +Thumbs.db +.DS_Store +__pycache__ + +# Docs that don't belong in the runtime image +CLAUDE.md +DESIGN.md +README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf9cb63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Local rclone config (contains secrets — never commit) +config/rclone/rclone.conf + +# Docker build artifacts / runtime +.docker/ + +# Editor / OS cruft +.idea/ +.vscode/ +.history/ +*~ +.DS_Store +Thumbs.db + +# Bash / Python +__pycache__/ +*.pyc diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..0d690f7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "rclone"] + path = rclone + url = https://github.com/rclone/rclone.git diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c32ca46 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,100 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +Rclone ("rsync for cloud storage") is a command-line Go program that syncs files and directories to/from 70+ cloud storage providers. Entry point is `rclone.go` → `cmd.Main()`. Current Go toolchain requirement is in `go.mod` (the `go` directive); check it before assuming a version. + +## Build & install + +```bash +go build # plain build +make # build with version metadata baked in (preferred on Unix) +make rclone # same as above, also installs into $(go env GOPATH)/bin +``` + +On Windows, `make rclone` also generates a version-stamped `.syso` resource file via `bin/resource_windows.go` before building, then deletes it. If you see a stray `resource_windows_*.syso` it was left behind by an interrupted build. + +`-X github.com/rclone/rclone/fs.Version=$(TAG)` is passed via `-ldflags`; the version string the binary reports is built from `VERSION` + git metadata in the Makefile (`TAG := $(VERSION)$(VERSION_SUFFIX)$(TAG_BRANCH)`). Bump `VERSION` only via the `startdev`/`startstable` Makefile targets, not by hand. + +## Tests + +Rclone's tests run against **real backends** configured as `Test:` sections in the rclone config file. Tests requiring a missing section are skipped automatically — a clean `go test ./...` from a fresh checkout just runs the local-FS-only subset. + +```bash +# Run a single package's tests +cd backend/drive && go test -v + +# Run one test in one package +cd fs/sync && go test -v -run TestSync + +# Quick test of everything (no remote creds needed; sets bogus config to force skips) +make quicktest + +# Run a package's integration tests against a configured remote +cd fs/sync && go test -v -remote TestDrive: +cd fs/sync && go test -v -remote TestDrive: -fast-list # exercise ListR path +cd fs/operations && go test -v -remote TestDrive: + +# Full integration framework from repo root, one backend at a time +go run ./fstest/test_all -backends drive + +# Race detector +make racequicktest +``` + +Backend test remotes are registered in `fstest/test_all/config.yaml`. Some tests there have `ignore:` entries that document intentional skips (e.g. B2 versioning makes certain purge tests impossible). + +## Lint / quality + +```bash +make check # golangci-lint + markdownlint, same as CI +golangci-lint run ./... # Go only +bin/markdown-lint # Markdown only +``` + +`.golangci.yml` enables: errcheck, govet, ineffassign, staticcheck, unused, gocritic, misspell, revive, unconvert, plus the `goimports` formatter. `gocritic` ruleguard rules live in `bin/rules.go`. golangci-lint v2 config format is in use. + +## Architecture + +Layered, with `fs/` as the contract layer: + +- **`fs/`** — core interfaces and primitives. `fs.Fs`, `fs.Object`, `fs.DirInfo` (in `fs/types.go` and `fs/fs.go`) are the contracts every backend implements. Sub-packages: + - `fs/operations` — primitives (`Copy`, `Move`, `MoveDir`, `check`, dedupe, listdirsorted, lsjson) + - `fs/sync` — `sync.Sync` (one-way) and the pipe-based engine; also drives `bisync` + - `fs/march` — walks two Fs trees in lock step, emitting diffs + - `fs/filter` — include/exclude rules + - `fs/config` — config file, flags, obscured creds, rc config; `configfile`/`configflags`/`configstruct`/`configmap` are sub-packages + - `fs/fshttp` — rclone's `http.Client`/`Transport`; backends **must** use this to inherit `--dump`, `--tpslimit`, `--user-agent`, etc. + - `fs/accounting`, `fs/cache`, `fs/fspath`, `fs/hash`, `fs/log`, `fs/rc` (+ `rcserver`) +- **`backend/`** — one subdirectory per provider (drive, s3, dropbox, sftp, local, …). `backend/all/all.go` imports them all via blank imports; `rclone.go` imports `backend/all`. There are virtual/wrapper backends too: `alias`, `archive`, `cache`, `chunker`, `combine`, `compress`, `crypt`, `hasher`, `union`. +- **`cmd/`** — one subdirectory per CLI subcommand (`copy`, `sync`, `mount`, `serve`, `bisync`, …). `cmd/all/all.go` registers them; `cmd/cmd.go` is the cobra entry point. +- **`lib/`** — cross-cutting libraries backends and fs both use: `rest` (thin net/http wrapper for REST APIs), `pacer` (retry/backoff), `dircache`, `oauthutil`, `encoder` (filename encoding), `readers`, `atexit`, `errcount`, `exitcode`, `multipart`, `plugin`, etc. +- **`vfs/`** — virtual filesystem layer for `mount`/`cmount`/`mountlib`/`nfsmount`. +- **`librclone/`** — embeddable in-memory API for users who want rclone as a library. +- **`fstest/`** — integration test framework: `fstests` (per-backend suite), `mockdir`/`mockobject`, `test_all` (driver that reads `config.yaml`). +- **`cmdtest/`** — end-to-end tests of CLI flags, env vars, exit codes. + +`rclone.go` is intentionally tiny: it blank-imports `backend/all`, `cmd/all`, `lib/plugin`, then calls `cmd.Main()`. + +## Conventions + +**Commit messages** follow `: ` where `` is the directory touched (`drive:`, `fs/sync:`, `mount:`, `completion:`). The changelog is generated from these first lines, so make them user-readable. Long form goes after a blank line, mentioning `Fixes #N` to auto-close. + +**New backend layout** (see CONTRIBUTING.md "Writing a new backend"): +- Implement in a single `backend//.go` — **do not** split into `fs.go` and `object.go`. The maintainers explicitly reject this for the >50 existing backends. +- Put API type definitions in `api/types.go`. +- Follow the structure of `backend/box` (directory-based) or `backend/b2` (bucket-based) exactly — same function names, same order, same comments. +- For HTTP backends, use `lib/rest` and the client from `fs/fshttp`. Don't roll your own transport. +- Register in `backend/all/all.go` and add to `fstest/test_all/config.yaml`. + +**Backend options** are declared in Go with `Help:` fields that get rendered into docs and `--flag` help. See CONTRIBUTING.md "Writing Documentation" for the rules: first sentence on one line ≤80 chars, ends with period, more detail after a blank `"\n\n"`. The Markdown in `docs/content/.md` between `` markers is regenerated by `bin/make_backend_docs.py` — never edit those regions by hand. + +**Documentation autogeneration.** `MANUAL.md`, `MANUAL.html`, `MANUAL.txt`, `rclone.1`, `docs/content/flags.md`, `docs/content/commands/*.md`, and the autogenerated portions of backend docs are all produced from Go source by Makefile targets (`make doc`, `make commanddocs`, `make backenddocs`, `make rcdocs`). `bin/check_autogenerated_edits.py` runs in CI and rejects PRs that hand-edit those regions. You normally don't run these locally — they run during the release process. The `docs/` site is built with Hugo (`make serve` to preview). + +**`go generate` is used** in `lib/transform` and `cmd/bisync` (see the `commanddocs` target). Run those before regenerating command docs. + +**Configuration system.** Top-level flags live in `fs/config/configflags`. Backend options are defined per-backend via `Options` structs with `configstruct` tags and surfaced automatically. The `--config` flag points at the rclone.conf used both at runtime and for `Test:` test remotes. + +**Version tagging.** Tags are GPG-signed (`make retag`). `make beta` / `make ci_beta` publish to `beta.rclone.org`. Full release flow is in `RELEASE.md`. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..f92dc46 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,589 @@ +--- +version: alpha +name: Claude-design-analysis +description: A warm-canvas editorial interface for Anthropic's Claude product. The system anchors on a tinted cream canvas with serif display headlines, warm coral CTAs, and dark navy product surfaces (code editor mockups, model showcase cards). Brand voltage comes from the cream/coral pairing — deliberately warm and humanist where most AI brands use cool blue + slate. Type voice runs a slab-serif display ("Copernicus" / Tiempos Headline) for h1/h2 and a humanist sans for body. The signature Anthropic black-radial-spike mark anchors the wordmark. + +colors: + primary: "#cc785c" + primary-active: "#a9583e" + primary-disabled: "#e6dfd8" + ink: "#141413" + body: "#3d3d3a" + body-strong: "#252523" + muted: "#6c6a64" + muted-soft: "#8e8b82" + hairline: "#e6dfd8" + hairline-soft: "#ebe6df" + canvas: "#faf9f5" + surface-soft: "#f5f0e8" + surface-card: "#efe9de" + surface-cream-strong: "#e8e0d2" + surface-dark: "#181715" + surface-dark-elevated: "#252320" + surface-dark-soft: "#1f1e1b" + on-primary: "#ffffff" + on-dark: "#faf9f5" + on-dark-soft: "#a09d96" + accent-teal: "#5db8a6" + accent-amber: "#e8a55a" + success: "#5db872" + warning: "#d4a017" + error: "#c64545" + +typography: + display-xl: + fontFamily: "Copernicus, Tiempos Headline, serif" + fontSize: 64px + fontWeight: 400 + lineHeight: 1.05 + letterSpacing: -1.5px + display-lg: + fontFamily: "Copernicus, Tiempos Headline, serif" + fontSize: 48px + fontWeight: 400 + lineHeight: 1.1 + letterSpacing: -1px + display-md: + fontFamily: "Copernicus, Tiempos Headline, serif" + fontSize: 36px + fontWeight: 400 + lineHeight: 1.15 + letterSpacing: -0.5px + display-sm: + fontFamily: "Copernicus, Tiempos Headline, serif" + fontSize: 28px + fontWeight: 400 + lineHeight: 1.2 + letterSpacing: -0.3px + title-lg: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 22px + fontWeight: 500 + lineHeight: 1.3 + letterSpacing: 0 + title-md: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 18px + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: 0 + title-sm: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 16px + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: 0 + body-md: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 16px + fontWeight: 400 + lineHeight: 1.55 + letterSpacing: 0 + body-sm: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 14px + fontWeight: 400 + lineHeight: 1.55 + letterSpacing: 0 + caption: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 13px + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: 0 + caption-uppercase: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 12px + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: 1.5px + code: + fontFamily: "JetBrains Mono, ui-monospace, monospace" + fontSize: 14px + fontWeight: 400 + lineHeight: 1.6 + letterSpacing: 0 + button: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 14px + fontWeight: 500 + lineHeight: 1 + letterSpacing: 0 + nav-link: + fontFamily: "StyreneB, Inter, sans-serif" + fontSize: 14px + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: 0 + +rounded: + xs: 4px + sm: 6px + md: 8px + lg: 12px + xl: 16px + pill: 9999px + full: 9999px + +spacing: + xxs: 4px + xs: 8px + sm: 12px + md: 16px + lg: 24px + xl: 32px + xxl: 48px + section: 96px + +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.button}" + rounded: "{rounded.md}" + padding: 12px 20px + height: 40px + button-primary-active: + backgroundColor: "{colors.primary-active}" + textColor: "{colors.on-primary}" + rounded: "{rounded.md}" + button-primary-disabled: + backgroundColor: "{colors.primary-disabled}" + textColor: "{colors.muted}" + rounded: "{rounded.md}" + button-secondary: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.button}" + rounded: "{rounded.md}" + padding: 12px 20px + height: 40px + button-secondary-on-dark: + backgroundColor: "{colors.surface-dark-elevated}" + textColor: "{colors.on-dark}" + typography: "{typography.button}" + rounded: "{rounded.md}" + padding: 12px 20px + button-text-link: + backgroundColor: transparent + textColor: "{colors.ink}" + typography: "{typography.button}" + button-icon-circular: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + rounded: "{rounded.full}" + size: 36px + text-link: + backgroundColor: transparent + textColor: "{colors.primary}" + typography: "{typography.body-md}" + top-nav: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.nav-link}" + height: 64px + hero-band: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.display-xl}" + padding: 96px + hero-illustration-card: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + rounded: "{rounded.xl}" + feature-card: + backgroundColor: "{colors.surface-card}" + textColor: "{colors.ink}" + typography: "{typography.title-md}" + rounded: "{rounded.lg}" + padding: 32px + product-mockup-card-dark: + backgroundColor: "{colors.surface-dark}" + textColor: "{colors.on-dark}" + typography: "{typography.title-md}" + rounded: "{rounded.lg}" + padding: 32px + code-window-card: + backgroundColor: "{colors.surface-dark}" + textColor: "{colors.on-dark}" + typography: "{typography.code}" + rounded: "{rounded.lg}" + padding: 24px + model-comparison-card: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.title-md}" + rounded: "{rounded.lg}" + padding: 32px + pricing-tier-card: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.title-lg}" + rounded: "{rounded.lg}" + padding: 32px + pricing-tier-card-featured: + backgroundColor: "{colors.surface-dark}" + textColor: "{colors.on-dark}" + typography: "{typography.title-lg}" + rounded: "{rounded.lg}" + padding: 32px + callout-card-coral: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.title-md}" + rounded: "{rounded.lg}" + padding: 32px + connector-tile: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.title-sm}" + rounded: "{rounded.lg}" + padding: 20px + text-input: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + typography: "{typography.body-md}" + rounded: "{rounded.md}" + padding: 10px 14px + height: 40px + text-input-focused: + backgroundColor: "{colors.canvas}" + textColor: "{colors.ink}" + rounded: "{rounded.md}" + cookie-consent-card: + backgroundColor: "{colors.surface-dark}" + textColor: "{colors.on-dark}" + typography: "{typography.body-sm}" + rounded: "{rounded.lg}" + padding: 24px + category-tab: + backgroundColor: transparent + textColor: "{colors.muted}" + typography: "{typography.nav-link}" + padding: 8px 14px + rounded: "{rounded.md}" + category-tab-active: + backgroundColor: "{colors.surface-card}" + textColor: "{colors.ink}" + typography: "{typography.nav-link}" + rounded: "{rounded.md}" + badge-pill: + backgroundColor: "{colors.surface-card}" + textColor: "{colors.ink}" + typography: "{typography.caption}" + rounded: "{rounded.pill}" + padding: 4px 12px + badge-coral: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.caption-uppercase}" + rounded: "{rounded.pill}" + padding: 4px 12px + cta-band-coral: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + typography: "{typography.display-sm}" + rounded: "{rounded.lg}" + padding: 64px + cta-band-dark: + backgroundColor: "{colors.surface-dark}" + textColor: "{colors.on-dark}" + typography: "{typography.display-sm}" + rounded: "{rounded.lg}" + padding: 64px + footer: + backgroundColor: "{colors.surface-dark}" + textColor: "{colors.on-dark-soft}" + typography: "{typography.body-sm}" + padding: 64px +--- + +## Overview + +Claude.com is the warmest, most editorial interface in the AI-product category. The base atmosphere is a **tinted cream canvas** (`{colors.canvas}` — #faf9f5) — distinctly warm, deliberately not the cool gray-white that every other AI brand uses. Headlines run a **slab-serif display** ("Copernicus" / Tiempos Headline) at weight 400 with negative letter-spacing, paired with **StyreneB / Inter** body sans. The combination feels like a literary publication, not a SaaS marketing page. + +Brand voltage comes from the **cream + coral pairing** — coral (`{colors.primary}` — #cc785c) is the signature Anthropic accent, used on every primary CTA, on the brand wordmark, and on full-bleed callout cards. The coral is warm, slightly muted, never cyan/blue — a deliberate counter-positioning against OpenAI's cool slate, Google's saturated blue, and Microsoft's corporate cyan. + +The system has three surface modes that alternate page-by-page: +1. **Cream canvas** (`{colors.canvas}`) — default body floor +2. **Light cream cards** (`{colors.surface-card}`) — feature card backgrounds +3. **Dark navy product surfaces** (`{colors.surface-dark}`) — code editor mockups, model showcase cards, pre-footer CTAs, footer itself + +The dark surfaces are where Claude shows its product chrome — code blocks, terminal output, model comparison tables, agentic-flow diagrams. The cream-to-dark contrast is the page's pacing rhythm. + +**Key Characteristics:** +- Warm cream canvas (`{colors.canvas}` — #faf9f5) with dark warm-ink text (`{colors.ink}` — #141413). The brand's defining color choice. +- Coral primary CTA (`{colors.primary}` — #cc785c). Used scarcely on individual buttons, generously on full-bleed coral callout cards. +- Slab-serif display headlines via Copernicus / Tiempos Headline at weight 400 with negative letter-spacing. Pairs with humanist sans body for a literary editorial voice. +- Dark navy product mockup cards (`{colors.surface-dark}` — #181715) carrying code blocks, terminal panels, model comparison data — the brand shows the product chrome at scale rather than abstract marketing illustrations. +- Light cream feature cards (`{colors.surface-card}` — #efe9de) — slightly darker than canvas, used for content-driven feature explanations. +- Anthropic radial-spike mark — a small black asterisk-like glyph (4-spoke radial) — appears as the brand wordmark prefix and as a content marker. +- Border radius is hierarchical: `{rounded.md}` (8px) for buttons + inputs, `{rounded.lg}` (12px) for content + product cards, `{rounded.xl}` (16px) for the hero illustration container, `{rounded.pill}` for badges. +- Section rhythm `{spacing.section}` (96px) — modern-SaaS standard. Internal card padding stays generous at `{spacing.xl}` (32px). + +## Colors + +### Brand & Accent +- **Coral / Primary** (`{colors.primary}` — #cc785c): The signature Anthropic warm coral. Used on every primary CTA background, on full-bleed coral callout cards, on the brand wordmark accent. The most-recognized Anthropic color outside of the spike-mark logo. +- **Coral Active** (`{colors.primary-active}` — #a9583e): The press / hover-darker variant. +- **Coral Disabled** (`{colors.primary-disabled}` — #e6dfd8): A desaturated cream-tinted disabled state. +- **Accent Teal** (`{colors.accent-teal}` — #5db8a6): Used sparingly on secondary product surfaces (terminal status indicators, "active connection" dots in connectors page). +- **Accent Amber** (`{colors.accent-amber}` — #e8a55a): A small companion warm-tone used on category badges and inline highlights. + +### Surface +- **Canvas** (`{colors.canvas}` — #faf9f5): The default page floor. Tinted cream — warm, deliberately not pure white. +- **Surface Soft** (`{colors.surface-soft}` — #f5f0e8): Section dividers, very-soft band backgrounds. +- **Surface Card** (`{colors.surface-card}` — #efe9de): Feature cards, content cards. One step darker than canvas. +- **Surface Cream Strong** (`{colors.surface-cream-strong}` — #e8e0d2): A strongest-cream variant used on selected category tabs and emphasized section bands. +- **Surface Dark** (`{colors.surface-dark}` — #181715): Code editor mockups, model showcase cards, footer. The dominant dark surface. +- **Surface Dark Elevated** (`{colors.surface-dark-elevated}` — #252320): Elevated cards inside dark bands (settings panels in mockups). +- **Surface Dark Soft** (`{colors.surface-dark-soft}` — #1f1e1b): Slightly lighter dark, used for code block backgrounds inside larger dark cards. +- **Hairline** (`{colors.hairline}` — #e6dfd8): The 1px border tone on cream surfaces. Same hex as `{colors.primary-disabled}` — borders feel like one elevation step rather than ink lines. +- **Hairline Soft** (`{colors.hairline-soft}` — #ebe6df): Barely-visible divider used inside the same band. + +### Text +- **Ink** (`{colors.ink}` — #141413): All headlines and primary text. Warm dark, slightly off-pure-black. +- **Body Strong** (`{colors.body-strong}` — #252523): Emphasized paragraphs, lead text. +- **Body** (`{colors.body}` — #3d3d3a): Default running-text color. +- **Muted** (`{colors.muted}` — #6c6a64): Sub-headings, breadcrumbs, footer-adjacent secondary text. +- **Muted Soft** (`{colors.muted-soft}` — #8e8b82): Captions, fine-print, copyright lines. +- **On Primary** (`{colors.on-primary}` — #ffffff): Text on coral buttons. +- **On Dark** (`{colors.on-dark}` — #faf9f5): Cream-tinted white used on dark surfaces (echoes the canvas tone). +- **On Dark Soft** (`{colors.on-dark-soft}` — #a09d96): Footer body text, secondary labels in dark mockups. + +### Semantic +- **Success** (`{colors.success}` — #5db872): Green status dots, "available" indicators. +- **Warning** (`{colors.warning}` — #d4a017): Warning callouts (rare on marketing surfaces). +- **Error** (`{colors.error}` — #c64545): Validation errors. + +## Typography + +### Font Family +The system runs **Copernicus** (or **Tiempos Headline** as substitute) as the slab-serif display face for headlines, and **StyreneB** (or **Inter** as substitute) as the humanist sans for body, navigation, and UI labels. **JetBrains Mono** handles code blocks. The fallback stack walks `Tiempos Headline, Garamond, "Times New Roman", serif` for display and `Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` for body. + +The display/body split is editorial: +- Copernicus serif (weight 400, negative tracking) → h1, h2, h3, hero display +- StyreneB sans (weight 400-500) → body, navigation, buttons, captions, labels +- JetBrains Mono → all code blocks and terminal text + +### Hierarchy + +| Token | Size | Weight | Line Height | Letter Spacing | Use | +|---|---|---|---|---|---| +| `{typography.display-xl}` | 64px | 400 | 1.05 | -1.5px | Homepage h1 ("Meet your thinking partner") — Copernicus serif | +| `{typography.display-lg}` | 48px | 400 | 1.1 | -1px | Section heads — Copernicus | +| `{typography.display-md}` | 36px | 400 | 1.15 | -0.5px | Sub-section heads, model names — Copernicus | +| `{typography.display-sm}` | 28px | 400 | 1.2 | -0.3px | Pricing tier names, callout headlines — Copernicus | +| `{typography.title-lg}` | 22px | 500 | 1.3 | 0 | Pricing plan size labels — StyreneB | +| `{typography.title-md}` | 18px | 500 | 1.4 | 0 | Feature card titles, intro paragraphs | +| `{typography.title-sm}` | 16px | 500 | 1.4 | 0 | Connector tile titles, list labels | +| `{typography.body-md}` | 16px | 400 | 1.55 | 0 | Default running-text — StyreneB | +| `{typography.body-sm}` | 14px | 400 | 1.55 | 0 | Footer body, fine-print | +| `{typography.caption}` | 13px | 500 | 1.4 | 0 | Badge labels, captions | +| `{typography.caption-uppercase}` | 12px | 500 | 1.4 | 1.5px | Category tags, "NEW" badges | +| `{typography.code}` | 14px | 400 | 1.6 | 0 | Code blocks — JetBrains Mono | +| `{typography.button}` | 14px | 500 | 1.0 | 0 | Standard button labels | +| `{typography.nav-link}` | 14px | 500 | 1.4 | 0 | Top-nav menu items | + +### Principles +Display sizes use weight 400 (regular), never bold. Negative letter-spacing (-0.3 to -1.5px) is essential — Copernicus without it reads as off-brand. The serif character is what gives Anthropic its literary, considered voice; switching to a sans-serif display would make Claude feel like every other AI tool. + +Body type stays at weight 400 for paragraphs, weight 500 for labels and emphasized phrases. The sans body is humanist (StyreneB) — never geometric. Inter is an acceptable substitute because of its similar humanist proportions; Helvetica or Arial would be too neutral and break the warm-editorial feel. + +### Note on Font Substitutes +If Copernicus / Tiempos Headline is unavailable, **Cormorant Garamond** at weight 500 with -0.02em letter-spacing is the closest open-source approximation. **EB Garamond** is a fallback. For StyreneB, **Inter** is the closest match — both are humanist sans designed for screen reading. **Söhne** is another close alternative if licensed. + +## Layout + +### Spacing System +- **Base unit:** 4px. +- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 96px. +- **Section padding:** `{spacing.section}` (96px) — modern-SaaS rhythm. +- **Card internal padding:** `{spacing.xl}` (32px) for feature cards, pricing tier cards, model comparison cards; `{spacing.lg}` (24px) for code-window cards and connector tiles. +- **Callout / CTA bands:** `{spacing.xxl}` (48px) inside coral callout cards; 64px inside the larger dark CTA band. + +### Grid & Container +- **Max content width:** ~1200px centered. +- **Editorial body:** Single 12-column grid; hero often uses 6/6 split (h1 left, illustration right). +- **Feature card grids:** 3-up at desktop, 2-up at tablet, 1-up at mobile. +- **Connector tile grids:** 4-up or 6-up at desktop, 2-up at tablet, 1-up at mobile. +- **Pricing grid:** 3-up at desktop (Free / Pro / Team / Enterprise often), 1-up at mobile. + +### Whitespace Philosophy +The cream canvas + serif display + generous internal padding create an editorial pacing — Claude reads like a long-form magazine column rather than a marketing template. Whitespace between bands stays uniform at 96px; whitespace inside cards is generous (32px), letting type breathe. + +## Elevation & Depth + +| Level | Treatment | Use | +|---|---|---| +| Flat | No shadow, no border | Body sections, top nav, hero bands | +| Soft hairline | 1px `{colors.hairline}` border | Inputs, sub-nav, occasionally on cards | +| Cream card | `{colors.surface-card}` background — no shadow | Feature cards, content cards | +| Dark surface card | `{colors.surface-dark}` background — no shadow | Code editor mockups, model showcase cards | +| Subtle drop shadow | Faint shadow at low alpha | Hover-elevated states (the system uses `0 1px 3px rgba(20,20,19,0.08)` rarely) | + +The elevation philosophy is **color-block first, shadow rare**. Most depth comes from the cream-vs-dark surface contrast. Shadows are minimal. The dark surface mockups have their own internal product chrome (code editor scrollbars, line numbers, syntax highlighting) which adds detail without needing external shadows. + +### Decorative Depth +- The Anthropic spike-mark glyph (4-spoke radial asterisk) appears as a small black mark in the brand wordmark and inline as a content marker. +- Code editor mockups carry their own internal depth: syntax-highlighted text in muted blues / oranges / grays, line numbers in `{colors.muted-soft}`, status bars at the bottom in `{colors.surface-dark-elevated}`. +- Some hero illustrations use simple line-art with coral and dark-navy strokes on cream — minimal, hand-drawn-feeling, never photorealistic. + +## Shapes + +### Border Radius Scale + +| Token | Value | Use | +|---|---|---| +| `{rounded.xs}` | 4px | Reserved for badge accents and tiny dropdowns | +| `{rounded.sm}` | 6px | Small inline buttons, dropdown items | +| `{rounded.md}` | 8px | Standard CTA buttons, text inputs, category tabs | +| `{rounded.lg}` | 12px | Content cards (feature, pricing, code-window, model-comparison) | +| `{rounded.xl}` | 16px | Hero illustration container, the larger marquee components | +| `{rounded.pill}` | 9999px | Badge pills, "NEW" tags | +| `{rounded.full}` | 9999px / 50% | Avatar substitutes, icon buttons | + +### Photography & Illustrations +Claude's hero rarely uses photography. Instead it uses: +- Simple line-art illustrations with coral + dark-navy strokes on the cream canvas +- Code editor mockups (the dominant "hero" treatment on developer-focused pages) +- Terminal output mockups with monospace text on dark +- Model comparison cards (Opus / Sonnet / Haiku) with abstract geometric thumbnails + +When photography is used (rare — mostly testimonials), avatars crop to perfect circles at 40px diameter. + +## Components + +### Top Navigation + +**`top-nav`** — Cream nav bar pinned to the top of every page. 64px tall, `{colors.canvas}` background. Carries the Anthropic spike-mark + "Claude" wordmark at left, primary horizontal menu (Product, Solutions, Use Cases, Pricing, Research, Company) center-left, right-side cluster with "Sign in" text-link, "Try Claude" `{component.button-primary}` (coral). Menu items in `{typography.nav-link}` (StyreneB 14px / 500). + +### Buttons + +**`button-primary`** — The signature coral CTA. Background `{colors.primary}` (#cc785c), text `{colors.on-primary}` (white), type `{typography.button}` (StyreneB 14px / 500), padding 12px × 20px, height 40px, rounded `{rounded.md}` (8px). Active state `button-primary-active` darkens to `{colors.primary-active}` (#a9583e). + +**`button-secondary`** — Cream button with hairline outline. Background `{colors.canvas}`, text `{colors.ink}`, 1px hairline border, same padding + height + radius as primary. + +**`button-secondary-on-dark`** — Used over `{colors.surface-dark}` cards. Background `{colors.surface-dark-elevated}` (#252320), text `{colors.on-dark}`. Stays dark — the system never inverts to a light secondary on dark surfaces. + +**`button-text-link`** — Inline text button, no background. Used for "Sign in" in the top nav and inline CTA links. + +**`button-icon-circular`** — 36px circular icon button. Background `{colors.canvas}`, hairline border, ink-color icon. Used for carousel arrows, share, "view more". + +**`text-link`** — Inline body links in `{colors.primary}` (the coral). Underlined on press; the coral inline link is one of the system's most distinctive small details. + +### Cards & Containers + +**`hero-band`** — Cream-canvas hero with a 6-6 grid: h1 + sub-headline + button row on the left, hero illustration card or product mockup card on the right. Vertical padding `{spacing.section}` (96px). + +**`hero-illustration-card`** — A larger card holding the hero's right-side artifact — sometimes a coral-stroke line illustration on cream background, sometimes a dark code editor mockup. Background `{colors.canvas}` or `{colors.surface-dark}` depending on context, rounded `{rounded.xl}` (16px). + +**`feature-card`** — Used in 3-up feature grids. Background `{colors.surface-card}` (#efe9de — slightly darker cream), rounded `{rounded.lg}` (12px), internal padding `{spacing.xl}` (32px). Carries a small icon at top, an `{typography.title-md}` headline, and a body description in `{typography.body-md}`. + +**`product-mockup-card-dark`** — Dark navy card showing actual Claude product chrome (chat interface, code editor, agent controls). Background `{colors.surface-dark}`, rounded `{rounded.lg}`, internal padding `{spacing.xl}` (32px). Carries text labels in `{colors.on-dark}` and product UI fragments below. + +**`code-window-card`** — A specialized dark card showing a code editor with line numbers, syntax-highlighted code in `{typography.code}` (JetBrains Mono), and sometimes a "Run" button or terminal output panel below. Background `{colors.surface-dark}` with `{colors.surface-dark-soft}` for the inner code block, rounded `{rounded.lg}`, padding `{spacing.lg}` (24px). The signature visual element of Claude Code product pages. + +**`model-comparison-card`** — Used on the homepage's "Which problem are you up against?" section comparing Opus / Sonnet / Haiku. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, internal padding `{spacing.xl}` (32px). Carries the model name, a short capability blurb, and a `{component.text-link}` to learn more. + +**`pricing-tier-card`** — Standard tier card. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, padding `{spacing.xl}` (32px). Carries the plan name in `{typography.title-lg}` (StyreneB), price in `{typography.display-sm}` (Copernicus serif!), feature checklist in `{typography.body-md}`, and a `{component.button-primary}` at the bottom. + +**`pricing-tier-card-featured`** — The featured tier (typically "Pro" or "Team"). Background flips to `{colors.surface-dark}`, text inverts to `{colors.on-dark}`. The dark surface IS the featured-tier signal. + +**`callout-card-coral`** — A full-bleed coral card carrying a major call-to-action. Background `{colors.primary}` (#cc785c), text `{colors.on-primary}` (white), rounded `{rounded.lg}`, padding `{spacing.xxl}` (48px). The coral surface IS the voltage; the CTA inside uses an inverted button style (cream/canvas button on coral). + +**`connector-tile`** — Used on the connectors page's integration grid. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, padding 20px. Each tile carries a logo at top, a `{typography.title-sm}` connector name, and a short description. + +### Inputs & Forms + +**`text-input`** — Standard text input. Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-md}`, rounded `{rounded.md}` (8px), padding 10px × 14px, height 40px. 1px hairline border in `{colors.hairline}`. + +**`text-input-focused`** — Focus state. Border thickens or shifts to `{colors.primary}` (coral) for emphasis. Carries a 3px coral-at-15%-alpha outer ring. + +**`cookie-consent-card`** — Bottom-right floating dark cookie banner. Background `{colors.surface-dark}`, text `{colors.on-dark}`, rounded `{rounded.lg}`, padding `{spacing.lg}` (24px). One of the few places dark surface appears at small scale on cream pages. + +### Tags / Badges + +**`badge-pill`** — Small pill label used for category tags. Background `{colors.surface-card}`, text `{colors.ink}`, type `{typography.caption}` (13px / 500), rounded `{rounded.pill}`, padding 4px × 12px. + +**`badge-coral`** — Coral-fill badge for "NEW", "BETA", featured highlights. Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.caption-uppercase}` (12px / 500 / 1.5px tracking), rounded `{rounded.pill}`, padding 4px × 12px. + +### Tab / Filter + +**`category-tab`** + **`category-tab-active`** — Used in sub-nav rows on solutions / connectors pages. Inactive: transparent background, `{colors.muted}` text. Active: `{colors.surface-card}` background, `{colors.ink}` text. Padding 8px × 14px, rounded `{rounded.md}`. + +### CTA / Footer + +**`cta-band-coral`** — A pre-footer "Try Claude" CTA card. Full-width coral fill, white type, rounded `{rounded.lg}`, padding 64px. Carries an h2 in `{typography.display-sm}` (still serif!), a sub-line, and a cream-button CTA. + +**`cta-band-dark`** — Alternative pre-footer band on developer-focused pages. Background `{colors.surface-dark}`, text `{colors.on-dark}`, rounded `{rounded.lg}`, padding 64px. Often pairs with a code-window card. + +**`footer`** — Dark navy footer that closes every page. Background `{colors.surface-dark}` (#181715), text `{colors.on-dark-soft}`. 4-column link list at desktop covering Product / Company / Resources / Legal. Vertical padding 64px. The Anthropic spike-mark + "Anthropic" wordmark sits at the top in `{colors.on-dark}`. The footer never inverts. + +## Do's and Don'ts + +### Do +- Anchor every page on the cream canvas. Pure white reads as "any other AI tool"; the warm tint is the brand differentiator. +- Use Copernicus serif for every display headline. Pair with StyreneB sans body. Negative letter-spacing on display sizes is non-negotiable. +- Reserve `{colors.primary}` (coral) for primary CTAs and full-bleed `{component.callout-card-coral}` moments. Don't paint accent moments coral elsewhere. +- Use `{component.product-mockup-card-dark}` and `{component.code-window-card}` to show actual Claude product chrome. Don't paint marketing illustrations of code when you can show real code. +- Pair `{component.feature-card}` (cream) with `{component.product-mockup-card-dark}` (navy) in alternating bands. The cream-to-dark rhythm is the brand's pacing mechanism. +- Use the Anthropic spike-mark glyph as the brand wordmark prefix. Never invert the mark to white-on-dark within the wordmark itself. +- Apply `{spacing.section}` (96px) between major bands. + +### Don't +- Don't use cool grays or pure white for canvas. Cream is the brand. +- Don't bold serif display weight. Copernicus at 700 reads as bombastic; the system stays at 400. +- Don't use cool blue or saturated cyan as a brand accent. The coral is the brand voltage. +- Don't put coral everywhere. The coral is scarce on individual elements and generous only on full-bleed coral callout cards. +- Don't use Inter for display headlines. The serif character is the brand voice. +- Don't repeat the same surface mode in two consecutive bands. The pacing alternates: cream → cream-card → dark-mockup → cream → coral-callout → dark-footer. +- Don't add hover state styling beyond what the system already encodes — primary darkens on press; nothing else changes. + +## Responsive Behavior + +### Breakpoints + +| Name | Width | Key Changes | +|---|---|---| +| Mobile | < 768px | Hamburger nav; hero h1 64→32px; hero-illustration-card stacks below content; feature grids 1-up; connector tiles 2-up; pricing 1-up; footer 4 cols → 1 | +| Tablet | 768–1024px | Top nav stays horizontal but tightens; feature cards 2-up; connector tiles 3-up; pricing 2-up | +| Desktop | 1024–1440px | Full top-nav with all menu items; 3-up feature cards; 4-up or 6-up connector tiles; 3-up pricing tiers | +| Wide | > 1440px | Same as desktop with more outer breathing room; max content width caps at 1200px | + +### Touch Targets +- `{component.button-primary}` at minimum 40 × 40px. +- `{component.button-icon-circular}` at exactly 36 × 36 — slightly under WCAG 44 but visually centered. +- `{component.text-input}` height is 40px. +- Connector tile entire card area is tappable; effective tap area >> 44px. + +### Collapsing Strategy +- Top nav collapses to hamburger at < 768px; menu opens as a full-screen cream sheet. +- Hero band's 6-6 grid collapses to single-column on mobile — h1 + sub-head + buttons first, then the illustration / mockup card below. +- Feature grids reduce columns rather than scaling cards down. +- Pricing tier cards collapse 4 → 2 → 1; featured-tier dark surface stays visually distinct at every breakpoint. +- Code-window cards retain code legibility at every breakpoint by allowing horizontal scroll within the card rather than wrapping code lines. + +### Image Behavior +- Code blocks inside dark mockups stay at fixed font-size; horizontal scroll on mobile rather than wrapping. +- Hero illustrations scale proportionally; line-art strokes thin slightly on mobile. +- Avatar photos in testimonials crop to circles at every breakpoint. + +## Iteration Guide + +1. Focus on ONE component at a time. Reference its YAML key (`{component.feature-card}`, `{component.code-window-card}`). +2. Variants of an existing component (`-active`, `-disabled`, `-focused`) live as separate entries in `components:`. +3. Use `{token.refs}` everywhere — never inline hex. +4. Never document hover. Default and Active/Pressed states only. +5. Display headlines stay Copernicus serif 400 with negative tracking. Body stays StyreneB / Inter 400. The split is unbreakable. +6. Cream + coral + dark navy is the trinity. Don't introduce a fourth surface tone (no purple cards, no green sections). +7. When in doubt about emphasis: bigger Copernicus serif before bolder weight. + +## Known Gaps + +- Copernicus and StyreneB are licensed Anthropic typefaces and not available as public web fonts. Substitutes (Tiempos Headline / Cormorant Garamond / EB Garamond for serif; Inter / Söhne for sans) are documented in the typography section. +- The Anthropic radial-spike-mark is a brand glyph rendered as inline SVG; it's not formalized as a system token here. Treat it as a logo asset. +- Animation and transition timings (chat message reveal, code block typewriter effect on the homepage, agentic-flow diagram animations) are not in scope. +- Form validation states beyond `{component.text-input-focused}` are not extracted — error / success states would need a sign-up or feedback flow to confirm. +- The actual Claude product surface (claude.ai chat interface) shares some tokens with the marketing site but adds many product-specific components (chat bubbles, message tools, file upload chips, conversation history sidebar) that are out of scope for this marketing-surface document. +- The "agent" / "computer use" demo cards on certain pages display animated Claude controlling a browser — the static screenshot doesn't fully capture the animation chrome. diff --git a/Dockerfile.webgui b/Dockerfile.webgui new file mode 100644 index 0000000..3984cec --- /dev/null +++ b/Dockerfile.webgui @@ -0,0 +1,13 @@ +# Frontend image for the rclone webgui. +# Serves the static bundle (./webgui/web/) via nginx and reverse-proxies +# RC API calls + remote file downloads to the rclone rcd container on +# the compose network. Same-origin from the browser's perspective, so +# no CORS headaches. + +FROM nginx:1.27-alpine + +LABEL org.opencontainers.image.title="rclone-webgui-frontend" +LABEL org.opencontainers.image.description="Static frontend for the rclone webgui, served by nginx with a reverse proxy to rclone rcd." + +COPY webgui/web/ /usr/share/nginx/html/ +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf diff --git a/README.md b/README.md new file mode 100644 index 0000000..205c67f --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# rclone-webgui + +浏览器端的 rclone 图形界面,使用 Anthropic / Claude 设计语言。 + +`rclone/` 是 [github.com/rclone/rclone](https://github.com/rclone/rclone) +的 git submodule,锁定在上游某个 commit,**不携带我们的任何改动**。 +所有 webgui 源码、Docker 编排、文档都在本仓库。 + +## 项目结构 + +``` +. +├── webgui/ # webgui 源码(在父仓库,不在 rclone 子模块里) +│ ├── webgui.go # Go 子命令源码(仅当自行构建 rclone 时需要) +│ ├── rclone-cmd-all-add-webgui-import.patch # 注解:把 webgui 注册进 rclone 的 cmd/all +│ └── web/ # 静态前端(nginx 服务的就是这一份) +│ ├── index.html +│ └── assets/ +├── docker/ # nginx 反向代理配置 +├── config/rclone/ # rclone.conf 挂载点(bind mount,不提交) +├── Dockerfile.webgui # 前端镜像构建(nginx + 静态资源) +├── docker-compose.yml # rclone rcd + gui 双服务编排 +├── DESIGN.md # UI 设计系统规范 +├── CLAUDE.md # Claude Code 协作指引 +└── rclone/ # submodule → github.com/rclone/rclone,纯净不改动 +``` + +## 功能 + +- **Remotes 管理** — 在浏览器里创建 / 编辑 / 删除 rclone remote,表单从 + `/config/providers` 动态生成,覆盖全部 70+ 后端的全部选项。 +- **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete / + rename / download。 +- **同步任务** — copy / sync / move 异步任务,1.5 秒轮询进度(速度、 + ETA、已传输 / 总量、错误计数),任务元信息(src→dst)持久化到 + localStorage,刷新页面不丢。 + +> OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅, +> 引导用户在终端跑 `rclone config` 完成授权。 + +## 快速开始 + +```bash +# 1. 拉取子模块 +git clone --recurse-submodules +# 或在已克隆的仓库里: +git submodule update --init + +# 2. 启动堆栈 +docker compose up -d --build + +# 3. 打开 http://localhost:5580 +``` + +## 端口 + +| 端口 | 服务 | 说明 | +|---|---|---| +| 5580 | gui (nginx) | 浏览器入口;同源反代 RC API | +| 5572 | rclone rcd | 可直接 curl / `rclone rc` 访问 | + +## 自行构建 rclone(可选) + +Docker 编排默认使用官方 `rclone/rclone:latest` 镜像,配合 rcd 即可。 +如果你想构建一个内置 webgui 命令的 rclone 二进制(`rclone webgui` +能像 `rclone gui` 那样独立运行),可以: + +```bash +# 1. 把 webgui 源码软链或拷贝到 rclone 子模块的 cmd/ 下 +ln -s ../../webgui rclone/cmd/webgui + +# 2. 应用注册补丁 +cd rclone && git apply ../webgui/rclone-cmd-all-add-webgui-import.patch + +# 3. 构建 +make +``` + +## 升级 rclone 子模块 + +```bash +cd rclone +git fetch origin +git checkout +cd .. +git add rclone +git commit -m "chore: 升级 rclone 至 " +``` + +## 协议 + +- 本外层仓库:MIT +- `rclone/` 子模块:遵循上游 [rclone](https://github.com/rclone/rclone) + 的 MIT 协议 diff --git a/config/rclone/.gitkeep b/config/rclone/.gitkeep new file mode 100644 index 0000000..86c1059 --- /dev/null +++ b/config/rclone/.gitkeep @@ -0,0 +1,4 @@ +# rclone config directory — bind-mounted into the rclone container at +# /config/rclone. Place your rclone.conf here, or run: +# docker compose exec rclone rclone config +# to create remotes interactively. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..be4d622 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +# Docker Compose stack for the rclone webgui. +# +# Two services: +# - rclone : official rclone/rclone image running `rcd` with --rc-serve +# and --rc-no-auth. Exposed on host port 5572 so you can +# also drive it directly with curl / rclone rc if needed. +# - gui : nginx serving the embedded frontend bundle on host port +# 5580, reverse-proxying RC API + remote-file downloads +# to the rclone container. Open this in your browser. +# +# rclone config lives in ./config/rclone/rclone.conf (bind-mounted). +# If it doesn't exist yet, create your remotes with: +# docker compose exec rclone rclone config +# or copy an existing rclone.conf into ./config/rclone/ before starting. +# +# Usage: +# docker compose up -d --build # build + start +# open http://localhost:5580 +# docker compose logs -f gui rclone # tail logs +# docker compose down # stop + +services: + rclone: + image: rclone/rclone:latest + container_name: rclone-rcd + command: + - rcd + - --rc-no-auth + - --rc-addr=:5572 + - --rc-serve + - --rc-job-expire-duration=24h + - --rc-job-expire-interval=1m + - -vv + volumes: + # rclone looks for $XDG_CONFIG_HOME/rclone/rclone.conf; the + # official image sets XDG_CONFIG_HOME=/config. + - ./config/rclone:/config/rclone + # Cache dir for tokens, chunk cache, vfs cache, etc. + - rclone-cache:/cache + # Scratch space for any local-FS remotes that point here. + - rclone-data:/data + environment: + - XDG_CONFIG_HOME=/config + - RCLONE_CACHE_DIR=/cache + ports: + - "5572:5572" + expose: + - "5572" + restart: unless-stopped + + gui: + build: + context: . + dockerfile: Dockerfile.webgui + image: rclone-webgui-frontend:local + container_name: rclone-webgui + depends_on: + - rclone + ports: + - "5580:80" + restart: unless-stopped + +volumes: + rclone-cache: + rclone-data: diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..d1cd999 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,66 @@ +# nginx config for the webgui frontend container. +# +# Two jobs: +# 1. Serve the SPA static bundle (HTML/CSS/JS) with SPA fallback. +# 2. Reverse-proxy RC API calls and remote-file downloads to the +# rclone rcd container on the compose network. +# +# Same-origin from the browser, so the frontend's fetch() calls hit +# this nginx and get forwarded to rclone — no CORS, no basic-auth +# popups, no embedded credentials in URLs. + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Uploads can be large; let rclone decide on size limits. + client_max_body_size 0; + + # Light gzip for text assets. + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + text/plain + text/css + application/javascript + application/json + image/svg+xml; + + # --- Static assets --- + # Anything that resolves to a real file is served from the bundle. + # Unknown paths fall through to index.html so client-side hash + # routing (#/remotes, #/browse/...) keeps working. + location / { + try_files $uri $uri/ /index.html; + } + + # --- RC API endpoints (POST JSON) --- + # rclone registers endpoints under well-known top-level prefixes. + # Match any of them and forward to rclone. + location ~ ^/(config|operations|sync|job|fs|rc|cache|vfs|subsystem|options|metrics)/ { + proxy_pass http://rclone:5572; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_request_buffering off; + } + + # --- Remote file downloads (GET) --- + # rclone rcd with --rc-serve exposes remotes at /:. + # The colon in the first path segment is the tell — static asset + # paths never have one. + location ~ ^/([^/]+): { + proxy_pass http://rclone:5572; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/rclone b/rclone new file mode 160000 index 0000000..59c86b0 --- /dev/null +++ b/rclone @@ -0,0 +1 @@ +Subproject commit 59c86b01bb39624650badd39f3acfd20be2b743b diff --git a/webgui/rclone-cmd-all-add-webgui-import.patch b/webgui/rclone-cmd-all-add-webgui-import.patch new file mode 100644 index 0000000..8bcab4d --- /dev/null +++ b/webgui/rclone-cmd-all-add-webgui-import.patch @@ -0,0 +1,10 @@ +diff --git a/cmd/all/all.go b/cmd/all/all.go +index 912cde629..cf3c7f4bd 100644 +--- a/cmd/all/all.go ++++ b/cmd/all/all.go +@@ -80,4 +80,5 @@ import ( + _ "github.com/rclone/rclone/cmd/touch" + _ "github.com/rclone/rclone/cmd/tree" + _ "github.com/rclone/rclone/cmd/version" ++ _ "github.com/rclone/rclone/cmd/webgui" + ) diff --git a/webgui/web/assets/favicon.svg b/webgui/web/assets/favicon.svg new file mode 100644 index 0000000..9e6b879 --- /dev/null +++ b/webgui/web/assets/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js new file mode 100644 index 0000000..377bae1 --- /dev/null +++ b/webgui/web/assets/js/app.js @@ -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 = `

Something went wrong

${escapeHtml(e.message)}

`; + } + } +}); + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/webgui/web/assets/js/rc.js b/webgui/web/assets/js/rc.js new file mode 100644 index 0000000..774dedb --- /dev/null +++ b/webgui/web/assets/js/rc.js @@ -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 /: + 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; +} diff --git a/webgui/web/assets/js/state.js b/webgui/web/assets/js/state.js new file mode 100644 index 0000000..26aaa86 --- /dev/null +++ b/webgui/web/assets/js/state.js @@ -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// +// #/jobs +// #/jobs/new +// #/configure/new +// #/configure/new/ +// #/configure/edit/ + +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; + } +} diff --git a/webgui/web/assets/js/views/browser.js b/webgui/web/assets/js/views/browser.js new file mode 100644 index 0000000..49c9566 --- /dev/null +++ b/webgui/web/assets/js/views/browser.js @@ -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 = `

No remote selected

Pick a remote to browse.

`; + return; + } + + const fs = `${remote}:`; + + app.innerHTML = ` +
+
+

${escapeHtml(remote)}

+

${escapeHtml(path || "(root)")}

+
+
+ + + +
+
+ +
+

Loading…

+
+ `; + + 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 = `

Couldn’t list

${escapeHtml(e.message)}

`; + 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 = `Remotes/`; + html += `${escapeHtml(remote)}`; + 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 += `/`; + if (isLast) { + html += `${escapeHtml(seg)}`; + } else { + html += `${escapeHtml(seg)}`; + } + } + el.innerHTML = html; +} + +function renderTable(card, fs, path, items) { + if (!items || items.length === 0) { + card.innerHTML = ` +
+

Empty folder

+

No files here. Use Upload in the toolbar to add some.

+
+ `; + 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 = ` + + + + + + + + + + + ${parentHref ? `` : ""} + ${rows} + +
NameSizeModifiedActions
../
+ `; + + // 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 ` + + ${icon("dir")} ${escapeHtml(item.Name)}/ + — + ${escapeHtml(formatTime(item.ModTime))} + + + `; + } + return ` + + + ${icon("file")} ${escapeHtml(item.Name)} + + ${escapeHtml(formatBytes(item.Size))} + ${escapeHtml(formatTime(item.ModTime))} + + + + + + `; +} + +function icon(kind) { + if (kind === "dir") { + return ``; + } + return `📄`; +} + +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) => ` +
+ + +
`, + ) + .join(""); + + root.innerHTML = ` + + `; + + 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, "'"); +} diff --git a/webgui/web/assets/js/views/configure.js b/webgui/web/assets/js/views/configure.js new file mode 100644 index 0000000..72dd402 --- /dev/null +++ b/webgui/web/assets/js/views/configure.js @@ -0,0 +1,445 @@ +// views/configure.js — dynamic remote form builder. +// +// Two modes: +// #/configure/new → provider picker (searchable grid) +// #/configure/new/ → form for that backend +// #/configure/edit/ → 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 = ` +
+

Unknown backend

+

No provider named ${escapeHtml(provider)}.

+

← Back to provider picker

+
`; + 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 = `

Loading remote ${escapeHtml(remote)}

`; + + // 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 = ` +
+

Remote not found

+

No remote named ${escapeHtml(remote)}.

+

← Back to remotes

+
`; + 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 = ` +
+
+

New remote

+

Pick a storage backend to configure.

+
+ Cancel +
+ +
+

Loading backends…

+
+ `; + + const grid = document.getElementById("provider-grid"); + const filter = document.getElementById("provider-filter"); + + let providers; + try { + providers = await ensureProviders(); + } catch (e) { + grid.innerHTML = `

Couldn’t load backends

${escapeHtml(e.message)}

`; + 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 = `

No backends match.

`; + return; + } + grid.innerHTML = list + .map( + (p) => ` + + ${escapeHtml(p.Name)} + ${escapeHtml(p.Description || "")} + `, + ) + .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 = ` +
+
+

${isEdit ? "Edit" : "New"} ${escapeHtml(provider.Name)} remote

+

${escapeHtml(provider.Description || "")}

+
+ Cancel +
+ +
+ ${requiresOAuth ? oauthBanner(provider.Name) : ""} + +
+
+ + +
+ + ${required.length > 0 ? `
Required
` : ""} + ${required.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")} + + ${basic.length > 0 ? `
Options
` : ""} + ${basic.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")} + + ${advanced.length > 0 ? ` +
+ +
+ + ` : ""} +
+ +
+ + Cancel +
+ + ${isEdit ? ` +
+

Delete this remote

+

Permanently remove ${escapeHtml(remoteName)} from rclone.conf.

+ +
+ ` : ""} +
+ `; + + const form = document.getElementById("remote-form"); + + // Wire up "Custom…" reveal on `; + } + + const name = `opt_${escapeHtml(opt.Name)}`; + const currentValue = value != null ? String(value) : ""; + + if (opt.Type === "bool") { + const checked = value === true || value === "true" ? "checked" : ""; + return ``; + } + + if (opt.Type === "int" || opt.Type === "int64" || opt.Type === "Duration") { + return ``; + } + + if (opt.IsPassword) { + return ``; + } + + // Examples → select with custom override + if (opt.Examples && opt.Examples.length > 0) { + const opts = [''] + .concat( + opt.Examples.map( + (ex) => + ``, + ), + ) + .join(""); + return ` + + + `; + } + + return ``; +} + +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 + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + `; + + // 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 = `

Couldn’t load jobs

${escapeHtml(e.message)}

`; + return; + } + + if (jobIds.length === 0) { + card.innerHTML = ` +
+

No jobs yet

+

Use New Job to start a copy, sync, or move.

+
+ `; + 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 ` + + + + + + + + + + + + + + + + ${rows} + +
#JobStatusProgressSpeedETAFilesErrorsAction
+ `; +} + +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 = `running`; + } else if (errored || (!success && errored)) { + badge = `failed`; + } else if (success) { + badge = `done`; + } else { + badge = `finished`; + } + + const meta = getJobMeta(id); + const jobCell = meta + ? ` +
+ ${escapeHtml(meta.action)} + + ${escapeHtml(meta.src)} + + ${escapeHtml(meta.dst)} + +
` + : `— submitted via CLI —`; + + const pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0; + const progress = ` +
+
+ + ${formatBytes(p.bytes)} / ${formatBytes(p.totalBytes)} + +
+ `; + + const stopBtn = !finished + ? `` + : ""; + + return ` + + ${id} + ${jobCell} + ${badge} + ${progress} + ${finished ? "—" : escapeHtml(formatSpeed(p.speed || 0))} + ${finished ? "—" : escapeHtml(formatDuration(p.eta || 0))} + ${p.transfers ?? 0} / ${p.totalTransfers ?? 0} + ${(p.errors && p.errors.length) || 0} + ${stopBtn} + + `; +} + +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, "'"); +} diff --git a/webgui/web/assets/js/views/remotes.js b/webgui/web/assets/js/views/remotes.js new file mode 100644 index 0000000..8175a7f --- /dev/null +++ b/webgui/web/assets/js/views/remotes.js @@ -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 = ` +
+
+

Remotes

+

Configured cloud storage providers. Click a tile to browse, hover for edit/delete.

+
+ New remote +
+
+

Loading remotes…

+
+ `; + + 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 = ` +
+

No remotes configured

+

Click New remote above to add one from this GUI.

+
+ `; + return; + } + + grid.innerHTML = remotes + .map( + (r) => ` + + ${escapeHtml(r.name)} + ${escapeHtml(r.type)} +
+ + +
+
+ `, + ) + .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 = ` +
+

Couldn’t reach rclone

+

${escapeHtml(e.message)}

+
+ `; + toast(`Failed to load remotes: ${e.message}`, "error"); + } +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/webgui/web/assets/styles/base.css b/webgui/web/assets/styles/base.css new file mode 100644 index 0000000..36da63f --- /dev/null +++ b/webgui/web/assets/styles/base.css @@ -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); +} diff --git a/webgui/web/assets/styles/components.css b/webgui/web/assets/styles/components.css new file mode 100644 index 0000000..f8c9f93 --- /dev/null +++ b/webgui/web/assets/styles/components.css @@ -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; + } +} diff --git a/webgui/web/assets/styles/tokens.css b/webgui/web/assets/styles/tokens.css new file mode 100644 index 0000000..618f838 --- /dev/null +++ b/webgui/web/assets/styles/tokens.css @@ -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); +} diff --git a/webgui/web/index.html b/webgui/web/index.html new file mode 100644 index 0000000..eb35659 --- /dev/null +++ b/webgui/web/index.html @@ -0,0 +1,78 @@ + + + + + + rclone + + + + + + +
+ + + rclone + + + New Job +
+ +
+
+

Loading

+

Connecting to rclone RC…

+
+
+ + + +
+ + + + + diff --git a/webgui/webgui.go b/webgui/webgui.go new file mode 100644 index 0000000..9b5e860 --- /dev/null +++ b/webgui/webgui.go @@ -0,0 +1,310 @@ +// Package webgui implements the "rclone webgui" command — an in-process +// web GUI for rclone with the Anthropic design system. It mirrors the +// architecture of cmd/gui/gui.go (two in-process HTTP servers: a static +// GUI server and an RC API server, with the browser opened automatically), +// but serves our own embedded vanilla HTML/CSS/JS frontend instead of +// the upstream React bundle. +package webgui + +import ( + "context" + _ "embed" + "fmt" + iofs "io/fs" + "net/http" + "net/url" + "os" + "strings" + "sync" + + "github.com/go-chi/chi/v5/middleware" + "github.com/rclone/rclone/cmd" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/rc" + "github.com/rclone/rclone/fs/rc/rcserver" + libhttp "github.com/rclone/rclone/lib/http" + "github.com/rclone/rclone/lib/random" + "github.com/rclone/rclone/lib/systemd" + "github.com/skratchdot/open-golang/open" + "github.com/spf13/cobra" +) + +//go:embed web +var embedFS iofs.FS + +var ( + guiAddr []string + apiAddr []string + user string + pass string + noAuth bool + noOpenBrowser bool + enableMetrics bool +) + +func init() { + cmd.Root.AddCommand(commandDefinition) + f := commandDefinition.Flags() + f.StringArrayVar(&guiAddr, "addr", nil, "IPaddress:Port for the GUI server (default auto-chosen localhost port)") + f.StringArrayVar(&apiAddr, "api-addr", nil, "IPaddress:Port for the RC API server (default auto-chosen localhost port)") + f.StringVar(&user, "user", "", "User name for RC authentication") + f.StringVar(&pass, "pass", "", "Password for RC authentication") + f.BoolVar(&noAuth, "no-auth", false, "Don't require auth for the RC API") + f.BoolVar(&noOpenBrowser, "no-open-browser", false, "Skip opening the browser automatically") + f.BoolVar(&enableMetrics, "enable-metrics", false, "Enable OpenMetrics/Prometheus compatible endpoint at /metrics") +} + +var commandDefinition = &cobra.Command{ + Use: "webgui [path]", + Short: `Open the web based GUI.`, + Long: `This command starts an embedded web GUI for rclone and opens it in +your default browser. + +Two localhost ports are bound: one serves the static GUI, the other is +the rclone RC API server that the GUI talks to. Credentials are +generated automatically unless --no-auth is specified. + + rclone webgui + +By default ` + "`rclone webgui`" + ` serves the GUI embedded into the rclone +binary at build time. You can override this by passing a path to an +unpacked GUI directory, which is useful for iterating on the frontend +without rebuilding rclone: + + rclone webgui ./cmd/webgui/web + +Use --no-open-browser to skip opening the browser automatically: + + rclone webgui --no-open-browser + +Use --addr to bind the GUI to a specific address: + + rclone webgui --addr localhost:5580 + +Use --user and --pass to set specific credentials: + + rclone webgui --user admin --pass secret + +Use --no-auth to disable authentication entirely (localhost only): + + rclone webgui --no-auth + +Note: --no-auth enables the RC API's --rc-serve mode, which exposes an +HTTP fileserver on every configured remote. Only run this on a trusted +network. +`, + Annotations: map[string]string{ + "versionIntroduced": "v1.75", + "groups": "RC", + }, + RunE: func(command *cobra.Command, args []string) error { + cmd.CheckArgs(0, 1, command, args) + ctx := context.Background() + + // Resolve the GUI source (embedded subtree or local directory) + // before binding any sockets so errors surface immediately. + var srcPath string + if len(args) == 1 { + srcPath = args[0] + } + srcFS, err := guiSourceFS(srcPath) + if err != nil { + return err + } + + // Create the GUI server (binds port eagerly, before Serve) + guiCfg := libhttp.DefaultCfg() + if command.Flags().Changed("addr") { + guiCfg.ListenAddr = guiAddr + } else { + guiCfg.ListenAddr = []string{"localhost:0"} + } + guiServer, err := libhttp.NewServer(ctx, libhttp.WithConfig(guiCfg)) + if err != nil { + return fmt.Errorf("failed to create GUI server: %w", err) + } + + // Read the GUI origin from the bound address (available before Serve). + guiOrigin := originFromURL(guiServer.URLs()[0]) + + // Configure the RC API server + opt := rc.Opt // copy global defaults + opt.Enabled = true + opt.WebUI = false + // opt.Serve = true exposes an HTTP fileserver on every configured + // remote so the GUI can download files via GET /:. + opt.Serve = true + + if command.Flags().Changed("api-addr") { + opt.HTTP.ListenAddr = apiAddr + } else { + opt.HTTP.ListenAddr = []string{"localhost:0"} + } + + // CORS: allow the GUI origin to make cross-port API requests. + opt.HTTP.AllowOrigin = guiOrigin + + // Forward metrics flag to the RC server. + if command.Flags().Changed("enable-metrics") { + opt.EnableMetrics = enableMetrics + } + + // Auth + if command.Flags().Changed("user") { + opt.Auth.BasicUser = user + } + if command.Flags().Changed("pass") { + opt.Auth.BasicPass = pass + } + if command.Flags().Changed("no-auth") { + opt.NoAuth = noAuth + } + + if !opt.NoAuth { + if opt.Auth.BasicUser == "" { + opt.Auth.BasicUser = "gui" + fs.Infof(nil, "No username specified. Using default username: %s", opt.Auth.BasicUser) + } + if opt.Auth.BasicPass == "" { + randomPass, err := random.Password(128) + if err != nil { + return fmt.Errorf("failed to make password: %w", err) + } + opt.Auth.BasicPass = randomPass + fs.Infof(nil, "No password specified. Using random password: %s", randomPass) + } + } + + // Start the RC server + rcServer, err := rcserver.Start(ctx, &opt) + if err != nil || rcServer == nil { + return fmt.Errorf("failed to start RC server: %w", err) + } + + // Read the bound RC URL back from rcserver, in case we asked + // libhttp to pick a free port (localhost:0). + rcURL := rcServer.URLs()[0] + + // Mount the GUI handler and start serving + spaHandler := guiHandler(srcFS) + guiServer.Router().Use(middleware.Compress(5)) + guiServer.Router().Get("/*", spaHandler.ServeHTTP) + guiServer.Router().Head("/*", spaHandler.ServeHTTP) + guiServer.Serve() + + guiURL := guiServer.URLs()[0] + guiSource := "embedded bundle" + if srcPath != "" { + guiSource = fmt.Sprintf("from %s", srcPath) + } + fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL) + + // Build the launch URL: always pass ?url= so the SPA can + // discover the RC base; embed user/pass only when auth is on. + loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth) + + fs.Logf(nil, "GUI available at %s", loginURL) + if !noOpenBrowser { + if err := open.Start(loginURL); err != nil { + fs.Errorf(nil, "failed to open GUI in browser: %v", err) + } + } + + // Wait for either server to exit, then shut both down and + // join the second goroutine before returning. + defer systemd.Notify()() + var wg sync.WaitGroup + done := make(chan struct{}, 2) + wg.Add(2) + go func() { defer wg.Done(); rcServer.Wait(); done <- struct{}{} }() + go func() { defer wg.Done(); guiServer.Wait(); done <- struct{}{} }() + <-done + _ = rcServer.Shutdown() + _ = guiServer.Shutdown() + wg.Wait() + return nil + }, +} + +// originFromURL extracts the origin (scheme://host) from a URL string, +// stripping any path or trailing slash. +func originFromURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return strings.TrimRight(rawURL, "/") + } + return u.Scheme + "://" + u.Host +} + +// guiSourceFS opens the GUI bundle at the given path. An empty path +// returns the embedded bundle (the `web/` directory compiled into the +// binary). A non-empty path must be an existing directory whose contents +// are served directly — useful for hot-reload during development. +func guiSourceFS(path string) (iofs.FS, error) { + if path == "" { + sub, err := iofs.Sub(embedFS, "web") + if err != nil { + return nil, fmt.Errorf("failed to read embedded GUI: %w", err) + } + if _, err := iofs.Stat(sub, "index.html"); err != nil { + return nil, fmt.Errorf("embedded GUI has no index.html: %w", err) + } + return sub, nil + } + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("failed to stat GUI source %q: %w", path, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("GUI source must be a directory: %q", path) + } + if _, err := os.Stat(path + "/index.html"); err != nil { + return nil, fmt.Errorf("GUI source directory has no index.html: %w", err) + } + return os.DirFS(path), nil +} + +// guiHandler returns an http.Handler that serves the GUI bundle from +// srcFS with SPA fallback: paths that don't match a real file return +// index.html so client-side hash routing keeps working. +func guiHandler(srcFS iofs.FS) http.Handler { + fileServer := http.FileServer(http.FS(srcFS)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + if _, err := iofs.Stat(srcFS, path); err == nil { + fileServer.ServeHTTP(w, r) + return + } + // SPA fallback: serve index.html for unknown paths so that + // client-side routing (e.g. /login) works. + r.URL.Path = "/" + fileServer.ServeHTTP(w, r) + }) +} + +// buildLoginURL constructs the URL the browser should open. The query +// string always carries the RC API base URL so the SPA can find it. +// When auth is enabled, user/pass and a /login hash are added so the +// SPA can present credentials to the cross-port RC server. +func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string { + u, err := url.Parse(guiBaseURL) + if err != nil { + return guiBaseURL + } + q := u.Query() + q.Set("url", rcURL) + if !noAuth { + u.Path = "/login" + q.Set("user", user) + q.Set("pass", pass) + } + // Always land on the remotes view. + if u.Fragment == "" { + u.Fragment = "/remotes" + } + u.RawQuery = q.Encode() + return u.String() +}