init: 初始化 rclone-webgui 项目结构
外层仓库管理 webgui 源码、Docker 编排与项目文档;rclone 作为
git submodule 锁定在上游 master HEAD(59c86b01b),不携带任何
我们的改动。
- webgui/: webgui 源码(原本位于 rclone/cmd/webgui/)
- web/: 原生 HTML/CSS/JS 静态前端(Anthropic 设计语言)
- webgui.go: Go 子命令源码,仅当自行构建 rclone 二进制时需要
- rclone-cmd-all-add-webgui-import.patch: 把 webgui 注册进
rclone 的 cmd/all/all.go 的补丁,留作 fork 时使用
- rclone/: submodule → github.com/rclone/rclone,纯净不改动
- Dockerfile.webgui: 基于 nginx:1.27-alpine,从 ./webgui/web/
COPY 静态资源
- docker/nginx.conf: SPA 静态托管 + 反向代理 RC API
(/config/、/operations/、/sync/、/job/ 等) 与文件下载
(/<remote>:<path>) 到 rclone rcd 容器,前端同源访问无 CORS
- docker-compose.yml: rclone (官方镜像 + rcd --rc-no-auth
--rc-serve) + gui (nginx) 双服务编排,config 走 bind mount
持久化
- DESIGN.md / CLAUDE.md / README.md: 文档
- .gitignore / .dockerignore: 排除 rclone.conf 等敏感文件,
Docker 构建上下文只剩 webgui/web/ + nginx 配置(几十 KB)
This commit is contained in:
@@ -0,0 +1,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
|
||||
+17
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "rclone"]
|
||||
path = rclone
|
||||
url = https://github.com/rclone/rclone.git
|
||||
@@ -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<Name>:` 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 `<area>: <summary>` where `<area>` 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/<name>/<name>.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/<name>.md` between `<!-- autogenerated options start -->…<!-- autogenerated options stop -->` 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<Name>:` 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`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 <your-fork-url>
|
||||
# 或在已克隆的仓库里:
|
||||
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 <new-tag-or-commit>
|
||||
cd ..
|
||||
git add rclone
|
||||
git commit -m "chore: 升级 rclone 至 <new-tag>"
|
||||
```
|
||||
|
||||
## 协议
|
||||
|
||||
- 本外层仓库:MIT
|
||||
- `rclone/` 子模块:遵循上游 [rclone](https://github.com/rclone/rclone)
|
||||
的 MIT 协议
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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 /<remote>:<path>.
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
Submodule
+1
Submodule rclone added at 59c86b01bb
@@ -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"
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="#141413">
|
||||
<path d="M8 0 L9.2 6.8 L16 8 L9.2 9.2 L8 16 L6.8 9.2 L0 8 L6.8 6.8 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 157 B |
@@ -0,0 +1,62 @@
|
||||
// app.js — entry point. Routes hash changes to view renderers and
|
||||
// keeps the top-nav active state in sync.
|
||||
|
||||
import { onRoute } from "./state.js";
|
||||
import { renderRemotes } from "./views/remotes.js";
|
||||
import { renderBrowse } from "./views/browser.js";
|
||||
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js";
|
||||
import {
|
||||
renderConfigureNew,
|
||||
renderConfigureEdit,
|
||||
} from "./views/configure.js";
|
||||
|
||||
const views = {
|
||||
remotes: renderRemotes,
|
||||
browse: renderBrowse,
|
||||
jobs: renderJobs,
|
||||
"jobs-new": renderNewJob,
|
||||
"configure-new": renderConfigureNew,
|
||||
"configure-edit": renderConfigureEdit,
|
||||
};
|
||||
|
||||
function setActiveNav(routeName) {
|
||||
const links = document.querySelectorAll("#nav-links a");
|
||||
for (const a of links) {
|
||||
const target = a.dataset.route;
|
||||
let isActive = target === routeName;
|
||||
// "Configure" is active on both configure-new and configure-edit
|
||||
if (target === "configure" && routeName.startsWith("configure-")) {
|
||||
isActive = true;
|
||||
}
|
||||
a.classList.toggle("active", isActive);
|
||||
}
|
||||
}
|
||||
|
||||
onRoute(async (route) => {
|
||||
setActiveNav(route.name);
|
||||
|
||||
// Stop jobs polling when leaving the jobs view
|
||||
if (route.name !== "jobs") {
|
||||
stopJobPolling();
|
||||
}
|
||||
|
||||
const renderer = views[route.name] || renderRemotes;
|
||||
try {
|
||||
await renderer(route.params);
|
||||
} catch (e) {
|
||||
console.error("view error", e);
|
||||
const app = document.getElementById("app");
|
||||
if (app) {
|
||||
app.innerHTML = `<div class="empty"><h3>Something went wrong</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// rc.js — minimal client for rclone's RC HTTP API.
|
||||
// The RC base URL is discovered from the ?url= query param that the
|
||||
// webgui command appends to the GUI URL on launch. If absent (e.g. when
|
||||
// developing the SPA from another server), fall back to same-origin.
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const QUERY_URL = params.get("url");
|
||||
const RC_BASE = QUERY_URL
|
||||
? QUERY_URL.replace(/\/$/, "")
|
||||
: location.origin;
|
||||
|
||||
const AUTH_USER = params.get("user");
|
||||
const AUTH_PASS = params.get("pass");
|
||||
|
||||
let authHeader = null;
|
||||
if (AUTH_USER && AUTH_PASS) {
|
||||
authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`);
|
||||
}
|
||||
|
||||
export function rcURL() {
|
||||
return RC_BASE;
|
||||
}
|
||||
|
||||
// Are we running with basic auth configured from the launch URL?
|
||||
export function hasAuth() {
|
||||
return authHeader !== null;
|
||||
}
|
||||
|
||||
export function isNoAuth() {
|
||||
return !authHeader;
|
||||
}
|
||||
|
||||
// POST JSON to an RC endpoint. Returns the parsed JSON response, or throws.
|
||||
export async function post(path, body = {}) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (authHeader) headers["Authorization"] = authHeader;
|
||||
const res = await fetch(RC_BASE + "/" + path.replace(/^\//, ""), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return parseResponse(res, path);
|
||||
}
|
||||
|
||||
// POST JSON and request an async job. Returns { jobid, executeId }.
|
||||
export async function postAsync(path, body = {}) {
|
||||
return post(path, { ...body, _async: true });
|
||||
}
|
||||
|
||||
// Upload one or more files via multipart form-data.
|
||||
// Matches operations/uploadfile: form fields `fs`, `remote`, and one
|
||||
// file part per uploaded file. The server uses the part's filename.
|
||||
export async function uploadFile(fs, remote, files) {
|
||||
const form = new FormData();
|
||||
form.set("fs", fs);
|
||||
form.set("remote", remote);
|
||||
for (const file of files) {
|
||||
form.append("file", file, file.name);
|
||||
}
|
||||
const headers = {};
|
||||
if (authHeader) headers["Authorization"] = authHeader;
|
||||
// Do NOT set Content-Type — the browser sets multipart boundary.
|
||||
const res = await fetch(RC_BASE + "/operations/uploadfile", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: form,
|
||||
});
|
||||
return parseResponse(res, "/operations/uploadfile");
|
||||
}
|
||||
|
||||
// Build a download URL for a file. Requires opt.Serve = true on the rc server.
|
||||
export function downloadURL(remoteFs, remotePath, fileName) {
|
||||
const base = RC_BASE.replace(/\/$/, "");
|
||||
// rc server serves remote files at /<remote>:<path>
|
||||
const trimmed = (remotePath || "").replace(/^\/+|\/+$/g, "");
|
||||
const path = trimmed ? `${remoteFs}/${trimmed}/${fileName}` : `${remoteFs}/${fileName}`;
|
||||
let url = `${base}/${path}`;
|
||||
if (authHeader) {
|
||||
// Embed basic auth into the URL so the browser can fetch it directly.
|
||||
url = url.replace(/^(https?:\/\/)/, `$1${encodeURIComponent(AUTH_USER)}:${encodeURIComponent(AUTH_PASS)}@`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function parseResponse(res, path) {
|
||||
let body = null;
|
||||
const ct = res.headers.get("Content-Type") || "";
|
||||
if (ct.includes("application/json")) {
|
||||
body = await res.json();
|
||||
} else {
|
||||
const text = await res.text();
|
||||
body = text ? { raw: text } : {};
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = (body && (body.error || body.message)) || `HTTP ${res.status}`;
|
||||
const err = new Error(`${path}: ${msg}`);
|
||||
err.status = res.status;
|
||||
err.body = body;
|
||||
throw err;
|
||||
}
|
||||
if (body && body.error) {
|
||||
throw new Error(`${path}: ${body.error}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// state.js — minimal pub/sub store + hash router + localStorage-backed
|
||||
// job metadata cache. Vanilla, no framework.
|
||||
|
||||
const JOB_META_KEY = "webgui:jobMeta";
|
||||
const JOB_META_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
const listeners = new Set();
|
||||
const state = {
|
||||
remotes: [], // [{ name, type }]
|
||||
jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst }
|
||||
providers: null, // cached /config/providers response
|
||||
// jobMeta is persisted to localStorage so the jobs view can show
|
||||
// src/dst for jobs after a page reload. Keyed by jobid (number).
|
||||
jobMeta: loadJobMeta(),
|
||||
};
|
||||
|
||||
function loadJobMeta() {
|
||||
try {
|
||||
const raw = localStorage.getItem(JOB_META_KEY);
|
||||
if (!raw) return new Map();
|
||||
const arr = JSON.parse(raw);
|
||||
if (!Array.isArray(arr)) return new Map();
|
||||
const now = Date.now();
|
||||
const map = new Map();
|
||||
for (const [id, meta] of arr) {
|
||||
if (meta && typeof meta.submittedAt === "number") {
|
||||
if (now - meta.submittedAt < JOB_META_TTL_MS) {
|
||||
map.set(Number(id), meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function saveJobMeta() {
|
||||
try {
|
||||
const arr = Array.from(state.jobMeta.entries());
|
||||
localStorage.setItem(JOB_META_KEY, JSON.stringify(arr));
|
||||
} catch {
|
||||
// localStorage might be unavailable (private mode, quota); ignore.
|
||||
}
|
||||
}
|
||||
|
||||
export function getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function setState(patch) {
|
||||
Object.assign(state, patch);
|
||||
for (const l of listeners) {
|
||||
try {
|
||||
l(state);
|
||||
} catch (e) {
|
||||
console.error("state listener error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
// --- Job metadata API ---
|
||||
|
||||
export function rememberJob(jobid, { action, src, dst }) {
|
||||
state.jobMeta.set(Number(jobid), {
|
||||
action,
|
||||
src,
|
||||
dst,
|
||||
submittedAt: Date.now(),
|
||||
});
|
||||
saveJobMeta();
|
||||
}
|
||||
|
||||
export function getJobMeta(jobid) {
|
||||
return state.jobMeta.get(Number(jobid));
|
||||
}
|
||||
|
||||
export function forgetJob(jobid) {
|
||||
state.jobMeta.delete(Number(jobid));
|
||||
saveJobMeta();
|
||||
}
|
||||
|
||||
// --- Router ---
|
||||
// Hash-based routes:
|
||||
// #/remotes
|
||||
// #/browse/<remote>/<path...>
|
||||
// #/jobs
|
||||
// #/jobs/new
|
||||
// #/configure/new
|
||||
// #/configure/new/<providerName>
|
||||
// #/configure/edit/<remoteName>
|
||||
|
||||
const routeListeners = new Set();
|
||||
|
||||
export function onRoute(fn) {
|
||||
routeListeners.add(fn);
|
||||
return () => routeListeners.delete(fn);
|
||||
}
|
||||
|
||||
function parseHash(hash) {
|
||||
const raw = hash.replace(/^#\/?/, "");
|
||||
const parts = raw.split("/").filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return { name: "remotes", params: {} };
|
||||
}
|
||||
switch (parts[0]) {
|
||||
case "remotes":
|
||||
return { name: "remotes", params: {} };
|
||||
case "browse":
|
||||
return {
|
||||
name: "browse",
|
||||
params: {
|
||||
remote: decodeURIComponent(parts[1] || ""),
|
||||
path: parts.slice(2).map(decodeURIComponent).join("/"),
|
||||
},
|
||||
};
|
||||
case "jobs":
|
||||
if (parts[1] === "new") {
|
||||
return { name: "jobs-new", params: {} };
|
||||
}
|
||||
return { name: "jobs", params: {} };
|
||||
case "configure":
|
||||
if (parts[1] === "new") {
|
||||
return {
|
||||
name: "configure-new",
|
||||
params: { provider: parts[2] ? decodeURIComponent(parts[2]) : "" },
|
||||
};
|
||||
}
|
||||
if (parts[1] === "edit") {
|
||||
return {
|
||||
name: "configure-edit",
|
||||
params: { remote: decodeURIComponent(parts[2] || "") },
|
||||
};
|
||||
}
|
||||
return { name: "configure-new", params: { provider: "" } };
|
||||
default:
|
||||
return { name: "remotes", params: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export function navigate(hash) {
|
||||
if (location.hash !== hash) {
|
||||
location.hash = hash;
|
||||
} else {
|
||||
dispatch();
|
||||
}
|
||||
}
|
||||
|
||||
function dispatch() {
|
||||
const route = parseHash(location.hash);
|
||||
for (const l of routeListeners) {
|
||||
try {
|
||||
l(route);
|
||||
} catch (e) {
|
||||
console.error("route listener error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", dispatch);
|
||||
window.addEventListener("load", dispatch);
|
||||
|
||||
// --- Toast ---
|
||||
export function toast(message, kind = "default", ttl = 4000) {
|
||||
const stack = document.getElementById("toast-stack");
|
||||
if (!stack) return;
|
||||
const el = document.createElement("div");
|
||||
el.className = `toast toast-${kind}`;
|
||||
el.textContent = message;
|
||||
stack.appendChild(el);
|
||||
setTimeout(() => el.remove(), ttl);
|
||||
}
|
||||
|
||||
// --- Format helpers ---
|
||||
export function formatBytes(n) {
|
||||
if (n == null || isNaN(n)) return "—";
|
||||
if (n < 1024) return `${n} B`;
|
||||
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
let v = n / 1024;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatSpeed(bytesPerSec) {
|
||||
return formatBytes(bytesPerSec) + "/s";
|
||||
}
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
if (seconds == null || !isFinite(seconds)) return "—";
|
||||
const s = Math.round(seconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${sec}s`;
|
||||
return `${sec}s`;
|
||||
}
|
||||
|
||||
export function formatTime(iso) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
|
||||
|
||||
import { post, uploadFile, downloadURL } from "../rc.js";
|
||||
import { toast, formatBytes, formatTime } from "../state.js";
|
||||
|
||||
export async function renderBrowse({ remote, path }) {
|
||||
const app = document.getElementById("app");
|
||||
if (!remote) {
|
||||
app.innerHTML = `<div class="empty"><h3>No remote selected</h3><p><a href="#/remotes">Pick a remote</a> to browse.</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const fs = `${remote}:`;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>${escapeHtml(remote)}</h2>
|
||||
<p class="subtitle">${escapeHtml(path || "(root)")}</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input type="file" id="upload-input" multiple style="display:none">
|
||||
<button class="btn btn-secondary btn-sm" data-action="mkdir">New folder</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="upload">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="breadcrumbs" class="breadcrumbs"></div>
|
||||
<div id="browser-card" class="card-outline">
|
||||
<p class="empty">Loading…</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const breadcrumbsEl = document.getElementById("breadcrumbs");
|
||||
renderBreadcrumbs(breadcrumbsEl, remote, path);
|
||||
|
||||
const card = document.getElementById("browser-card");
|
||||
const fileInput = document.getElementById("upload-input");
|
||||
|
||||
try {
|
||||
const res = await post("operations/list", {
|
||||
fs,
|
||||
remote: path || "",
|
||||
opt: { noModTime: false, noMimeType: true, showHash: false },
|
||||
});
|
||||
const items = (res && res.list) || [];
|
||||
renderTable(card, fs, path, items);
|
||||
} catch (e) {
|
||||
card.innerHTML = `<div class="empty"><h3>Couldn’t list</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||
toast(`List failed: ${e.message}`, "error");
|
||||
}
|
||||
|
||||
// --- Toolbar handlers ---
|
||||
app.querySelector('[data-action="mkdir"]').addEventListener("click", () => {
|
||||
openModal(
|
||||
"New folder",
|
||||
[
|
||||
{ name: "name", label: "Folder name", type: "text", placeholder: "new-folder" },
|
||||
],
|
||||
async ({ name }) => {
|
||||
if (!name) return;
|
||||
const target = path ? `${path}/${name}` : name;
|
||||
await post("operations/mkdir", { fs, remote: target });
|
||||
toast(`Created ${name}`, "success");
|
||||
},
|
||||
).catch((e) => toast(`mkdir failed: ${e.message}`, "error"));
|
||||
});
|
||||
|
||||
app.querySelector('[data-action="upload"]').addEventListener("click", () => {
|
||||
fileInput.value = "";
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
await uploadFile(fs, path || "", files);
|
||||
toast(`Uploaded ${files.length} file(s)`, "success");
|
||||
// Re-render list
|
||||
location.reload();
|
||||
} catch (e) {
|
||||
toast(`Upload failed: ${e.message}`, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderBreadcrumbs(el, remote, path) {
|
||||
const segments = (path || "").split("/").filter(Boolean);
|
||||
let html = `<a href="#/remotes">Remotes</a><span class="sep">/</span>`;
|
||||
html += `<a href="#/browse/${encodeURIComponent(remote)}">${escapeHtml(remote)}</a>`;
|
||||
let acc = "";
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
acc = acc ? `${acc}/${seg}` : seg;
|
||||
const isLast = i === segments.length - 1;
|
||||
html += `<span class="sep">/</span>`;
|
||||
if (isLast) {
|
||||
html += `<span class="current">${escapeHtml(seg)}</span>`;
|
||||
} else {
|
||||
html += `<a href="#/browse/${encodeURIComponent(remote)}/${acc.split("/").map(encodeURIComponent).join("/")}">${escapeHtml(seg)}</a>`;
|
||||
}
|
||||
}
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderTable(card, fs, path, items) {
|
||||
if (!items || items.length === 0) {
|
||||
card.innerHTML = `
|
||||
<div class="empty">
|
||||
<h3>Empty folder</h3>
|
||||
<p>No files here. Use <strong>Upload</strong> in the toolbar to add some.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort: directories first, then files; alphabetical within each group.
|
||||
items.sort((a, b) => {
|
||||
if (a.IsDir !== b.IsDir) return a.IsDir ? -1 : 1;
|
||||
return a.Name.localeCompare(b.Name);
|
||||
});
|
||||
|
||||
const parentHref = parentLink(fs, path);
|
||||
const rows = items.map((item) => row(fs, path, item)).join("");
|
||||
|
||||
card.innerHTML = `
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="col-num">Size</th>
|
||||
<th>Modified</th>
|
||||
<th class="col-num">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${parentHref ? `<tr class="row-dir"><td class="col-name"><a href="${parentHref}">../</a></td><td></td><td></td><td></td></tr>` : ""}
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
// Wire row action buttons
|
||||
card.querySelectorAll("[data-delete]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onDelete(fs, path, btn.dataset.delete));
|
||||
});
|
||||
card.querySelectorAll("[data-rename]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onRename(fs, path, btn.dataset.rename));
|
||||
});
|
||||
}
|
||||
|
||||
function row(fs, path, item) {
|
||||
const itemPath = path ? `${path}/${item.Name}` : item.Name;
|
||||
if (item.IsDir) {
|
||||
const href = `#/browse/${fs.replace(/:$/, "")}/${itemPath.split("/").map(encodeURIComponent).join("/")}`;
|
||||
return `
|
||||
<tr class="row-dir">
|
||||
<td class="col-name"><a href="${href}">${icon("dir")} ${escapeHtml(item.Name)}/</a></td>
|
||||
<td class="col-num col-mono">—</td>
|
||||
<td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td>
|
||||
<td class="col-num"></td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<tr>
|
||||
<td class="col-name">
|
||||
<a href="${downloadURL(fs, path, item.Name)}" download="${escapeHtml(item.Name)}">${icon("file")} ${escapeHtml(item.Name)}</a>
|
||||
</td>
|
||||
<td class="col-num col-mono">${escapeHtml(formatBytes(item.Size))}</td>
|
||||
<td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td>
|
||||
<td class="col-num">
|
||||
<button class="btn btn-secondary btn-sm" data-rename="${escapeHtml(itemPath)}">Rename</button>
|
||||
<button class="btn btn-danger btn-sm" data-delete="${escapeHtml(itemPath)}">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function icon(kind) {
|
||||
if (kind === "dir") {
|
||||
return `<span style="display:inline-block;width:1em;color:var(--color-primary)">▸</span>`;
|
||||
}
|
||||
return `<span style="display:inline-block;width:1em;color:var(--color-muted-soft)">📄</span>`;
|
||||
}
|
||||
|
||||
function parentLink(fs, path) {
|
||||
if (!path) return "";
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
segments.pop();
|
||||
const parent = segments.join("/");
|
||||
const remote = fs.replace(/:$/, "");
|
||||
if (!parent) {
|
||||
return `#/browse/${encodeURIComponent(remote)}`;
|
||||
}
|
||||
return `#/browse/${encodeURIComponent(remote)}/${parent.split("/").map(encodeURIComponent).join("/")}`;
|
||||
}
|
||||
|
||||
async function onDelete(fs, path, itemPath) {
|
||||
// itemPath is relative to fs root
|
||||
if (!confirm(`Delete ${itemPath}? This cannot be undone.`)) return;
|
||||
try {
|
||||
await post("operations/deletefile", { fs, remote: itemPath });
|
||||
toast(`Deleted ${itemPath}`, "success");
|
||||
location.reload();
|
||||
} catch (e) {
|
||||
toast(`Delete failed: ${e.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function onRename(fs, path, itemPath) {
|
||||
const segments = itemPath.split("/");
|
||||
const oldName = segments.pop();
|
||||
try {
|
||||
const result = await openModal(
|
||||
`Rename ${oldName}`,
|
||||
[{ name: "name", label: "New name", type: "text", value: oldName }],
|
||||
async ({ name }) => {
|
||||
if (!name || name === oldName) return;
|
||||
const dir = segments.join("/");
|
||||
const dst = dir ? `${dir}/${name}` : name;
|
||||
await post("operations/movefile", {
|
||||
srcFs: fs,
|
||||
srcRemote: itemPath,
|
||||
dstFs: fs,
|
||||
dstRemote: dst,
|
||||
});
|
||||
toast(`Renamed to ${name}`, "success");
|
||||
},
|
||||
);
|
||||
// Modal succeeded → reload to refresh list
|
||||
location.reload();
|
||||
} catch (e) {
|
||||
toast(`Rename failed: ${e.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Modal helper ---
|
||||
let modalResolver = null;
|
||||
|
||||
export function openModal(title, fields, onSubmit) {
|
||||
return new Promise((resolve) => {
|
||||
const root = document.getElementById("modal-root");
|
||||
const formHtml = fields
|
||||
.map(
|
||||
(f) => `
|
||||
<div class="field">
|
||||
<label>${escapeHtml(f.label)}</label>
|
||||
<input class="input" name="${escapeHtml(f.name)}" type="${escapeHtml(f.type || "text")}" value="${escapeHtml(f.value || "")}" placeholder="${escapeHtml(f.placeholder || "")}">
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="modal-overlay">
|
||||
<form class="modal">
|
||||
<h3>${escapeHtml(title)}</h3>
|
||||
${formHtml}
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" data-cancel>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const overlay = root.querySelector(".modal-overlay");
|
||||
const form = root.querySelector("form");
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = "";
|
||||
};
|
||||
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
form.querySelector("[data-cancel]").addEventListener("click", () => {
|
||||
close();
|
||||
resolve(null);
|
||||
});
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {};
|
||||
for (const f of fields) {
|
||||
data[f.name] = form.elements[f.name].value;
|
||||
}
|
||||
try {
|
||||
await onSubmit(data);
|
||||
close();
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
// Focus first input
|
||||
const first = form.elements[fields[0].name];
|
||||
if (first) first.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// views/configure.js — dynamic remote form builder.
|
||||
//
|
||||
// Two modes:
|
||||
// #/configure/new → provider picker (searchable grid)
|
||||
// #/configure/new/<provider> → form for that backend
|
||||
// #/configure/edit/<remote> → form pre-filled from /config/get
|
||||
//
|
||||
// Pulls backend metadata from /config/providers (cached in app state).
|
||||
// OAuth backends (option named "token" with IsPassword) get a banner
|
||||
// and disabled submit — user must run `rclone config` in a terminal.
|
||||
|
||||
import { post } from "../rc.js";
|
||||
import { getState, setState, toast } from "../state.js";
|
||||
|
||||
// --- Route entrypoints ---
|
||||
|
||||
export async function renderConfigureNew({ provider = "" }) {
|
||||
if (!provider) {
|
||||
return renderProviderPicker();
|
||||
}
|
||||
const providers = await ensureProviders();
|
||||
const info = providers.find((p) => p.Name === provider);
|
||||
if (!info) {
|
||||
document.getElementById("app").innerHTML = `
|
||||
<div class="empty">
|
||||
<h3>Unknown backend</h3>
|
||||
<p>No provider named <code>${escapeHtml(provider)}</code>.</p>
|
||||
<p><a href="#/configure/new">← Back to provider picker</a></p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
return renderForm({ provider: info, mode: "create" });
|
||||
}
|
||||
|
||||
export async function renderConfigureEdit({ remote }) {
|
||||
if (!remote) {
|
||||
location.hash = "#/remotes";
|
||||
return;
|
||||
}
|
||||
const app = document.getElementById("app");
|
||||
app.innerHTML = `<div class="empty"><p>Loading remote <code>${escapeHtml(remote)}</code>…</p></div>`;
|
||||
|
||||
// Find the remote's type from /config/dump
|
||||
let typeName;
|
||||
try {
|
||||
const dump = await post("config/dump");
|
||||
typeName = dump && dump[remote] && dump[remote].type;
|
||||
} catch (e) {
|
||||
toast(`Couldn't read remote: ${e.message}`, "error");
|
||||
return;
|
||||
}
|
||||
if (!typeName) {
|
||||
app.innerHTML = `
|
||||
<div class="empty">
|
||||
<h3>Remote not found</h3>
|
||||
<p>No remote named <code>${escapeHtml(remote)}</code>.</p>
|
||||
<p><a href="#/remotes">← Back to remotes</a></p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const providers = await ensureProviders();
|
||||
const info = providers.find((p) => p.Name === typeName);
|
||||
if (!info) {
|
||||
toast(`Backend ${typeName} not found in registry`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
let currentValues = {};
|
||||
try {
|
||||
currentValues = await post("config/get", { name: remote });
|
||||
} catch (e) {
|
||||
toast(`Couldn't read config: ${e.message}`, "error");
|
||||
}
|
||||
|
||||
return renderForm({
|
||||
provider: info,
|
||||
mode: "edit",
|
||||
remoteName: remote,
|
||||
currentValues,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Provider picker ---
|
||||
|
||||
async function renderProviderPicker() {
|
||||
const app = document.getElementById("app");
|
||||
app.innerHTML = `
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>New remote</h2>
|
||||
<p class="subtitle">Pick a storage backend to configure.</p>
|
||||
</div>
|
||||
<a class="btn btn-secondary btn-sm" href="#/remotes">Cancel</a>
|
||||
</div>
|
||||
<div class="provider-search">
|
||||
<input id="provider-filter" class="input" type="search" placeholder="Filter backends (e.g. s3, sftp, local)…" autocomplete="off">
|
||||
</div>
|
||||
<div id="provider-grid" class="connector-grid">
|
||||
<p class="empty" style="grid-column:1/-1">Loading backends…</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const grid = document.getElementById("provider-grid");
|
||||
const filter = document.getElementById("provider-filter");
|
||||
|
||||
let providers;
|
||||
try {
|
||||
providers = await ensureProviders();
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div class="empty" style="grid-column:1/-1"><h3>Couldn’t load backends</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip hidden + alias/all wrapper backends
|
||||
const visible = providers.filter(
|
||||
(p) => !p.Hide && p.Name !== "all" && p.Name !== "alias",
|
||||
);
|
||||
|
||||
function paint(list) {
|
||||
if (list.length === 0) {
|
||||
grid.innerHTML = `<p class="empty" style="grid-column:1/-1">No backends match.</p>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = list
|
||||
.map(
|
||||
(p) => `
|
||||
<a class="connector-tile" href="#/configure/new/${encodeURIComponent(p.Name)}">
|
||||
<span class="tile-name">${escapeHtml(p.Name)}</span>
|
||||
<span class="tile-type">${escapeHtml(p.Description || "")}</span>
|
||||
</a>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
paint(visible);
|
||||
|
||||
filter.addEventListener("input", () => {
|
||||
const q = filter.value.trim().toLowerCase();
|
||||
if (!q) return paint(visible);
|
||||
paint(
|
||||
visible.filter((p) =>
|
||||
(p.Name + " " + (p.Description || "")).toLowerCase().includes(q),
|
||||
),
|
||||
);
|
||||
});
|
||||
filter.focus();
|
||||
}
|
||||
|
||||
// --- Dynamic form ---
|
||||
|
||||
async function renderForm({ provider, mode, remoteName = "", currentValues = {} }) {
|
||||
const app = document.getElementById("app");
|
||||
|
||||
const isEdit = mode === "edit";
|
||||
const requiresOAuth = provider.Options.some(
|
||||
(o) => o.Name === "token" && (o.IsPassword || o.Sensitive),
|
||||
);
|
||||
|
||||
// Partition options into required, optional-basic, optional-advanced.
|
||||
const required = provider.Options.filter((o) => o.Required && !o.Hide);
|
||||
const basic = provider.Options.filter((o) => !o.Required && !o.Advanced && !o.Hide);
|
||||
const advanced = provider.Options.filter((o) => o.Advanced && !o.Hide);
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>${isEdit ? "Edit" : "New"} ${escapeHtml(provider.Name)} remote</h2>
|
||||
<p class="subtitle">${escapeHtml(provider.Description || "")}</p>
|
||||
</div>
|
||||
<a class="btn btn-secondary btn-sm" href="#/remotes">Cancel</a>
|
||||
</div>
|
||||
|
||||
<form id="remote-form" class="card-outline" style="display:flex;flex-direction:column;gap:16px;max-width:760px">
|
||||
${requiresOAuth ? oauthBanner(provider.Name) : ""}
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field field-full">
|
||||
<label>Remote name <span class="field-required">*</span></label>
|
||||
<input
|
||||
class="input"
|
||||
name="_remote_name"
|
||||
type="text"
|
||||
required
|
||||
${isEdit ? `value="${escapeHtml(remoteName)}" readonly` : 'placeholder="my-remote"'}
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
|
||||
${required.length > 0 ? `<div class="form-section-title">Required</div>` : ""}
|
||||
${required.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
|
||||
|
||||
${basic.length > 0 ? `<div class="form-section-title">Options</div>` : ""}
|
||||
${basic.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
|
||||
|
||||
${advanced.length > 0 ? `
|
||||
<div class="advanced-toggle-wrap">
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="advanced-toggle">
|
||||
Show advanced options (${advanced.length})
|
||||
</button>
|
||||
</div>
|
||||
<div id="advanced-section" class="advanced-section hidden">
|
||||
<div class="form-section-title">Advanced</div>
|
||||
${advanced.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button type="submit" class="btn btn-primary" ${requiresOAuth ? "disabled" : ""}>
|
||||
${isEdit ? "Save changes" : "Create remote"}
|
||||
</button>
|
||||
<a class="btn btn-secondary" href="#/remotes">Cancel</a>
|
||||
</div>
|
||||
|
||||
${isEdit ? `
|
||||
<div class="danger-zone">
|
||||
<h4>Delete this remote</h4>
|
||||
<p>Permanently remove <code>${escapeHtml(remoteName)}</code> from rclone.conf.</p>
|
||||
<button type="button" class="btn btn-danger" id="delete-btn">Delete remote</button>
|
||||
</div>
|
||||
` : ""}
|
||||
</form>
|
||||
`;
|
||||
|
||||
const form = document.getElementById("remote-form");
|
||||
|
||||
// Wire up "Custom…" reveal on <select> fields that have it.
|
||||
wireCustomSelects(form);
|
||||
|
||||
// Advanced toggle
|
||||
const advToggle = document.getElementById("advanced-toggle");
|
||||
const advSection = document.getElementById("advanced-section");
|
||||
if (advToggle && advSection) {
|
||||
advToggle.addEventListener("click", () => {
|
||||
const hidden = advSection.classList.toggle("hidden");
|
||||
advToggle.textContent = hidden
|
||||
? `Show advanced options (${advanced.length})`
|
||||
: `Hide advanced options (${advanced.length})`;
|
||||
});
|
||||
}
|
||||
|
||||
// Delete button
|
||||
const deleteBtn = document.getElementById("delete-btn");
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener("click", async () => {
|
||||
if (!confirm(`Delete remote ${remoteName}? This cannot be undone.`)) return;
|
||||
try {
|
||||
await post("config/delete", { name: remoteName });
|
||||
toast(`Deleted ${remoteName}`, "success");
|
||||
location.hash = "#/remotes";
|
||||
} catch (e) {
|
||||
toast(`Delete failed: ${e.message}`, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Submit
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (requiresOAuth) return;
|
||||
|
||||
const name = form.elements._remote_name.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
const parameters = collectParameters(form, provider.Options, isEdit);
|
||||
|
||||
try {
|
||||
const body = {
|
||||
name,
|
||||
parameters,
|
||||
opt: { nonInteractive: true },
|
||||
};
|
||||
if (!isEdit) body.type = provider.Name;
|
||||
|
||||
const endpoint = isEdit ? "config/update" : "config/create";
|
||||
await post(endpoint, body);
|
||||
toast(`${isEdit ? "Updated" : "Created"} ${name}`, "success");
|
||||
location.hash = "#/remotes";
|
||||
} catch (err) {
|
||||
toast(`Save failed: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function oauthBanner(providerName) {
|
||||
return `
|
||||
<div class="banner banner-warning">
|
||||
<div>
|
||||
<strong>${escapeHtml(providerName)}</strong> requires OAuth authorization,
|
||||
which this web GUI can’t complete inside a container. Please configure it
|
||||
from a terminal first:
|
||||
<code>rclone config</code>
|
||||
Once the remote exists, you can edit its non-secret options here.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function fieldHtml(opt, currentValues, isEdit) {
|
||||
const isFull = opt.Type === "bool";
|
||||
const classes = ["field"];
|
||||
if (isFull) classes.push("field-bool");
|
||||
if (opt.Examples && opt.Examples.length > 0 && opt.Type !== "bool") {
|
||||
// Examples go full-width to fit the dropdown + custom override
|
||||
classes.push("field-full");
|
||||
} else if (opt.Help && opt.Help.length > 60) {
|
||||
classes.push("field-full");
|
||||
}
|
||||
const labelHtml = `${escapeHtml(opt.Name)}${opt.Required ? '<span class="field-required">*</span>' : ""}`;
|
||||
const helpHtml = opt.Help ? `<span class="field-help">${escapeHtml(opt.Help)}</span>` : "";
|
||||
const inputHtml = inputHtmlFor(opt, currentValues[opt.Name], isEdit);
|
||||
|
||||
return `
|
||||
<div class="${classes.join(" ")}">
|
||||
<label>${labelHtml}</label>
|
||||
${inputHtml}
|
||||
${helpHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function inputHtmlFor(opt, value, isEdit) {
|
||||
// In edit mode, never pre-fill password fields — server returns them
|
||||
// already obscured and resubmitting would double-obscure. Show empty
|
||||
// with a "(unchanged)" placeholder.
|
||||
if (opt.IsPassword && isEdit) {
|
||||
return `<input class="input" name="opt_${escapeHtml(opt.Name)}" type="password" autocomplete="off" placeholder="(unchanged)">`;
|
||||
}
|
||||
|
||||
const name = `opt_${escapeHtml(opt.Name)}`;
|
||||
const currentValue = value != null ? String(value) : "";
|
||||
|
||||
if (opt.Type === "bool") {
|
||||
const checked = value === true || value === "true" ? "checked" : "";
|
||||
return `<input type="checkbox" name="${name}" ${checked}>`;
|
||||
}
|
||||
|
||||
if (opt.Type === "int" || opt.Type === "int64" || opt.Type === "Duration") {
|
||||
return `<input class="input" name="${name}" type="number" value="${escapeAttr(currentValue)}" autocomplete="off">`;
|
||||
}
|
||||
|
||||
if (opt.IsPassword) {
|
||||
return `<input class="input" name="${name}" type="password" autocomplete="off" placeholder="secret">`;
|
||||
}
|
||||
|
||||
// Examples → select with custom override
|
||||
if (opt.Examples && opt.Examples.length > 0) {
|
||||
const opts = ['<option value="">— pick —</option>']
|
||||
.concat(
|
||||
opt.Examples.map(
|
||||
(ex) =>
|
||||
`<option value="${escapeAttr(ex.Value)}"${ex.Value === currentValue ? " selected" : ""}>${escapeHtml(ex.Help || ex.Value)}${ex.Provider ? ` (${escapeHtml(ex.Provider)})` : ""}</option>`,
|
||||
),
|
||||
)
|
||||
.join("");
|
||||
return `
|
||||
<select class="select" name="${name}" data-has-custom="1">
|
||||
${opts}
|
||||
<option value="__custom__"${currentValue && !opt.Examples.some((e) => e.Value === currentValue) ? " selected" : ""}>Custom…</option>
|
||||
</select>
|
||||
<input class="input" name="${name}__custom" type="text" value="${escapeAttr(currentValue)}" style="margin-top:8px;display:none" placeholder="custom value" autocomplete="off">
|
||||
`;
|
||||
}
|
||||
|
||||
return `<input class="input" name="${name}" type="text" value="${escapeAttr(currentValue)}" autocomplete="off">`;
|
||||
}
|
||||
|
||||
function collectParameters(form, options, isEdit) {
|
||||
const params = {};
|
||||
|
||||
for (const opt of options) {
|
||||
if (opt.Hide) continue;
|
||||
const baseName = `opt_${opt.Name}`;
|
||||
const el = form.elements[baseName];
|
||||
if (!el) continue;
|
||||
|
||||
let val;
|
||||
if (opt.Type === "bool") {
|
||||
val = el.checked ? "true" : "false";
|
||||
} else if (el.dataset && el.dataset.hasCustom === "1" || el.getAttribute("data-has-custom") === "1") {
|
||||
// It's a <select> with a sibling text override
|
||||
if (el.value === "__custom__") {
|
||||
const customEl = form.elements[`${baseName}__custom`];
|
||||
val = customEl ? customEl.value.trim() : "";
|
||||
} else {
|
||||
val = el.value;
|
||||
}
|
||||
} else {
|
||||
val = el.value.trim();
|
||||
}
|
||||
|
||||
// In edit mode, skip empty password fields (leave unchanged).
|
||||
if (isEdit && opt.IsPassword && val === "") continue;
|
||||
// Skip empty non-required fields.
|
||||
if (val === "" && !opt.Required) continue;
|
||||
|
||||
params[opt.Name] = val;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
// Wire up the "Custom…" reveal on selects with data-has-custom.
|
||||
// Called from app.js after every form render via a MutationObserver-
|
||||
// free approach: we attach the listener at form-render time inside
|
||||
// renderForm. Use event delegation for simplicity.
|
||||
export function wireCustomSelects(container) {
|
||||
container.querySelectorAll('select[data-has-custom="1"]').forEach((sel) => {
|
||||
const customName = sel.name + "__custom";
|
||||
const customEl = container.elements
|
||||
? container.elements[customName]
|
||||
: container.querySelector(`[name="${CSS.escape(customName)}"]`);
|
||||
if (!customEl) return;
|
||||
const sync = () => {
|
||||
customEl.style.display = sel.value === "__custom__" ? "block" : "none";
|
||||
};
|
||||
sync();
|
||||
sel.addEventListener("change", sync);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureProviders() {
|
||||
const cached = getState().providers;
|
||||
if (cached) return cached;
|
||||
const res = await post("config/providers");
|
||||
const providers = (res && res.providers) || [];
|
||||
setState({ providers });
|
||||
return providers;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return escapeHtml(s);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// views/jobs.js — submit sync/copy/move jobs and poll their progress.
|
||||
// Job src/dst metadata is persisted to localStorage (via state.rememberJob)
|
||||
// so the table can show what each job is doing even after a page reload.
|
||||
|
||||
import { post, postAsync } from "../rc.js";
|
||||
import {
|
||||
toast,
|
||||
rememberJob,
|
||||
getJobMeta,
|
||||
formatBytes,
|
||||
formatSpeed,
|
||||
formatDuration,
|
||||
} from "../state.js";
|
||||
|
||||
let pollTimer = null;
|
||||
|
||||
export async function renderJobs() {
|
||||
const app = document.getElementById("app");
|
||||
app.innerHTML = `
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>Jobs</h2>
|
||||
<p class="subtitle">Running and recently completed transfers.</p>
|
||||
</div>
|
||||
<a class="btn btn-primary btn-sm" href="#/jobs/new">New Job</a>
|
||||
</div>
|
||||
<div id="jobs-card" class="card-outline">
|
||||
<p class="empty">Loading jobs…</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await refreshJobs();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
export async function renderNewJob() {
|
||||
const app = document.getElementById("app");
|
||||
|
||||
// Pull remotes for the dropdowns
|
||||
let remotes = [];
|
||||
try {
|
||||
const res = await post("config/listremotes");
|
||||
remotes = (res && res.remotes) || [];
|
||||
} catch (e) {
|
||||
toast(`Couldn’t load remotes: ${e.message}`, "error");
|
||||
}
|
||||
|
||||
const remoteOpts = remotes
|
||||
.map((r) => `<option value="${escapeHtml(r)}:">${escapeHtml(r)}</option>`)
|
||||
.join("");
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>New Job</h2>
|
||||
<p class="subtitle">Copy, sync, or move between remotes.</p>
|
||||
</div>
|
||||
<a class="btn btn-secondary btn-sm" href="#/jobs">Cancel</a>
|
||||
</div>
|
||||
<form id="new-job-form" class="card-outline" style="display:grid;gap:16px;max-width:640px">
|
||||
<div class="field">
|
||||
<label>Action</label>
|
||||
<select name="action" class="select">
|
||||
<option value="copy">copy (mirror src → dst, keep both)</option>
|
||||
<option value="sync">sync (mirror src → dst, delete extras on dst)</option>
|
||||
<option value="move">move (mirror src → dst, delete src after)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>Source remote</label>
|
||||
<select name="srcRemote" class="select">
|
||||
${remoteOpts || '<option value="">(no remotes)</option>'}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Source path (optional)</label>
|
||||
<input class="input" name="srcPath" placeholder="folder/subfolder" autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Destination remote</label>
|
||||
<select name="dstRemote" class="select">
|
||||
${remoteOpts || '<option value="">(no remotes)</option>'}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Destination path (optional)</label>
|
||||
<input class="input" name="dstPath" placeholder="folder/subfolder" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button type="submit" class="btn btn-primary">Start Job</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
// Prefill first remote in both selectors for convenience
|
||||
if (remotes.length > 0) {
|
||||
app.querySelector('select[name="srcRemote"]').value = `${remotes[0]}:`;
|
||||
app.querySelector('select[name="dstRemote"]').value = `${remotes[0]}:`;
|
||||
}
|
||||
|
||||
const form = document.getElementById("new-job-form");
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const action = form.elements.action.value;
|
||||
const srcRemote = form.elements.srcRemote.value;
|
||||
const dstRemote = form.elements.dstRemote.value;
|
||||
const srcPath = form.elements.srcPath.value.trim().replace(/^\/+|\/+$/g, "");
|
||||
const dstPath = form.elements.dstPath.value.trim().replace(/^\/+|\/+$/g, "");
|
||||
if (!srcRemote || !dstRemote) {
|
||||
toast("Pick source and destination remotes", "error");
|
||||
return;
|
||||
}
|
||||
const src = srcPath ? `${srcRemote}${srcPath}` : srcRemote;
|
||||
const dst = dstPath ? `${dstRemote}${dstPath}` : dstRemote;
|
||||
try {
|
||||
const body = { srcFs: src, dstFs: dst };
|
||||
if (action === "move") body.deleteEmptySrcDirs = true;
|
||||
const res = await postAsync(`sync/${action}`, body);
|
||||
const jobid = res && res.jobid;
|
||||
if (jobid != null) {
|
||||
rememberJob(jobid, { action, src, dst });
|
||||
}
|
||||
toast(`Started ${action} job #${jobid}`, "success");
|
||||
location.hash = "#/jobs";
|
||||
} catch (e) {
|
||||
toast(`Job start failed: ${e.message}`, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshJobs() {
|
||||
const card = document.getElementById("jobs-card");
|
||||
if (!card) return; // user navigated away
|
||||
|
||||
let jobIds = [];
|
||||
try {
|
||||
const list = await post("job/list");
|
||||
const running = (list && list.jobids) || [];
|
||||
const finished = (list && list.finishedIds) || [];
|
||||
// Show both running and recently-finished. Sort happens in render.
|
||||
jobIds = [...new Set([...running, ...finished])];
|
||||
} catch (e) {
|
||||
card.innerHTML = `<div class="empty"><h3>Couldn’t load jobs</h3><p>${escapeHtml(e.message)}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (jobIds.length === 0) {
|
||||
card.innerHTML = `
|
||||
<div class="empty">
|
||||
<h3>No jobs yet</h3>
|
||||
<p>Use <a href="#/jobs/new">New Job</a> to start a copy, sync, or move.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch each job's status in parallel
|
||||
const statuses = await Promise.all(
|
||||
jobIds.map((id) =>
|
||||
post("job/status", { jobid: id }).catch((e) => ({
|
||||
jobid: id,
|
||||
error: e.message,
|
||||
finished: true,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
card.innerHTML = renderJobTable(statuses);
|
||||
card.querySelectorAll("[data-stop]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10)));
|
||||
});
|
||||
}
|
||||
|
||||
function renderJobTable(statuses) {
|
||||
// Newest jobid first
|
||||
statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0));
|
||||
|
||||
const rows = statuses.map(renderJobRow).join("");
|
||||
|
||||
return `
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Job</th>
|
||||
<th>Status</th>
|
||||
<th class="col-num">Progress</th>
|
||||
<th class="col-num">Speed</th>
|
||||
<th class="col-num">ETA</th>
|
||||
<th class="col-num">Files</th>
|
||||
<th class="col-num">Errors</th>
|
||||
<th class="col-num">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderJobRow(s) {
|
||||
const p = s.progress || {};
|
||||
const id = s.jobid;
|
||||
const finished = s.finished;
|
||||
const success = s.success;
|
||||
const errored = !!s.error;
|
||||
|
||||
let badge;
|
||||
if (!finished) {
|
||||
badge = `<span class="badge">running</span>`;
|
||||
} else if (errored || (!success && errored)) {
|
||||
badge = `<span class="badge badge-error">failed</span>`;
|
||||
} else if (success) {
|
||||
badge = `<span class="badge badge-success">done</span>`;
|
||||
} else {
|
||||
badge = `<span class="badge badge-warning">finished</span>`;
|
||||
}
|
||||
|
||||
const meta = getJobMeta(id);
|
||||
const jobCell = meta
|
||||
? `
|
||||
<div class="job-cell">
|
||||
<span class="job-action">${escapeHtml(meta.action)}</span>
|
||||
<span class="job-paths">
|
||||
<code>${escapeHtml(meta.src)}</code>
|
||||
<span class="arrow">→</span>
|
||||
<code>${escapeHtml(meta.dst)}</code>
|
||||
</span>
|
||||
</div>`
|
||||
: `<span class="col-mono" style="color:var(--color-muted-soft)">— submitted via CLI —</span>`;
|
||||
|
||||
const pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0;
|
||||
const progress = `
|
||||
<div style="display:flex;flex-direction:column;gap:4px;min-width:120px">
|
||||
<div class="progress"><span style="width:${pct.toFixed(1)}%"></span></div>
|
||||
<span class="col-mono" style="font-size:11px;color:var(--color-muted)">
|
||||
${formatBytes(p.bytes)} / ${formatBytes(p.totalBytes)}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const stopBtn = !finished
|
||||
? `<button class="btn btn-danger btn-sm" data-stop="${id}">Stop</button>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="col-mono">${id}</td>
|
||||
<td>${jobCell}</td>
|
||||
<td>${badge}</td>
|
||||
<td class="col-num">${progress}</td>
|
||||
<td class="col-num col-mono">${finished ? "—" : escapeHtml(formatSpeed(p.speed || 0))}</td>
|
||||
<td class="col-num col-mono">${finished ? "—" : escapeHtml(formatDuration(p.eta || 0))}</td>
|
||||
<td class="col-num col-mono">${p.transfers ?? 0} / ${p.totalTransfers ?? 0}</td>
|
||||
<td class="col-num col-mono">${(p.errors && p.errors.length) || 0}</td>
|
||||
<td class="col-num">${stopBtn}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
async function onStop(jobid) {
|
||||
if (!confirm(`Stop job #${jobid}?`)) return;
|
||||
try {
|
||||
await post("job/stop", { jobid });
|
||||
toast(`Stopped job #${jobid}`, "success");
|
||||
await refreshJobs();
|
||||
} catch (e) {
|
||||
toast(`Stop failed: ${e.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!document.getElementById("jobs-card")) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
await refreshJobs();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function stopJobPolling() {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
|
||||
|
||||
import { post } from "../rc.js";
|
||||
import { getState, setState, toast } from "../state.js";
|
||||
|
||||
export async function renderRemotes() {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) return;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>Remotes</h2>
|
||||
<p class="subtitle">Configured cloud storage providers. Click a tile to browse, hover for edit/delete.</p>
|
||||
</div>
|
||||
<a class="btn btn-primary btn-sm" href="#/configure/new">New remote</a>
|
||||
</div>
|
||||
<div id="remote-grid" class="connector-grid">
|
||||
<p class="empty" style="grid-column:1/-1">Loading remotes…</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const grid = document.getElementById("remote-grid");
|
||||
|
||||
try {
|
||||
const [listRes, dumpRes] = await Promise.all([
|
||||
post("config/listremotes"),
|
||||
post("config/dump"),
|
||||
]);
|
||||
const names = (listRes && listRes.remotes) || [];
|
||||
const dump = dumpRes || {};
|
||||
const remotes = names.map((name) => ({
|
||||
name,
|
||||
type: (dump[name] && dump[name].type) || "unknown",
|
||||
}));
|
||||
|
||||
setState({ remotes });
|
||||
|
||||
if (remotes.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty" style="grid-column:1/-1">
|
||||
<h3>No remotes configured</h3>
|
||||
<p>Click <strong>New remote</strong> above to add one from this GUI.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = remotes
|
||||
.map(
|
||||
(r) => `
|
||||
<a class="connector-tile tile-with-actions" href="#/browse/${encodeURIComponent(r.name)}">
|
||||
<span class="tile-name">${escapeHtml(r.name)}</span>
|
||||
<span class="badge">${escapeHtml(r.type)}</span>
|
||||
<div class="tile-actions">
|
||||
<button type="button" title="Edit" data-edit="${escapeHtml(r.name)}">✎</button>
|
||||
<button type="button" title="Delete" class="danger" data-delete="${escapeHtml(r.name)}">🗑</button>
|
||||
</div>
|
||||
</a>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
// Wire action buttons
|
||||
grid.querySelectorAll("[data-edit]").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
location.hash = `#/configure/edit/${encodeURIComponent(btn.dataset.edit)}`;
|
||||
});
|
||||
});
|
||||
grid.querySelectorAll("[data-delete]").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const name = btn.dataset.delete;
|
||||
if (!confirm(`Delete remote ${name}? This cannot be undone.`)) return;
|
||||
try {
|
||||
await post("config/delete", { name });
|
||||
toast(`Deleted ${name}`, "success");
|
||||
await renderRemotes();
|
||||
} catch (err) {
|
||||
toast(`Delete failed: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty" style="grid-column:1/-1">
|
||||
<h3>Couldn’t reach rclone</h3>
|
||||
<p>${escapeHtml(e.message)}</p>
|
||||
</div>
|
||||
`;
|
||||
toast(`Failed to load remotes: ${e.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* Base reset, font loading, and document defaults. */
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400&display=swap");
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-body);
|
||||
font: var(--typo-body-md);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1 0 auto;
|
||||
width: 100%;
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font: var(--typo-display-lg);
|
||||
letter-spacing: var(--typo-display-lg-tracking);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font: var(--typo-display-md);
|
||||
letter-spacing: var(--typo-display-md-tracking);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: var(--typo-display-sm);
|
||||
letter-spacing: var(--typo-display-sm-tracking);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font: var(--typo-code);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
/* Component library — all UI primitives map to DESIGN.md tokens. */
|
||||
|
||||
/* ---------- Top Navigation ---------- */
|
||||
.top-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
height: var(--nav-height);
|
||||
padding: 0 var(--space-xl);
|
||||
background-color: var(--color-canvas);
|
||||
border-bottom: 1px solid var(--color-hairline);
|
||||
}
|
||||
|
||||
.top-nav .wordmark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font: var(--typo-title-md);
|
||||
font-weight: 500;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.top-nav .wordmark .spike {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.top-nav nav {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.top-nav nav a {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
color: var(--color-muted);
|
||||
font: var(--typo-nav-link);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-nav nav a:hover {
|
||||
color: var(--color-ink);
|
||||
background-color: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.top-nav nav a.active {
|
||||
color: var(--color-ink);
|
||||
background-color: var(--color-surface-card);
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
height: 40px;
|
||||
padding: 12px 20px;
|
||||
font: var(--typo-button);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background-color 120ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-primary-active);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background-color: var(--color-primary-disabled);
|
||||
color: var(--color-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
border-color: var(--color-hairline);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-surface-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: transparent;
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-hairline);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: var(--color-error);
|
||||
color: var(--color-on-primary);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
border: 1px solid var(--color-hairline);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background-color: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
height: 32px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---------- Cards ---------- */
|
||||
.card {
|
||||
background-color: var(--color-surface-card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.card-outline {
|
||||
background-color: var(--color-canvas);
|
||||
border: 1px solid var(--color-hairline);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.card-dark {
|
||||
background-color: var(--color-surface-dark);
|
||||
color: var(--color-on-dark);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
/* ---------- Connector tiles (remote cards) ---------- */
|
||||
.connector-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.connector-tile {
|
||||
background-color: var(--color-canvas);
|
||||
border: 1px solid var(--color-hairline);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.connector-tile:hover {
|
||||
background-color: var(--color-surface-soft);
|
||||
border-color: var(--color-surface-cream-strong);
|
||||
}
|
||||
|
||||
.connector-tile .tile-name {
|
||||
font: var(--typo-title-sm);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.connector-tile .tile-type {
|
||||
font: var(--typo-caption);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Tile with action overlay (edit/delete buttons in top-right corner) */
|
||||
.connector-tile.tile-with-actions {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.connector-tile.tile-with-actions .tile-actions {
|
||||
position: absolute;
|
||||
top: var(--space-xs);
|
||||
right: var(--space-xs);
|
||||
display: flex;
|
||||
gap: var(--space-xxs);
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.connector-tile.tile-with-actions:hover .tile-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.connector-tile.tile-with-actions .tile-actions button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.connector-tile.tile-with-actions .tile-actions button:hover {
|
||||
background-color: var(--color-surface-card);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.connector-tile.tile-with-actions .tile-actions button.danger:hover {
|
||||
background-color: var(--color-error);
|
||||
color: var(--color-on-primary);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
/* ---------- Inputs ---------- */
|
||||
.input,
|
||||
.select {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 10px 14px;
|
||||
font: var(--typo-body-md);
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
border: 1px solid var(--color-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.select:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-15);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.field label {
|
||||
font: var(--typo-caption);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* ---------- Badges ---------- */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
font: var(--typo-caption);
|
||||
border-radius: var(--radius-pill);
|
||||
background-color: var(--color-surface-card);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.badge-coral {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
font: var(--typo-caption-uppercase);
|
||||
letter-spacing: var(--typo-caption-uppercase-tracking);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: var(--color-success);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background-color: var(--color-error);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
/* ---------- Breadcrumbs ---------- */
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xxs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.breadcrumbs a,
|
||||
.breadcrumbs span {
|
||||
padding: var(--space-xs) calc(var(--space-sm) + 2px);
|
||||
font: var(--typo-nav-link);
|
||||
color: var(--color-muted);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumbs a:hover {
|
||||
color: var(--color-ink);
|
||||
background-color: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.breadcrumbs .current {
|
||||
color: var(--color-ink);
|
||||
background-color: var(--color-surface-card);
|
||||
}
|
||||
|
||||
.breadcrumbs .sep {
|
||||
padding: 0 var(--space-xxs);
|
||||
color: var(--color-muted-soft);
|
||||
}
|
||||
|
||||
/* ---------- Tables ---------- */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font: var(--typo-body-sm);
|
||||
color: var(--color-body);
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
font: var(--typo-caption);
|
||||
color: var(--color-muted);
|
||||
border-bottom: 1px solid var(--color-hairline);
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
border-bottom: 1px solid var(--color-hairline-soft);
|
||||
}
|
||||
|
||||
.table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background-color: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.table .col-name {
|
||||
font: var(--typo-body-md);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.table .col-name a {
|
||||
color: var(--color-ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.table .col-name a:hover {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.table .col-mono {
|
||||
font: var(--typo-code);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.table .col-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.table .row-dir .col-name a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ---------- Progress bar ---------- */
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: var(--color-surface-card);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background-color: var(--color-primary);
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
|
||||
/* ---------- Toolbar ---------- */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---------- Section header ---------- */
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
font: var(--typo-display-md);
|
||||
letter-spacing: var(--typo-display-md-tracking);
|
||||
}
|
||||
|
||||
.section-head .subtitle {
|
||||
color: var(--color-muted);
|
||||
font: var(--typo-body-sm);
|
||||
}
|
||||
|
||||
/* ---------- Empty state ---------- */
|
||||
.empty {
|
||||
padding: var(--space-section) var(--space-xl);
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.empty h3 {
|
||||
margin-bottom: var(--space-sm);
|
||||
font: var(--typo-display-sm);
|
||||
letter-spacing: var(--typo-display-sm-tracking);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
/* ---------- Toast ---------- */
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
bottom: var(--space-lg);
|
||||
right: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
background-color: var(--color-surface-dark);
|
||||
color: var(--color-on-dark);
|
||||
border-radius: var(--radius-lg);
|
||||
font: var(--typo-body-sm);
|
||||
max-width: 360px;
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background-color: var(--color-error);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background-color: var(--color-success);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
/* ---------- Modal ---------- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(20, 20, 19, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: var(--color-surface-dark);
|
||||
color: var(--color-on-dark);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
font: var(--typo-title-lg);
|
||||
color: var(--color-on-dark);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal label {
|
||||
color: var(--color-on-dark-soft);
|
||||
font: var(--typo-caption);
|
||||
}
|
||||
|
||||
.modal .input,
|
||||
.modal .select {
|
||||
background-color: var(--color-surface-dark-elevated);
|
||||
color: var(--color-on-dark);
|
||||
border-color: var(--color-surface-dark-elevated);
|
||||
}
|
||||
|
||||
.modal .input:focus,
|
||||
.modal .select:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.modal .btn-secondary {
|
||||
background-color: var(--color-surface-dark-elevated);
|
||||
color: var(--color-on-dark);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
.footer {
|
||||
background-color: var(--color-surface-dark);
|
||||
color: var(--color-on-dark-soft);
|
||||
padding: var(--space-xxl) var(--space-xl);
|
||||
margin-top: var(--space-section);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.footer h4 {
|
||||
color: var(--color-on-dark);
|
||||
font: var(--typo-title-sm);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--color-on-dark-soft);
|
||||
font: var(--typo-body-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
color: var(--color-on-dark);
|
||||
}
|
||||
|
||||
.footer .colophon {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: var(--space-lg);
|
||||
padding-top: var(--space-lg);
|
||||
border-top: 1px solid var(--color-surface-dark-elevated);
|
||||
color: var(--color-on-dark-soft);
|
||||
font: var(--typo-caption);
|
||||
}
|
||||
|
||||
/* ---------- Configure: provider picker + dynamic form ---------- */
|
||||
.provider-search {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.provider-search input {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-surface-card);
|
||||
color: var(--color-body-strong);
|
||||
font: var(--typo-body-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.banner-warning {
|
||||
background-color: color-mix(in srgb, var(--color-warning) 18%, var(--color-canvas));
|
||||
border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||
}
|
||||
|
||||
.banner code {
|
||||
display: block;
|
||||
margin-top: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background-color: var(--color-surface-dark);
|
||||
color: var(--color-on-dark);
|
||||
border-radius: var(--radius-sm);
|
||||
font: var(--typo-code);
|
||||
white-space: pre-wrap;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
.form-grid .field-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-grid .field-bool {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-grid .field-bool input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.field .field-help {
|
||||
font: var(--typo-body-sm);
|
||||
color: var(--color-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.field .field-required {
|
||||
color: var(--color-error);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
grid-column: 1 / -1;
|
||||
font: var(--typo-caption-uppercase);
|
||||
letter-spacing: var(--typo-caption-uppercase-tracking);
|
||||
color: var(--color-muted-soft);
|
||||
margin-top: var(--space-sm);
|
||||
padding-top: var(--space-sm);
|
||||
border-top: 1px solid var(--color-hairline-soft);
|
||||
}
|
||||
|
||||
.form-section-title:first-child {
|
||||
margin-top: 0;
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.advanced-toggle-wrap {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.advanced-section {
|
||||
grid-column: 1 / -1;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.advanced-section.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
border: 1px solid var(--color-error);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
margin-top: var(--space-xl);
|
||||
background-color: color-mix(in srgb, var(--color-error) 5%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.danger-zone h4 {
|
||||
font: var(--typo-title-sm);
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-error);
|
||||
margin: 0 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.danger-zone p {
|
||||
font: var(--typo-body-sm);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
/* ---------- Job cell with action + src→dst ---------- */
|
||||
.job-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.job-cell .job-action {
|
||||
font: var(--typo-caption-uppercase);
|
||||
letter-spacing: var(--typo-caption-uppercase-tracking);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.job-cell .job-paths {
|
||||
font: var(--typo-body-sm);
|
||||
color: var(--color-body);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.job-cell .job-paths .arrow {
|
||||
color: var(--color-muted-soft);
|
||||
margin: 0 var(--space-xxs);
|
||||
}
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
@media (max-width: 1024px) {
|
||||
.connector-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.footer-inner {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
.connector-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.footer-inner {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.top-nav {
|
||||
padding: 0 var(--space-md);
|
||||
}
|
||||
.top-nav nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* DESIGN.md tokens — Anthropic/Claude design system
|
||||
Source of truth: every color/size/radius used in the app is defined here. */
|
||||
|
||||
:root {
|
||||
/* --- Brand & Accent --- */
|
||||
--color-primary: #cc785c;
|
||||
--color-primary-active: #a9583e;
|
||||
--color-primary-disabled: #e6dfd8;
|
||||
--color-primary-15: rgba(204, 120, 92, 0.15);
|
||||
|
||||
--color-accent-teal: #5db8a6;
|
||||
--color-accent-amber: #e8a55a;
|
||||
|
||||
/* --- Surfaces --- */
|
||||
--color-canvas: #faf9f5;
|
||||
--color-surface-soft: #f5f0e8;
|
||||
--color-surface-card: #efe9de;
|
||||
--color-surface-cream-strong: #e8e0d2;
|
||||
--color-surface-dark: #181715;
|
||||
--color-surface-dark-elevated: #252320;
|
||||
--color-surface-dark-soft: #1f1e1b;
|
||||
|
||||
--color-hairline: #e6dfd8;
|
||||
--color-hairline-soft: #ebe6df;
|
||||
|
||||
/* --- Text --- */
|
||||
--color-ink: #141413;
|
||||
--color-body-strong: #252523;
|
||||
--color-body: #3d3d3a;
|
||||
--color-muted: #6c6a64;
|
||||
--color-muted-soft: #8e8b82;
|
||||
|
||||
--color-on-primary: #ffffff;
|
||||
--color-on-dark: #faf9f5;
|
||||
--color-on-dark-soft: #a09d96;
|
||||
|
||||
/* --- Semantic --- */
|
||||
--color-success: #5db872;
|
||||
--color-warning: #d4a017;
|
||||
--color-error: #c64545;
|
||||
|
||||
/* --- Typography families --- */
|
||||
--font-display: "Cormorant Garamond", "Tiempos Headline", "EB Garamond", Garamond, "Times New Roman", serif;
|
||||
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
|
||||
/* --- Typography scale (matches DESIGN.md 1:1) --- */
|
||||
--typo-display-xl: 400 64px/1.05 var(--font-display);
|
||||
--typo-display-xl-tracking: -1.5px;
|
||||
--typo-display-lg: 400 48px/1.1 var(--font-display);
|
||||
--typo-display-lg-tracking: -1px;
|
||||
--typo-display-md: 400 36px/1.15 var(--font-display);
|
||||
--typo-display-md-tracking: -0.5px;
|
||||
--typo-display-sm: 400 28px/1.2 var(--font-display);
|
||||
--typo-display-sm-tracking: -0.3px;
|
||||
--typo-title-lg: 500 22px/1.3 var(--font-body);
|
||||
--typo-title-md: 500 18px/1.4 var(--font-body);
|
||||
--typo-title-sm: 500 16px/1.4 var(--font-body);
|
||||
--typo-body-md: 400 16px/1.55 var(--font-body);
|
||||
--typo-body-sm: 400 14px/1.55 var(--font-body);
|
||||
--typo-caption: 500 13px/1.4 var(--font-body);
|
||||
--typo-caption-uppercase: 500 12px/1.4 var(--font-body);
|
||||
--typo-caption-uppercase-tracking: 1.5px;
|
||||
--typo-code: 400 14px/1.6 var(--font-mono);
|
||||
--typo-button: 500 14px/1 var(--font-body);
|
||||
--typo-nav-link: 500 14px/1.4 var(--font-body);
|
||||
|
||||
/* --- Spacing scale --- */
|
||||
--space-xxs: 4px;
|
||||
--space-xs: 8px;
|
||||
--space-sm: 12px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
--space-xxl: 48px;
|
||||
--space-section: 96px;
|
||||
|
||||
/* --- Radii --- */
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-pill: 9999px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* --- Layout --- */
|
||||
--content-max: 1200px;
|
||||
--nav-height: 64px;
|
||||
|
||||
/* --- Shadows (used sparingly per DESIGN.md elevation philosophy) --- */
|
||||
--shadow-hover: 0 1px 3px rgba(20, 20, 19, 0.08);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>rclone</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/styles/tokens.css">
|
||||
<link rel="stylesheet" href="/assets/styles/base.css">
|
||||
<link rel="stylesheet" href="/assets/styles/components.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-nav">
|
||||
<a class="wordmark" href="#/remotes">
|
||||
<svg class="spike" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0 L9.2 6.8 L16 8 L9.2 9.2 L8 16 L6.8 9.2 L0 8 L6.8 6.8 Z"/>
|
||||
</svg>
|
||||
<span>rclone</span>
|
||||
</a>
|
||||
<nav id="nav-links">
|
||||
<a href="#/remotes" data-route="remotes">Remotes</a>
|
||||
<a href="#/configure/new" data-route="configure">Configure</a>
|
||||
<a href="#/jobs" data-route="jobs">Jobs</a>
|
||||
</nav>
|
||||
<a class="btn btn-primary btn-sm" href="#/jobs/new">New Job</a>
|
||||
</header>
|
||||
|
||||
<main id="app">
|
||||
<div class="empty">
|
||||
<h3>Loading</h3>
|
||||
<p>Connecting to rclone RC…</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h4>Product</h4>
|
||||
<ul>
|
||||
<li><a href="#/remotes">Remotes</a></li>
|
||||
<li><a href="#/jobs">Jobs</a></li>
|
||||
<li><a href="#/jobs/new">New Job</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Documentation</h4>
|
||||
<ul>
|
||||
<li><a href="https://rclone.org/docs/" target="_blank" rel="noopener">rclone Docs</a></li>
|
||||
<li><a href="https://rclone.org/commands/rclone_rcd/" target="_blank" rel="noopener">rcd Command</a></li>
|
||||
<li><a href="https://rclone.org/rc/" target="_blank" rel="noopener">RC API</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Community</h4>
|
||||
<ul>
|
||||
<li><a href="https://forum.rclone.org/" target="_blank" rel="noopener">Forum</a></li>
|
||||
<li><a href="https://github.com/rclone/rclone" target="_blank" rel="noopener">GitHub</a></li>
|
||||
<li><a href="https://github.com/rclone/rclone/issues" target="_blank" rel="noopener">Issues</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>About</h4>
|
||||
<ul>
|
||||
<li><a href="https://rclone.org/" target="_blank" rel="noopener">rclone.org</a></li>
|
||||
<li><a href="https://rclone.org/changelog/" target="_blank" rel="noopener">Changelog</a></li>
|
||||
<li><a href="https://rclone.org/faq/" target="_blank" rel="noopener">FAQ</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="colophon">rclone — rsync for cloud storage.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div id="toast-stack" class="toast-stack"></div>
|
||||
<div id="modal-root"></div>
|
||||
|
||||
<script type="module" src="/assets/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 /<remote>:<path>.
|
||||
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=<rcURL> 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()
|
||||
}
|
||||
Reference in New Issue
Block a user