commit 04cd31e107c29ecb73128ddaf76c499b53a2098d Author: Jose Henrique Date: Wed Jul 8 14:26:28 2026 -0300 feat: transmission-manager — Go SPA for Transmission RPC diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d475cb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Binaries +transmission-manager +*.exe + +# Editor +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Docker +.docker/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..20511a8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,99 @@ +# Transmission Manager — AGENTS.md + +## Project Overview +A lightweight single-binary Go web app that provides a beautiful dark-mode SPA for managing/viewing torrents in a Transmission RPC client. + +## Architecture +- **Backend**: Go (stdlib net/http only, no external dependencies). Serves on port 8080. + - `GET /` → serves embedded static files (index.html, app.js, styles.css) + - `GET /api/torrents` → proxies to Transmission RPC, returns clean JSON + - `GET /api/stats` → global session stats +- **Frontend**: Vanilla HTML + CSS + JS. No frameworks. Single page. Dark mode only. + +## Transmission RPC Details +- RPC URL: `http://192.168.20.22:3210/transmission/rpc` +- No auth required (open on LAN) +- The RPC requires a session-ID handshake: + 1. POST to the RPC URL + 2. If response is 409, read the `X-Transmission-Session-Id` header from the response + 3. Re-send the request with that header + 4. Cache the session-ID for future requests +- For torrent list, request these fields: + ``` + id, name, totalSize, downloadedEver, uploadedEver, uploadRatio, + percentDone, status, eta, error, errorString, doneDate, isFinished, + rateDownload, rateUpload, peersConnected, peersGettingFromUs, peersSendingToUs, + peers (array of {address, clientName, progress, isDownloadingFrom, isUploadingTo, rateToClient, rateToPeer}) + ``` +- "Peers with 100%" = count of peers in the `peers` array where `progress === 1.0` +- "Finished date" = `doneDate` (Unix timestamp, 0 means not finished) + +## Design Requirements (CRITICAL) +- **Dark mode only** — deep dark background (#0a0a0f or similar) +- **Modern, stylish, clean** — think Linear, Vercel, Raycast aesthetic +- **Accent color**: Use a vibrant accent (cyan/teal #00d4ff or purple #8b5cf6) +- **Typography**: System font stack, -apple-system, Inter-like +- **Torrent cards or rows** with: + - Name (truncated with ellipsis if long) + - Size (human-readable: GB, MB, etc.) + - Progress bar (animated, smooth) + - Percentage done + - Ratio (uploadRatio) + - Downloaded / Uploaded amounts + - Download/Upload speeds + - ETA (when downloading) + - Status badge (downloading, seeding, paused, finished, error) + - Finish date (formatted nicely, e.g. "Jul 8, 2026") + - Peer count with 100% (e.g. "3/7 peers at 100%") +- **Auto-refresh** every 3 seconds +- **Header** with app title and global stats (total download/upload speed, torrent count) +- **Responsive** — works on mobile and desktop +- Use CSS variables for theming +- Smooth transitions and micro-animations +- Cards with subtle borders and hover effects + +## Code Structure +``` +main.go — Go backend (HTTP server, Transmission RPC proxy, static file serving via embed) +web/ + index.html — SPA shell + app.js — Frontend logic (fetch API, render, auto-refresh) + styles.css — All styling +Dockerfile — Multi-stage: golang:1.24-alpine (build) → alpine:latest (runtime) +go.mod — module transmission-manager, go 1.24 +``` + +## Go Implementation Notes +- Use `embed` package to embed web/ directory into the binary +- Transmission RPC communication via net/http client +- Implement session-ID handshake with retry on 409 +- Use sync.Mutex to protect session-ID cache +- Format numbers nicely (human-readable sizes) in the frontend, not backend +- CORS not needed (same origin) +- The Go server should be minimal and fast + +## Dockerfile +```dockerfile +FROM golang:1.24-alpine AS builder +WORKDIR /app +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o transmission-manager . + +FROM alpine:latest +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=builder /app/transmission-manager . +EXPOSE 8080 +CMD ["./transmission-manager"] +``` + +## Do NOT +- Do not use any Go web framework (gin, fiber, etc.) — stdlib only +- Do not use any JS framework (React, Vue, etc.) — vanilla only +- Do not add authentication +- Do not add CI/CD or GitHub Actions +- Do not create any GitHub repository +- Do not add vendored dependencies +- Do not add tests unless necessary — keep it simple diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..78fcc99 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.24-alpine AS builder +WORKDIR /app +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o transmission-manager . + +FROM alpine:latest +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=builder /app/transmission-manager . +EXPOSE 8080 +CMD ["./transmission-manager"] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26a53b2 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module transmission-manager + +go 1.24 diff --git a/main.go b/main.go new file mode 100644 index 0000000..373dca1 --- /dev/null +++ b/main.go @@ -0,0 +1,212 @@ +package main + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "sync" + "time" +) + +//go:embed web +var webFS embed.FS + +const ( + rpcURL = "http://192.168.20.22:3210/transmission/rpc" + sessionHeader = "X-Transmission-Session-Id" +) + +// Client is a minimal Transmission RPC client with session-id handshake. +type Client struct { + http *http.Client + mu sync.Mutex + sessionID string +} + +func NewClient() *Client { + return &Client{ + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// rpc sends a JSON-RPC request, handling the 409 session handshake with one retry. +func (c *Client) rpc(ctx context.Context, body []byte) ([]byte, error) { + doRequest := func(sessionID string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if sessionID != "" { + req.Header.Set(sessionHeader, sessionID) + } + return c.http.Do(req) + } + + c.mu.Lock() + currentSession := c.sessionID + c.mu.Unlock() + + resp, err := doRequest(currentSession) + if err != nil { + return nil, err + } + + if resp.StatusCode == http.StatusConflict { + newSession := resp.Header.Get(sessionHeader) + // drain so the connection can be reused + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if newSession == "" { + return nil, fmt.Errorf("409 received but no %s header present", sessionHeader) + } + c.mu.Lock() + c.sessionID = newSession + c.mu.Unlock() + // retry once with the fresh session id + resp, err = doRequest(newSession) + if err != nil { + return nil, err + } + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("rpc returned %d: %s", resp.StatusCode, string(data)) + } + return data, nil +} + +// torrentFields requested from Transmission (per AGENTS.md). +var torrentFields = []string{ + "id", "name", "totalSize", "downloadedEver", "uploadedEver", "uploadRatio", + "percentDone", "status", "eta", "error", "errorString", "doneDate", "isFinished", + "rateDownload", "rateUpload", "peersConnected", "peersGettingFromUs", "peersSendingToUs", + "peers", +} + +type rpcRequest struct { + Method string `json:"method"` + Arguments rpcArgs `json:"arguments"` + Tag interface{} `json:"tag,omitempty"` +} + +type rpcArgs struct { + Fields []string `json:"fields,omitempty"` +} + +type rpcResponse struct { + Result string `json:"result"` + Args json.RawMessage `json:"arguments"` +} + +func (c *Client) Torrents(ctx context.Context) (json.RawMessage, error) { + reqBody, _ := json.Marshal(rpcRequest{ + Method: "torrent-get", + Arguments: rpcArgs{Fields: torrentFields}, + }) + data, err := c.rpc(ctx, reqBody) + if err != nil { + return nil, err + } + var resp rpcResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + if resp.Result != "success" { + return nil, fmt.Errorf("rpc result: %s", resp.Result) + } + return resp.Args, nil +} + +// sessionFields requested for stats. +var sessionFields = []string{ + "downloadSpeed", "uploadSpeed", "torrentCount", "activeTorrentCount", + "pausedTorrentCount", "cumulativeDownloaded", "cumulativeUploaded", +} + +func (c *Client) Stats(ctx context.Context) (json.RawMessage, error) { + reqBody, _ := json.Marshal(rpcRequest{ + Method: "session-get", + Arguments: rpcArgs{Fields: sessionFields}, + }) + data, err := c.rpc(ctx, reqBody) + if err != nil { + return nil, err + } + var resp rpcResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + if resp.Result != "success" { + return nil, fmt.Errorf("rpc result: %s", resp.Result) + } + return resp.Args, nil +} + +func writeJSON(w http.ResponseWriter, data json.RawMessage) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(append(data, '\n')) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +func main() { + client := NewClient() + + mux := http.NewServeMux() + + // API routes + mux.HandleFunc("/api/torrents", func(w http.ResponseWriter, r *http.Request) { + args, err := client.Torrents(r.Context()) + if err != nil { + log.Printf("torrents: %v", err) + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, args) + }) + + mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) { + args, err := client.Stats(r.Context()) + if err != nil { + log.Printf("stats: %v", err) + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, args) + }) + + // Static files from embedded web/ directory + webRoot, err := fs.Sub(webFS, "web") + if err != nil { + log.Fatalf("embed sub: %v", err) + } + fileServer := http.FileServer(http.FS(webRoot)) + mux.Handle("/", fileServer) + + addr := ":8080" + log.Printf("transmission-manager listening on %s", addr) + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + if err := srv.ListenAndServe(); err != nil { + log.Fatalf("server: %v", err) + } +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..eb66fc5 --- /dev/null +++ b/web/app.js @@ -0,0 +1,366 @@ +"use strict"; + +/* ============================================================ + Transmission Manager — frontend + Vanilla SPA. Auto-refreshes every 3s. + ============================================================ */ + +const REFRESH_MS = 3000; +const TRANSMISSION_STATUSES = { + 0: "stopped", + 1: "check-wait", + 2: "checking", + 3: "download-wait", + 4: "downloading", + 5: "seed-wait", + 6: "seeding", +}; + +const state = { + torrents: [], + stats: {}, + filter: "", + sortKey: "status", + lastError: null, +}; + +const $ = (sel) => document.querySelector(sel); + +/* ---------- Fetching ---------- */ +async function fetchJSON(url) { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); + return res.json(); +} + +async function loadTorrents() { + const data = await fetchJSON("/api/torrents"); + // Transmission returns { torrents: [...] } + state.torrents = (data && data.torrents) || []; +} + +async function loadStats() { + const data = await fetchJSON("/api/stats"); + state.stats = data || {}; +} + +async function sync() { + setSyncing(true); + try { + await Promise.all([loadTorrents(), loadStats()]); + state.lastError = null; + renderAll(); + } catch (err) { + state.lastError = err.message || String(err); + renderError(); + } finally { + setSyncing(false); + $("#last-updated").textContent = `updated ${formatTime(new Date())}`; + } +} + +function setSyncing(on) { + const el = $("#refresh-indicator"); + if (!el) return; + el.classList.toggle("syncing", on); + const t = el.querySelector(".refresh-text"); + if (t) t.textContent = on ? "syncing" : "live"; +} + +/* ---------- Formatting helpers ---------- */ +function humanSize(bytes) { + if (bytes == null || isNaN(bytes) || bytes < 0) return "—"; + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const v = bytes / Math.pow(1024, i); + const digits = i === 0 ? 0 : v < 10 ? 2 : v < 100 ? 1 : 0; + return `${v.toFixed(digits)} ${units[i]}`; +} + +function humanSpeed(bytesPerSec) { + if (!bytesPerSec || bytesPerSec <= 0) return "0 kB/s"; + return humanSize(bytesPerSec) + "/s"; +} + +function humanRatio(ratio) { + if (ratio == null || ratio < 0 || isNaN(ratio)) return "—"; + if (ratio === -1) return "∞"; + return ratio.toFixed(2); +} + +function humanPct(p) { + if (p == null || isNaN(p)) return 0; + if (p < 0) return 0; + if (p >= 1) return 1; + return p; +} + +function formatETA(sec) { + if (sec == null || sec < 0 || isNaN(sec)) return "—"; + if (sec === 0) return "—"; + if (sec < 60) return `${sec}s`; + const m = Math.floor(sec / 60); + if (m < 60) return `${m}m ${sec % 60}s`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ${m % 60}m`; + const d = Math.floor(h / 24); + return `${d}d ${h % 24}h`; +} + +function formatDate(unix) { + if (!unix || unix <= 0) return "—"; + const d = new Date(unix * 1000); + if (isNaN(d.getTime())) return "—"; + return d.toLocaleDateString(undefined, { + year: "numeric", month: "short", day: "numeric", + }); +} + +function formatTime(d) { + return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function peerStatus(t) { + const peers = Array.isArray(t.peers) ? t.peers : []; + const connected = t.peersConnected || peers.length || 0; + const at100 = peers.filter((p) => p && p.progress === 1.0).length; + if (connected === 0) return { connected: 0, at100: 0, label: "no peers" }; + return { + connected, + at100, + label: `${at100}/${connected} @ 100%`, + }; +} + +/* ---------- Torrent status classification ---------- */ +function classify(t) { + if (t.error && t.error !== 0) { + return { key: "error", label: "Error", dot: true }; + } + const tr = TRANSMISSION_STATUSES[t.status]; + switch (tr) { + case "downloading": return { key: "downloading", label: "Downloading", dot: true }; + case "seeding": return { key: "seeding", label: "Seeding", dot: true }; + case "stopped": + if (t.isFinished || (t.percentDone >= 1.0)) return { key: "finished", label: "Finished", dot: false }; + return { key: "paused", label: "Paused", dot: false }; + case "checking": return { key: "checking", label: "Verifying", dot: true }; + case "check-wait": return { key: "queued", label: "Queued", dot: true }; + case "download-wait": return { key: "queued", label: "Queued ↓", dot: true }; + case "seed-wait": return { key: "queued", label: "Queued ↑", dot: true }; + default: + if (t.isFinished || (t.percentDone >= 1.0)) return { key: "finished", label: "Finished", dot: false }; + return { key: "idle", label: "Idle", dot: false }; + } +} + +/* ---------- Sorting ---------- */ +const STATUS_RANK = { error: 0, downloading: 1, seeding: 2, queued: 3, checking: 4, finished: 5, paused: 6, idle: 7 }; +function sortTorrents(list) { + const key = state.sortKey; + const sorted = list.slice(); + sorted.sort((a, b) => { + switch (key) { + case "name": return a.name.localeCompare(b.name); + case "progress": return (b.percentDone || 0) - (a.percentDone || 0); + case "ratio": return (b.uploadRatio || 0) - (a.uploadRatio || 0); + case "size": return (b.totalSize || 0) - (a.totalSize || 0); + case "status": { + const ra = STATUS_RANK[classify(a).key] ?? 99; + const rb = STATUS_RANK[classify(b).key] ?? 99; + if (ra !== rb) return ra - rb; + return (b.percentDone || 0) - (a.percentDone || 0); + } + default: return 0; + } + }); + return sorted; +} + +/* ---------- Rendering ---------- */ +function renderHeader() { + const s = state.stats || {}; + const torrents = state.torrents || []; + let dl = 0, ul = 0; + for (const t of torrents) { + dl += t.rateDownload || 0; + ul += t.rateUpload || 0; + } + $("#stat-torrents").textContent = String(torrents.length); + $("#stat-dl").textContent = humanSpeed(dl); + $("#stat-ul").textContent = humanSpeed(ul); +} + +function badgeHTML(cls) { + return `${escapeHTML(cls.label)}`; +} + +function escapeHTML(s) { + if (s == null) return ""; + return String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + })[c]); +} + +function initial(name) { + const n = (name || "").trim(); + if (!n) return "?"; + // try to grab a meaningful token from the filename + const base = n.replace(/\.[a-z0-9]+$/i, ""); + const m = base.match(/[A-Za-z0-9]/); + return m ? m[0].toUpperCase() : "?"; +} + +function renderCard(t) { + const cls = classify(t); + const pct = humanPct(t.percentDone); + const pctText = (pct * 100).toFixed(pct === 1 ? 0 : 1) + "%"; + const isDone = pct >= 1.0; + const ps = peerStatus(t); + + let barClass = ""; + if (cls.key === "error") barClass = "error"; + else if (isDone) barClass = "done"; + else if (cls.key === "paused" || cls.key === "finished") barClass = "paused"; + + const dl = t.rateDownload || 0; + const ul = t.rateUpload || 0; + const eta = (dl > 0 && !isDone) ? formatETA(t.eta) : (isDone ? "—" : "∞"); + + const hasError = cls.key === "error"; + + return ` +
+
+
${escapeHTML(initial(t.name))}
+
+
${escapeHTML(t.name)}
+
+ Size ${escapeHTML(humanSize(t.totalSize))} + Ratio ${escapeHTML(humanRatio(t.uploadRatio))} + Peers ${escapeHTML(ps.label)} + ${t.doneDate ? `Done ${escapeHTML(formatDate(t.doneDate))}` : ""} +
+
+
+ ${badgeHTML(cls)} + ${escapeHTML(pctText)} +
+
+ +
+
+
+ +
+
+ Downloaded + ${escapeHTML(humanSize(t.downloadedEver))}${isDone ? ` / ${escapeHTML(humanSize(t.totalSize))}` : ""} +
+
+ Uploaded + ${escapeHTML(humanSize(t.uploadedEver))} +
+
+ ↓ Speed + ${escapeHTML(humanSpeed(dl))} +
+
+ ↑ Speed + ${escapeHTML(humanSpeed(ul))} +
+
+ ETA + ${escapeHTML(eta)} +
+
+ Peers @ 100% + ${escapeHTML(`${ps.at100}/${ps.connected}`)} +
+
+ Finished + ${escapeHTML(formatDate(t.doneDate))} +
+
+ + ${hasError && t.errorString ? `
${escapeHTML(t.errorString)}
` : ""} +
+ `; +} + +function renderList() { + const list = $("#torrents"); + const empty = $("#empty-state"); + const errEl = $("#error-state"); + + let torrents = sortTorrents(state.torrents || []); + + const f = state.filter.trim().toLowerCase(); + if (f) torrents = torrents.filter((t) => (t.name || "").toLowerCase().includes(f)); + + errEl.classList.add("hidden"); + if (state.lastError) { + list.innerHTML = ""; + empty.classList.add("hidden"); + errEl.classList.remove("hidden"); + $("#error-msg").textContent = state.lastError; + return; + } + + if (torrents.length === 0) { + list.innerHTML = ""; + empty.classList.remove("hidden"); + return; + } + empty.classList.add("hidden"); + list.innerHTML = torrents.map(renderCard).join(""); +} + +function renderError() { + // keep stats list emptied on error + $("#torrents").innerHTML = ""; + $("#empty-state").classList.add("hidden"); + const errEl = $("#error-state"); + errEl.classList.remove("hidden"); + $("#error-msg").textContent = state.lastError || "Could not reach the Transmission daemon."; +} + +function renderAll() { + renderHeader(); + renderList(); +} + +/* ---------- Events ---------- */ +function wireEvents() { + const filterInput = $("#filter"); + filterInput.addEventListener("input", (e) => { + state.filter = e.target.value; + renderList(); + }); + + document.querySelectorAll(".sort-btn").forEach((btn) => { + btn.addEventListener("click", () => { + document.querySelectorAll(".sort-btn").forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + state.sortKey = btn.dataset.sort; + renderList(); + }); + }); + + document.addEventListener("visibilitychange", () => { + if (!document.hidden) { + // immediate refresh when returning to the tab + sync(); + } + }); +} + +/* ---------- Init ---------- */ +function boot() { + wireEvents(); + sync(); + setInterval(sync, REFRESH_MS); +} + +document.addEventListener("DOMContentLoaded", boot); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..d6cf617 --- /dev/null +++ b/web/index.html @@ -0,0 +1,90 @@ + + + + + + + Transmission Manager + + + + +
+
+
+ +
+

Transmission

+ Manager +
+
+ +
+
+ + Torrents + +
+
+ + Down + +
+
+ + Up + +
+
+
+
+ +
+
+ +
+ + + + + +
+
+ + live +
+
+ +
+ + + + +
+ + + + + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..524a594 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,394 @@ +:root { + /* Palette */ + --bg-0: #0a0a0f; + --bg-1: #0e0e16; + --bg-2: #12121c; + --bg-3: #181826; + --bg-elev: #1a1a28; + --border: rgba(255, 255, 255, 0.06); + --border-strong: rgba(255, 255, 255, 0.1); + --border-hover: rgba(0, 212, 255, 0.35); + + --text-0: #f5f6fa; + --text-1: #c2c5d0; + --text-2: #8a8d9b; + --text-3: #5c5f6e; + + --accent: #00d4ff; + --accent-soft: rgba(0, 212, 255, 0.12); + --accent-glow: rgba(0, 212, 255, 0.35); + --accent-2: #8b5cf6; + + --ok: #34d399; + --warn: #fbbf24; + --err: #f87171; + --info: #60a5fa; + + --green: #34d399; + --blue: #60a5fa; + --purple: #a78bfa; + --amber: #fbbf24; + --red: #f87171; + --gray: #8a8d9b; + --pink: #f472b6; + + --radius-sm: 8px; + --radius: 12px; + --radius-lg: 16px; + + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-2: 0 8px 28px rgba(0, 0, 0, 0.35); + + --font: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; + + --maxw: 1200px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + min-height: 100vh; +} + +body { + font-family: var(--font); + background: var(--bg-0); + background-image: + radial-gradient(900px 480px at 82% -8%, rgba(139, 92, 246, 0.07), transparent 70%), + radial-gradient(820px 460px at 12% -4%, rgba(0, 212, 255, 0.06), transparent 70%); + color: var(--text-1); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + display: flex; + flex-direction: column; +} + +h1 { margin: 0; font-weight: 700; letter-spacing: -0.02em; } +button { font-family: inherit; } +[hidden], .hidden { display: none !important; } + +/* ---------- Header ---------- */ +.app-header { + position: sticky; + top: 0; + z-index: 50; + background: rgba(10, 10, 15, 0.72); + backdrop-filter: saturate(160%) blur(14px); + -webkit-backdrop-filter: saturate(160%) blur(14px); + border-bottom: 1px solid var(--border); + padding: 0; +} + +.header-inner { + max-width: var(--maxw); + margin: 0 auto; + padding: 14px 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.brand { display: flex; align-items: center; gap: 12px; } + +.logo { + width: 38px; height: 38px; + color: var(--accent); + display: grid; place-items: center; + filter: drop-shadow(0 0 10px var(--accent-glow)); +} +.logo svg { width: 100%; height: 100%; } + +.brand-text { display: flex; flex-direction: column; line-height: 1; } +.brand-text h1 { font-size: 17px; color: var(--text-0); } +.brand-sub { + font-size: 11px; + color: var(--accent); + letter-spacing: 0.18em; + text-transform: uppercase; + margin-top: 4px; + font-weight: 600; +} + +.header-stats { display: flex; gap: 8px; flex-wrap: wrap; } +.stat-pill { + display: inline-flex; align-items: center; gap: 8px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 999px; + padding: 7px 14px; + font-size: 13px; + transition: border-color .2s, transform .2s; +} +.stat-pill:hover { border-color: var(--border-strong); } +.stat-pill .stat-ico { font-size: 12px; opacity: 0.7; } +.stat-pill .stat-label { color: var(--text-3); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; } +.stat-pill .stat-value { color: var(--text-0); font-weight: 600; font-variant-numeric: tabular-nums; min-width: 1ch; } +.stat-pill.download .stat-ico { color: var(--accent); } +.stat-pill.upload .stat-ico { color: var(--ok); } + +/* ---------- Container / Toolbar ---------- */ +.container { + max-width: var(--maxw); + width: 100%; + margin: 0 auto; + padding: 24px 24px 48px; + flex: 1; +} + +.toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 18px; + flex-wrap: wrap; +} + +.filter-input { + flex: 1 1 220px; + min-width: 180px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 14px; + color: var(--text-0); + font-size: 14px; + outline: none; + transition: border-color .2s, box-shadow .2s, background .2s; +} +.filter-input::placeholder { color: var(--text-3); } +.filter-input:focus { + border-color: var(--border-hover); + box-shadow: 0 0 0 3px var(--accent-soft); + background: var(--bg-3); +} + +.sort-group { display: flex; gap: 4px; background: var(--bg-2); padding: 4px; border-radius: var(--radius-sm); border: 1px solid var(--border); } +.sort-btn { + background: transparent; + border: none; + color: var(--text-2); + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + border-radius: 6px; + cursor: pointer; + transition: color .15s, background .15s; +} +.sort-btn:hover { color: var(--text-0); } +.sort-btn.active { color: var(--bg-0); background: var(--accent); } + +.refresh-indicator { + display: inline-flex; align-items: center; gap: 7px; + color: var(--text-3); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 600; + margin-left: auto; +} +.refresh-indicator .dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--ok); + box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.6); + animation: pulse 2s infinite; +} +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.45); } + 70% { box-shadow: 0 0 0 7px rgba(52, 211, 153, 0); } + 100% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0); } +} +.refresh-indicator.syncing .dot { background: var(--accent); animation: pulse-fast .7s infinite; } +@keyframes pulse-fast { + 0% { box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.55); } + 70% { box-shadow: 0 0 0 5px rgba(0, 212, 255, 0); } + 100% { box-shadow: 0 0 0 0 rgba(0, 212, 255, 0); } +} + +/* ---------- Torrent list / cards ---------- */ +.torrent-list { display: flex; flex-direction: column; gap: 10px; } + +.card { + position: relative; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease; + animation: cardIn .35s cubic-bezier(0.16, 1, 0.3, 1) both; +} +@keyframes cardIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +.card:hover { border-color: var(--border-hover); box-shadow: var(--shadow-2); transform: translateY(-1px); } +.card.is-error { border-color: rgba(248, 113, 113, 0.3); } +.card.is-error::before { + content: ""; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; + background: var(--red); border-radius: 3px; +} + +.card-top { + display: flex; align-items: flex-start; gap: 12px; + margin-bottom: 12px; +} +.favicon { + width: 30px; height: 30px; flex: 0 0 30px; + border-radius: 8px; + background: var(--bg-3); + display: grid; place-items: center; + color: var(--accent); + font-weight: 700; font-size: 13px; + border: 1px solid var(--border); + text-transform: uppercase; +} +.card-body { flex: 1; min-width: 0; } +.card-name { + font-size: 14px; font-weight: 600; color: var(--text-0); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + letter-spacing: -0.01em; +} +.card-meta { + display: flex; flex-wrap: wrap; gap: 4px 14px; + margin-top: 4px; + font-size: 12px; color: var(--text-2); + font-variant-numeric: tabular-nums; +} +.meta-item { display: inline-flex; align-items: center; gap: 5px; } +.meta-item .ico { font-size: 11px; opacity: 0.8; } +.meta-item.down .icon-color { color: var(--accent); } +.meta-item.up .icon-color { color: var(--ok); } +.meta-label { color: var(--text-3); } + +.card-status-col { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; } +.pct { + font-size: 13px; font-weight: 700; color: var(--text-0); + font-variant-numeric: tabular-nums; min-width: 44px; text-align: right; +} + +.badge { + display: inline-flex; align-items: center; gap: 6px; + font-size: 11px; font-weight: 600; + padding: 4px 10px; border-radius: 999px; + letter-spacing: 0.02em; + border: 1px solid transparent; + white-space: nowrap; +} +.badge .dotb { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.badge.downloading { color: var(--accent); background: rgba(0, 212, 255, 0.1); border-color: rgba(0, 212, 255, 0.25); } +.badge.seeding { color: var(--green); background: rgba(52, 211, 153, 0.1); border-color: rgba(52, 211, 153, 0.25); } +.badge.paused { color: var(--gray); background: rgba(138, 141, 155, 0.1); border-color: rgba(138, 141, 155, 0.22); } +.badge.finished { color: var(--purple); background: rgba(139, 92, 246, 0.12); border-color: rgba(139, 92, 246, 0.28); } +.badge.idle { color: var(--text-2); background: var(--bg-3); border-color: var(--border); } +.badge.error { color: var(--red); background: rgba(248, 113, 113, 0.12); border-color: rgba(248, 113, 113, 0.3); } +.badge.checking { color: var(--warn); background: rgba(251, 191, 36, 0.12); border-color: rgba(251, 191, 36, 0.3); } +.badge.queued { color: var(--info); background: rgba(96, 165, 250, 0.12); border-color: rgba(96, 165, 250, 0.28); } + +/* Progress bar */ +.progress-wrap { + margin: 4px 0 10px; + height: 6px; + background: var(--bg-3); + border-radius: 999px; + overflow: hidden; + position: relative; +} +.progress-bar { + height: 100%; + border-radius: 999px; + transition: width .6s cubic-bezier(0.22, 1, 0.36, 1); + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + box-shadow: 0 0 8px var(--accent-glow); + position: relative; +} +.progress-bar.done { background: linear-gradient(90deg, var(--green), var(--accent-2)); } +.progress-bar.error { background: var(--red); box-shadow: none; } +.progress-bar::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.18), transparent); + transform: translateX(-100%); + animation: shimmer 2.2s infinite; +} +@keyframes shimmer { 100% { transform: translateX(100%); } } +.progress-bar.done::after, .progress-bar.error::after, .progress-bar.paused::after { animation: none; } + +.card-bottom { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px 16px; + font-size: 12px; +} +.stat-cell { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.stat-cell .k { + color: var(--text-3); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 700; +} +.stat-cell .v { + color: var(--text-1); + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.stat-cell .v.mono { font-family: var(--mono); font-size: 11.5px; } +.stat-cell .v.success { color: var(--green); } +.stat-cell .v.accent { color: var(--accent); } +.stat-cell .v.warn { color: var(--warn); } +.stat-cell .v.dim { color: var(--text-3); } + +.error-string { + margin-top: 10px; + padding: 8px 12px; + background: rgba(248, 113, 113, 0.08); + border: 1px solid rgba(248, 113, 113, 0.25); + border-radius: 8px; + color: #fca5a5; + font-size: 12px; +} + +/* ---------- Empty / error states ---------- */ +.empty-state, .error-state { + text-align: center; + padding: 80px 20px; + color: var(--text-2); +} +.empty-box { + width: 72px; margin: 0 auto 18px; color: var(--accent); opacity: 0.5; +} +.empty-box svg { width: 100%; } +.empty-title { font-size: 16px; font-weight: 600; color: var(--text-1); margin: 0 0 4px; } +.empty-sub { font-size: 13px; color: var(--text-3); margin: 0; } + +.error-state .error-title { font-size: 16px; font-weight: 600; color: var(--red); margin: 0 0 6px; } +.error-state .error-sub { font-size: 13px; color: var(--text-2); margin: 0 0 8px; } +.error-state .error-hint { font-size: 12px; color: var(--text-3); margin: 0; } + +/* ---------- Footer ---------- */ +.app-footer { + text-align: center; + padding: 18px 24px 28px; + font-size: 11.5px; + color: var(--text-3); + letter-spacing: 0.04em; +} +.footer-sep { margin: 0 8px; opacity: 0.5; } + +/* ---------- Responsive ---------- */ +@media (max-width: 720px) { + .header-inner { flex-wrap: wrap; padding: 12px 16px; gap: 12px; } + .header-stats { width: 100%; } + .stat-pill { padding: 5px 10px; font-size: 12px; } + .container { padding: 16px 14px 36px; } + .toolbar { gap: 8px; } + .refresh-indicator { margin-left: 0; width: 100%; justify-content: flex-end; } + .sort-group { width: 100%; overflow-x: auto; } + .card { padding: 14px; } + .card-bottom { grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); } + .pct { min-width: 40px; } +} +@media (max-width: 440px) { + .brand-text h1 { font-size: 15px; } + .stat-pill .stat-label { display: none; } + .card-status-col { flex-direction: column-reverse; align-items: flex-end; gap: 6px; } +}