adding scheduled view in torrents tab
This commit is contained in:
+89
-19
@@ -22,6 +22,7 @@ const state = {
|
||||
stats: {},
|
||||
session: null,
|
||||
filter: "",
|
||||
scheduledOnly: false,
|
||||
sortKey: "status",
|
||||
refreshMs: DEFAULT_REFRESH_MS,
|
||||
lastError: null,
|
||||
@@ -35,8 +36,9 @@ let refreshTimer = null;
|
||||
let speedControlPending = false;
|
||||
let speedControlError = null;
|
||||
let schedulerData = null;
|
||||
let schedulerItems = new Map();
|
||||
let schedulerError = "";
|
||||
let schedulerLoading = false;
|
||||
let schedulerLoadPromise = null;
|
||||
let schedulerSettingsPending = false;
|
||||
let schedulerHistoryLoading = false;
|
||||
let schedulerHistoryLoaded = false;
|
||||
@@ -44,6 +46,7 @@ let schedulerHistoryEvents = [];
|
||||
let schedulerHistoryHasMore = false;
|
||||
let schedulerHistoryLatestID = -1;
|
||||
const schedulerActionPending = new Set();
|
||||
const schedulerActionErrors = new Map();
|
||||
const schedulerRows = new Map();
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
@@ -153,7 +156,7 @@ async function runTorrentAction(id, action) {
|
||||
await fetchJSON(`/api/torrents/${id}/${action}`, { method: "POST" });
|
||||
actionStates.delete(id);
|
||||
await sync();
|
||||
if (location.hash === "#scheduler") loadScheduler();
|
||||
loadScheduler();
|
||||
} catch (err) {
|
||||
actionStates.set(id, {
|
||||
pending: null,
|
||||
@@ -256,6 +259,24 @@ function schedulerStatus(item) {
|
||||
return { label: "Available", key: "" };
|
||||
}
|
||||
|
||||
function schedulerItemFor(torrent) {
|
||||
const hash = (torrent.hashString || "").toLowerCase();
|
||||
return schedulerItems.get(hash);
|
||||
}
|
||||
|
||||
function renderScheduleControls() {
|
||||
const filter = $("#scheduled-filter");
|
||||
filter.disabled = !schedulerData;
|
||||
filter.setAttribute("aria-pressed", String(state.scheduledOnly));
|
||||
const count = schedulerData ? state.torrents.filter((torrent) => schedulerItemFor(torrent)?.optedIn).length : 0;
|
||||
setText(filter, schedulerData ? `Scheduled (${count})` : "Scheduled");
|
||||
|
||||
const status = $("#schedule-status");
|
||||
const message = schedulerError || (schedulerData && schedulerData.error) || "";
|
||||
setText(status, message ? `Scheduler: ${message}` : "");
|
||||
status.classList.toggle("hidden", !message);
|
||||
}
|
||||
|
||||
function createSchedulerRow() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "scheduler-row";
|
||||
@@ -361,21 +382,25 @@ async function loadSchedulerHistory(reset = false) {
|
||||
}
|
||||
|
||||
async function loadScheduler() {
|
||||
if (schedulerLoading) return;
|
||||
schedulerLoading = true;
|
||||
try {
|
||||
schedulerData = await fetchJSON("/api/scheduler");
|
||||
schedulerError = "";
|
||||
renderScheduler();
|
||||
if (!schedulerHistoryLoaded || schedulerData.latestEventId !== schedulerHistoryLatestID) {
|
||||
await loadSchedulerHistory(true);
|
||||
if (schedulerLoadPromise) return schedulerLoadPromise;
|
||||
schedulerLoadPromise = (async () => {
|
||||
try {
|
||||
schedulerData = await fetchJSON("/api/scheduler");
|
||||
schedulerItems = new Map((schedulerData.torrents || []).map((item) => [item.hashString.toLowerCase(), item]));
|
||||
schedulerError = "";
|
||||
renderScheduler();
|
||||
if (location.hash === "#scheduler" && (!schedulerHistoryLoaded || schedulerData.latestEventId !== schedulerHistoryLatestID)) {
|
||||
await loadSchedulerHistory(true);
|
||||
}
|
||||
} catch (err) {
|
||||
schedulerError = err.message || String(err);
|
||||
renderScheduler();
|
||||
} finally {
|
||||
renderScheduleControls();
|
||||
renderList();
|
||||
}
|
||||
} catch (err) {
|
||||
schedulerError = err.message || String(err);
|
||||
renderScheduler();
|
||||
} finally {
|
||||
schedulerLoading = false;
|
||||
}
|
||||
})();
|
||||
try { await schedulerLoadPromise; } finally { schedulerLoadPromise = null; }
|
||||
}
|
||||
|
||||
async function setSchedulerSlots(value) {
|
||||
@@ -401,19 +426,25 @@ async function setSchedulerSlots(value) {
|
||||
async function setSchedulerOptIn(hash, enabled) {
|
||||
if (schedulerActionPending.has(hash)) return;
|
||||
schedulerActionPending.add(hash);
|
||||
schedulerActionErrors.delete(hash);
|
||||
renderScheduler();
|
||||
renderList();
|
||||
try {
|
||||
await fetchJSON(`/api/scheduler/torrents/${hash}/opt-in`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
schedulerError = "";
|
||||
if (schedulerLoadPromise) await schedulerLoadPromise;
|
||||
await loadScheduler();
|
||||
} catch (err) {
|
||||
schedulerError = err.message || String(err);
|
||||
schedulerActionErrors.set(hash, schedulerError);
|
||||
} finally {
|
||||
schedulerActionPending.delete(hash);
|
||||
renderScheduler();
|
||||
renderScheduleControls();
|
||||
renderList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,6 +678,7 @@ function createCard() {
|
||||
<div class="card-body">
|
||||
<div class="card-name" data-field="name"></div>
|
||||
<div class="card-meta">
|
||||
<span class="schedule-badge" data-field="schedule-badge"></span>
|
||||
<span class="meta-item"><span class="meta-label">Size</span> <span data-field="size"></span></span>
|
||||
<span class="meta-item"><span class="meta-label">Ratio</span> <span data-field="ratio"></span></span>
|
||||
<span class="meta-item"><span class="meta-label">Peers</span> <span data-field="peers"></span></span>
|
||||
@@ -658,6 +690,7 @@ function createCard() {
|
||||
<span class="badge" data-field="badge"><span class="dotb"></span><span data-field="status-label"></span></span>
|
||||
<span class="pct" data-field="pct"></span>
|
||||
</div>
|
||||
<button type="button" class="card-schedule-toggle hidden" data-schedule-button></button>
|
||||
<button type="button" class="action-btn" data-action-button></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -761,6 +794,23 @@ function updateCard(card, t) {
|
||||
setText(field(card, "status-label"), cls.label);
|
||||
setText(field(card, "pct"), pctText);
|
||||
|
||||
const scheduleItem = schedulerItemFor(t);
|
||||
const scheduled = Boolean(scheduleItem && scheduleItem.optedIn);
|
||||
const scheduleBadge = field(card, "schedule-badge");
|
||||
scheduleBadge.classList.toggle("is-scheduled", scheduled);
|
||||
setText(scheduleBadge, !schedulerData ? (schedulerError ? "Schedule unavailable" : "Schedule loading") : scheduled
|
||||
? `Scheduled · ${schedulerStatus(scheduleItem).label}` : "Not scheduled");
|
||||
scheduleBadge.title = !schedulerData
|
||||
? (schedulerError ? "Scheduler state is unavailable" : "Loading scheduler state")
|
||||
: (!scheduleItem ? "Torrent must be complete and error-free before scheduling" : "");
|
||||
const scheduleButton = card.querySelector("[data-schedule-button]");
|
||||
const schedulable = Boolean(scheduleItem && (scheduleItem.available || scheduled));
|
||||
scheduleButton.classList.toggle("hidden", !schedulable);
|
||||
scheduleButton.disabled = schedulerActionPending.has((t.hashString || "").toLowerCase());
|
||||
scheduleButton.setAttribute("aria-pressed", String(scheduled));
|
||||
scheduleButton.setAttribute("aria-label", `${scheduled ? "Remove" : "Add"} ${t.name || "torrent"} ${scheduled ? "from" : "to"} scheduler`);
|
||||
setText(scheduleButton, scheduleButton.disabled ? "Saving..." : (scheduled ? "Unschedule" : "Schedule"));
|
||||
|
||||
const progress = field(card, "progress");
|
||||
progress.className = `progress-bar ${barClass}`.trim();
|
||||
progress.style.width = `${(pct * 100).toFixed(2)}%`;
|
||||
@@ -801,7 +851,7 @@ function updateCard(card, t) {
|
||||
setText(button, actionState && actionState.pending ? actionState.pendingText : action.label);
|
||||
|
||||
const actionError = field(card, "action-error");
|
||||
const actionErrorText = actionState && actionState.error ? actionState.error : "";
|
||||
const actionErrorText = (actionState && actionState.error) || schedulerActionErrors.get((t.hashString || "").toLowerCase()) || "";
|
||||
setText(actionError, actionErrorText);
|
||||
actionError.classList.toggle("hidden", !actionErrorText);
|
||||
}
|
||||
@@ -860,15 +910,21 @@ function renderList() {
|
||||
}
|
||||
|
||||
let torrents = sortTorrents(state.torrents || []);
|
||||
if (state.scheduledOnly) torrents = torrents.filter((t) => schedulerItemFor(t)?.optedIn);
|
||||
const f = state.filter.trim().toLowerCase();
|
||||
if (f) torrents = torrents.filter((t) => (t.name || "").toLowerCase().includes(f));
|
||||
|
||||
reconcileCards(torrents);
|
||||
empty.classList.toggle("hidden", torrents.length !== 0 || Boolean(state.lastError));
|
||||
setText($("#empty-title"), state.filter ? "No matching torrents" : (state.scheduledOnly ? "No scheduled torrents" : "No torrents"));
|
||||
setText($("#empty-sub"), state.filter ? "Try a different filter." : (state.scheduledOnly
|
||||
? "Schedule a completed torrent here to see it in this view."
|
||||
: "Add torrents to your Transmission client to see them here."));
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderHeader();
|
||||
renderScheduleControls();
|
||||
renderList();
|
||||
}
|
||||
|
||||
@@ -912,6 +968,11 @@ function wireEvents() {
|
||||
state.filter = e.target.value;
|
||||
renderList();
|
||||
});
|
||||
$("#scheduled-filter").addEventListener("click", () => {
|
||||
state.scheduledOnly = !state.scheduledOnly;
|
||||
renderScheduleControls();
|
||||
renderList();
|
||||
});
|
||||
|
||||
document.querySelectorAll(".sort-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
@@ -944,6 +1005,14 @@ function wireEvents() {
|
||||
}
|
||||
|
||||
$("#torrents").addEventListener("click", (e) => {
|
||||
const scheduleButton = e.target.closest("[data-schedule-button]");
|
||||
if (scheduleButton && !scheduleButton.disabled) {
|
||||
const card = scheduleButton.closest(".card");
|
||||
const torrent = (state.torrents || []).find((t) => String(t.id) === card.dataset.id);
|
||||
const item = torrent && schedulerItemFor(torrent);
|
||||
if (item) setSchedulerOptIn(item.hashString, !item.optedIn);
|
||||
return;
|
||||
}
|
||||
const button = e.target.closest("[data-action-button]");
|
||||
if (!button || button.disabled) return;
|
||||
|
||||
@@ -958,7 +1027,7 @@ function wireEvents() {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
sync();
|
||||
if (location.hash === "#scheduler") loadScheduler();
|
||||
loadScheduler();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -969,9 +1038,10 @@ function boot() {
|
||||
activateTab(location.hash.slice(1));
|
||||
setRefreshInterval(state.refreshMs);
|
||||
setInterval(() => {
|
||||
if (!document.hidden && location.hash === "#scheduler") loadScheduler();
|
||||
if (!document.hidden) loadScheduler();
|
||||
}, 10000);
|
||||
sync();
|
||||
if (location.hash !== "#scheduler") loadScheduler();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
|
||||
+4
-2
@@ -75,6 +75,7 @@
|
||||
<button class="sort-btn" data-sort="availability">Availability</button>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button type="button" id="scheduled-filter" class="scheduled-filter" aria-pressed="false" disabled>Scheduled</button>
|
||||
<label class="interval-control" for="refresh-interval">
|
||||
<span class="sr-only">Refresh interval</span>
|
||||
<select id="refresh-interval" class="refresh-select" aria-label="Refresh interval">
|
||||
@@ -90,6 +91,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="schedule-status" class="schedule-status hidden" role="status"></p>
|
||||
|
||||
<section id="torrents" class="torrent-list" aria-live="polite"></section>
|
||||
|
||||
@@ -100,8 +102,8 @@
|
||||
opacity="0.4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-title">No torrents</p>
|
||||
<p class="empty-sub">Add torrents to your Transmission client to see them here.</p>
|
||||
<p class="empty-title" id="empty-title">No torrents</p>
|
||||
<p class="empty-sub" id="empty-sub">Add torrents to your Transmission client to see them here.</p>
|
||||
</div>
|
||||
|
||||
<div id="error-state" class="error-state hidden">
|
||||
|
||||
+44
-1
@@ -290,6 +290,22 @@ button { font-family: inherit; }
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.scheduled-filter {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-1);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 8px 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.scheduled-filter:hover:not(:disabled) { border-color: var(--border-hover); color: var(--text-0); }
|
||||
.scheduled-filter[aria-pressed="true"] { border-color: rgba(0, 212, 255, .3); background: var(--accent-soft); color: var(--accent); }
|
||||
.scheduled-filter:disabled { opacity: .5; cursor: wait; }
|
||||
.scheduled-filter:focus-visible, .card-schedule-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.schedule-status { margin: -6px 0 16px; color: var(--err); font-size: 12px; }
|
||||
.interval-control { display: inline-flex; align-items: center; }
|
||||
.refresh-select {
|
||||
color-scheme: dark;
|
||||
@@ -385,6 +401,18 @@ button { font-family: inherit; }
|
||||
font-size: 12px; color: var(--text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.schedule-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schedule-badge.is-scheduled { border-color: rgba(139, 92, 246, .35); background: rgba(139, 92, 246, .12); color: var(--purple); }
|
||||
.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); }
|
||||
@@ -402,6 +430,21 @@ button { font-family: inherit; }
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.card-schedule-toggle {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-1);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 6px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-schedule-toggle:hover:not(:disabled) { border-color: var(--border-hover); color: var(--text-0); }
|
||||
.card-schedule-toggle[aria-pressed="true"] { border-color: rgba(139, 92, 246, .35); color: var(--purple); }
|
||||
.card-schedule-toggle:disabled { opacity: .6; cursor: wait; }
|
||||
.action-btn {
|
||||
position: relative;
|
||||
margin-left: 6px;
|
||||
@@ -581,7 +624,7 @@ button { font-family: inherit; }
|
||||
.speed-control-status { max-width: 100%; width: 100%; }
|
||||
.card-top { flex-wrap: wrap; }
|
||||
.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; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.card-status-col { flex-direction: column-reverse; align-items: flex-start; gap: 6px; }
|
||||
.scheduler-row { align-items: flex-start; }
|
||||
.scheduler-row-controls { flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||
|
||||
Reference in New Issue
Block a user