webui: RSS + Search tab management UI
Wire the RSS and Search tabs to the new backend: - RSS: add/remove feeds, per-article "Add" (download), and a full auto-download rule editor (match/regex, must[-not]-contain, per-feed scope, category, save path, paused) with edit/delete. - Search: results now add via the server-side download endpoint (magnet/.torrent); add a Torznab indexer manager (list/add/remove). - api.js: feed/rule/indexer/download client methods. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d8c4a96e88
commit
ed906a3549
3 changed files with 163 additions and 21 deletions
|
|
@ -295,6 +295,15 @@ table.dtbl td { padding: 3px 8px; border-bottom: 1px solid var(--line-soft); whi
|
||||||
.split2 { display: grid; grid-template-columns: 280px 1fr; gap: 16px; height: 100%; }
|
.split2 { display: grid; grid-template-columns: 280px 1fr; gap: 16px; height: 100%; }
|
||||||
.card { background: var(--bg-1); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin-bottom: 12px; }
|
.card { background: var(--bg-1); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin-bottom: 12px; }
|
||||||
.card h3 { margin: 0 0 10px; font-size: 13px; color: var(--txt); }
|
.card h3 { margin: 0 0 10px; font-size: 13px; color: var(--txt); }
|
||||||
|
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||||
|
.card-head h3 { margin: 0; }
|
||||||
|
.feed-row { padding: 6px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.feed-row:last-child { border-bottom: none; }
|
||||||
|
.iconbtn { background: transparent; border: none; color: var(--txt-faint); cursor: pointer;
|
||||||
|
font-size: 13px; padding: 2px 6px; border-radius: 5px; }
|
||||||
|
.iconbtn:hover { color: var(--err); background: var(--bg-3); }
|
||||||
|
.rule-feeds { display: flex; flex-wrap: wrap; gap: 4px 12px; max-height: 120px; overflow: auto;
|
||||||
|
border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: var(--bg); }
|
||||||
.rule { border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; background: var(--bg-1); }
|
.rule { border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; background: var(--bg-1); }
|
||||||
.rule.off { opacity: .55; }
|
.rule.off { opacity: .55; }
|
||||||
.rule-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
.rule-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,13 @@ export const api = {
|
||||||
|
|
||||||
rss: () => jget('/api/rss'),
|
rss: () => jget('/api/rss'),
|
||||||
rssRules: () => jget('/api/rss/rules'),
|
rssRules: () => jget('/api/rss/rules'),
|
||||||
|
addFeed: (name, url) => jpost('/api/rss', { name, url }),
|
||||||
|
deleteFeed: (name) => jpost('/api/rss/delete', { name }),
|
||||||
|
saveRssRule: (rule) => jpost('/api/rss/rules', rule),
|
||||||
|
deleteRssRule: (name) => jpost('/api/rss/rules/delete', { name }),
|
||||||
|
rssDownload: (item) => jpost('/api/rss/download', item),
|
||||||
|
saveIndexer: (ix) => jpost('/api/indexers', ix),
|
||||||
|
deleteIndexer: (name) => jpost('/api/indexers/delete', { name }),
|
||||||
script: () => jget('/api/script'),
|
script: () => jget('/api/script'),
|
||||||
saveScript: (source) => jpost('/api/script', { source }),
|
saveScript: (source) => jpost('/api/script', { source }),
|
||||||
saveScriptSettings: (settings) => jpost('/api/script/settings', { settings }),
|
saveScriptSettings: (settings) => jpost('/api/script/settings', { settings }),
|
||||||
|
|
|
||||||
168
public/js/app.js
168
public/js/app.js
|
|
@ -956,26 +956,114 @@ async function renderRssView(host, seq) {
|
||||||
host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
|
host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
|
||||||
const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]);
|
const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]);
|
||||||
if (seq !== viewRenderSeq || state.view !== 'rss') return;
|
if (seq !== viewRenderSeq || state.view !== 'rss') return;
|
||||||
|
// flatten articles, newest first, tagged with their feed
|
||||||
|
const articles = feeds.flatMap((fd) => fd.articles.map((a) => ({ ...a, feed: fd.name })))
|
||||||
|
.sort((a, b) => (b.pubDate || '').localeCompare(a.pubDate || ''));
|
||||||
host.innerHTML = `<div class="pane"><div class="split2">
|
host.innerHTML = `<div class="pane"><div class="split2">
|
||||||
<div>
|
<div>
|
||||||
<div class="card"><h3>Feeds</h3>
|
<div class="card">
|
||||||
${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="card-head"><h3>Feeds</h3><button class="btn" id="addFeedBtn">+ Add feed</button></div>
|
||||||
<div class="kvrow dim"><code class="inline">${f.esc(fd.url)}</code></div>`).join('<div style="height:8px"></div>')}
|
${feeds.length ? feeds.map((fd) => `<div class="feed-row" data-feed="${f.esc(fd.name)}">
|
||||||
|
<div class="kvrow"><span style="flex:1">📡 <b>${f.esc(fd.name)}</b> <span class="dim">(${fd.articles.length})</span></span>
|
||||||
|
<span class="dim">${fd.lastUpdate ? f.ago(fd.lastUpdate) : 'not fetched yet'}</span>
|
||||||
|
<button class="iconbtn del-feed" title="Remove feed">✕</button></div>
|
||||||
|
<div class="kvrow dim"><code class="inline">${f.esc(fd.url)}</code></div>
|
||||||
|
</div>`).join('<div style="height:8px"></div>') : '<div class="dim">No feeds yet. Add one to start polling.</div>'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="card"><h3>Unread articles</h3>
|
<div class="card"><h3>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>
|
${articles.length ? `<table class="dtbl"><thead><tr><th>Feed</th><th>Title</th><th class="num">Size</th><th class="num">Published</th><th></th></tr></thead><tbody>
|
||||||
${feeds.flatMap((fd) => fd.articles.map((a) => `<tr style="${a.isRead ? 'opacity:.5' : ''}">
|
${articles.map((a, i) => `<tr style="${a.isRead ? 'opacity:.5' : ''}">
|
||||||
<td class="dim">${f.esc(fd.name)}</td><td>${f.esc(a.title)}</td>
|
<td class="dim">${f.esc(a.feed)}</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 class="num">${a.size ? f.bytes(a.size) : '—'}</td><td class="num dim">${f.esc(a.pubDate || '')}</td>
|
||||||
<td>${a.isRead ? '<span class="faint">read</span>' : '<span style="color:var(--accent)">● new</span>'}</td></tr>`)).join('')}
|
<td>${(a.magnet || a.torrentUrl) ? `<button class="btn dl-art" data-art="${i}">+ Add</button>` : '<span class="faint">—</span>'}</td></tr>`).join('')}
|
||||||
</tbody></table>
|
</tbody></table>` : '<div class="dim">No articles yet.</div>'}
|
||||||
</div>
|
</div>
|
||||||
<h3 style="margin:16px 0 8px">Auto-download rules</h3>
|
<div class="card-head" style="margin-top:16px"><h3>Auto-download rules</h3><button class="btn" id="addRuleBtn">+ New rule</button></div>
|
||||||
${rules.map((r) => renderRule(r)).join('')}
|
${rules.length ? rules.map((r) => renderRule(r)).join('') : '<div class="dim">No rules yet. New matching articles are not auto-downloaded.</div>'}
|
||||||
</div>
|
</div>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
|
|
||||||
|
document.getElementById('addFeedBtn').addEventListener('click', openAddFeed);
|
||||||
|
document.getElementById('addRuleBtn').addEventListener('click', () => openRuleEditor(null, feeds));
|
||||||
|
host.querySelectorAll('.del-feed').forEach((b) => b.addEventListener('click', async () => {
|
||||||
|
const name = b.closest('.feed-row').dataset.feed;
|
||||||
|
await guard(() => api.deleteFeed(name), 'Failed to remove feed');
|
||||||
|
renderView();
|
||||||
|
}));
|
||||||
|
host.querySelectorAll('.dl-art').forEach((b) => b.addEventListener('click', () => guard(async () => {
|
||||||
|
const a = articles[+b.dataset.art];
|
||||||
|
await api.rssDownload({ magnet: a.magnet || '', torrentUrl: a.torrentUrl || '' });
|
||||||
|
b.textContent = '✓ Added'; b.disabled = true;
|
||||||
|
}, 'Failed to add torrent')));
|
||||||
|
host.querySelectorAll('[data-rule-edit]').forEach((b) => b.addEventListener('click', () =>
|
||||||
|
openRuleEditor(rules.find((r) => r.name === b.dataset.ruleEdit), feeds)));
|
||||||
|
host.querySelectorAll('[data-rule-del]').forEach((b) => b.addEventListener('click', async () => {
|
||||||
|
await guard(() => api.deleteRssRule(b.dataset.ruleDel), 'Failed to delete rule');
|
||||||
|
renderView();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddFeed() {
|
||||||
|
openModal('Add RSS feed', `
|
||||||
|
<div class="field"><label>Name</label><input type="text" id="feedName" placeholder="e.g. EZTV" /></div>
|
||||||
|
<div class="field"><label>Feed URL</label><input type="text" id="feedUrl" placeholder="https://…/rss.xml" /></div>`,
|
||||||
|
[{ label: 'Cancel', act: closeModal }, {
|
||||||
|
label: 'Add', primary: true, act: () => guard(async () => {
|
||||||
|
const name = document.getElementById('feedName').value.trim();
|
||||||
|
const url = document.getElementById('feedUrl').value.trim();
|
||||||
|
if (!name || !url) return closeModal();
|
||||||
|
await api.addFeed(name, url);
|
||||||
|
closeModal();
|
||||||
|
renderView();
|
||||||
|
}, 'Failed to add feed'),
|
||||||
|
}]);
|
||||||
|
setTimeout(() => document.getElementById('feedName')?.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule editor: create (rule=null) or edit an existing auto-download rule.
|
||||||
|
function openRuleEditor(rule, feeds) {
|
||||||
|
const r = rule || { name: '', enabled: true, useRegex: false, addPaused: false,
|
||||||
|
mustContain: '', mustNotContain: '', assignedCategory: '', savePath: '', affectedFeeds: [] };
|
||||||
|
const catOpts = '<option value="">— none —</option>' +
|
||||||
|
state.meta.categories.filter((c) => c.name).map((c) =>
|
||||||
|
`<option value="${f.esc(c.name)}"${c.name === r.assignedCategory ? ' selected' : ''}>${f.esc(c.name)}</option>`).join('');
|
||||||
|
const feedChecks = (feeds || []).map((fd) =>
|
||||||
|
`<label class="cbdrop-item"><input type="checkbox" value="${f.esc(fd.name)}"${r.affectedFeeds.includes(fd.name) ? ' checked' : ''}><span>${f.esc(fd.name)}</span></label>`).join('')
|
||||||
|
|| '<div class="dim" style="padding:4px">No feeds yet.</div>';
|
||||||
|
openModal(rule ? 'Edit rule' : 'New rule', `
|
||||||
|
<div class="field"><label>Rule name</label><input type="text" id="rName" value="${f.esc(r.name)}" ${rule ? 'readonly' : ''} placeholder="e.g. My show 1080p" /></div>
|
||||||
|
<div class="field"><label>Must contain</label><input type="text" id="rMust" value="${f.esc(r.mustContain)}" placeholder="title substring or regex" /></div>
|
||||||
|
<div class="field"><label>Must not contain</label><input type="text" id="rMustNot" value="${f.esc(r.mustNotContain)}" placeholder="optional" /></div>
|
||||||
|
<div class="field"><label>Apply to feeds (none = all)</label><div class="rule-feeds">${feedChecks}</div></div>
|
||||||
|
<div class="field"><label>Assign category</label><select id="rCat">${catOpts}</select></div>
|
||||||
|
<div class="field"><label>Save path (blank = category/default)</label><input type="text" id="rPath" value="${f.esc(r.savePath)}" placeholder="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>
|
||||||
|
<div class="checks">
|
||||||
|
<label><input type="checkbox" id="rEnabled" ${r.enabled ? 'checked' : ''}> Enabled</label>
|
||||||
|
<label><input type="checkbox" id="rRegex" ${r.useRegex ? 'checked' : ''}> Use regex</label>
|
||||||
|
<label><input type="checkbox" id="rPaused" ${r.addPaused ? 'checked' : ''}> Add paused</label>
|
||||||
|
</div>`,
|
||||||
|
[{ label: 'Cancel', act: closeModal }, {
|
||||||
|
label: 'Save', primary: true, act: () => guard(async () => {
|
||||||
|
const name = document.getElementById('rName').value.trim();
|
||||||
|
if (!name) return closeModal();
|
||||||
|
await api.saveRssRule({
|
||||||
|
name,
|
||||||
|
enabled: document.getElementById('rEnabled').checked,
|
||||||
|
useRegex: document.getElementById('rRegex').checked,
|
||||||
|
addPaused: document.getElementById('rPaused').checked,
|
||||||
|
mustContain: document.getElementById('rMust').value.trim(),
|
||||||
|
mustNotContain: document.getElementById('rMustNot').value.trim(),
|
||||||
|
assignedCategory: document.getElementById('rCat').value,
|
||||||
|
savePath: document.getElementById('rPath').value.trim(),
|
||||||
|
affectedFeeds: [...document.querySelectorAll('.rule-feeds input:checked')].map((c) => c.value),
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
renderView();
|
||||||
|
}, 'Failed to save rule'),
|
||||||
|
}]);
|
||||||
|
setTimeout(() => document.getElementById(rule ? 'rMust' : 'rName')?.focus(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRule(r) {
|
function renderRule(r) {
|
||||||
|
|
@ -985,11 +1073,14 @@ function renderRule(r) {
|
||||||
<span class="pill ${r.enabled ? 'on' : ''}">${r.enabled ? 'enabled' : 'disabled'}</span>
|
<span class="pill ${r.enabled ? 'on' : ''}">${r.enabled ? 'enabled' : 'disabled'}</span>
|
||||||
${r.useRegex ? '<span class="pill">regex</span>' : ''}
|
${r.useRegex ? '<span class="pill">regex</span>' : ''}
|
||||||
${r.addPaused ? '<span class="pill paused">add paused</span>' : ''}
|
${r.addPaused ? '<span class="pill paused">add paused</span>' : ''}
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button class="iconbtn" data-rule-edit="${f.esc(r.name)}" title="Edit rule">✎</button>
|
||||||
|
<button class="iconbtn" data-rule-del="${f.esc(r.name)}" title="Delete rule">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="kvrow">must contain <code class="inline">${f.esc(r.mustContain)}</code></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>` : ''}
|
${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">→ category <b>${f.esc(r.assignedCategory || '—')}</b> · save to <b>${f.esc(r.savePath || 'default')}</b></div>
|
||||||
<div class="kvrow dim">feeds: ${r.affectedFeeds.join(', ')} · last match ${f.ago(r.lastMatch)}</div>
|
<div class="kvrow dim">feeds: ${r.affectedFeeds.length ? r.affectedFeeds.join(', ') : 'all'} · last match ${r.lastMatch ? f.ago(r.lastMatch) : 'never'}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1202,30 +1293,65 @@ function bindSettingsPanel(script) {
|
||||||
|
|
||||||
/* ===================== Search view ===================== */
|
/* ===================== Search view ===================== */
|
||||||
function renderSearchView(host) {
|
function renderSearchView(host) {
|
||||||
const plugins = state.meta.searchPlugins.map((p) =>
|
const idx = state.meta.searchPlugins || [];
|
||||||
`<span class="pill ${p.enabled ? 'on' : ''}">${f.esc(p.name)}</span>`).join(' ');
|
const plugins = idx.length
|
||||||
|
? idx.map((p) => `<span class="pill ${p.enabled ? 'on' : ''}">${f.esc(p.name)}</span>`).join(' ')
|
||||||
|
: '<span class="dim">none configured</span>';
|
||||||
host.innerHTML = `<div class="pane">
|
host.innerHTML = `<div class="pane">
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<input type="text" id="searchInput" placeholder="Search indexers… e.g. debian, sintel, dataset" value="${f.esc(state.searchQuery)}" />
|
<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>
|
<button class="btn primary" id="searchBtn">Search</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="kvrow" style="margin-bottom:12px">Indexers: ${plugins}</div>
|
<div class="kvrow" style="margin-bottom:12px">Indexers: ${plugins}
|
||||||
|
<span style="flex:1"></span><button class="btn" id="manageIdxBtn">Manage indexers</button></div>
|
||||||
<div id="searchResults">${state.searchResults ? searchTable(state.searchResults) : '<div class="empty" style="height:200px">Enter a query to search configured indexers</div>'}</div>
|
<div id="searchResults">${state.searchResults ? searchTable(state.searchResults) : '<div class="empty" style="height:200px">Enter a query to search configured indexers</div>'}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
const input = document.getElementById('searchInput');
|
const input = document.getElementById('searchInput');
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
state.searchQuery = input.value.trim();
|
state.searchQuery = input.value.trim();
|
||||||
|
if (!state.searchQuery) return;
|
||||||
document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Searching…</div>';
|
document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Searching…</div>';
|
||||||
state.searchResults = await api.search(state.searchQuery);
|
try { state.searchResults = await api.search(state.searchQuery); }
|
||||||
|
catch (e) { document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Search failed</div>'; return; }
|
||||||
document.getElementById('searchResults').innerHTML = searchTable(state.searchResults);
|
document.getElementById('searchResults').innerHTML = searchTable(state.searchResults);
|
||||||
bindSearchRows();
|
bindSearchRows();
|
||||||
};
|
};
|
||||||
document.getElementById('searchBtn').addEventListener('click', run);
|
document.getElementById('searchBtn').addEventListener('click', run);
|
||||||
|
document.getElementById('manageIdxBtn').addEventListener('click', openIndexerManager);
|
||||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
|
||||||
input.focus();
|
input.focus();
|
||||||
bindSearchRows();
|
bindSearchRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manage Torznab indexers (list, add, remove).
|
||||||
|
function openIndexerManager() {
|
||||||
|
const idx = state.meta.searchPlugins || [];
|
||||||
|
const rows = idx.length ? idx.map((p) => `<div class="kvrow" data-idx="${f.esc(p.name)}">
|
||||||
|
<span style="flex:1">🔎 <b>${f.esc(p.name)}</b> <code class="inline">${f.esc(p.url || '')}</code></span>
|
||||||
|
<button class="iconbtn del-idx" title="Remove">✕</button></div>`).join('')
|
||||||
|
: '<div class="dim">No indexers yet.</div>';
|
||||||
|
openModal('Torznab indexers', `
|
||||||
|
<div id="idxList" style="margin-bottom:12px">${rows}</div>
|
||||||
|
<div class="field"><label>Name</label><input type="text" id="ixName" placeholder="e.g. Jackett/Prowlarr indexer" /></div>
|
||||||
|
<div class="field"><label>Torznab API URL</label><input type="text" id="ixUrl" placeholder="https://host/api/v2.0/indexers/.../results/torznab/api" /></div>
|
||||||
|
<div class="field"><label>API key</label><input type="text" id="ixKey" placeholder="optional" /></div>`,
|
||||||
|
[{ label: 'Close', act: () => { closeModal(); refreshMeta().then(renderView); } }, {
|
||||||
|
label: 'Add indexer', primary: true, act: () => guard(async () => {
|
||||||
|
const name = document.getElementById('ixName').value.trim();
|
||||||
|
const url = document.getElementById('ixUrl').value.trim();
|
||||||
|
if (!name || !url) return;
|
||||||
|
await api.saveIndexer({ name, url, apikey: document.getElementById('ixKey').value.trim(), enabled: true });
|
||||||
|
await refreshMeta();
|
||||||
|
openIndexerManager();
|
||||||
|
}, 'Failed to add indexer'),
|
||||||
|
}]);
|
||||||
|
document.querySelectorAll('.del-idx').forEach((b) => b.addEventListener('click', async () => {
|
||||||
|
await guard(() => api.deleteIndexer(b.closest('[data-idx]').dataset.idx), 'Failed to remove indexer');
|
||||||
|
await refreshMeta();
|
||||||
|
openIndexerManager();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function searchTable(rows) {
|
function searchTable(rows) {
|
||||||
if (!rows.length) return '<div class="empty" style="height:160px">No results</div>';
|
if (!rows.length) return '<div class="empty" style="height:160px">No results</div>';
|
||||||
return `<table class="dtbl"><thead><tr>
|
return `<table class="dtbl"><thead><tr>
|
||||||
|
|
@ -1241,11 +1367,11 @@ function searchTable(rows) {
|
||||||
|
|
||||||
function bindSearchRows() {
|
function bindSearchRows() {
|
||||||
document.querySelectorAll('[data-sr]').forEach((b) =>
|
document.querySelectorAll('[data-sr]').forEach((b) =>
|
||||||
b.addEventListener('click', async () => {
|
b.addEventListener('click', () => guard(async () => {
|
||||||
const r = state.searchResults[+b.dataset.sr];
|
const r = state.searchResults[+b.dataset.sr];
|
||||||
await api.add({ name: r.name });
|
await api.rssDownload({ magnet: r.magnet || '', torrentUrl: r.torrentUrl || '' });
|
||||||
b.textContent = '✓ Added'; b.disabled = true;
|
b.textContent = '✓ Added'; b.disabled = true;
|
||||||
}));
|
}, 'Failed to add torrent')));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== Engine/settings view ===================== */
|
/* ===================== Engine/settings view ===================== */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue