diff --git a/public/css/styles.css b/public/css/styles.css index ff38293..fa550a0 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -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); +} diff --git a/public/js/app.js b/public/js/app.js index 087d982..002c02c 100644 --- a/public/js/app.js +++ b/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 `${c.label}${arrow}`; }).join(''); - const rows = list.map((t) => ` - ${cols.map((k) => cell(t, k)).join('')} - `).join(''); + const rows = list.map((t) => `${cols.map((k) => cell(t, k)).join('')}`).join(''); wrap.innerHTML = `${head}${rows || emptyRow(cols.length)}
`; @@ -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); - if (action === 'altspeed') { await api.toggleAltSpeed(); syncAltToggle(); return; } - await api.action(action, 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) {

Choose whether to also delete the downloaded data from disk.

`, [{ 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: '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', ` +
+

Applies to ${hashes.length} torrent(s).

`, + [{ 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)`, ` +
+
`, + [{ 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)`, ` +
+
+

−1 = use global · 0 = unlimited

`, + [{ 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 diff --git a/public/js/detail.js b/public/js/detail.js index a0b67e4..0334947 100644 --- a/public/js/detail.js +++ b/public/js/detail.js @@ -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) { `; } +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; diff --git a/server/data.js b/server/data.js index 2833807..80bb25f 100644 --- a/server/data.js +++ b/server/data.js @@ -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 = [ diff --git a/server/index.js b/server/index.js index 683b583..9c5b753 100644 --- a/server/index.js +++ b/server/index.js @@ -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); },