Comma-separated tags input with a datalist of existing tags. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1279 lines
57 KiB
JavaScript
1279 lines
57 KiB
JavaScript
// 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();
|