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
|
|
@ -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.
|
||||
|
|
|
|||
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(); });
|
||||
|
|
|
|||
|
|
@ -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
99
public/js/plugins.js
Normal 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('');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue