Naut-Plugin-WebUI/public/js/app.js
ookami125 bc1be49a37 Initial commit: NAUT torrent web UI with stubbed server
Advanced torrent client web UI aimed at power users, with a
zero-dependency Node stub server (built-in http + SSE) serving live
mock data.

- Dense sortable/multi-select torrent grid with live updates
- Detail panel: General/Trackers/Peers/Content/Pieces (resizable)
- Sidebar filters: status, categories, tags, trackers
- Create/delete categories and tags (sidebar + right-click)
- Add via magnet or client-side-parsed .torrent upload
- RSS, integrated search, and read-only engine views
- Hand-drawn SVG icon set, dark theme, keyboard shortcuts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:15:31 -04:00

826 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// NAUT — main application controller.
// Wires the live stream into the sidebar, torrent grid, detail panel,
// status bar, RSS/Search/Engine views, selection, context menu, hotkeys.
import { api } from './api.js';
import * as f from './format.js';
import { renderDetailShell, closeDetail, detailTab } from './detail.js';
/* ===================== state ===================== */
const state = {
view: 'torrents',
snapshot: { torrents: [], server: {} },
meta: { categories: [], tags: [], trackers: [], preferences: {}, searchPlugins: [] },
selected: new Set(),
lastClicked: null,
detailHash: null,
filter: { type: 'status', value: 'all' }, // type: status|category|tag|tracker
quick: '',
sort: { key: 'addedOn', dir: -1 },
columns: ['name', 'size', 'progress', 'state', 'seeds', 'peers', 'dlspeed', 'upspeed', 'eta', 'ratio', 'category', 'tags', 'addedOn'],
searchResults: null,
searchQuery: '',
};
const COLUMNS = {
name: { label: 'Name' }, // flexible: absorbs remaining width
size: { label: 'Size', num: true, w: '90px' },
progress: { label: 'Done', w: '120px' },
state: { label: 'Status', w: '120px' },
seeds: { label: 'Seeds', num: true, w: '92px' },
peers: { label: 'Peers', num: true, w: '92px' },
dlspeed: { label: 'Down', num: true, w: '100px' },
upspeed: { label: 'Up', num: true, w: '100px' },
eta: { label: 'ETA', num: true, w: '90px' },
ratio: { label: 'Ratio', num: true, w: '70px' },
availability: { label: 'Avail.', num: true, w: '72px' },
category: { label: 'Category', w: '130px' },
tags: { label: 'Tags', w: '170px' },
addedOn: { label: 'Added', num: true, w: '128px' },
completionOn: { label: 'Completed', num: true, w: '128px' },
savePath: { label: 'Save path', w: '220px' },
priority: { label: '#', num: true, w: '56px' },
};
const STATUS_FILTERS = [
['all', 'All', '◎'],
['downloading', 'Downloading', '▼'],
['seeding', 'Seeding', '▲'],
['completed', 'Completed', '✓'],
['active', 'Active', '⚡'],
['inactive', 'Inactive', '○'],
['stalled', 'Stalled', '◍'],
['paused', 'Paused', '⏸'],
['errored', 'Errored', '⚠'],
];
/* ===================== bootstrap ===================== */
async function boot() {
state.meta = await api.meta();
syncAltToggle();
renderSidebar();
renderView();
api.stream(onSnapshot, onStatus);
bindGlobal();
}
function onSnapshot(snap) {
state.snapshot = snap;
updateRates();
renderStatusbar();
// keep sidebar counts fresh + grid live
renderSidebar();
if (state.view === 'torrents') renderGrid();
}
function onStatus(s) {
const led = document.querySelector('.statusbar .led');
if (led) led.style.background = s === 'connected' ? 'var(--ok)' : 'var(--warn)';
}
/* ===================== derived ===================== */
function visibleTorrents() {
let list = state.snapshot.torrents;
const { type, value } = state.filter;
list = list.filter((t) => matchFilter(t, type, value));
if (state.quick) {
const q = state.quick.toLowerCase();
list = list.filter((t) => t.name.toLowerCase().includes(q) || (t.category || '').toLowerCase().includes(q) || t.tags.some((x) => x.includes(q)));
}
const { key, dir } = state.sort;
list = [...list].sort((a, b) => cmp(a, b, key) * dir);
return list;
}
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)
// status
const s = t.state;
switch (value) {
case 'all': return true;
case 'downloading': return ['downloading', 'forcedDL', 'metaDL', 'stalledDL', 'queuedDL'].includes(s);
case 'seeding': return ['uploading', 'forcedUP', 'stalledUP', 'queuedUP'].includes(s);
case 'completed': return t.progress >= 1;
case 'active': return t.dlspeed > 0 || t.upspeed > 0;
case 'inactive': return t.dlspeed === 0 && t.upspeed === 0;
case 'stalled': return s === 'stalledDL' || s === 'stalledUP';
case 'paused': return s.startsWith('paused');
case 'errored': return s === 'error' || s === 'missingFiles';
default: return true;
}
}
function cmp(a, b, key) {
if (key === 'name' || key === 'state' || key === 'category' || key === 'savePath') {
return String(a[key] || '').localeCompare(String(b[key] || ''));
}
if (key === 'tags') return a.tags.join().localeCompare(b.tags.join());
return (a[key] ?? 0) - (b[key] ?? 0);
}
/* ===================== sidebar ===================== */
function renderSidebar() {
const all = state.snapshot.torrents;
const count = (type, value) => all.filter((t) => matchFilter(t, type, value)).length;
const el = document.getElementById('sidebar');
const statusItems = STATUS_FILTERS.map(([v, label, ic]) =>
sideItem('status', v, ic, label, count('status', v))).join('');
// Categories/tags: source names from server meta (so empty ones still show) merged with live counts.
const catCounts = new Map();
for (const t of all) catCounts.set(t.category || '', (catCounts.get(t.category || '') || 0) + 1);
const catNames = new Set(state.meta.categories.map((c) => c.name));
for (const k of catCounts.keys()) catNames.add(k);
const cats = [...catNames].sort((a, b) => a.localeCompare(b))
.map((c) => sideItem('category', c, '🗂', c || 'Uncategorized', catCounts.get(c) || 0, c !== '')).join('');
const tagCounts = new Map();
for (const t of all) for (const tag of t.tags) tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
const tagNames = new Set(state.meta.tags);
for (const k of tagCounts.keys()) tagNames.add(k);
const tags = [...tagNames].sort((a, b) => (tagCounts.get(b) || 0) - (tagCounts.get(a) || 0) || a.localeCompare(b))
.map((tg) => sideItem('tag', tg, '#', tg, tagCounts.get(tg) || 0, true)).join('');
const trackers = state.meta.trackers.slice(0, 10)
.map((tr) => sideItem('tracker', tr.host, '🛰', tr.host, tr.count)).join('');
el.innerHTML = `
<div class="side-group"><div class="side-head">Status</div>${statusItems}</div>
<div class="side-group"><div class="side-head">Categories<button class="side-add" data-add="category" title="New category">+</button></div>${cats}</div>
<div class="side-group"><div class="side-head">Tags<button class="side-add" data-add="tag" title="New tag">+</button></div>${tags || '<div class="side-item dim"><span class="lbl">none</span></div>'}</div>
<div class="side-group"><div class="side-head">Trackers</div>${trackers}</div>`;
el.querySelectorAll('.side-item[data-type]').forEach((it) => {
it.addEventListener('click', () => {
state.filter = { type: it.dataset.type, value: it.dataset.value };
if (state.view !== 'torrents') { state.view = 'torrents'; switchViewTab('torrents'); }
renderSidebar(); renderView();
});
if (it.dataset.type === 'category' || it.dataset.type === 'tag') {
it.addEventListener('contextmenu', (e) =>
onSidebarContext(e, it.dataset.type, it.dataset.value, it.dataset.removable === '1'));
}
});
el.querySelectorAll('.side-add[data-add]').forEach((b) =>
b.addEventListener('click', (e) => { e.stopPropagation(); b.dataset.add === 'category' ? openCreateCategory() : openCreateTag(); }));
}
function onSidebarContext(e, type, value, removable) {
e.preventDefault();
const items = [
{ label: type === 'category' ? 'New category…' : 'New tag…', act: () => (type === 'category' ? openCreateCategory() : openCreateTag()) },
];
if (removable) {
items.push({ sep: true });
items.push({ label: `Delete ${type}`, danger: true, act: () => confirmDeleteMeta(type, value) });
}
showContextMenu(e.clientX, e.clientY, items);
}
function sideItem(type, value, ic, label, cnt, removable) {
const active = state.filter.type === type && state.filter.value === value;
return `<div class="side-item ${active ? 'active' : ''}" data-type="${type}" data-value="${f.esc(value)}" data-removable="${removable ? 1 : 0}">
<span class="ic">${ic}</span><span class="lbl">${f.esc(label)}</span><span class="cnt">${cnt}</span></div>`;
}
async function refreshMeta() {
state.meta = await api.meta();
renderSidebar();
}
/* ===================== views ===================== */
function renderView() {
const host = document.getElementById('viewHost');
if (state.view === 'torrents') return renderTorrentsView(host);
if (state.view === 'rss') return renderRssView(host);
if (state.view === 'search') return renderSearchView(host);
if (state.view === 'settings') return renderSettingsView(host);
}
/* ---------- torrents view ---------- */
function renderTorrentsView(host) {
host.innerHTML = `
<div class="torrents-view">
<div class="grid-wrap" id="gridWrap"></div>
<div id="detailHost"></div>
</div>`;
renderGrid();
if (state.detailHash) renderDetailShell(state.detailHash, document.getElementById('detailHost'));
}
function renderGrid() {
const wrap = document.getElementById('gridWrap');
if (!wrap) return;
const list = visibleTorrents();
const cols = state.columns;
const head = cols.map((k) => {
const c = COLUMNS[k];
const arrow = state.sort.key === k ? `<span class="sort">${state.sort.dir < 0 ? '▾' : '▴'}</span>` : '';
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('');
wrap.innerHTML = `<table class="grid"><thead><tr>${head}</tr></thead><tbody>${rows || emptyRow(cols.length)}</tbody></table>`;
wrap.querySelectorAll('th[data-col]').forEach((th) =>
th.addEventListener('click', () => {
const k = th.dataset.col;
if (state.sort.key === k) state.sort.dir *= -1; else state.sort = { key: k, dir: k === 'name' ? 1 : -1 };
renderGrid();
}));
wrap.querySelectorAll('tr[data-hash]').forEach((tr) => {
tr.addEventListener('click', (e) => onRowClick(e, tr.dataset.hash, list));
tr.addEventListener('dblclick', () => openDetail(tr.dataset.hash));
tr.addEventListener('contextmenu', (e) => onRowContext(e, tr.dataset.hash));
});
}
function emptyRow(span) {
return `<tr><td colspan="${span}"><div class="empty" style="height:200px">No torrents match this filter</div></td></tr>`;
}
function cell(t, k) {
switch (k) {
case 'name': return `<td><div class="name-cell">
<span class="state-dot" style="color:${f.stateColor(t.state)};background:${f.stateColor(t.state)}"></span>
${t.private ? '<span class="priv-badge">PRIV</span>' : ''}
<span title="${f.esc(t.name)}">${f.esc(t.name)}</span></div></td>`;
case 'size': return `<td class="num">${f.bytes(t.size)}</td>`;
case 'progress': return `<td>${progressBar(t)}</td>`;
case 'state': return `<td class="s-${t.state}">${f.stateLabel(t.state)}</td>`;
case 'seeds': return `<td class="num">${t.seeds}<span class="faint"> (${t.seedsTotal})</span></td>`;
case 'peers': return `<td class="num">${t.peers}<span class="faint"> (${t.peersTotal})</span></td>`;
case 'dlspeed': return `<td class="num" style="color:${t.dlspeed ? 'var(--dl)' : 'var(--txt-faint)'}">${t.dlspeed ? f.rate(t.dlspeed) : ''}</td>`;
case 'upspeed': return `<td class="num" style="color:${t.upspeed ? 'var(--up)' : 'var(--txt-faint)'}">${t.upspeed ? f.rate(t.upspeed) : ''}</td>`;
case 'eta': return `<td class="num dim">${f.eta(t.eta)}</td>`;
case 'ratio': return `<td class="num">${f.ratio(t.ratio)}</td>`;
case 'availability': return `<td class="num dim">${t.availability.toFixed(2)}</td>`;
case 'category': return `<td>${t.category ? `<span class="cat-chip">${f.esc(t.category)}</span>` : '<span class="faint">—</span>'}</td>`;
case 'tags': return `<td><div class="tags-cell">${t.tags.map((x) => `<span class="tag">${f.esc(x)}</span>`).join('') || '<span class="faint">—</span>'}</div></td>`;
case 'addedOn': return `<td class="num dim">${f.date(t.addedOn)}</td>`;
case 'completionOn': return `<td class="num dim">${f.date(t.completionOn)}</td>`;
case 'savePath': return `<td class="dim">${f.esc(t.savePath)}</td>`;
case 'priority': return `<td class="num dim">${t.priority || '—'}</td>`;
default: return '<td></td>';
}
}
function progressBar(t) {
const cls = t.progress >= 1 ? 'done' : t.state.startsWith('paused') ? 'paused' : t.state.startsWith('checking') ? 'check' : (t.state === 'error' || t.state === 'missingFiles') ? 'error' : '';
return `<div class="pbar ${cls}"><i style="width:${(t.progress * 100).toFixed(1)}%"></i><span>${f.pct(t.progress)}</span></div>`;
}
/* ---------- 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 = '';
}
}
/* ===================== 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);
}
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)?`, `
<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 () => {
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();
},
}]);
}
/* ---------- add torrent modal ---------- */
function openAddModal() {
const cats = state.meta.categories.map((c) => `<option value="${f.esc(c.name)}">${f.esc(c.name || 'Uncategorized')}</option>`).join('');
openModal('Add torrent', `
<div class="field"><label>Magnet link / URL</label>
<input type="text" id="addMagnet" placeholder="magnet:?xt=urn:btih:…" /></div>
<div class="field"><label>Or upload .torrent file(s)</label>
<input type="file" id="addFile" accept=".torrent,application/x-bittorrent" multiple />
<div id="addFileInfo" class="dim" style="margin-top:6px;font-size:12px"></div></div>
<div class="field"><label>Category</label><select id="addCat">${cats}</select></div>
<div class="field"><label>Save path</label>
<input type="text" id="addPath" value="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>
<div class="checks">
<label><input type="checkbox" id="addPaused"> Add paused</label>
<label><input type="checkbox" id="addSeq"> Sequential download</label>
<label><input type="checkbox" id="addSkip"> Skip hash check</label>
</div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Add', primary: true, act: async () => {
const magnet = document.getElementById('addMagnet').value.trim();
const common = {
category: document.getElementById('addCat').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)) });
} else if (magnet) {
await api.add({ ...common, magnet });
}
closeModal();
},
}]);
// 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) =>
`📄 <b>${f.esc(m.name)}</b> — ${f.bytes(m.size)} · ${m.pieceCount} pieces · ${(m.files || []).length || 1} file(s)`).join('<br>');
});
}
// 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) {
let i = 0;
const td = new TextDecoder('utf-8');
const readUntil = (term) => { let s = ''; while (buf[i] !== term) s += String.fromCharCode(buf[i++]); i++; return s; };
const parseStr = () => { const len = parseInt(readUntil(0x3a), 10); const out = buf.subarray(i, i + len); i += len; return out; };
const parse = () => {
const c = buf[i];
if (c === 0x69) { i++; return parseInt(readUntil(0x65), 10); } // i…e
if (c === 0x6c) { i++; const a = []; while (buf[i] !== 0x65) a.push(parse()); i++; return a; } // l…e
if (c === 0x64) { i++; const o = {}; while (buf[i] !== 0x65) { const k = td.decode(parseStr()); o[k] = parse(); } i++; return o; } // d…e
return parseStr();
};
return parse();
}
async function readTorrentFile(file) {
const td = new TextDecoder('utf-8');
try {
const meta = bdecode(new Uint8Array(await file.arrayBuffer()));
const info = meta.info;
const name = td.decode(info.name);
const pieceSize = info['piece length'];
const pieceCount = Math.round((info.pieces?.length || 0) / 20) || undefined;
let size, files;
if (info.length != null) { size = info.length; files = [{ name, size }]; }
else {
files = (info.files || []).map((fl) => ({ name: `${name}/${fl.path.map((p) => td.decode(p)).join('/')}`, size: fl.length }));
size = files.reduce((a, fl) => a + fl.size, 0);
}
return { name, size, pieceSize, pieceCount, files };
} catch {
// Not parseable as bencode — fall back to the filename.
return { name: file.name.replace(/\.torrent$/i, '') };
}
}
/* ===================== context menu ===================== */
function onRowContext(e, hash) {
e.preventDefault();
if (!state.selected.has(hash)) { state.selected.clear(); state.selected.add(hash); renderGrid(); }
const n = state.selected.size;
const items = [
{ label: 'Resume', sc: '', act: () => doAction('resume') },
{ label: 'Pause', sc: '', act: () => doAction('pause') },
{ label: 'Force start', act: () => doAction('forceStart') },
{ label: 'Force recheck', act: () => doAction('recheck') },
{ label: 'Reannounce', act: () => doAction('reannounce') },
{ sep: true },
{ sub: 'Queue' },
{ label: 'Move to top', act: () => doAction('topPriority') },
{ label: 'Move up', act: () => doAction('increasePriority') },
{ label: 'Move down', act: () => doAction('decreasePriority') },
{ label: 'Move to bottom', act: () => doAction('bottomPriority') },
{ sep: true },
{ label: 'Toggle sequential', act: () => doAction('toggleSeqDl') },
{ label: 'Toggle super seeding', act: () => doAction('toggleSuperSeeding') },
{ label: 'Set category…', act: () => promptCategory() },
{ label: 'Edit tags…', act: () => promptTags() },
{ label: 'Properties', sc: '⏎', act: () => openDetail(hash) },
{ sep: true },
{ label: `Remove ${n > 1 ? `(${n})` : ''}`, danger: true, sc: 'Del', act: () => doAction('delete') },
];
showContextMenu(e.clientX, e.clientY, items);
}
function promptCategory() {
const opts = state.meta.categories.map((c) => `<option value="${f.esc(c.name)}">${f.esc(c.name || 'Uncategorized')}</option>`).join('');
openModal('Set category', `
<div class="field"><label>Category</label><select id="catSel">${opts}</select></div>
<p class="dim" style="font-size:12px">Need a new one? Use <b>+</b> next to “Categories” in the sidebar.</p>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Apply', primary: true, act: async () => {
await api.action('setCategory', [...state.selected], { category: document.getElementById('catSel').value });
closeModal();
},
}]);
}
/* ---------- create / delete categories & tags ---------- */
function openCreateCategory() {
openModal('New category', `
<div class="field"><label>Name</label><input type="text" id="catName" placeholder="e.g. Documentaries" /></div>
<div class="field"><label>Save path</label>
<input type="text" id="catPath" placeholder="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Create', primary: true, act: async () => {
const name = document.getElementById('catName').value.trim();
if (!name) return closeModal();
await api.createCategory(name, document.getElementById('catPath').value.trim());
await refreshMeta();
closeModal();
},
}]);
setTimeout(() => document.getElementById('catName')?.focus(), 0);
}
function openCreateTag() {
openModal('New tag', `<div class="field"><label>Name</label><input type="text" id="tagName" placeholder="e.g. seed-2weeks" /></div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Create', primary: true, act: async () => {
const name = document.getElementById('tagName').value.trim();
if (!name) return closeModal();
await api.createTag(name);
await refreshMeta();
closeModal();
},
}]);
setTimeout(() => document.getElementById('tagName')?.focus(), 0);
}
function confirmDeleteMeta(type, value) {
const label = type === 'category' ? `category “${value || 'Uncategorized'}` : `tag “${value}`;
openModal(`Delete ${type}`, `<p class="dim">Remove the ${label}? It will be unassigned from all torrents. Downloaded files are not affected.</p>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Delete', cls: 'danger', primary: true, act: async () => {
if (type === 'category') await api.deleteCategory(value); else await api.deleteTag(value);
await refreshMeta();
if (state.filter.type === type && state.filter.value === value) state.filter = { type: 'status', value: 'all' };
renderView();
closeModal();
},
}]);
}
// Add/remove tags on the current selection (with inline create).
function promptTags() {
const hashes = [...state.selected];
if (!hashes.length) return;
const torrents = state.snapshot.torrents.filter((t) => hashes.includes(t.hash));
const tagRow = (tag) => {
const have = torrents.filter((t) => t.tags.includes(tag)).length;
const some = have > 0 && have < torrents.length;
return `<label class="check-row"><input type="checkbox" data-tag="${f.esc(tag)}" ${have === torrents.length ? 'checked' : ''}>
<span>${f.esc(tag)}</span>${some ? '<span class="dim"> · on some</span>' : ''}</label>`;
};
openModal(`Edit tags · ${hashes.length} torrent(s)`, `
<div class="field"><label>Add new tag</label>
<div style="display:flex;gap:8px">
<input type="text" id="newTag" placeholder="tag name" style="flex:1" />
<button class="btn" id="addTagBtn">Add</button></div></div>
<div class="taglist" id="tagList">${state.meta.tags.map(tagRow).join('') || '<span class="dim">No tags yet — add one above.</span>'}</div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Apply', primary: true, act: async () => {
const boxes = [...document.querySelectorAll('#tagList [data-tag]')];
const toAdd = boxes.filter((b) => b.checked).map((b) => b.dataset.tag);
const toRemove = boxes.filter((b) => !b.checked).map((b) => b.dataset.tag);
if (toAdd.length) await api.action('addTags', hashes, { tags: toAdd });
if (toRemove.length) await api.action('removeTags', hashes, { tags: toRemove });
closeModal();
},
}]);
const addNew = async () => {
const inp = document.getElementById('newTag');
const v = inp.value.trim();
if (!v) return;
await api.createTag(v);
await refreshMeta();
const list = document.getElementById('tagList');
if (!list.querySelector(`[data-tag="${CSS.escape(v)}"]`)) {
if (list.querySelector('span.dim') && !list.querySelector('[data-tag]')) list.innerHTML = '';
const lbl = document.createElement('label');
lbl.className = 'check-row';
lbl.innerHTML = `<input type="checkbox" data-tag="${f.esc(v)}" checked><span>${f.esc(v)}</span>`;
list.appendChild(lbl);
}
inp.value = '';
inp.focus();
};
document.getElementById('addTagBtn').addEventListener('click', addNew);
document.getElementById('newTag').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addNew(); } });
setTimeout(() => document.getElementById('newTag')?.focus(), 0);
}
function showContextMenu(x, y, items) {
closeContextMenu();
const menu = document.createElement('div');
menu.className = 'ctxmenu';
menu.id = 'ctxmenu';
menu.innerHTML = items.map((it) => {
if (it.sep) return '<div class="sep"></div>';
if (it.sub) return `<div class="sub">${it.sub}</div>`;
return `<div class="mi ${it.danger ? 'danger' : ''}" data-idx="${items.indexOf(it)}"><span>${it.label}</span><span class="sc">${it.sc || ''}</span></div>`;
}).join('');
document.body.appendChild(menu);
const r = menu.getBoundingClientRect();
menu.style.left = `${Math.min(x, window.innerWidth - r.width - 6)}px`;
menu.style.top = `${Math.min(y, window.innerHeight - r.height - 6)}px`;
menu.querySelectorAll('.mi').forEach((mi) =>
mi.addEventListener('click', () => { closeContextMenu(); items[+mi.dataset.idx].act(); }));
}
function closeContextMenu() { document.getElementById('ctxmenu')?.remove(); }
/* ===================== RSS view ===================== */
async function renderRssView(host) {
host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]);
host.innerHTML = `<div class="pane"><div class="split2">
<div>
<div class="card"><h3>Feeds</h3>
${feeds.map((fd) => `<div class="kvrow"><span style="flex:1">📡 <b>${f.esc(fd.name)}</b></span><span class="dim">${f.ago(fd.lastUpdate)}</span></div>
<div class="kvrow dim"><code class="inline">${f.esc(fd.url)}</code></div>`).join('<div style="height:8px"></div>')}
</div>
</div>
<div>
<div class="card"><h3>Unread articles</h3>
<table class="dtbl"><thead><tr><th>Feed</th><th>Title</th><th class="num">Size</th><th class="num">Published</th><th></th></tr></thead><tbody>
${feeds.flatMap((fd) => fd.articles.map((a) => `<tr style="${a.isRead ? 'opacity:.5' : ''}">
<td class="dim">${f.esc(fd.name)}</td><td>${f.esc(a.title)}</td>
<td class="num">${f.bytes(a.size)}</td><td class="num dim">${f.ago(a.date)}</td>
<td>${a.isRead ? '<span class="faint">read</span>' : '<span style="color:var(--accent)">● new</span>'}</td></tr>`)).join('')}
</tbody></table>
</div>
<h3 style="margin:16px 0 8px">Auto-download rules</h3>
${rules.map((r) => renderRule(r)).join('')}
</div>
</div></div>`;
}
function renderRule(r) {
return `<div class="rule ${r.enabled ? '' : 'off'}">
<div class="rule-head">
<span class="rule-name">${f.esc(r.name)}</span>
<span class="pill ${r.enabled ? 'on' : ''}">${r.enabled ? 'enabled' : 'disabled'}</span>
${r.useRegex ? '<span class="pill">regex</span>' : ''}
${r.addPaused ? '<span class="pill paused">add paused</span>' : ''}
</div>
<div class="kvrow">must contain <code class="inline">${f.esc(r.mustContain)}</code></div>
${r.mustNotContain ? `<div class="kvrow">must not contain <code class="inline">${f.esc(r.mustNotContain)}</code></div>` : ''}
<div class="kvrow">→ category <b>${f.esc(r.assignedCategory)}</b> · save to <b>${f.esc(r.savePath)}</b></div>
<div class="kvrow dim">feeds: ${r.affectedFeeds.join(', ')} · last match ${f.ago(r.lastMatch)}</div>
</div>`;
}
/* ===================== Search view ===================== */
function renderSearchView(host) {
const plugins = state.meta.searchPlugins.map((p) =>
`<span class="pill ${p.enabled ? 'on' : ''}">${f.esc(p.name)}</span>`).join(' ');
host.innerHTML = `<div class="pane">
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Search indexers… e.g. debian, sintel, dataset" value="${f.esc(state.searchQuery)}" />
<button class="btn primary" id="searchBtn">Search</button>
</div>
<div class="kvrow" style="margin-bottom:12px">Indexers: ${plugins}</div>
<div id="searchResults">${state.searchResults ? searchTable(state.searchResults) : '<div class="empty" style="height:200px">Enter a query to search configured indexers</div>'}</div>
</div>`;
const input = document.getElementById('searchInput');
const run = async () => {
state.searchQuery = input.value.trim();
document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Searching…</div>';
state.searchResults = await api.search(state.searchQuery);
document.getElementById('searchResults').innerHTML = searchTable(state.searchResults);
bindSearchRows();
};
document.getElementById('searchBtn').addEventListener('click', run);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
input.focus();
bindSearchRows();
}
function searchTable(rows) {
if (!rows.length) return '<div class="empty" style="height:160px">No results</div>';
return `<table class="dtbl"><thead><tr>
<th>Name</th><th class="num">Size</th><th class="num">Seeds</th><th class="num">Leeches</th>
<th>Engine</th><th class="num">Published</th><th></th></tr></thead><tbody>
${rows.map((r, i) => `<tr>
<td>${f.esc(r.name)}</td><td class="num">${f.bytes(r.size)}</td>
<td class="num" style="color:var(--up)">${r.seeds}</td><td class="num dim">${r.leeches}</td>
<td class="dim">${f.esc(r.engine)}</td><td class="num dim">${f.ago(r.pubDate)}</td>
<td><button class="btn" data-sr="${i}"> Add</button></td></tr>`).join('')}
</tbody></table>`;
}
function bindSearchRows() {
document.querySelectorAll('[data-sr]').forEach((b) =>
b.addEventListener('click', async () => {
const r = state.searchResults[+b.dataset.sr];
await api.add({ name: r.name });
b.textContent = '✓ Added'; b.disabled = true;
}));
}
/* ===================== Engine/settings view ===================== */
function renderSettingsView(host) {
const p = state.meta.preferences;
const card = (title, rows) => `<div class="card"><h3>${title}</h3>${rows.map(([k, v]) =>
`<div class="prop"><span class="k">${k}</span><span class="v mono">${v}</span></div>`).join('')}</div>`;
host.innerHTML = `<div class="pane"><div class="settings-grid">
${card('Bandwidth', [
['Global download limit', p.dl_limit ? f.rate(p.dl_limit) : '∞'],
['Global upload limit', p.up_limit ? f.rate(p.up_limit) : '∞'],
['Alt download limit', f.rate(p.alt_dl_limit)],
['Alt upload limit', f.rate(p.alt_up_limit)],
['Alt limits active', p.alt_speed_enabled ? 'yes' : 'no'],
])}
${card('Connections', [
['Global max connections', p.max_connec],
['Max per torrent', p.max_connec_per_torrent],
['Max upload slots', p.max_uploads],
['Listen port', p.listen_port],
['UPnP / NAT-PMP', p.upnp ? 'on' : 'off'],
['µTP enabled', p.utp ? 'on' : 'off'],
])}
${card('Queueing', [
['Queueing enabled', p.queueing_enabled ? 'on' : 'off'],
['Max active downloads', p.max_active_downloads],
['Max active uploads', p.max_active_uploads],
['Max active torrents', p.max_active_torrents],
])}
${card('Privacy / BitTorrent', [
['DHT', p.dht ? 'on' : 'off'],
['Peer Exchange (PeX)', p.pex ? 'on' : 'off'],
['Local Peer Discovery', p.lsd ? 'on' : 'off'],
['Encryption', ['Prefer encryption', 'Require encryption', 'Disable encryption'][p.encryption]],
])}
${card('Paths', [
['Default save path', p.save_path],
['RSS scan interval', `${p.scan_interval} min`],
])}
</div>
<p class="dim" style="margin-top:14px">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.</p>
</div>`;
}
/* ===================== status bar ===================== */
function renderStatusbar() {
const s = state.snapshot.server || {};
const el = document.getElementById('statusbar');
el.innerHTML = `
<span class="sb"><span class="led"></span><span class="conn-ok">${s.connection_status || 'connecting'}</span></span>
<span class="sb">DHT: <b>${s.dht_nodes ?? ''}</b> nodes</span>
<span class="sb">Port: <b>${s.listen_port ?? ''}</b></span>
<span class="sb">Active: <b>${s.active_torrents ?? 0}</b>/${s.total_torrents ?? 0}</span>
<span class="sb">Session ▼ <b>${f.bytes(s.dl_info_data)}</b> ▲ <b>${f.bytes(s.up_info_data)}</b></span>
<span class="sb">Global ratio: <b>${f.ratio(s.global_ratio)}</b></span>
<span class="spacer"></span>
<span class="sb">Cache hit: <b>${s.read_cache_hits ?? ''}%</b></span>
<span class="sb">Queued I/O: <b>${s.queued_io_jobs ?? 0}</b></span>
<span class="sb">Free space: <b>${f.bytes(s.free_space)}</b></span>`;
}
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 = `<h2>${f.esc(title)}</h2><div class="mbody">${bodyHtml}</div>
<div class="mfoot">${buttons.map((b, i) => `<button class="btn ${b.primary ? 'primary' : ''} ${b.cls || ''}" data-mb="${i}">${b.label}</button>`).join('')}</div>`;
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 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 === 'add') return openAddModal();
doAction(a);
}));
document.querySelectorAll('.vtab').forEach((b) =>
b.addEventListener('click', () => switchViewTab(b.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' || e.key === 'Backspace') 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();