webui: automation settings panel, set-location, responsive layout

- Automation tab: settings form (define_settings) beside the editor,
  stacking above it when narrow; script/settings save split.
- Set location modal: current location + reset checkbox.
- saveScriptSettings/setSavePath API wiring; plugins panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-21 23:22:00 -04:00
parent 5a5b5f4330
commit 9a1d67acd4
11 changed files with 741 additions and 13 deletions

View file

@ -11,10 +11,16 @@ async function jpost(url, body) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
if (!r.ok) throw new Error(`${url}${r.status}`);
return r.json();
}
export const api = {
authStatus: () => jget('/api/auth/status'),
login: (username, password) => jpost('/api/login', { username, password }),
logout: () => jpost('/api/logout', {}),
plugins: () => jget('/api/plugins'),
meta: () => jget('/api/meta'),
snapshot: () => jget('/api/snapshot'),
torrent: (hash) => jget(`/api/torrents/${hash}`),
@ -35,6 +41,9 @@ export const api = {
rss: () => jget('/api/rss'),
rssRules: () => jget('/api/rss/rules'),
script: () => jget('/api/script'),
saveScript: (source) => jpost('/api/script', { source }),
saveScriptSettings: (settings) => jpost('/api/script/settings', { settings }),
search: (q) => jget(`/api/search?q=${encodeURIComponent(q)}`),
// Live snapshot stream. onSnapshot(snapshot) called ~1/s.

View file

@ -1,6 +1,7 @@
// NAUT — main application controller.
// Wires the live stream into the sidebar, torrent grid, detail panel,
// status bar, RSS/Search/Engine views, selection, context menu, hotkeys.
// status bar, RSS/Automation/Search/Engine views, selection, context menu,
// hotkeys.
import { api } from './api.js';
import * as f from './format.js';
@ -30,6 +31,8 @@ const state = {
searchQuery: '',
};
let viewRenderSeq = 0;
const COLUMNS = {
name: { label: 'Name' }, // flexible: absorbs remaining width
size: { label: 'Size', num: true, w: '90px' },
@ -281,12 +284,15 @@ async function refreshMeta() {
/* ===================== 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);
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 ---------- */
@ -541,12 +547,20 @@ function promptLocation() {
if (!hashes.length) return;
const cur = state.snapshot.torrents.find((t) => t.hash === hashes[0]);
openModal('Set location', `
<div class="field"><label>Save path</label><input type="text" id="locPath" value="${f.esc(cur?.savePath || '')}" /></div>
<p class="dim" style="font-size:12px">Applies to ${hashes.length} torrent(s).</p>`,
<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();
if (savePath) await api.action('setSavePath', hashes, { savePath });
const reset = document.getElementById('locReset').checked;
if (savePath) await api.action('setSavePath', hashes, { savePath, reset });
closeModal();
}, 'Failed to set location'),
}]);
@ -802,9 +816,10 @@ function showContextMenu(x, y, items) {
function closeContextMenu() { document.getElementById('ctxmenu')?.remove(); }
/* ===================== RSS view ===================== */
async function renderRssView(host) {
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>
@ -842,6 +857,213 @@ function renderRule(r) {
</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) =>
@ -997,8 +1219,10 @@ function bindGlobal() {
doAction(a);
}));
document.querySelectorAll('.vtab').forEach((b) =>
b.addEventListener('click', () => switchViewTab(b.dataset.view)));
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(); });

View file

