Make existing controls real + add robustness pass
Wire up controls that were rendered but inert, expose unused server actions, and harden the live update path. Controls now functional: - File priority dropdowns (Content tab) now persist via new setFilePriority server action - Context menu: Set location, Limit download/upload rate, Set share limit (wire setSavePath/setDownLimit/setUpLimit/setRatioLimit) - Trackers sidebar filter actually filters (snapshot now carries per-torrent trackerHosts) Robustness: - Grid updates cells in place when row order/columns are unchanged (preserves text selection/scroll, no per-second listener re-bind) - Boot retries until the server responds; SSE drops show a "Reconnecting" banner; failed actions/add/delete surface a toast - Prune selection/detail for torrents that disappear - Drop Backspace-as-delete (kept Delete) Cleanup: - Remove pseudo DHT/PeX/LSD names from the real-tracker host pool in mock data (they leaked into announce URLs and the sidebar) - Remove dead detailTab import/export Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
bc1be49a37
commit
0b35571565
5 changed files with 172 additions and 21 deletions
|
|
@ -346,3 +346,22 @@ code.inline { background: var(--bg-3); border: 1px solid var(--line); border-rad
|
|||
.ctxmenu .sub { color: var(--txt-faint); padding: 4px 12px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
|
||||
.empty { display: grid; place-items: center; height: 100%; color: var(--txt-faint); }
|
||||
|
||||
/* toasts */
|
||||
.toast-host { position: fixed; right: 16px; bottom: 36px; z-index: 300; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
|
||||
.toast {
|
||||
background: var(--bg-2); border: 1px solid var(--line); border-left: 3px solid var(--accent);
|
||||
border-radius: 6px; padding: 9px 14px; color: var(--txt); box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||
max-width: 360px; animation: toast-in .18s ease-out;
|
||||
}
|
||||
.toast.err { border-left-color: var(--err); }
|
||||
.toast.ok { border-left-color: var(--ok); }
|
||||
.toast.out { opacity: 0; transform: translateY(6px); transition: opacity .25s, transform .25s; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* connection banner */
|
||||
.conn-banner {
|
||||
position: fixed; top: 52px; left: 50%; transform: translateX(-50%); z-index: 300;
|
||||
background: var(--warn); color: #1a1300; font-weight: 600; font-size: 12px;
|
||||
padding: 6px 16px; border-radius: 14px; box-shadow: 0 6px 20px rgba(0,0,0,.4);
|
||||
}
|
||||
|
|
|
|||
148
public/js/app.js
148
public/js/app.js
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { api } from './api.js';
|
||||
import * as f from './format.js';
|
||||
import { renderDetailShell, closeDetail, detailTab } from './detail.js';
|
||||
import { renderDetailShell, closeDetail } from './detail.js';
|
||||
|
||||
/* ===================== state ===================== */
|
||||
const state = {
|
||||
|
|
@ -56,19 +56,29 @@ const STATUS_FILTERS = [
|
|||
|
||||
/* ===================== bootstrap ===================== */
|
||||
async function boot() {
|
||||
state.meta = await api.meta();
|
||||
bindGlobal();
|
||||
await loadMeta(); // retries until the server answers
|
||||
syncAltToggle();
|
||||
renderSidebar();
|
||||
renderView();
|
||||
api.stream(onSnapshot, onStatus);
|
||||
bindGlobal();
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
for (;;) {
|
||||
try { state.meta = await api.meta(); return; }
|
||||
catch { onStatus('reconnecting'); await new Promise((r) => setTimeout(r, 2000)); }
|
||||
}
|
||||
}
|
||||
|
||||
function onSnapshot(snap) {
|
||||
state.snapshot = snap;
|
||||
// drop selection / detail for torrents that no longer exist
|
||||
const live = new Set(snap.torrents.map((t) => t.hash));
|
||||
for (const h of [...state.selected]) if (!live.has(h)) state.selected.delete(h);
|
||||
if (state.detailHash && !live.has(state.detailHash)) closeDetailPanel();
|
||||
updateRates();
|
||||
renderStatusbar();
|
||||
// keep sidebar counts fresh + grid live
|
||||
renderSidebar();
|
||||
if (state.view === 'torrents') renderGrid();
|
||||
}
|
||||
|
|
@ -76,6 +86,7 @@ function onSnapshot(snap) {
|
|||
function onStatus(s) {
|
||||
const led = document.querySelector('.statusbar .led');
|
||||
if (led) led.style.background = s === 'connected' ? 'var(--ok)' : 'var(--warn)';
|
||||
setBanner(s === 'connected' ? '' : 'Reconnecting to server…');
|
||||
}
|
||||
|
||||
/* ===================== derived ===================== */
|
||||
|
|
@ -95,7 +106,7 @@ function visibleTorrents() {
|
|||
function matchFilter(t, type, value) {
|
||||
if (type === 'category') return (t.category || '') === value;
|
||||
if (type === 'tag') return t.tags.includes(value);
|
||||
if (type === 'tracker') return true; // tracker host not in compact snapshot; show all (stub)
|
||||
if (type === 'tracker') return (t.trackerHosts || []).includes(value);
|
||||
// status
|
||||
const s = t.state;
|
||||
switch (value) {
|
||||
|
|
@ -212,11 +223,32 @@ function renderTorrentsView(host) {
|
|||
if (state.detailHash) renderDetailShell(state.detailHash, document.getElementById('detailHost'));
|
||||
}
|
||||
|
||||
let gridSig = '';
|
||||
|
||||
function renderGrid() {
|
||||
const wrap = document.getElementById('gridWrap');
|
||||
if (!wrap) return;
|
||||
const list = visibleTorrents();
|
||||
const cols = state.columns;
|
||||
// signature of structure (columns + ordered hashes). When unchanged, update
|
||||
// cell contents in place instead of rebuilding — preserves text selection,
|
||||
// scroll, focus, and avoids re-binding listeners every second.
|
||||
const sig = cols.join(',') + '|' + list.map((t) => t.hash).join(',');
|
||||
|
||||
if (sig === gridSig) {
|
||||
const tbody = wrap.querySelector('tbody');
|
||||
if (tbody && tbody.children.length === list.length) {
|
||||
for (let r = 0; r < list.length; r++) {
|
||||
const t = list[r];
|
||||
const tr = tbody.children[r];
|
||||
tr.classList.toggle('sel', state.selected.has(t.hash));
|
||||
const inner = cols.map((k) => cell(t, k)).join('');
|
||||
if (tr.__inner !== inner) { tr.innerHTML = inner; tr.__inner = inner; }
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
gridSig = sig;
|
||||
|
||||
const head = cols.map((k) => {
|
||||
const c = COLUMNS[k];
|
||||
|
|
@ -224,9 +256,7 @@ function renderGrid() {
|
|||
return `<th data-col="${k}" class="${c.num ? 'num' : ''}" ${c.w ? `style="width:${c.w}"` : ''}>${c.label}${arrow}</th>`;
|
||||
}).join('');
|
||||
|
||||
const rows = list.map((t) => `<tr data-hash="${t.hash}" class="${state.selected.has(t.hash) ? 'sel' : ''}">
|
||||
${cols.map((k) => cell(t, k)).join('')}
|
||||
</tr>`).join('');
|
||||
const rows = list.map((t) => `<tr data-hash="${t.hash}" class="${state.selected.has(t.hash) ? 'sel' : ''}">${cols.map((k) => cell(t, k)).join('')}</tr>`).join('');
|
||||
|
||||
wrap.innerHTML = `<table class="grid"><thead><tr>${head}</tr></thead><tbody>${rows || emptyRow(cols.length)}</tbody></table>`;
|
||||
|
||||
|
|
@ -237,8 +267,9 @@ function renderGrid() {
|
|||
renderGrid();
|
||||
}));
|
||||
|
||||
wrap.querySelectorAll('tr[data-hash]').forEach((tr) => {
|
||||
tr.addEventListener('click', (e) => onRowClick(e, tr.dataset.hash, list));
|
||||
wrap.querySelectorAll('tr[data-hash]').forEach((tr, idx) => {
|
||||
tr.__inner = cols.map((k) => cell(list[idx], k)).join(''); // cache computed (not DOM-normalized) html
|
||||
tr.addEventListener('click', (e) => onRowClick(e, tr.dataset.hash, visibleTorrents()));
|
||||
tr.addEventListener('dblclick', () => openDetail(tr.dataset.hash));
|
||||
tr.addEventListener('contextmenu', (e) => onRowContext(e, tr.dataset.hash));
|
||||
});
|
||||
|
|
@ -324,13 +355,38 @@ function closeDetailPanel() {
|
|||
}
|
||||
}
|
||||
|
||||
/* ===================== feedback (toasts + connection banner) ===================== */
|
||||
function toast(msg, kind = '') {
|
||||
let host = document.getElementById('toastHost');
|
||||
if (!host) { host = document.createElement('div'); host.id = 'toastHost'; host.className = 'toast-host'; document.body.appendChild(host); }
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast ${kind}`;
|
||||
el.textContent = msg;
|
||||
host.appendChild(el);
|
||||
setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 250); }, 3200);
|
||||
}
|
||||
|
||||
function setBanner(msg) {
|
||||
let b = document.getElementById('connBanner');
|
||||
if (!msg) { b?.remove(); return; }
|
||||
if (!b) { b = document.createElement('div'); b.id = 'connBanner'; b.className = 'conn-banner'; document.body.appendChild(b); }
|
||||
b.textContent = msg;
|
||||
}
|
||||
|
||||
// Run an async handler, surfacing failures as a toast instead of an unhandled rejection.
|
||||
function guard(fn, msg) {
|
||||
return Promise.resolve().then(fn).catch((e) => { console.error(e); toast(msg || 'Something went wrong', 'err'); });
|
||||
}
|
||||
|
||||
/* ===================== actions ===================== */
|
||||
async function doAction(action, hashes) {
|
||||
hashes = hashes || [...state.selected];
|
||||
if (!hashes.length) return;
|
||||
if (action === 'delete') return confirmDelete(hashes);
|
||||
try {
|
||||
if (action === 'altspeed') { await api.toggleAltSpeed(); syncAltToggle(); return; }
|
||||
await api.action(action, hashes);
|
||||
} catch (e) { console.error(e); toast(`Action failed: ${action}`, 'err'); }
|
||||
}
|
||||
|
||||
function syncAltToggle() {
|
||||
|
|
@ -343,13 +399,13 @@ async function confirmDelete(hashes) {
|
|||
<p class="dim">Choose whether to also delete the downloaded data from disk.</p>
|
||||
<div class="checks"><label><input type="checkbox" id="delFiles"> Also delete files on disk</label></div>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
label: 'Remove', cls: 'danger', primary: true, act: async () => {
|
||||
label: 'Remove', cls: 'danger', primary: true, act: () => guard(async () => {
|
||||
const delFiles = document.getElementById('delFiles').checked;
|
||||
await api.remove(hashes, delFiles);
|
||||
hashes.forEach((h) => state.selected.delete(h));
|
||||
if (hashes.includes(state.detailHash)) closeDetailPanel();
|
||||
closeModal();
|
||||
},
|
||||
}, 'Failed to remove torrent(s)'),
|
||||
}]);
|
||||
}
|
||||
|
||||
|
|
@ -371,7 +427,7 @@ function openAddModal() {
|
|||
<label><input type="checkbox" id="addSkip"> Skip hash check</label>
|
||||
</div>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
label: 'Add', primary: true, act: async () => {
|
||||
label: 'Add', primary: true, act: () => guard(async () => {
|
||||
const magnet = document.getElementById('addMagnet').value.trim();
|
||||
const common = {
|
||||
category: document.getElementById('addCat').value,
|
||||
|
|
@ -383,11 +439,13 @@ function openAddModal() {
|
|||
const files = [...document.getElementById('addFile').files];
|
||||
if (files.length) {
|
||||
for (const file of files) await api.add({ ...common, ...(await readTorrentFile(file)) });
|
||||
toast(`Added ${files.length} torrent(s)`, 'ok');
|
||||
} else if (magnet) {
|
||||
await api.add({ ...common, magnet });
|
||||
toast('Torrent added', 'ok');
|
||||
}
|
||||
closeModal();
|
||||
},
|
||||
}, 'Failed to add torrent'),
|
||||
}]);
|
||||
// live readout of the chosen file(s)
|
||||
document.getElementById('addFile').addEventListener('change', async (e) => {
|
||||
|
|
@ -400,6 +458,59 @@ function openAddModal() {
|
|||
});
|
||||
}
|
||||
|
||||
/* ---------- per-torrent location / rate / share limits ---------- */
|
||||
function promptLocation() {
|
||||
const hashes = [...state.selected];
|
||||
if (!hashes.length) return;
|
||||
const cur = state.snapshot.torrents.find((t) => t.hash === hashes[0]);
|
||||
openModal('Set location', `
|
||||
<div class="field"><label>Save path</label><input type="text" id="locPath" value="${f.esc(cur?.savePath || '')}" /></div>
|
||||
<p class="dim" style="font-size:12px">Applies to ${hashes.length} torrent(s).</p>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
label: 'Apply', primary: true, act: () => guard(async () => {
|
||||
const savePath = document.getElementById('locPath').value.trim();
|
||||
if (savePath) await api.action('setSavePath', hashes, { savePath });
|
||||
closeModal();
|
||||
}, 'Failed to set location'),
|
||||
}]);
|
||||
setTimeout(() => document.getElementById('locPath')?.focus(), 0);
|
||||
}
|
||||
|
||||
function promptRateLimit(dir) {
|
||||
const hashes = [...state.selected];
|
||||
if (!hashes.length) return;
|
||||
const action = dir === 'down' ? 'setDownLimit' : 'setUpLimit';
|
||||
openModal(`Limit ${dir === 'down' ? 'download' : 'upload'} rate · ${hashes.length} torrent(s)`, `
|
||||
<div class="field"><label>Rate (KiB/s — 0 = unlimited)</label>
|
||||
<input type="text" id="rateVal" placeholder="0" inputmode="numeric" /></div>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
label: 'Apply', primary: true, act: () => guard(async () => {
|
||||
const kib = Math.max(0, parseInt(document.getElementById('rateVal').value, 10) || 0);
|
||||
await api.action(action, hashes, { limit: kib * 1024 });
|
||||
closeModal();
|
||||
}, 'Failed to set rate limit'),
|
||||
}]);
|
||||
setTimeout(() => document.getElementById('rateVal')?.focus(), 0);
|
||||
}
|
||||
|
||||
function promptShareLimit() {
|
||||
const hashes = [...state.selected];
|
||||
if (!hashes.length) return;
|
||||
openModal(`Set share limit · ${hashes.length} torrent(s)`, `
|
||||
<div class="field"><label>Seeding ratio limit</label>
|
||||
<input type="text" id="ratioVal" placeholder="-1" inputmode="decimal" /></div>
|
||||
<p class="dim" style="font-size:12px">−1 = use global · 0 = unlimited</p>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
label: 'Apply', primary: true, act: () => guard(async () => {
|
||||
const v = document.getElementById('ratioVal').value.trim();
|
||||
const limit = v === '' ? -1 : parseFloat(v);
|
||||
await api.action('setRatioLimit', hashes, { limit: Number.isNaN(limit) ? -1 : limit });
|
||||
closeModal();
|
||||
}, 'Failed to set share limit'),
|
||||
}]);
|
||||
setTimeout(() => document.getElementById('ratioVal')?.focus(), 0);
|
||||
}
|
||||
|
||||
// Minimal bencode decoder (operates on a Uint8Array) — enough to read a .torrent's
|
||||
// info dict client-side so an uploaded file shows realistic name/size/pieces.
|
||||
function bdecode(buf) {
|
||||
|
|
@ -460,6 +571,13 @@ function onRowContext(e, hash) {
|
|||
{ label: 'Toggle super seeding', act: () => doAction('toggleSuperSeeding') },
|
||||
{ label: 'Set category…', act: () => promptCategory() },
|
||||
{ label: 'Edit tags…', act: () => promptTags() },
|
||||
{ sep: true },
|
||||
{ sub: 'Limits & location' },
|
||||
{ label: 'Set location…', act: () => promptLocation() },
|
||||
{ label: 'Limit download rate…', act: () => promptRateLimit('down') },
|
||||
{ label: 'Limit upload rate…', act: () => promptRateLimit('up') },
|
||||
{ label: 'Set share limit…', act: () => promptShareLimit() },
|
||||
{ sep: true },
|
||||
{ label: 'Properties', sc: '⏎', act: () => openDetail(hash) },
|
||||
{ sep: true },
|
||||
{ label: `Remove ${n > 1 ? `(${n})` : ''}`, danger: true, sc: 'Del', act: () => doAction('delete') },
|
||||
|
|
@ -806,7 +924,7 @@ function bindGlobal() {
|
|||
if (typing) return;
|
||||
if (e.key === '/') { e.preventDefault(); qf.focus(); }
|
||||
else if (e.key.toLowerCase() === 'n') openAddModal();
|
||||
else if (e.key === 'Delete' || e.key === 'Backspace') doAction('delete');
|
||||
else if (e.key === 'Delete') doAction('delete');
|
||||
else if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
// toggle pause/resume on selection
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ let currentHash = null;
|
|||
let refreshTimer = null;
|
||||
let detailHeight = 300; // px, persisted across re-renders so resizing sticks
|
||||
|
||||
export function detailTab() { return activeTab; }
|
||||
|
||||
export function renderDetailShell(hash, host) {
|
||||
currentHash = hash;
|
||||
// Drive the container's grid track (persists across row clicks / re-renders),
|
||||
|
|
@ -58,7 +56,7 @@ async function loadTab() {
|
|||
if (activeTab === 'general') body.innerHTML = renderGeneral(await api.torrent(currentHash));
|
||||
else if (activeTab === 'trackers') body.innerHTML = renderTrackers(await api.trackers(currentHash));
|
||||
else if (activeTab === 'peers') body.innerHTML = renderPeers(await api.peers(currentHash));
|
||||
else if (activeTab === 'content') body.innerHTML = renderFiles(await api.files(currentHash));
|
||||
else if (activeTab === 'content') { body.innerHTML = renderFiles(await api.files(currentHash)); bindFilePriorities(body); }
|
||||
else if (activeTab === 'pieces') body.innerHTML = renderPieces(await api.pieces(currentHash));
|
||||
} catch { /* ignore transient */ }
|
||||
};
|
||||
|
|
@ -176,6 +174,13 @@ function renderFiles(files) {
|
|||
</tbody></table>`;
|
||||
}
|
||||
|
||||
function bindFilePriorities(body) {
|
||||
const hash = currentHash;
|
||||
body.querySelectorAll('.prio-sel').forEach((sel) =>
|
||||
sel.addEventListener('change', () =>
|
||||
api.action('setFilePriority', [hash], { index: +sel.dataset.file, priority: +sel.value })));
|
||||
}
|
||||
|
||||
/* ---------- Pieces ---------- */
|
||||
function renderPieces(data) {
|
||||
const { pieces, pieceSize, pieceCount } = data;
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const TAGS = ['archive', 'public', 'private', 'seed-forever', 'hit-and-run', 've
|
|||
const TRACKER_HOSTS = [
|
||||
'tracker.opentrackr.org:1337', 'open.demonii.com:1337', 'tracker.torrent.eu.org:451',
|
||||
'exodus.desync.com:6969', 'tracker.openbittorrent.com:6969', 'private.tracker.lan:2710',
|
||||
'tracker.dler.org:6969', 'open.stealth.si:80', '** [DHT] **', '** [PeX] **', '** [LSD] **',
|
||||
'tracker.dler.org:6969', 'open.stealth.si:80',
|
||||
];
|
||||
|
||||
const NAME_TEMPLATES = [
|
||||
|
|
|
|||
|
|
@ -68,10 +68,18 @@ function snapshot() {
|
|||
forceStart: t.forceStart,
|
||||
timeActive: t.timeActive,
|
||||
private: t.private,
|
||||
trackerHosts: trackerHostsOf(t),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function hostOf(url) {
|
||||
try { return new URL(url).host; } catch { return url; }
|
||||
}
|
||||
function trackerHostsOf(t) {
|
||||
return t.trackers.filter((tr) => tr.tier >= 0).map((tr) => hostOf(tr.url));
|
||||
}
|
||||
|
||||
function json(res, data, code = 200) {
|
||||
const body = JSON.stringify(data);
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
|
|
@ -107,6 +115,7 @@ const ACTIONS = {
|
|||
setDownLimit: (t, p) => { t.downLimit = p.limit ?? 0; },
|
||||
setUpLimit: (t, p) => { t.upLimit = p.limit ?? 0; },
|
||||
setRatioLimit: (t, p) => { t.ratioLimit = p.limit ?? -1; },
|
||||
setFilePriority: (t, p) => { if (t.files[p.index]) t.files[p.index].priority = p.priority; },
|
||||
topPriority: (t) => { t.priority = 1; },
|
||||
bottomPriority: (t) => { t.priority = 99; },
|
||||
increasePriority: (t) => { t.priority = Math.max(1, (t.priority || 1) - 1); },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue