diff --git a/project-context.md b/project-context.md index 6f69a61..480ee6b 100644 --- a/project-context.md +++ b/project-context.md @@ -65,7 +65,7 @@ The UI definition of "peers at 100%" is an absolute count of `peers[]` entries w ## Frontend Architecture -The frontend is a single page with module-level state in `web/app.js`. It polls every 3 seconds and fetches torrents and stats in parallel. +The frontend is a single page with module-level state in `web/app.js`. It polls on a user-selectable interval, defaults to 3 seconds, and fetches torrents and stats in parallel. Important patterns: @@ -83,6 +83,7 @@ Current sort keys: - `progress` - `ratio` - `size` +- `speed`: orders by combined download and upload speed descending, then download speed, upload speed, and name. - `peer100`: orders by peers at 100% descending, then connected peers descending, progress descending, and name ascending. ## UI And Design Rules diff --git a/web/app.js b/web/app.js index b4dfdf0..1aebaff 100644 --- a/web/app.js +++ b/web/app.js @@ -2,10 +2,11 @@ /* ============================================================ Transmission Manager - frontend - Vanilla SPA. Auto-refreshes every 3s with in-place updates. + Vanilla SPA. Auto-refreshes on a selectable interval with in-place updates. ============================================================ */ -const REFRESH_MS = 3000; +const DEFAULT_REFRESH_MS = 3000; +const REFRESH_INTERVALS = new Set([1000, 3000, 5000, 10000]); const TRANSMISSION_STATUSES = { 0: "stopped", 1: "check-wait", @@ -21,6 +22,7 @@ const state = { stats: {}, filter: "", sortKey: "status", + refreshMs: DEFAULT_REFRESH_MS, lastError: null, }; @@ -28,6 +30,7 @@ const torrentCards = new Map(); const actionStates = new Map(); let syncInFlight = false; let syncQueued = false; +let refreshTimer = null; const $ = (sel) => document.querySelector(sel); @@ -91,8 +94,34 @@ 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"; + updateRefreshMetadata(on); +} + +function refreshLabel() { + return `${state.refreshMs / 1000}s`; +} + +function updateRefreshMetadata(syncing = false) { + const label = refreshLabel(); + const indicator = $("#refresh-indicator"); + if (indicator) { + indicator.title = `Auto-refreshes every ${label}`; + indicator.setAttribute( + "aria-label", + syncing ? `Syncing; auto-refresh every ${label}` : `Auto-refresh active every ${label}`, + ); + } + + const hint = $("#error-hint"); + if (hint) hint.textContent = `Retrying in ${label}...`; +} + +function setRefreshInterval(ms) { + state.refreshMs = REFRESH_INTERVALS.has(ms) ? ms : DEFAULT_REFRESH_MS; + updateRefreshMetadata(syncInFlight); + + if (refreshTimer) clearInterval(refreshTimer); + refreshTimer = setInterval(sync, state.refreshMs); } async function runTorrentAction(id, action) { @@ -217,6 +246,10 @@ function compareName(a, b) { return (a.name || "").localeCompare(b.name || ""); } +function activitySpeed(t) { + return (t.rateDownload || 0) + (t.rateUpload || 0); +} + function sortTorrents(list) { const key = state.sortKey; const sorted = list.slice(); @@ -230,6 +263,11 @@ function sortTorrents(list) { return (b.uploadRatio || 0) - (a.uploadRatio || 0) || compareName(a, b); case "size": return (b.totalSize || 0) - (a.totalSize || 0) || compareName(a, b); + case "speed": + return (activitySpeed(b) - activitySpeed(a)) + || ((b.rateDownload || 0) - (a.rateDownload || 0)) + || ((b.rateUpload || 0) - (a.rateUpload || 0)) + || compareName(a, b); case "peer100": { const pa = peerStatus(a); const pb = peerStatus(b); @@ -513,6 +551,19 @@ function wireEvents() { }); }); + const refreshSelect = $("#refresh-interval"); + if (refreshSelect) { + refreshSelect.value = String(state.refreshMs); + refreshSelect.addEventListener("change", (e) => { + const next = Number(e.target.value); + if (!REFRESH_INTERVALS.has(next)) { + e.target.value = String(state.refreshMs); + return; + } + setRefreshInterval(next); + }); + } + $("#torrents").addEventListener("click", (e) => { const button = e.target.closest("[data-action-button]"); if (!button || button.disabled) return; @@ -535,8 +586,8 @@ function wireEvents() { /* ---------- Init ---------- */ function boot() { wireEvents(); + setRefreshInterval(state.refreshMs); sync(); - setInterval(sync, REFRESH_MS); } document.addEventListener("DOMContentLoaded", boot); diff --git a/web/index.html b/web/index.html index 636adfe..fef178c 100644 --- a/web/index.html +++ b/web/index.html @@ -53,11 +53,22 @@ + -
- - live +
+ +
+ +
@@ -76,7 +87,7 @@ diff --git a/web/styles.css b/web/styles.css index d9a004b..f503f31 100644 --- a/web/styles.css +++ b/web/styles.css @@ -69,6 +69,17 @@ body { h1 { margin: 0; font-weight: 700; letter-spacing: -0.02em; } button { font-family: inherit; } [hidden], .hidden { display: none !important; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} /* ---------- Header ---------- */ .app-header { @@ -181,31 +192,59 @@ button { font-family: inherit; } .sort-btn:hover { color: var(--text-0); } .sort-btn.active { color: var(--bg-0); background: var(--accent); } +.toolbar-actions { + display: inline-flex; + align-items: center; + gap: 10px; + margin-left: auto; +} +.interval-control { display: inline-flex; align-items: center; } +.refresh-select { + color-scheme: dark; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-1); + cursor: pointer; + font-size: 12px; + font-weight: 700; + outline: none; + padding: 7px 9px; + transition: border-color .2s, box-shadow .2s, background .2s, color .2s; +} +.refresh-select:hover { + border-color: var(--border-strong); + color: var(--text-0); +} +.refresh-select:focus { + border-color: var(--border-hover); + box-shadow: 0 0 0 3px var(--accent-soft); + background: var(--bg-3); +} .refresh-indicator { - display: inline-flex; align-items: center; gap: 7px; + display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; 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%; + width: 8px; height: 8px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.6); - animation: pulse 2s infinite; + animation: heartbeat 1.6s ease-in-out 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); } +@keyframes heartbeat { + 0%, 28%, 70%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.28); opacity: 0.82; } + 14% { transform: scale(1.35); box-shadow: 0 0 0 6px rgba(52, 211, 153, 0); opacity: 1; } + 42% { transform: scale(1.18); box-shadow: 0 0 0 4px rgba(52, 211, 153, 0); opacity: 0.95; } } -.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); } +.refresh-indicator.syncing .dot { background: var(--accent); animation: heartbeat-sync .7s ease-in-out infinite; } +@keyframes heartbeat-sync { + 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.4); opacity: 0.82; } + 45% { transform: scale(1.35); box-shadow: 0 0 0 5px rgba(0, 212, 255, 0); opacity: 1; } } /* ---------- Torrent list / cards ---------- */ @@ -272,6 +311,8 @@ button { font-family: inherit; } gap: 10px; } .action-btn { + position: relative; + margin-left: 6px; min-width: 78px; border: 1px solid var(--border-strong); border-radius: 999px; @@ -285,6 +326,17 @@ button { font-family: inherit; } text-transform: uppercase; transition: background .15s, border-color .15s, color .15s, transform .15s, opacity .15s; } +.action-btn::before { + content: ""; + position: absolute; + left: -11px; + top: 50%; + width: 1px; + height: 24px; + background: var(--border-strong); + transform: translateY(-50%); + pointer-events: none; +} .action-btn:hover:not(:disabled) { border-color: var(--border-hover); color: var(--text-0); @@ -422,7 +474,7 @@ button { font-family: inherit; } .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; } + .toolbar-actions { margin-left: 0; width: 100%; justify-content: flex-end; } .sort-group { width: 100%; overflow-x: auto; } .card { padding: 14px; } .card-top { align-items: flex-start; }