`;
}
/* ---------- selection ---------- */
function onRowClick(e, hash, list) {
if (e.shiftKey && state.lastClicked) {
const order = list.map((t) => t.hash);
const a = order.indexOf(state.lastClicked), b = order.indexOf(hash);
if (a > -1 && b > -1) {
const [lo, hi] = a < b ? [a, b] : [b, a];
if (!(e.ctrlKey || e.metaKey)) state.selected.clear();
for (let i = lo; i <= hi; i++) state.selected.add(order[i]);
}
} else if (e.ctrlKey || e.metaKey) {
state.selected.has(hash) ? state.selected.delete(hash) : state.selected.add(hash);
state.lastClicked = hash;
} else {
state.selected.clear();
state.selected.add(hash);
state.lastClicked = hash;
}
renderGrid();
// a single-selection click opens/updates the detail panel; multi-select leaves it alone
if (state.selected.size === 1) showDetail([...state.selected][0]);
}
function showDetail(hash) {
state.detailHash = hash;
const host = document.getElementById('detailHost');
if (host) renderDetailShell(hash, host);
}
function openDetail(hash) {
state.selected.clear(); state.selected.add(hash); state.lastClicked = hash;
renderGrid();
showDetail(hash);
}
function closeDetailPanel() {
state.detailHash = null;
closeDetail();
const host = document.getElementById('detailHost');
if (host) {
host.closest('.torrents-view')?.style.setProperty('--detail-h', '0px');
host.innerHTML = '';
}
}
/* ===================== 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() {
const btn = document.querySelector('[data-act="altspeed"]');
if (btn) btn.classList.toggle('on', !!state.snapshot.server?.alt_speed_enabled);
}
async function confirmDelete(hashes) {
openModal(`Remove ${hashes.length} torrent(s)?`, `
Choose whether to also delete the downloaded data from disk.
`,
[{ label: 'Cancel', act: closeModal }, {
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)'),
}]);
}
/* ---------- add torrent modal ---------- */
// Default download location for a category: its own save path if set,
// otherwise the global default.
function categoryDefaultPath(catName) {
const c = state.meta.categories.find((x) => x.name === catName);
if (c && c.savePath) return c.savePath;
return state.meta.preferences.save_path || '/data/downloads';
}
function openAddModal() {
const cats = '' +
state.meta.categories.filter((c) => c.name).map((c) => ``).join('');
const tagOpts = state.meta.tags.map((t) =>
``).join('');
openModal('Add torrent', `
${tagOpts || '
No tags yet — add one below.
'}
Category default — click to change.
`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Add', primary: true, act: () => guard(async () => {
const magnet = document.getElementById('addMagnet').value.trim();
const common = {
category: document.getElementById('addCat').value,
tags: [...document.querySelectorAll('#addTagsList input:checked')].map((c) => c.value),
savePath: document.getElementById('addPath').value.trim(),
paused: document.getElementById('addPaused').checked,
seqDl: document.getElementById('addSeq').checked,
skipCheck: document.getElementById('addSkip').checked,
};
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'),
}]);
// tags checkbox dropdown: a fixed-position menu that floats over the dialog
(() => {
const btn = document.getElementById('addTagsBtn');
const menu = document.getElementById('addTagsMenu');
const list = document.getElementById('addTagsList');
const label = document.getElementById('addTagsLabel');
const input = document.getElementById('addTagNew');
const refresh = () => {
const sel = [...list.querySelectorAll('input:checked')].map((c) => c.value);
label.textContent = sel.length ? sel.join(', ') : 'No tags selected';
};
const addTag = () => {
const name = input.value.trim();
if (!name) return;
let cb = [...list.querySelectorAll('input')].find((c) => c.value === name);
if (!cb) {
const ph = list.querySelector('.dim'); if (ph) ph.remove();
const lbl = document.createElement('label');
lbl.className = 'cbdrop-item';
lbl.innerHTML = '';
cb = lbl.querySelector('input');
cb.value = name;
lbl.querySelector('span').textContent = name;
list.appendChild(lbl);
}
cb.checked = true;
input.value = '';
refresh();
place();
};
// Anchor the floating menu under the button and keep it on-screen.
const place = () => {
const r = btn.getBoundingClientRect();
menu.style.left = r.left + 'px';
menu.style.width = r.width + 'px';
menu.style.top = (r.bottom + 4) + 'px';
menu.style.maxHeight = Math.max(120, window.innerHeight - r.bottom - 16) + 'px';
};
let onOutside; let onReflow;
const close = () => {
menu.hidden = true;
document.removeEventListener('mousedown', onOutside, true);
document.removeEventListener('scroll', onReflow, true);
window.removeEventListener('resize', onReflow);
};
const open = () => {
menu.hidden = false;
place();
onOutside = (e) => { if (!menu.contains(e.target) && !btn.contains(e.target)) close(); };
onReflow = () => place();
document.addEventListener('mousedown', onOutside, true);
document.addEventListener('scroll', onReflow, true); // reposition on modal scroll
window.addEventListener('resize', onReflow);
};
btn.addEventListener('click', () => (menu.hidden ? open() : close()));
list.addEventListener('change', refresh);
document.getElementById('addTagAdd').addEventListener('click', addTag);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(); } });
})();
// save path: greyed default that tracks the category until the user edits it
(() => {
const path = document.getElementById('addPath');
const cat = document.getElementById('addCat');
const hint = document.getElementById('addPathHint');
let manual = false;
const enable = () => {
if (manual) return;
manual = true;
path.readOnly = false;
path.classList.remove('path-default');
hint.textContent = "Custom location — won't change with the category.";
path.focus();
path.select();
};
path.addEventListener('mousedown', (e) => { if (!manual) { e.preventDefault(); enable(); } });
cat.addEventListener('change', () => {
if (!manual) path.value = categoryDefaultPath(cat.value);
});
})();
// live readout of the chosen file(s)
document.getElementById('addFile').addEventListener('change', async (e) => {
const info = document.getElementById('addFileInfo');
const files = [...e.target.files];
if (!files.length) { info.textContent = ''; return; }
const metas = await Promise.all(files.map(readTorrentFile));
info.innerHTML = metas.map((m) =>
`📄 ${f.esc(m.name)} — ${f.bytes(m.size)} · ${m.pieceCount} pieces · ${(m.files || []).length || 1} file(s)`).join(' ');
});
}
/* ---------- 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', `
${f.esc(cur?.savePath || '—')}
Moves the torrent's files now. Unchecked, files you
moved individually keep their relative path (or stay put if moved elsewhere).
Applies to ${hashes.length} torrent(s).
This is a stubbed engine view — values are read from the mock server. Wire these to a real client's API (qBittorrent WebAPI, Transmission RPC, Deluge JSON-RPC) to make them editable.
`;
}
/* ===================== status bar ===================== */
function renderStatusbar() {
const s = state.snapshot.server || {};
const el = document.getElementById('statusbar');
el.innerHTML = `
${s.connection_status || 'connecting'}DHT: ${s.dht_nodes ?? '–'} nodesPort: ${s.listen_port ?? '–'}Active: ${s.active_torrents ?? 0}/${s.total_torrents ?? 0}Session ▼ ${f.bytes(s.dl_info_data)} ▲ ${f.bytes(s.up_info_data)}Global ratio: ${f.ratio(s.global_ratio)}Cache hit: ${s.read_cache_hits ?? '–'}%Queued I/O: ${s.queued_io_jobs ?? 0}Free space: ${f.bytes(s.free_space)}`;
}
function updateRates() {
const s = state.snapshot.server || {};
const dl = document.getElementById('globalDl');
const up = document.getElementById('globalUp');
if (dl) dl.textContent = f.rate(s.dl_info_speed) + (s.dl_rate_limit ? ` /${f.rate(s.dl_rate_limit)}` : '');
if (up) up.textContent = f.rate(s.up_info_speed) + (s.up_rate_limit ? ` /${f.rate(s.up_rate_limit)}` : '');
}
/* ===================== modal ===================== */
function openModal(title, bodyHtml, buttons) {
const back = document.getElementById('modalBackdrop');
const modal = document.getElementById('modal');
modal.innerHTML = `
${f.esc(title)}
${bodyHtml}
${buttons.map((b, i) => ``).join('')}
`;
back.hidden = false;
modal.querySelectorAll('[data-mb]').forEach((b) => b.addEventListener('click', () => buttons[+b.dataset.mb].act()));
}
function closeModal() { document.getElementById('modalBackdrop').hidden = true; }
/* ===================== view tabs ===================== */
function renderPluginViewTabs() {
const nav = document.getElementById('viewtabs');
for (const view of pluginViews()) {
if (nav.querySelector(`[data-view="${CSS.escape(view.id)}"]`)) continue;
const button = document.createElement('button');
button.className = 'vtab plugin-vtab';
button.dataset.view = view.id;
button.textContent = view.label;
nav.appendChild(button);
}
}
function switchViewTab(view) {
state.view = view;
document.querySelectorAll('.vtab').forEach((b) => b.classList.toggle('active', b.dataset.view === view));
renderView();
}
/* ===================== global bindings ===================== */
function bindGlobal() {
document.querySelectorAll('.toolbar [data-act]').forEach((b) =>
b.addEventListener('click', () => {
const a = b.dataset.act;
if (a === 'logout') return guard(async () => { await api.logout(); location.reload(); }, 'Failed to sign out');
if (a === 'add') return openAddModal();
doAction(a);
}));
document.getElementById('viewtabs').addEventListener('click', (e) => {
const tab = e.target.closest('.vtab[data-view]');
if (tab) switchViewTab(tab.dataset.view);
});
const qf = document.getElementById('quickFilter');
qf.addEventListener('input', () => { state.quick = qf.value; if (state.view === 'torrents') renderGrid(); });
document.addEventListener('click', (e) => {
if (!e.target.closest('#ctxmenu')) closeContextMenu();
});
document.getElementById('modalBackdrop').addEventListener('click', (e) => {
if (e.target.id === 'modalBackdrop') closeModal();
});
// detail close + esc handling delegated
document.addEventListener('click', (e) => {
if (e.target.closest('[data-act="closeDetail"]')) closeDetailPanel();
});
document.addEventListener('keydown', (e) => {
const typing = /input|textarea|select/i.test(document.activeElement.tagName);
if (e.key === 'Escape') {
closeContextMenu();
if (!document.getElementById('modalBackdrop').hidden) return closeModal();
if (state.detailHash) return closeDetailPanel();
}
if (typing) return;
if (e.key === '/') { e.preventDefault(); qf.focus(); }
else if (e.key.toLowerCase() === 'n') openAddModal();
else if (e.key === 'Delete') doAction('delete');
else if (e.key === ' ') {
e.preventDefault();
// toggle pause/resume on selection
const sel = state.snapshot.torrents.filter((t) => state.selected.has(t.hash));
const anyRunning = sel.some((t) => !t.state.startsWith('paused'));
doAction(anyRunning ? 'pause' : 'resume');
} else if (e.key === 'Enter' && state.selected.size === 1) {
openDetail([...state.selected][0]);
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a' && state.view === 'torrents') {
e.preventDefault();
visibleTorrents().forEach((t) => state.selected.add(t.hash));
renderGrid();
}
});
}
boot();