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:
parent
5a5b5f4330
commit
9a1d67acd4
11 changed files with 741 additions and 13 deletions
240
public/js/app.js
240
public/js/app.js
|
|
@ -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(); });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue