Naut-Plugin-WebUI/public/js/app.js
ookami125 d1b90cfdcb webui: fix relative timestamps (seconds, not milliseconds)
f.date/f.ago assumed JS milliseconds, but the backend emits Unix epoch
seconds everywhere (created_at, feed lastUpdate, rule lastMatch, …), so
every real timestamp rendered as ~20607 days ago (now - ~1.78e9 ms).
Treat the formatter input as seconds. Search results show the indexer's
pubDate string directly rather than mis-parsing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:25:43 -04:00

1698 lines
80 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: '',
auth: { user: '', role: '' },
};
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() {
let auth = await api.authStatus();
if (!auth.authenticated) {
document.getElementById('app').hidden = true;
auth = await new Promise((resolve) => showLogin(auth, resolve));
document.getElementById('app').hidden = false;
}
state.auth = { user: auth.user || '', role: auth.role || '' };
}
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 {
const res = await api.login(host.querySelector('#loginUser').value.trim(), host.querySelector('#loginPass').value);
host.remove();
done(res || {});
} 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 (type === 'category') {
items.push({ label: 'Edit category…', act: () => openEditCategory(value) });
}
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 ---------- */
// Default download location for a category: its own save path if set,
// otherwise the global default.
function categoryDefaultPath(catName) {
const c = state.meta.categories.find((x) => x.name === catName);
if (c && c.savePath) return c.savePath;
return state.meta.preferences.save_path || '/data/downloads';
}
function openAddModal() {
const cats = '<option value="" selected>Uncategorized</option>' +
state.meta.categories.filter((c) => c.name).map((c) => `<option value="${f.esc(c.name)}">${f.esc(c.name)}</option>`).join('');
const tagOpts = state.meta.tags.map((t) =>
`<label class="cbdrop-item"><input type="checkbox" value="${f.esc(t)}"><span>${f.esc(t)}</span></label>`).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>
<div class="cbdrop">
<button type="button" class="cbdrop-btn" id="addTagsBtn">
<span id="addTagsLabel">No tags selected</span><span class="cbdrop-caret">▾</span></button>
<div class="cbdrop-menu" id="addTagsMenu" hidden>
<div id="addTagsList">${tagOpts || '<div class="dim" style="padding:4px 4px 8px">No tags yet — add one below.</div>'}</div>
<div class="cbdrop-new"><input type="text" id="addTagNew" placeholder="New tag…" />
<button type="button" class="btn" id="addTagAdd">Add</button></div>
</div>
</div></div>
<div class="field"><label>Save path</label>
<input type="text" id="addPath" class="path-default" readonly title="Click to change"
value="${f.esc(categoryDefaultPath(''))}" />
<div class="dim" id="addPathHint" style="font-size:12px;margin-top:4px">Category default — click to change.</div></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.querySelectorAll('#addTagsList input:checked')].map((c) => c.value),
savePath: document.getElementById('addPath').value.trim(),
paused: document.getElementById('addPaused').checked,
seqDl: document.getElementById('addSeq').checked,
skipCheck: document.getElementById('addSkip').checked,
};
const files = [...document.getElementById('addFile').files];
if (files.length) {
for (const file of files) await api.add({ ...common, ...(await readTorrentFile(file)) });
toast(`Added ${files.length} torrent(s)`, 'ok');
} else if (magnet) {
await api.add({ ...common, magnet });
toast('Torrent added', 'ok');
}
closeModal();
}, 'Failed to add torrent'),
}]);
// tags checkbox dropdown: a fixed-position menu that floats over the dialog
(() => {
const btn = document.getElementById('addTagsBtn');
const menu = document.getElementById('addTagsMenu');
const list = document.getElementById('addTagsList');
const label = document.getElementById('addTagsLabel');
const input = document.getElementById('addTagNew');
const refresh = () => {
const sel = [...list.querySelectorAll('input:checked')].map((c) => c.value);
label.textContent = sel.length ? sel.join(', ') : 'No tags selected';
};
const addTag = () => {
const name = input.value.trim();
if (!name) return;
let cb = [...list.querySelectorAll('input')].find((c) => c.value === name);
if (!cb) {
const ph = list.querySelector('.dim'); if (ph) ph.remove();
const lbl = document.createElement('label');
lbl.className = 'cbdrop-item';
lbl.innerHTML = '<input type="checkbox"><span></span>';
cb = lbl.querySelector('input');
cb.value = name;
lbl.querySelector('span').textContent = name;
list.appendChild(lbl);
}
cb.checked = true;
input.value = '';
refresh();
place();
};
// Anchor the floating menu under the button and keep it on-screen.
const place = () => {
const r = btn.getBoundingClientRect();
menu.style.left = r.left + 'px';
menu.style.width = r.width + 'px';
menu.style.top = (r.bottom + 4) + 'px';
menu.style.maxHeight = Math.max(120, window.innerHeight - r.bottom - 16) + 'px';
};
let onOutside; let onReflow;
const close = () => {
menu.hidden = true;
document.removeEventListener('mousedown', onOutside, true);
document.removeEventListener('scroll', onReflow, true);
window.removeEventListener('resize', onReflow);
};
const open = () => {
menu.hidden = false;
place();
onOutside = (e) => { if (!menu.contains(e.target) && !btn.contains(e.target)) close(); };
onReflow = () => place();
document.addEventListener('mousedown', onOutside, true);
document.addEventListener('scroll', onReflow, true); // reposition on modal scroll
window.addEventListener('resize', onReflow);
};
btn.addEventListener('click', () => (menu.hidden ? open() : close()));
list.addEventListener('change', refresh);
document.getElementById('addTagAdd').addEventListener('click', addTag);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(); } });
})();
// save path: greyed default that tracks the category until the user edits it
(() => {
const path = document.getElementById('addPath');
const cat = document.getElementById('addCat');
const hint = document.getElementById('addPathHint');
let manual = false;
const enable = () => {
if (manual) return;
manual = true;
path.readOnly = false;
path.classList.remove('path-default');
hint.textContent = "Custom location — won't change with the category.";
path.focus();
path.select();
};
path.addEventListener('mousedown', (e) => { if (!manual) { e.preventDefault(); enable(); } });
cat.addEventListener('change', () => {
if (!manual) path.value = categoryDefaultPath(cat.value);
});
})();
// live readout of the chosen file(s)
document.getElementById('addFile').addEventListener('change', async (e) => {
const info = document.getElementById('addFileInfo');
const files = [...e.target.files];
if (!files.length) { info.textContent = ''; return; }
const metas = await Promise.all(files.map(readTorrentFile));
info.innerHTML = metas.map((m) =>
`📄 <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.filter((c) => c.name).map((c) => `<option value="${f.esc(c.name)}"${sel(c.name)}>${f.esc(c.name)}</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 openEditCategory(value) {
const isUncat = value === '';
const cur = state.meta.categories.find((c) => c.name === value);
const curPath = cur ? (cur.savePath || '') : '';
const nameField = isUncat
? `<div class="field"><label>Name</label><input type="text" value="Uncategorized" disabled /></div>`
: `<div class="field"><label>Name</label><input type="text" id="catName" value="${f.esc(value)}" /></div>`;
openModal('Edit category', `
${nameField}
<div class="field"><label>Save path</label>
<input type="text" id="catPath" value="${f.esc(curPath)}" placeholder="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Save', primary: true, act: async () => {
const newName = isUncat ? '' : document.getElementById('catName').value.trim();
if (!isUncat && !newName) return closeModal();
const savePath = document.getElementById('catPath').value.trim();
await api.editCategory(value, newName, savePath);
await refreshMeta();
if (!isUncat && newName !== value && state.filter.type === 'category' && state.filter.value === value)
state.filter = { type: 'category', value: newName };
renderView();
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;
// flatten articles, newest first, tagged with their feed
const articles = feeds.flatMap((fd) => fd.articles.map((a) => ({ ...a, feed: fd.name })))
.sort((a, b) => (b.pubDate || '').localeCompare(a.pubDate || ''));
host.innerHTML = `<div class="pane"><div class="split2">
<div>
<div class="card">
<div class="card-head"><h3>Feeds</h3><span>
<button class="btn" id="refreshAllBtn" title="Re-fetch all feeds now">⟳ Refresh all</button>
<button class="btn" id="addFeedBtn"> Add feed</button></span></div>
${feeds.length ? feeds.map((fd) => `<div class="feed-row" data-feed="${f.esc(fd.name)}">
<div class="kvrow"><span style="flex:1">📡 <b>${f.esc(fd.name)}</b> <span class="dim">(${fd.articles.length})</span></span>
<span class="dim">${fd.lastUpdate ? f.ago(fd.lastUpdate) : 'not fetched yet'}</span>
<button class="iconbtn repull-feed" title="Re-fetch this feed now">⟳</button>
<button class="iconbtn del-feed" title="Remove feed">✕</button></div>
<div class="kvrow dim"><code class="inline">${f.esc(fd.url)}</code></div>
</div>`).join('<div style="height:8px"></div>') : '<div class="dim">No feeds yet. Add one to start polling.</div>'}
</div>
</div>
<div>
<div class="card"><h3>Articles</h3>
${articles.length ? `<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>
${articles.map((a, i) => `<tr style="${a.isRead ? 'opacity:.5' : ''}">
<td class="dim">${f.esc(a.feed)}</td><td>${f.esc(a.title)}</td>
<td class="num">${a.size ? f.bytes(a.size) : '—'}</td><td class="num dim">${f.esc(a.pubDate || '')}</td>
<td>${(a.magnet || a.torrentUrl || a.link)
? `<button class="btn dl-art" data-art="${i}"${a.grabbed ? ' disabled' : ''}>${a.grabbed ? '✓ Added' : ' Add'}</button>`
: '<span class="faint">—</span>'}</td></tr>`).join('')}
</tbody></table>` : '<div class="dim">No articles yet.</div>'}
</div>
<div class="card-head" style="margin-top:16px"><h3>Auto-download rules</h3><button class="btn" id="addRuleBtn"> New rule</button></div>
${rules.length ? rules.map((r) => renderRule(r)).join('') : '<div class="dim">No rules yet. New matching articles are not auto-downloaded.</div>'}
</div>
</div></div>`;
document.getElementById('addFeedBtn').addEventListener('click', openAddFeed);
document.getElementById('addRuleBtn').addEventListener('click', () => openRuleEditor(null, feeds));
document.getElementById('refreshAllBtn').addEventListener('click', (e) => guard(async () => {
e.target.disabled = true; e.target.textContent = '⟳ Refreshing…';
await api.refreshFeeds();
renderView();
}, 'Failed to refresh feeds'));
host.querySelectorAll('.repull-feed').forEach((b) => b.addEventListener('click', () => guard(async () => {
const name = b.closest('.feed-row').dataset.feed;
b.disabled = true;
await api.refreshFeeds(name);
renderView();
}, 'Failed to refresh feed')));
host.querySelectorAll('.del-feed').forEach((b) => b.addEventListener('click', async () => {
const name = b.closest('.feed-row').dataset.feed;
await guard(() => api.deleteFeed(name), 'Failed to remove feed');
renderView();
}));
host.querySelectorAll('.dl-art').forEach((b) => b.addEventListener('click', () => guard(async () => {
const a = articles[+b.dataset.art];
await api.rssDownload({ title: a.title || '', magnet: a.magnet || '', torrentUrl: a.torrentUrl || a.link || '', key: a.key || '' });
b.textContent = '✓ Added'; b.disabled = true;
}, 'Failed to add torrent')));
host.querySelectorAll('[data-rule-edit]').forEach((b) => b.addEventListener('click', () =>
openRuleEditor(rules.find((r) => r.name === b.dataset.ruleEdit), feeds)));
host.querySelectorAll('[data-rule-run]').forEach((b) => b.addEventListener('click', () => guard(async () => {
const res = await api.runRssRule(b.dataset.ruleRun);
toast(`Rule run: ${res.grabbed} added of ${res.matched} match(es)`, 'ok');
renderView();
}, 'Failed to run rule')));
host.querySelectorAll('[data-rule-del]').forEach((b) => b.addEventListener('click', async () => {
await guard(() => api.deleteRssRule(b.dataset.ruleDel), 'Failed to delete rule');
renderView();
}));
}
function openAddFeed() {
openModal('Add RSS feed', `
<div class="field"><label>Name</label><input type="text" id="feedName" placeholder="e.g. EZTV" /></div>
<div class="field"><label>Feed URL</label><input type="text" id="feedUrl" placeholder="https://…/rss.xml" /></div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Add', primary: true, act: () => guard(async () => {
const name = document.getElementById('feedName').value.trim();
const url = document.getElementById('feedUrl').value.trim();
if (!name || !url) return closeModal();
await api.addFeed(name, url);
closeModal();
renderView();
}, 'Failed to add feed'),
}]);
setTimeout(() => document.getElementById('feedName')?.focus(), 0);
}
// Rule editor: create (rule=null) or edit an existing auto-download rule.
function openRuleEditor(rule, feeds) {
const r = rule || { name: '', enabled: true, useRegex: false, addPaused: false,
mustContain: '', mustNotContain: '', assignedCategory: '', savePath: '', affectedFeeds: [] };
const catOpts = '<option value="">— none —</option>' +
state.meta.categories.filter((c) => c.name).map((c) =>
`<option value="${f.esc(c.name)}"${c.name === r.assignedCategory ? ' selected' : ''}>${f.esc(c.name)}</option>`).join('');
const feedChecks = (feeds || []).map((fd) =>
`<label class="cbdrop-item"><input type="checkbox" value="${f.esc(fd.name)}"${r.affectedFeeds.includes(fd.name) ? ' checked' : ''}><span>${f.esc(fd.name)}</span></label>`).join('')
|| '<div class="dim" style="padding:4px">No feeds yet.</div>';
openModal(rule ? 'Edit rule' : 'New rule', `
<div class="field"><label>Rule name</label><input type="text" id="rName" value="${f.esc(r.name)}" ${rule ? 'readonly' : ''} placeholder="e.g. My show 1080p" /></div>
<div class="field"><label>Must contain</label><input type="text" id="rMust" value="${f.esc(r.mustContain)}" placeholder="title substring or regex" /></div>
<div class="field"><label>Must not contain</label><input type="text" id="rMustNot" value="${f.esc(r.mustNotContain)}" placeholder="optional" /></div>
<div class="field"><label>Apply to feeds (none = all)</label><div class="rule-feeds">${feedChecks}</div></div>
<div class="field"><label>Assign category</label><select id="rCat">${catOpts}</select></div>
<div class="field"><label>Save path (blank = category/default)</label><input type="text" id="rPath" value="${f.esc(r.savePath)}" placeholder="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>
<div class="checks">
<label><input type="checkbox" id="rEnabled" ${r.enabled ? 'checked' : ''}> Enabled</label>
<label><input type="checkbox" id="rRegex" ${r.useRegex ? 'checked' : ''}> Use regex</label>
<label><input type="checkbox" id="rPaused" ${r.addPaused ? 'checked' : ''}> Add paused</label>
</div>
<div class="field" style="margin-top:6px"><label>Current matches <span id="rMatchCount" class="dim"></span></label>
<div id="rMatches" class="rule-matches"></div></div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Save', primary: true, act: () => guard(async () => {
const name = document.getElementById('rName').value.trim();
if (!name) return closeModal();
await api.saveRssRule({
name,
enabled: document.getElementById('rEnabled').checked,
useRegex: document.getElementById('rRegex').checked,
addPaused: document.getElementById('rPaused').checked,
mustContain: document.getElementById('rMust').value.trim(),
mustNotContain: document.getElementById('rMustNot').value.trim(),
assignedCategory: document.getElementById('rCat').value,
savePath: document.getElementById('rPath').value.trim(),
affectedFeeds: [...document.querySelectorAll('.rule-feeds input:checked')].map((c) => c.value),
});
closeModal();
renderView();
}, 'Failed to save rule'),
}]);
// Live preview of which current articles this rule matches.
const allArticles = (feeds || []).flatMap((fd) => fd.articles.map((a) => ({ title: a.title, feed: fd.name, grabbed: a.grabbed })));
const updateMatches = () => {
const opts = {
must: document.getElementById('rMust').value.trim(),
mustNot: document.getElementById('rMustNot').value.trim(),
regex: document.getElementById('rRegex').checked,
feeds: [...document.querySelectorAll('.rule-feeds input:checked')].map((c) => c.value),
};
const matches = allArticles.filter((a) => ruleMatchesArticle(opts, a.feed, a.title));
document.getElementById('rMatchCount').textContent = `${matches.length} of ${allArticles.length} article(s)`;
const box = document.getElementById('rMatches');
box.innerHTML = matches.length
? matches.slice(0, 50).map((a) => `<div class="match-row${a.grabbed ? ' grabbed' : ''}">
<span class="dim">${f.esc(a.feed)}</span> ${f.esc(a.title)}${a.grabbed ? ' <span class="faint">(grabbed)</span>' : ''}</div>`).join('')
+ (matches.length > 50 ? `<div class="dim" style="padding:4px">…and ${matches.length - 50} more</div>` : '')
: '<div class="dim" style="padding:4px">No current articles match.</div>';
};
['rMust', 'rMustNot'].forEach((id) => document.getElementById(id).addEventListener('input', updateMatches));
document.getElementById('rRegex').addEventListener('change', updateMatches);
document.querySelectorAll('.rule-feeds input').forEach((c) => c.addEventListener('change', updateMatches));
updateMatches();
setTimeout(() => document.getElementById(rule ? 'rMust' : 'rName')?.focus(), 0);
}
// Client-side mirror of the daemon's rule matcher (for live preview).
function ruleMatchesArticle(opts, feedName, title) {
if (opts.feeds && opts.feeds.length && !opts.feeds.includes(feedName)) return false;
if (opts.regex) {
try {
if (opts.must && !new RegExp(opts.must, 'i').test(title)) return false;
if (opts.mustNot && new RegExp(opts.mustNot, 'i').test(title)) return false;
} catch (e) { return false; } // invalid regex matches nothing
} else {
const t = title.toLowerCase();
if (opts.must && !t.includes(opts.must.toLowerCase())) return false;
if (opts.mustNot && t.includes(opts.mustNot.toLowerCase())) return false;
}
return true;
}
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>' : ''}
<span style="flex:1"></span>
<button class="iconbtn" data-rule-run="${f.esc(r.name)}" title="Run now against existing articles">⏵</button>
<button class="iconbtn" data-rule-edit="${f.esc(r.name)}" title="Edit rule">✎</button>
<button class="iconbtn" data-rule-del="${f.esc(r.name)}" title="Delete rule">✕</button>
</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 || 'default')}</b></div>
<div class="kvrow dim">feeds: ${r.affectedFeeds.length ? r.affectedFeeds.join(', ') : 'all'} · last match ${r.lastMatch ? f.ago(r.lastMatch) : 'never'}</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 idx = state.meta.searchPlugins || [];
const plugins = idx.length
? idx.map((p) => `<span class="pill ${p.enabled ? 'on' : ''}">${f.esc(p.name)}</span>`).join(' ')
: '<span class="dim">none configured</span>';
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}
<span style="flex:1"></span><button class="btn" id="manageIdxBtn">Manage indexers</button></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();
if (!state.searchQuery) return;
document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Searching…</div>';
try { state.searchResults = await api.search(state.searchQuery); }
catch (e) { document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Search failed</div>'; return; }
document.getElementById('searchResults').innerHTML = searchTable(state.searchResults);
bindSearchRows();
};
document.getElementById('searchBtn').addEventListener('click', run);
document.getElementById('manageIdxBtn').addEventListener('click', openIndexerManager);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
input.focus();
bindSearchRows();
}
// Manage Torznab indexers (list, add, remove).
function openIndexerManager() {
const idx = state.meta.searchPlugins || [];
const rows = idx.length ? idx.map((p) => `<div class="kvrow" data-idx="${f.esc(p.name)}">
<span style="flex:1">🔎 <b>${f.esc(p.name)}</b> <code class="inline">${f.esc(p.url || '')}</code></span>
<button class="iconbtn del-idx" title="Remove">✕</button></div>`).join('')
: '<div class="dim">No indexers yet.</div>';
openModal('Torznab indexers', `
<div id="idxList" style="margin-bottom:12px">${rows}</div>
<div class="field"><label>Name</label><input type="text" id="ixName" placeholder="e.g. Jackett/Prowlarr indexer" /></div>
<div class="field"><label>Torznab API URL</label><input type="text" id="ixUrl" placeholder="https://host/api/v2.0/indexers/.../results/torznab/api" /></div>
<div class="field"><label>API key</label><input type="text" id="ixKey" placeholder="optional" /></div>`,
[{ label: 'Close', act: () => { closeModal(); refreshMeta().then(renderView); } }, {
label: 'Add indexer', primary: true, act: () => guard(async () => {
const name = document.getElementById('ixName').value.trim();
const url = document.getElementById('ixUrl').value.trim();
if (!name || !url) return;
await api.saveIndexer({ name, url, apikey: document.getElementById('ixKey').value.trim(), enabled: true });
await refreshMeta();
openIndexerManager();
}, 'Failed to add indexer'),
}]);
document.querySelectorAll('.del-idx').forEach((b) => b.addEventListener('click', async () => {
await guard(() => api.deleteIndexer(b.closest('[data-idx]').dataset.idx), 'Failed to remove indexer');
await refreshMeta();
openIndexerManager();
}));
}
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">${r.pubDate ? f.esc(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', () => guard(async () => {
const r = state.searchResults[+b.dataset.sr];
await api.rssDownload({ title: r.name || '', magnet: r.magnet || '', torrentUrl: r.torrentUrl || '' });
b.textContent = '✓ Added'; b.disabled = true;
}, 'Failed to add torrent')));
}
/* ===================== Engine/settings view ===================== */
async function renderSettingsView(host) {
const isAdmin = state.auth.role === 'admin';
let users = [];
if (isAdmin) { try { users = await api.listUsers(); } catch (e) { users = []; } }
if (state.view !== 'settings') return;
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>`;
const accountCard = `<div class="card"><div class="card-head"><h3>Account</h3>
<button class="btn" id="changePwBtn">Change password</button></div>
<div class="prop"><span class="k">Signed in as</span><span class="v mono">${f.esc(state.auth.user)}</span></div>
<div class="prop"><span class="k">Role</span><span class="v mono">${f.esc(state.auth.role)}</span></div></div>`;
const usersCard = isAdmin ? `<div class="card"><div class="card-head"><h3>Users</h3>
<button class="btn" id="addUserBtn"> Add user</button></div>
<table class="dtbl"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead><tbody>
${users.map((u) => `<tr data-user="${f.esc(u.username)}">
<td>${f.esc(u.username)}</td>
<td><span class="pill ${u.role === 'admin' ? 'on' : ''}">${f.esc(u.role)}</span></td>
<td class="dim">${u.createdAt ? f.ago(u.createdAt) : '—'}</td>
<td style="white-space:nowrap">
<button class="iconbtn u-role" title="Toggle admin/user">${u.role === 'admin' ? '▼ user' : '▲ admin'}</button>
<button class="iconbtn u-pw" title="Reset password">✎</button>
<button class="iconbtn u-del" title="Delete user">✕</button>
</td></tr>`).join('')}
</tbody></table></div>` : '';
host.innerHTML = `<div class="pane">
<div class="settings-grid">
${accountCard}
${usersCard}
${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>`;
document.getElementById('changePwBtn')?.addEventListener('click', openChangePassword);
document.getElementById('addUserBtn')?.addEventListener('click', openAddUser);
host.querySelectorAll('.u-del').forEach((b) => b.addEventListener('click', () => guard(async () => {
const u = b.closest('[data-user]').dataset.user;
if (u === state.auth.user && !confirm('Delete your own account? You will be signed out.')) return;
await api.deleteUser(u);
if (u === state.auth.user) return location.reload();
renderView();
}, 'Failed to delete user')));
host.querySelectorAll('.u-pw').forEach((b) => b.addEventListener('click', () =>
openResetPassword(b.closest('[data-user]').dataset.user)));
host.querySelectorAll('.u-role').forEach((b) => b.addEventListener('click', () => guard(async () => {
const row = b.closest('[data-user]');
const u = row.dataset.user;
const cur = users.find((x) => x.username === u);
await api.setUserRole(u, cur && cur.role === 'admin' ? 'user' : 'admin');
renderView();
}, 'Failed to change role')));
}
function openChangePassword() {
openModal('Change password', `
<div class="field"><label>Current password</label><input type="password" id="cpOld" autocomplete="current-password" /></div>
<div class="field"><label>New password</label><input type="password" id="cpNew" autocomplete="new-password" /></div>
<div class="field"><label>Confirm new password</label><input type="password" id="cpConf" autocomplete="new-password" /></div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Update', primary: true, act: () => guard(async () => {
const oldp = document.getElementById('cpOld').value;
const newp = document.getElementById('cpNew').value;
if (newp !== document.getElementById('cpConf').value) { toast('Passwords do not match', 'err'); return; }
if (!newp) { toast('New password is empty', 'err'); return; }
await api.changePassword(oldp, newp);
closeModal();
toast('Password changed', 'ok');
}, 'Failed to change password'),
}]);
setTimeout(() => document.getElementById('cpOld')?.focus(), 0);
}
function openAddUser() {
openModal('Add user', `
<div class="field"><label>Username</label><input type="text" id="auName" placeholder="letters, digits, . _ -" /></div>
<div class="field"><label>Password</label><input type="password" id="auPass" autocomplete="new-password" /></div>
<div class="field"><label>Role</label>
<select id="auRole"><option value="user" selected>user</option><option value="admin">admin</option></select></div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Create', primary: true, act: () => guard(async () => {
const name = document.getElementById('auName').value.trim();
const pass = document.getElementById('auPass').value;
if (!name || !pass) { toast('Username and password required', 'err'); return; }
await api.createUser(name, pass, document.getElementById('auRole').value);
closeModal();
renderView();
}, 'Failed to create user'),
}]);
setTimeout(() => document.getElementById('auName')?.focus(), 0);
}
function openResetPassword(username) {
openModal(`Reset password — ${username}`, `
<div class="field"><label>New password</label><input type="password" id="rpNew" autocomplete="new-password" /></div>
<p class="dim" style="font-size:12px">${f.esc(username)} will be signed out and must use the new password.</p>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Reset', primary: true, act: () => guard(async () => {
const newp = document.getElementById('rpNew').value;
if (!newp) { toast('Password is empty', 'err'); return; }
await api.setUserPassword(username, newp);
closeModal();
toast('Password reset', 'ok');
}, 'Failed to reset password'),
}]);
setTimeout(() => document.getElementById('rpNew')?.focus(), 0);
}
/* ===================== 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();