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