Naut-Plugin-WebUI/public/js/app.js
ookami125 54ac49fa33 webui: tags field in the add-torrent modal
Comma-separated tags input with a datalist of existing tags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:02:11 -04:00

1279 lines
57 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/Automation/Search/Engine views, selection, context menu,
// hotkeys.
import { api } from './api.js';
import * as f from './format.js';
import { renderDetailShell, closeDetail } from './detail.js';
import {
getPluginView,
loadPlugins,
mountPluginSidebarSections,
pluginViews,
renderPluginSidebarSections,
renderPluginView,
} from './plugins.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: '',
};
let viewRenderSeq = 0;
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() {
await ensureLogin();
await loadMeta(); // retries until the server answers
await loadPlugins(api, appContext());
renderPluginViewTabs();
bindGlobal();
syncAltToggle();
renderSidebar();
renderView();
api.stream(onSnapshot, onStatus);
}
function appContext() {
return {
api,
f,
state,
toast,
refreshMeta,
setView: switchViewTab,
renderView,
selectedHashes: () => [...state.selected],
selectHashes: (hashes) => {
state.selected = new Set(hashes || []);
renderGrid();
},
};
}
async function loadMeta() {
for (;;) {
try { state.meta = await api.meta(); return; }
catch { onStatus('reconnecting'); await new Promise((r) => setTimeout(r, 2000)); }
}
}
async function ensureLogin() {
const auth = await api.authStatus();
if (auth.authenticated) return;
document.getElementById('app').hidden = true;
await new Promise((resolve) => showLogin(auth, resolve));
document.getElementById('app').hidden = false;
}
function showLogin(auth, done) {
const host = document.createElement('div');
host.className = 'login-screen';
host.innerHTML = `
<form class="login-box" id="loginForm">
<div class="brand login-brand">
<span class="brand-mark">⬡</span>
<span class="brand-name">NAUT</span>
<span class="brand-sub">torrent console</span>
</div>
<div class="field"><label>Username</label><input type="text" id="loginUser" value="${f.esc(auth.user || 'admin')}" autocomplete="username" /></div>
<div class="field"><label>Password</label><input type="password" id="loginPass" autocomplete="current-password" /></div>
<div class="login-error" id="loginError" hidden></div>
<button class="btn primary login-submit" type="submit">Sign in</button>
${auth.generatedPassword ? '<p class="dim login-note">A password was generated for this server process. Check the server console output.</p>' : ''}
</form>`;
document.body.appendChild(host);
const form = host.querySelector('#loginForm');
const err = host.querySelector('#loginError');
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
form.querySelector('.login-submit').disabled = true;
try {
await api.login(host.querySelector('#loginUser').value.trim(), host.querySelector('#loginPass').value);
host.remove();
done();
} catch {
err.textContent = 'Invalid username or password';
err.hidden = false;
form.querySelector('.login-submit').disabled = false;
host.querySelector('#loginPass').select();
}
});
setTimeout(() => host.querySelector('#loginPass')?.focus(), 0);
}
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();
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)';
setBanner(s === 'connected' ? '' : 'Reconnecting to server…');
}
/* ===================== 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 (t.trackerHosts || []).includes(value);
// 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('');
const pluginSections = renderPluginSidebarSections(appContext());
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>
${pluginSections}`;
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(); }));
mountPluginSidebarSections(el, appContext());
}
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 seq = ++viewRenderSeq;
const host = document.getElementById('viewHost');
if (state.view === 'torrents') return renderTorrentsView(host);
if (state.view === 'rss') return renderRssView(host, seq);
if (state.view === 'automation') return renderAutomationView(host, seq);
if (state.view === 'search') return renderSearchView(host);
if (state.view === 'settings') return renderSettingsView(host);
if (getPluginView(state.view)) return renderPluginView(state.view, host, appContext());
host.innerHTML = '<div class="pane"><div class="empty">Unknown view</div></div>';
}
/* ---------- 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'));
}
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];
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, 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));
});
}
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 = '';
}
}
/* ===================== 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)?`, `
<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: () => 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 ---------- */
function openAddModal() {
const cats = '<option value="" selected>Uncategorized</option>' +
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>Tags</label>
<input type="text" id="addTags" placeholder="comma-separated, e.g. anime, airing"${
state.meta.tags.length ? ` list="addTagList"` : ''} />
${state.meta.tags.length ? `<datalist id="addTagList">${state.meta.tags.map((t) => `<option value="${f.esc(t)}">`).join('')}</datalist>` : ''}</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: () => guard(async () => {
const magnet = document.getElementById('addMagnet').value.trim();
const common = {
category: document.getElementById('addCat').value,
tags: document.getElementById('addTags').value.split(',').map((s) => s.trim()).filter(Boolean),
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'),
}]);
// 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>');
});
}
/* ---------- 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>Current location</label>
<div class="dim" style="font-family:var(--mono);font-size:12px;word-break:break-all">${f.esc(cur?.savePath || '—')}</div></div>
<div class="field"><label>New save path</label><input type="text" id="locPath" value="${f.esc(cur?.savePath || '')}" /></div>
<label class="field" style="flex-direction:row;align-items:center;gap:8px">
<input type="checkbox" id="locReset" />
<span>Reset files to their original paths</span></label>
<p class="dim" style="font-size:12px">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).</p>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Apply', primary: true, act: () => guard(async () => {
const savePath = document.getElementById('locPath').value.trim();
const reset = document.getElementById('locReset').checked;
if (savePath) await api.action('setSavePath', hashes, { savePath, reset });
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) {
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();
}
function bytesToBase64(bytes) {
let bin = '';
const chunk = 0x8000; // avoid arg-count limits on String.fromCharCode
for (let i = 0; i < bytes.length; i += chunk)
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
return btoa(bin);
}
async function readTorrentFile(file) {
const td = new TextDecoder('utf-8');
const bytes = new Uint8Array(await file.arrayBuffer());
// The raw .torrent bytes are what the server actually adds; the parsed
// fields below are only for the modal's preview readout.
const data = bytesToBase64(bytes);
try {
const meta = bdecode(bytes);
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, data };
} catch {
// Not parseable as bencode — still upload the bytes; the server validates.
return { name: file.name.replace(/\.torrent$/i, ''), data };
}
}
/* ===================== 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() },
{ 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') },
];
showContextMenu(e.clientX, e.clientY, items);
}
function promptCategory() {
const cur = state.snapshot?.torrents?.find((t) => t.hash === [...state.selected][0])?.category || '';
const sel = (v) => (v === cur ? ' selected' : '');
const opts = `<option value=""${sel('')}>Uncategorized</option>` +
state.meta.categories.map((c) => `<option value="${f.esc(c.name)}"${sel(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, seq) {
host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]);
if (seq !== viewRenderSeq || state.view !== 'rss') return;
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>`;
}
/* ===================== Automation view ===================== */
function highlightLua(source) {
const tokenRe = /--\[\[[\s\S]*?\]\]|\[\[[\s\S]*?\]\]|--[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b(?:and|break|do|else|elseif|end|false|for|function|goto|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b|\b\d+(?:\.\d+)?\b|\b(?:naut|event)\b/g;
let out = '';
let last = 0;
let m;
while ((m = tokenRe.exec(source)) !== null) {
const token = m[0];
out += f.esc(source.slice(last, m.index));
let cls = 'tok-keyword';
if (token.startsWith('--')) cls = 'tok-comment';
else if (token[0] === '"' || token[0] === "'" || token.startsWith('[[')) cls = 'tok-string';
else if (/^\d/.test(token)) cls = 'tok-number';
else if (token === 'naut' || token === 'event') cls = 'tok-api';
out += `<span class="${cls}">${f.esc(token)}</span>`;
last = tokenRe.lastIndex;
}
out += f.esc(source.slice(last));
return out;
}
function syncScriptHighlight(editor, highlight) {
highlight.innerHTML = `${highlightLua(editor.value)}\n`;
highlight.parentElement.scrollTop = editor.scrollTop;
highlight.parentElement.scrollLeft = editor.scrollLeft;
}
// One form control per declared setting, keyed for collection on save.
function settingField(s) {
const key = f.esc(s.key);
const label = f.esc(s.label || s.key);
const val = s.value ?? s.default ?? '';
if (s.type === 'bool') {
const on = String(val) === 'true' || String(val) === '1';
return `<label class="setting-field bool">
<input type="checkbox" data-skey="${key}" data-stype="bool" ${on ? 'checked' : ''}>
<span class="setting-label">${label}</span>
</label>`;
}
const inputType = s.type === 'number' ? 'number' : 'text';
return `<label class="setting-field">
<span class="setting-label">${label}</span>
<input type="${inputType}" data-skey="${key}" data-stype="${f.esc(s.type || 'string')}"
value="${f.esc(String(val))}" spellcheck="false">
</label>`;
}
function settingsPanel(settings) {
const list = Array.isArray(settings) ? settings : [];
const body = list.length
? list.map(settingField).join('')
: `<div class="dim" style="padding:4px 0">This script exposes no settings.
Call <code>naut.define_settings{…}</code> in the script to add configurable
variables here.</div>`;
return `<aside class="pane automation-settings">
<div class="automation-head">
<div>
<h3>Settings</h3>
<div class="dim">Configure the script without editing it</div>
</div>
<div class="script-actions">
<span class="script-save-status" id="settingsSaveStatus"></span>
<button class="btn primary" id="saveSettings" ${list.length ? '' : 'disabled'}>Save</button>
</div>
</div>
<div class="settings-form" id="settingsForm">${body}</div>
</aside>`;
}
async function renderAutomationView(host, seq) {
host.innerHTML = '<div class="pane"><div class="empty">Loading automation script…</div></div>';
let script;
try {
script = await api.script();
} catch (e) {
if (seq !== viewRenderSeq || state.view !== 'automation') return;
host.innerHTML = '<div class="pane"><div class="empty">Unable to load script status</div></div>';
return;
}
if (seq !== viewRenderSeq || state.view !== 'automation') return;
const stat = (label, value) => `<div class="script-stat"><span>${label}</span><b>${value ?? 0}</b></div>`;
const source = script.source || '';
host.innerHTML = `<div class="automation-layout">
<div class="pane automation-main">
<div class="automation-head">
<div>
<h3>Automation Script</h3>
<div class="dim">${script.loaded ? f.esc(script.path || 'loaded') : 'No script loaded'}</div>
</div>
<div class="script-actions">
<span class="script-save-status" id="scriptSaveStatus"></span>
<button class="btn primary" id="saveScript" ${script.loaded ? '' : 'disabled'}>Save</button>
<button class="btn" id="refreshScript">Revert</button>
</div>
</div>
<div class="script-stats">
${stat('Queued', script.queued)}
${stat('Handled', script.handled)}
${stat('Dropped', script.dropped)}
${stat('Errors', script.errors)}
${stat('Move requests', script.move_requests)}
</div>
${script.last_error ? `<div class="script-error"><b>Last error</b><code>${f.esc(script.last_error)}</code></div>` : ''}
<div class="script-editor ${script.loaded ? '' : 'disabled'}">
<pre class="script-highlight" aria-hidden="true"><code id="scriptHighlight"></code></pre>
<textarea id="scriptEditor" spellcheck="false" ${script.loaded ? '' : 'disabled'}>${f.esc(script.loaded ? source : '-- no script loaded')}</textarea>
</div>
</div>
${settingsPanel(script.settings)}
</div>`;
bindSettingsPanel(script);
const editor = document.getElementById('scriptEditor');
const highlight = document.getElementById('scriptHighlight');
const saveBtn = document.getElementById('saveScript');
const refreshBtn = document.getElementById('refreshScript');
const status = document.getElementById('scriptSaveStatus');
let initialSource = script.loaded ? source : editor.value;
let saving = false;
const setStatus = (message, kind = '') => {
status.textContent = message;
status.className = `script-save-status ${kind}`;
};
const updateDirty = (preserveStatus = false) => {
const dirty = editor.value !== initialSource;
saveBtn.disabled = !script.loaded || !dirty || saving;
if (!saving && !preserveStatus) setStatus(dirty ? 'Unsaved changes' : '', dirty ? 'dirty' : '');
};
syncScriptHighlight(editor, highlight);
editor.addEventListener('input', () => {
syncScriptHighlight(editor, highlight);
updateDirty();
});
editor.addEventListener('scroll', () => syncScriptHighlight(editor, highlight));
editor.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
editor.setRangeText(' ', editor.selectionStart, editor.selectionEnd, 'end');
editor.dispatchEvent(new Event('input'));
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
if (!saveBtn.disabled) saveBtn.click();
}
});
saveBtn.addEventListener('click', async () => {
saving = true;
saveBtn.disabled = true;
setStatus('Saving…');
try {
const updated = await api.saveScript(editor.value);
initialSource = updated.source || editor.value;
editor.value = initialSource;
syncScriptHighlight(editor, highlight);
script = updated;
saving = false;
updateDirty(true);
setStatus('Saved', 'ok');
toast('Script saved', 'ok');
} catch (e) {
console.error(e);
saving = false;
updateDirty(true);
setStatus('Save failed', 'err');
toast('Script save failed', 'err');
}
});
refreshBtn.addEventListener('click', () => renderAutomationView(host, viewRenderSeq));
updateDirty();
}
// Wire the script-settings form: collect values by key and save them, without
// touching the script source.
function bindSettingsPanel(script) {
const form = document.getElementById('settingsForm');
const saveBtn = document.getElementById('saveSettings');
const status = document.getElementById('settingsSaveStatus');
if (!form || !saveBtn) return;
const setStatus = (msg, kind = '') => {
status.textContent = msg;
status.className = `script-save-status ${kind}`;
};
const collect = () => {
const out = {};
form.querySelectorAll('[data-skey]').forEach((el) => {
out[el.dataset.skey] = el.dataset.stype === 'bool'
? (el.checked ? 'true' : 'false')
: el.value;
});
return out;
};
form.addEventListener('input', () => setStatus('Unsaved changes', 'dirty'));
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
setStatus('Saving…');
try {
await api.saveScriptSettings(collect());
setStatus('Saved', 'ok');
toast('Settings saved', 'ok');
} catch (e) {
console.error(e);
setStatus('Save failed', 'err');
toast('Settings save failed', 'err');
} finally {
saveBtn.disabled = false;
}
});
}
/* ===================== 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 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();