@ -2,8 +2,9 @@
import { api } from './api.js';
import * as f from './format.js';
import { pluginDetailTabs, renderPluginDetailPanels, renderPluginDetailTab } from './plugins.js';
const TABS = ['general', 'trackers', 'peers', 'content', 'pieces'];
const BUILTIN_TABS = ['general', 'trackers', 'peers', 'content', 'pieces'];
let activeTab = 'general';
let currentHash = null;
let refreshTimer = null;
@ -22,7 +23,7 @@ export function renderDetailShell(hash, host) {
<div class="detail" id="detailPanel">
<div class="detail-resize" id="detailResize" title="Drag to resize"></div>
<div class="detail-tabs">
${TABS.map((t) => `<button class="dtab ${t === activeTab ? 'active' : ''}" data-dtab="${t}">${tabLabel(t)}</button>`).join('')}
${detailTabs().map((t) => `<button class="dtab ${t.id === activeTab ? 'active' : ''}" data-dtab="${t.id}">${t.label}</button>`).join('')}
<button class="dtab detail-close" data-act="closeDetail" title="Close detail (Esc)"></button>
</div>
<div class="detail-body" id="detailBody"></div>
@ -39,6 +40,13 @@ function tabLabel(t) {
return { general: 'General', trackers: 'Trackers', peers: 'Peers', content: 'Content', pieces: 'Pieces' }[t];
}
function detailTabs() {
return [
...BUILTIN_TABS.map((id) => ({ id, label: tabLabel(id) })),
...pluginDetailTabs().map((tab) => ({ id: tab.id, label: tab.label })),
];
}
export function closeDetail() {
currentHash = null;
if (refreshTimer) clearInterval(refreshTimer);
@ -58,6 +66,7 @@ async function loadTab() {
else if (activeTab === 'peers') body.innerHTML = renderPeers(await api.peers(currentHash));
else if (activeTab === 'content') { body.innerHTML = renderFiles(await api.files(currentHash)); bindFilePriorities(body); }
else if (activeTab === 'pieces') body.innerHTML = renderPieces(await api.pieces(currentHash));
else await renderPluginDetailTab(activeTab, body, { api, f, hash: currentHash });
} catch { /* ignore transient */ }
};
await render();
@ -107,6 +116,7 @@ function renderGeneral(t) {
${row('Auto TMM', t.autoTMM ? 'On' : 'Off')}
${row('Force start', t.forceStart ? 'On' : 'Off')}
${t.comment ? row('Comment', f.esc(t.comment)) : ''}
${renderPluginDetailPanels(t, { api, f, hash: t.hash })}
</div>`;
}

99
public/js/plugins.js Normal file
View file

@ -0,0 +1,99 @@
// Frontend plugin registry.
// Plugin modules call window.Naut.registerPlugin({...}) after they are imported.
const registry = {
plugins: [],
views: [],
sidebarSections: [],
detailTabs: [],
detailPanels: [],
};
let sharedContext = {};
function normalizeList(value) {
return Array.isArray(value) ? value : [];
}
function registerPlugin(plugin) {
if (!plugin || !plugin.id || registry.plugins.some((p) => p.id === plugin.id)) return;
registry.plugins.push(plugin);
for (const view of normalizeList(plugin.views)) registry.views.push({ plugin, ...view });
for (const section of normalizeList(plugin.sidebarSections)) registry.sidebarSections.push({ plugin, ...section });
for (const tab of normalizeList(plugin.detailTabs)) registry.detailTabs.push({ plugin, ...tab });
for (const panel of normalizeList(plugin.detailPanels)) registry.detailPanels.push({ plugin, ...panel });
}
window.Naut = {
...(window.Naut || {}),
registerPlugin,
plugins: registry,
context: () => sharedContext,
};
export async function loadPlugins(api, context) {
sharedContext = context || {};
let manifest;
try { manifest = await api.plugins(); } catch { manifest = { modules: [] }; }
for (const modulePath of manifest.modules || []) {
try { await import(modulePath); }
catch (e) { console.error(`Failed to load plugin ${modulePath}`, e); }
}
return registry.plugins;
}
export function pluginViews() {
return registry.views.filter((v) => v.id && v.label && typeof v.render === 'function');
}
export function getPluginView(id) {
return pluginViews().find((v) => v.id === id);
}
export function renderPluginView(id, host, context) {
const view = getPluginView(id);
if (!view) return false;
view.render(host, context);
return true;
}
export function renderPluginSidebarSections(context) {
return registry.sidebarSections
.filter((s) => s.id && s.title && typeof s.render === 'function')
.map((s) => `<div class="side-group plugin-side" data-plugin-sidebar="${s.plugin.id}:${s.id}">
<div class="side-head">${context.f.esc(s.title)}</div>${s.render(context) || ''}
</div>`)
.join('');
}
export function mountPluginSidebarSections(root, context) {
for (const section of registry.sidebarSections) {
const el = root.querySelector(`[data-plugin-sidebar="${CSS.escape(`${section.plugin.id}:${section.id}`)}"]`);
if (el && typeof section.onMount === 'function') section.onMount(el, context);
}
}
export function pluginDetailTabs() {
return registry.detailTabs.filter((t) => t.id && t.label && typeof t.render === 'function');
}
export async function renderPluginDetailTab(id, host, context) {
const tab = pluginDetailTabs().find((t) => t.id === id);
if (!tab) return false;
await tab.render(host, context);
return true;
}
export function renderPluginDetailPanels(torrent, context) {
return registry.detailPanels
.filter((p) => p.title && typeof p.render === 'function')
.map((p) => {
try {
return `<div class="section-h">${context.f.esc(p.title)}</div>${p.render(torrent, context) || ''}`;
} catch (e) {
console.error(`Failed to render plugin panel ${p.plugin.id}:${p.id || p.title}`, e);
return '';
}
})
.join('');
}