new ui changes
All checks were successful
Build and Deploy (internal) / Build Transmission Manager Image (push) Successful in 1m27s
Build and Deploy (internal) / Deploy Transmission Manager (internal) (push) Successful in 9s

This commit is contained in:
2026-07-08 19:39:58 -03:00
parent eaade84817
commit 44d2f1a5e9
4 changed files with 139 additions and 24 deletions

View File

@@ -65,7 +65,7 @@ The UI definition of "peers at 100%" is an absolute count of `peers[]` entries w
## Frontend Architecture ## 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: Important patterns:
@@ -83,6 +83,7 @@ Current sort keys:
- `progress` - `progress`
- `ratio` - `ratio`
- `size` - `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. - `peer100`: orders by peers at 100% descending, then connected peers descending, progress descending, and name ascending.
## UI And Design Rules ## UI And Design Rules

View File

@@ -2,10 +2,11 @@
/* ============================================================ /* ============================================================
Transmission Manager - frontend 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 = { const TRANSMISSION_STATUSES = {
0: "stopped", 0: "stopped",
1: "check-wait", 1: "check-wait",
@@ -21,6 +22,7 @@ const state = {
stats: {}, stats: {},
filter: "", filter: "",
sortKey: "status", sortKey: "status",
refreshMs: DEFAULT_REFRESH_MS,
lastError: null, lastError: null,
}; };
@@ -28,6 +30,7 @@ const torrentCards = new Map();
const actionStates = new Map(); const actionStates = new Map();
let syncInFlight = false; let syncInFlight = false;
let syncQueued = false; let syncQueued = false;
let refreshTimer = null;
const $ = (sel) => document.querySelector(sel); const $ = (sel) => document.querySelector(sel);
@@ -91,8 +94,34 @@ function setSyncing(on) {
const el = $("#refresh-indicator"); const el = $("#refresh-indicator");
if (!el) return; if (!el) return;
el.classList.toggle("syncing", on); el.classList.toggle("syncing", on);
const t = el.querySelector(".refresh-text"); updateRefreshMetadata(on);
if (t) t.textContent = on ? "syncing" : "live"; }
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) { async function runTorrentAction(id, action) {
@@ -217,6 +246,10 @@ function compareName(a, b) {
return (a.name || "").localeCompare(b.name || ""); return (a.name || "").localeCompare(b.name || "");
} }
function activitySpeed(t) {
return (t.rateDownload || 0) + (t.rateUpload || 0);
}
function sortTorrents(list) { function sortTorrents(list) {
const key = state.sortKey; const key = state.sortKey;
const sorted = list.slice(); const sorted = list.slice();
@@ -230,6 +263,11 @@ function sortTorrents(list) {
return (b.uploadRatio || 0) - (a.uploadRatio || 0) || compareName(a, b); return (b.uploadRatio || 0) - (a.uploadRatio || 0) || compareName(a, b);
case "size": case "size":
return (b.totalSize || 0) - (a.totalSize || 0) || compareName(a, b); 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": { case "peer100": {
const pa = peerStatus(a); const pa = peerStatus(a);
const pb = peerStatus(b); 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) => { $("#torrents").addEventListener("click", (e) => {
const button = e.target.closest("[data-action-button]"); const button = e.target.closest("[data-action-button]");
if (!button || button.disabled) return; if (!button || button.disabled) return;
@@ -535,8 +586,8 @@ function wireEvents() {
/* ---------- Init ---------- */ /* ---------- Init ---------- */
function boot() { function boot() {
wireEvents(); wireEvents();
setRefreshInterval(state.refreshMs);
sync(); sync();
setInterval(sync, REFRESH_MS);
} }
document.addEventListener("DOMContentLoaded", boot); document.addEventListener("DOMContentLoaded", boot);

View File

@@ -53,11 +53,22 @@
<button class="sort-btn active" data-sort="status">Status</button> <button class="sort-btn active" data-sort="status">Status</button>
<button class="sort-btn" data-sort="ratio">Ratio</button> <button class="sort-btn" data-sort="ratio">Ratio</button>
<button class="sort-btn" data-sort="size">Size</button> <button class="sort-btn" data-sort="size">Size</button>
<button class="sort-btn" data-sort="speed">Speed</button>
<button class="sort-btn" data-sort="peer100">100% Peers</button> <button class="sort-btn" data-sort="peer100">100% Peers</button>
</div> </div>
<div class="refresh-indicator" id="refresh-indicator" title="Auto-refreshes every 3s"> <div class="toolbar-actions">
<span class="dot"></span> <label class="interval-control" for="refresh-interval">
<span class="refresh-text">live</span> <span class="sr-only">Refresh interval</span>
<select id="refresh-interval" class="refresh-select" aria-label="Refresh interval">
<option value="1000">1s</option>
<option value="3000" selected>3s</option>
<option value="5000">5s</option>
<option value="10000">10s</option>
</select>
</label>
<div class="refresh-indicator" id="refresh-indicator" title="Auto-refreshes every 3s" role="status" aria-label="Auto-refresh active">
<span class="dot" aria-hidden="true"></span>
</div>
</div> </div>
</div> </div>
@@ -76,7 +87,7 @@
<div id="error-state" class="error-state hidden"> <div id="error-state" class="error-state hidden">
<p class="error-title">Connection error</p> <p class="error-title">Connection error</p>
<p class="error-sub" id="error-msg">Could not reach the Transmission daemon.</p> <p class="error-sub" id="error-msg">Could not reach the Transmission daemon.</p>
<p class="error-hint">Retrying in 3s…</p> <p class="error-hint" id="error-hint">Retrying in 3s…</p>
</div> </div>
</main> </main>

View File

@@ -69,6 +69,17 @@ body {
h1 { margin: 0; font-weight: 700; letter-spacing: -0.02em; } h1 { margin: 0; font-weight: 700; letter-spacing: -0.02em; }
button { font-family: inherit; } button { font-family: inherit; }
[hidden], .hidden { display: none !important; } [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 ---------- */ /* ---------- Header ---------- */
.app-header { .app-header {
@@ -181,31 +192,59 @@ button { font-family: inherit; }
.sort-btn:hover { color: var(--text-0); } .sort-btn:hover { color: var(--text-0); }
.sort-btn.active { color: var(--bg-0); background: var(--accent); } .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 { .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); color: var(--text-3);
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
font-weight: 600; font-weight: 600;
margin-left: auto;
} }
.refresh-indicator .dot { .refresh-indicator .dot {
width: 7px; height: 7px; border-radius: 50%; width: 8px; height: 8px; border-radius: 50%;
background: var(--ok); background: var(--ok);
box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.6); 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 { @keyframes heartbeat {
0% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.45); } 0%, 28%, 70%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.28); opacity: 0.82; }
70% { box-shadow: 0 0 0 7px rgba(52, 211, 153, 0); } 14% { transform: scale(1.35); box-shadow: 0 0 0 6px rgba(52, 211, 153, 0); opacity: 1; }
100% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0); } 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; } .refresh-indicator.syncing .dot { background: var(--accent); animation: heartbeat-sync .7s ease-in-out infinite; }
@keyframes pulse-fast { @keyframes heartbeat-sync {
0% { box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.55); } 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.4); opacity: 0.82; }
70% { box-shadow: 0 0 0 5px rgba(0, 212, 255, 0); } 45% { transform: scale(1.35); box-shadow: 0 0 0 5px rgba(0, 212, 255, 0); opacity: 1; }
100% { box-shadow: 0 0 0 0 rgba(0, 212, 255, 0); }
} }
/* ---------- Torrent list / cards ---------- */ /* ---------- Torrent list / cards ---------- */
@@ -272,6 +311,8 @@ button { font-family: inherit; }
gap: 10px; gap: 10px;
} }
.action-btn { .action-btn {
position: relative;
margin-left: 6px;
min-width: 78px; min-width: 78px;
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
border-radius: 999px; border-radius: 999px;
@@ -285,6 +326,17 @@ button { font-family: inherit; }
text-transform: uppercase; text-transform: uppercase;
transition: background .15s, border-color .15s, color .15s, transform .15s, opacity .15s; 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) { .action-btn:hover:not(:disabled) {
border-color: var(--border-hover); border-color: var(--border-hover);
color: var(--text-0); color: var(--text-0);
@@ -422,7 +474,7 @@ button { font-family: inherit; }
.stat-pill { padding: 5px 10px; font-size: 12px; } .stat-pill { padding: 5px 10px; font-size: 12px; }
.container { padding: 16px 14px 36px; } .container { padding: 16px 14px 36px; }
.toolbar { gap: 8px; } .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; } .sort-group { width: 100%; overflow-x: auto; }
.card { padding: 14px; } .card { padding: 14px; }
.card-top { align-items: flex-start; } .card-top { align-items: flex-start; }