adding velocity control
This commit is contained in:
100
main.go
100
main.go
@@ -165,7 +165,7 @@ var torrentFields = []string{
|
|||||||
|
|
||||||
type rpcRequest struct {
|
type rpcRequest struct {
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Arguments rpcArgs `json:"arguments"`
|
Arguments interface{} `json:"arguments"`
|
||||||
Tag interface{} `json:"tag,omitempty"`
|
Tag interface{} `json:"tag,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +179,16 @@ type rpcResponse struct {
|
|||||||
Args json.RawMessage `json:"arguments"`
|
Args json.RawMessage `json:"arguments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sessionFields = []string{
|
||||||
|
"alt-speed-enabled", "alt-speed-down", "alt-speed-up",
|
||||||
|
"speed-limit-down-enabled", "speed-limit-down",
|
||||||
|
"speed-limit-up-enabled", "speed-limit-up", "units",
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionSetArgs struct {
|
||||||
|
AltSpeedEnabled bool `json:"alt-speed-enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) Torrents(ctx context.Context) (json.RawMessage, error) {
|
func (c *Client) Torrents(ctx context.Context) (json.RawMessage, error) {
|
||||||
reqBody, _ := json.Marshal(rpcRequest{
|
reqBody, _ := json.Marshal(rpcRequest{
|
||||||
Method: "torrent-get",
|
Method: "torrent-get",
|
||||||
@@ -216,6 +226,44 @@ func (c *Client) Stats(ctx context.Context) (json.RawMessage, error) {
|
|||||||
return resp.Args, nil
|
return resp.Args, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) Session(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 (c *Client) SetAlternativeSpeed(ctx context.Context, enabled bool) error {
|
||||||
|
reqBody, _ := json.Marshal(rpcRequest{
|
||||||
|
Method: "session-set",
|
||||||
|
Arguments: sessionSetArgs{AltSpeedEnabled: enabled},
|
||||||
|
})
|
||||||
|
data, err := c.rpc(ctx, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var resp rpcResponse
|
||||||
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Result != "success" {
|
||||||
|
return fmt.Errorf("rpc result: %s", resp.Result)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) TorrentAction(ctx context.Context, method string, ids []int) error {
|
func (c *Client) TorrentAction(ctx context.Context, method string, ids []int) error {
|
||||||
reqBody, _ := json.Marshal(rpcRequest{
|
reqBody, _ := json.Marshal(rpcRequest{
|
||||||
Method: method,
|
Method: method,
|
||||||
@@ -450,6 +498,38 @@ func handleTorrentAction(client *Client, setupErr error) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleAlternativeSpeed(client *Client, setupErr error) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", http.MethodPost)
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTransmissionClient(w, client, setupErr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1024))
|
||||||
|
if err := decoder.Decode(&payload); err != nil || payload.Enabled == nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "request body must include a boolean enabled field")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||||
|
writeError(w, http.StatusBadRequest, "request body must contain one JSON object")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := client.SetAlternativeSpeed(r.Context(), *payload.Enabled); err != nil {
|
||||||
|
log.Printf("alternative speed: %v", err)
|
||||||
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, json.RawMessage(`{"ok":true}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleStaticFiles(fileServer http.Handler, client *Client, setupErr error) http.HandlerFunc {
|
func handleStaticFiles(fileServer http.Handler, client *Client, setupErr error) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodGet && (r.URL.Path == "/" || r.URL.Path == "/index.html") {
|
if r.Method == http.MethodGet && (r.URL.Path == "/" || r.URL.Path == "/index.html") {
|
||||||
@@ -509,6 +589,24 @@ func main() {
|
|||||||
}
|
}
|
||||||
writeJSON(w, args)
|
writeJSON(w, args)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/api/session", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.Header().Set("Allow", http.MethodGet)
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTransmissionClient(w, client, setupErr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args, err := client.Session(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("session: %v", err)
|
||||||
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, args)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/session/alternative-speed", handleAlternativeSpeed(client, setupErr))
|
||||||
|
|
||||||
// Static files from embedded web/ directory
|
// Static files from embedded web/ directory
|
||||||
webRoot, err := fs.Sub(webFS, "web")
|
webRoot, err := fs.Sub(webFS, "web")
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ The app is intentionally small: one Go binary serves both the API and embedded f
|
|||||||
## Repository Organization
|
## Repository Organization
|
||||||
|
|
||||||
- `main.go`: backend entrypoint. Owns the HTTP server, embedded static file serving, environment-driven Transmission RPC client configuration, session-ID handshake, connection warning page, JSON helpers, and API routes.
|
- `main.go`: backend entrypoint. Owns the HTTP server, embedded static file serving, environment-driven Transmission RPC client configuration, session-ID handshake, connection warning page, JSON helpers, and API routes.
|
||||||
- `web/index.html`: SPA shell. Defines the header, stats pills, filter input, sort buttons, list container, empty state, error state, footer, and script/style links.
|
- `web/index.html`: SPA shell. Defines the header, stats pills, alternative-speed controls, filter input, sort buttons, list container, empty state, error state, footer, and script/style links.
|
||||||
- `web/icon.svg`: editable vector source for the app icon.
|
- `web/icon.svg`: editable vector source for the app icon.
|
||||||
- `web/icon.png`: reusable raster app icon used by the SPA header and browser tab.
|
- `web/icon.png`: reusable raster app icon used by the SPA header and browser tab.
|
||||||
- `web/app.js`: frontend application logic. Owns state, polling, fetch helpers, formatting, torrent classification, sorting, keyed DOM reconciliation, pause/resume actions, and event wiring.
|
- `web/app.js`: frontend application logic. Owns state, polling, fetch helpers, formatting, session-limit presentation and alternative-speed toggling, torrent classification, sorting, keyed DOM reconciliation, pause/resume actions, and event wiring.
|
||||||
- `web/styles.css`: all styling. Owns dark theme variables, layout, cards, badges, progress bars, action buttons, empty/error states, and responsive rules.
|
- `web/styles.css`: all styling. Owns dark theme variables, layout, cards, badges, progress bars, action buttons, alternative-speed controls, empty/error states, and responsive rules.
|
||||||
- `go.mod`: Go module declaration.
|
- `go.mod`: Go module declaration.
|
||||||
- `Dockerfile`: multi-stage production build.
|
- `Dockerfile`: multi-stage production build.
|
||||||
- `README.md`: public project documentation, configuration guide, and deployment instructions.
|
- `README.md`: public project documentation, configuration guide, and deployment instructions.
|
||||||
@@ -38,13 +38,15 @@ The backend uses `net/http` with a single `http.ServeMux`. It exposes:
|
|||||||
|
|
||||||
- `GET /api/torrents`: sends Transmission `torrent-get` with the required torrent fields and returns the RPC `arguments` JSON directly.
|
- `GET /api/torrents`: sends Transmission `torrent-get` with the required torrent fields and returns the RPC `arguments` JSON directly.
|
||||||
- `GET /api/stats`: sends Transmission `session-stats` and returns the RPC `arguments` JSON directly.
|
- `GET /api/stats`: sends Transmission `session-stats` and returns the RPC `arguments` JSON directly.
|
||||||
|
- `GET /api/session`: sends Transmission `session-get` for alternative-speed state, normal and alternative speed limits, and speed-unit metadata; returns the RPC `arguments` JSON directly.
|
||||||
|
- `POST /api/session/alternative-speed`: validates an `{"enabled": boolean}` body and sends Transmission `session-set` to toggle alternative speeds.
|
||||||
- `POST /api/torrents/{id}/pause`: validates a positive numeric torrent id and sends `torrent-stop`.
|
- `POST /api/torrents/{id}/pause`: validates a positive numeric torrent id and sends `torrent-stop`.
|
||||||
- `POST /api/torrents/{id}/resume`: validates a positive numeric torrent id and sends queue-respecting `torrent-start`.
|
- `POST /api/torrents/{id}/resume`: validates a positive numeric torrent id and sends queue-respecting `torrent-start`.
|
||||||
- `/`: checks the Transmission connection and serves a warning page with the connection error when setup fails; otherwise serves embedded files from `web/`.
|
- `/`: checks the Transmission connection and serves a warning page with the connection error when setup fails; otherwise serves embedded files from `web/`.
|
||||||
|
|
||||||
Transmission RPC calls must reuse the existing `Client.rpc` path so session-ID handling is consistent. The session ID is cached in `Client.sessionID` and guarded by `sync.Mutex`.
|
Transmission RPC calls must reuse the existing `Client.rpc` path so session-ID handling is consistent. The session ID is cached in `Client.sessionID` and guarded by `sync.Mutex`.
|
||||||
|
|
||||||
API errors return JSON with an `error` string. Invalid torrent action methods should return `405`; invalid ids should return `400`; unknown action paths should return `404`; Transmission failures should return `502`.
|
API errors return JSON with an `error` string. Invalid torrent action methods should return `405`; invalid ids or malformed alternative-speed bodies should return `400`; unknown action paths should return `404`; Transmission failures should return `502`.
|
||||||
|
|
||||||
## Transmission RPC Model
|
## Transmission RPC Model
|
||||||
|
|
||||||
@@ -67,9 +69,11 @@ The UI definition of "peers at 100%" is an absolute count of `peers[]` entries w
|
|||||||
|
|
||||||
The UI availability value comes from Transmission's `availability` per-piece array. Render and sort it as a complete-copy estimate, treating `-1` entries as pieces available locally.
|
The UI availability value comes from Transmission's `availability` per-piece array. Render and sort it as a complete-copy estimate, treating `-1` entries as pieces available locally.
|
||||||
|
|
||||||
|
Alternative-speed controls use the effective profile: alternative caps when `alt-speed-enabled` is true, otherwise enabled normal caps. Positive caps are displayed in the header; zero or negative values are treated as unrestricted.
|
||||||
|
|
||||||
## Frontend Architecture
|
## Frontend Architecture
|
||||||
|
|
||||||
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.
|
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, stats, and session settings in parallel.
|
||||||
|
|
||||||
Important patterns:
|
Important patterns:
|
||||||
|
|
||||||
|
|||||||
99
web/app.js
99
web/app.js
@@ -20,6 +20,7 @@ const TRANSMISSION_STATUSES = {
|
|||||||
const state = {
|
const state = {
|
||||||
torrents: [],
|
torrents: [],
|
||||||
stats: {},
|
stats: {},
|
||||||
|
session: null,
|
||||||
filter: "",
|
filter: "",
|
||||||
sortKey: "status",
|
sortKey: "status",
|
||||||
refreshMs: DEFAULT_REFRESH_MS,
|
refreshMs: DEFAULT_REFRESH_MS,
|
||||||
@@ -31,6 +32,8 @@ const actionStates = new Map();
|
|||||||
let syncInFlight = false;
|
let syncInFlight = false;
|
||||||
let syncQueued = false;
|
let syncQueued = false;
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
|
let speedControlPending = false;
|
||||||
|
let speedControlError = null;
|
||||||
|
|
||||||
const $ = (sel) => document.querySelector(sel);
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
|
||||||
@@ -63,6 +66,11 @@ async function loadStats() {
|
|||||||
state.stats = data || {};
|
state.stats = data || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSession() {
|
||||||
|
const data = await fetchJSON("/api/session");
|
||||||
|
state.session = data && typeof data === "object" ? data : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function sync() {
|
async function sync() {
|
||||||
if (syncInFlight) {
|
if (syncInFlight) {
|
||||||
syncQueued = true;
|
syncQueued = true;
|
||||||
@@ -72,8 +80,9 @@ async function sync() {
|
|||||||
syncInFlight = true;
|
syncInFlight = true;
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadTorrents(), loadStats()]);
|
await Promise.all([loadTorrents(), loadStats(), loadSession()]);
|
||||||
state.lastError = null;
|
state.lastError = null;
|
||||||
|
speedControlError = null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.lastError = err.message || String(err);
|
state.lastError = err.message || String(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -143,6 +152,28 @@ async function runTorrentAction(id, action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setAlternativeSpeed(enabled) {
|
||||||
|
if (speedControlPending || !state.session) return;
|
||||||
|
speedControlPending = true;
|
||||||
|
speedControlError = null;
|
||||||
|
renderHeader();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchJSON("/api/session/alternative-speed", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
await sync();
|
||||||
|
speedControlPending = false;
|
||||||
|
renderHeader();
|
||||||
|
} catch (err) {
|
||||||
|
speedControlError = err.message || String(err);
|
||||||
|
speedControlPending = false;
|
||||||
|
renderHeader();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Formatting helpers ---------- */
|
/* ---------- Formatting helpers ---------- */
|
||||||
function humanSize(bytes) {
|
function humanSize(bytes) {
|
||||||
if (bytes == null || isNaN(bytes) || bytes < 0) return "—";
|
if (bytes == null || isNaN(bytes) || bytes < 0) return "—";
|
||||||
@@ -337,6 +368,64 @@ function renderHeader() {
|
|||||||
$("#stat-torrents").textContent = String(torrents.length);
|
$("#stat-torrents").textContent = String(torrents.length);
|
||||||
$("#stat-dl").textContent = humanSpeed(dl);
|
$("#stat-dl").textContent = humanSpeed(dl);
|
||||||
$("#stat-ul").textContent = humanSpeed(ul);
|
$("#stat-ul").textContent = humanSpeed(ul);
|
||||||
|
renderSpeedControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionValue(session, kebab, snake) {
|
||||||
|
if (!session) return null;
|
||||||
|
if (session[kebab] != null) return session[kebab];
|
||||||
|
return snake ? session[snake] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionSpeedBytes(session) {
|
||||||
|
const units = session && session.units;
|
||||||
|
const value = units && (units["speed-bytes"] ?? units.speed_bytes ?? units.speedBytes);
|
||||||
|
return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeSpeedCap(session, direction) {
|
||||||
|
const enabled = Boolean(sessionValue(session, "alt-speed-enabled", "alt_speed_enabled"));
|
||||||
|
const alt = direction === "down"
|
||||||
|
? sessionValue(session, "alt-speed-down", "alt_speed_down")
|
||||||
|
: sessionValue(session, "alt-speed-up", "alt_speed_up");
|
||||||
|
const normalEnabled = Boolean(sessionValue(
|
||||||
|
session,
|
||||||
|
direction === "down" ? "speed-limit-down-enabled" : "speed-limit-up-enabled",
|
||||||
|
direction === "down" ? "speed_limit_down_enabled" : "speed_limit_up_enabled",
|
||||||
|
));
|
||||||
|
const normal = direction === "down"
|
||||||
|
? sessionValue(session, "speed-limit-down", "speed_limit_down")
|
||||||
|
: sessionValue(session, "speed-limit-up", "speed_limit_up");
|
||||||
|
const value = enabled ? alt : (normalEnabled ? normal : null);
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) && number > 0 ? number * sessionSpeedBytes(session) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSpeedControls() {
|
||||||
|
const toggle = $("#alt-speed-toggle");
|
||||||
|
const toggleState = $("#alt-speed-state");
|
||||||
|
const status = $("#speed-control-status");
|
||||||
|
if (!toggle || !toggleState || !status) return;
|
||||||
|
|
||||||
|
const session = state.session;
|
||||||
|
const enabled = Boolean(sessionValue(session, "alt-speed-enabled", "alt_speed_enabled"));
|
||||||
|
toggle.disabled = !session || speedControlPending;
|
||||||
|
toggle.setAttribute("aria-pressed", String(enabled));
|
||||||
|
toggle.classList.toggle("is-saving", speedControlPending);
|
||||||
|
setText(toggleState, !session ? (state.lastError ? "Unavailable" : "Loading...") : (speedControlPending ? "Saving..." : (enabled ? "On" : "Off")));
|
||||||
|
|
||||||
|
const caps = [
|
||||||
|
{ id: "speed-cap-down", value: activeSpeedCap(session, "down") },
|
||||||
|
{ id: "speed-cap-up", value: activeSpeedCap(session, "up") },
|
||||||
|
];
|
||||||
|
for (const cap of caps) {
|
||||||
|
const el = $("#" + cap.id);
|
||||||
|
if (!el) continue;
|
||||||
|
el.classList.toggle("hidden", cap.value == null);
|
||||||
|
const valueEl = el.querySelector("strong");
|
||||||
|
if (valueEl && cap.value != null) setText(valueEl, humanSpeed(cap.value));
|
||||||
|
}
|
||||||
|
setText(status, speedControlError || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function initial(name) {
|
function initial(name) {
|
||||||
@@ -611,6 +700,14 @@ function wireEvents() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const speedToggle = $("#alt-speed-toggle");
|
||||||
|
if (speedToggle) {
|
||||||
|
speedToggle.addEventListener("click", () => {
|
||||||
|
const enabled = !Boolean(sessionValue(state.session, "alt-speed-enabled", "alt_speed_enabled"));
|
||||||
|
setAlternativeSpeed(enabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$("#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;
|
||||||
|
|||||||
@@ -41,6 +41,18 @@
|
|||||||
<span class="stat-value" id="stat-ul">—</span>
|
<span class="stat-value" id="stat-ul">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="speed-controls" id="speed-controls" aria-live="polite">
|
||||||
|
<button type="button" class="speed-toggle" id="alt-speed-toggle" aria-pressed="false" disabled>
|
||||||
|
<span class="speed-toggle-label">Alternative speed</span>
|
||||||
|
<span class="speed-toggle-state" id="alt-speed-state">Loading...</span>
|
||||||
|
</button>
|
||||||
|
<div class="speed-caps" id="speed-caps" aria-label="Active speed caps">
|
||||||
|
<span class="speed-cap hidden" id="speed-cap-down" title="Active download cap">↓ <span class="speed-cap-label">Down</span> <strong></strong></span>
|
||||||
|
<span class="speed-cap hidden" id="speed-cap-up" title="Active upload cap">↑ <span class="speed-cap-label">Up</span> <strong></strong></span>
|
||||||
|
</div>
|
||||||
|
<span class="speed-control-status" id="speed-control-status" role="status"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,61 @@ button { font-family: inherit; }
|
|||||||
.stat-pill.download .stat-ico { color: var(--accent); }
|
.stat-pill.download .stat-ico { color: var(--accent); }
|
||||||
.stat-pill.upload .stat-ico { color: var(--ok); }
|
.stat-pill.upload .stat-ico { color: var(--ok); }
|
||||||
|
|
||||||
|
.speed-controls {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.speed-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 7px 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background .15s, border-color .15s, color .15s, opacity .15s;
|
||||||
|
}
|
||||||
|
.speed-toggle:hover:not(:disabled) { border-color: var(--border-hover); color: var(--text-0); }
|
||||||
|
.speed-toggle[aria-pressed="true"] {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: rgba(0, 212, 255, 0.3);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.speed-toggle:disabled { cursor: wait; opacity: 0.6; }
|
||||||
|
.speed-toggle-state {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.speed-toggle[aria-pressed="true"] .speed-toggle-state { color: var(--text-1); }
|
||||||
|
.speed-caps { display: inline-flex; align-items: center; gap: 5px; min-width: 0; }
|
||||||
|
.speed-cap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 6px 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.speed-cap strong { color: var(--text-0); font-weight: 700; }
|
||||||
|
.speed-cap:first-child { color: var(--accent); }
|
||||||
|
.speed-cap:last-child { color: var(--ok); }
|
||||||
|
.speed-control-status { color: var(--red); font-size: 11px; max-width: 180px; }
|
||||||
|
|
||||||
/* ---------- Container / Toolbar ---------- */
|
/* ---------- Container / Toolbar ---------- */
|
||||||
.container {
|
.container {
|
||||||
max-width: var(--maxw);
|
max-width: var(--maxw);
|
||||||
@@ -470,6 +525,7 @@ button { font-family: inherit; }
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.header-inner { flex-wrap: wrap; padding: 12px 16px; gap: 12px; }
|
.header-inner { flex-wrap: wrap; padding: 12px 16px; gap: 12px; }
|
||||||
.header-stats { width: 100%; }
|
.header-stats { width: 100%; }
|
||||||
|
.speed-controls { width: 100%; justify-content: flex-start; flex-wrap: wrap; }
|
||||||
.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; }
|
||||||
@@ -484,6 +540,7 @@ button { font-family: inherit; }
|
|||||||
@media (max-width: 440px) {
|
@media (max-width: 440px) {
|
||||||
.brand-text h1 { font-size: 15px; }
|
.brand-text h1 { font-size: 15px; }
|
||||||
.stat-pill .stat-label { display: none; }
|
.stat-pill .stat-label { display: none; }
|
||||||
|
.speed-control-status { max-width: 100%; width: 100%; }
|
||||||
.card-top { flex-wrap: wrap; }
|
.card-top { flex-wrap: wrap; }
|
||||||
.card-body { flex-basis: calc(100% - 42px); }
|
.card-body { flex-basis: calc(100% - 42px); }
|
||||||
.card-actions { width: 100%; flex-direction: row; justify-content: space-between; }
|
.card-actions { width: 100%; flex-direction: row; justify-content: space-between; }
|
||||||
|
|||||||
Reference in New Issue
Block a user