diff --git a/public/css/styles.css b/public/css/styles.css index 86961f6..a064769 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -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%; } .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-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.off { opacity: .55; } .rule-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } diff --git a/public/js/api.js b/public/js/api.js index cde919a..101c410 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -43,6 +43,13 @@ export const api = { rss: () => jget('/api/rss'), 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'), saveScript: (source) => jpost('/api/script', { source }), saveScriptSettings: (settings) => jpost('/api/script/settings', { settings }), diff --git a/public/js/app.js b/public/js/app.js index 0b98681..d35b826 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -956,26 +956,114 @@ async function renderRssView(host, seq) { host.innerHTML = '
Loading feeds…
'; const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]); 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 = `
-

Feeds

- ${feeds.map((fd) => `
📡 ${f.esc(fd.name)}${f.ago(fd.lastUpdate)}
-
${f.esc(fd.url)}
`).join('
')} +
+

Feeds

+ ${feeds.length ? feeds.map((fd) => `
+
📡 ${f.esc(fd.name)} (${fd.articles.length}) + ${fd.lastUpdate ? f.ago(fd.lastUpdate) : 'not fetched yet'} +
+
${f.esc(fd.url)}
+
`).join('
') : '
No feeds yet. Add one to start polling.
'}
-

Unread articles

- - ${feeds.flatMap((fd) => fd.articles.map((a) => ` - - - `)).join('')} -
FeedTitleSizePublished
${f.esc(fd.name)}${f.esc(a.title)}${f.bytes(a.size)}${f.ago(a.date)}${a.isRead ? 'read' : '● new'}
+

Articles

+ ${articles.length ? ` + ${articles.map((a, i) => ` + + + `).join('')} +
FeedTitleSizePublished
${f.esc(a.feed)}${f.esc(a.title)}${a.size ? f.bytes(a.size) : '—'}${f.esc(a.pubDate || '')}${(a.magnet || a.torrentUrl) ? `` : ''}
` : '
No articles yet.
'}
-

Auto-download rules

- ${rules.map((r) => renderRule(r)).join('')} +

Auto-download rules

+ ${rules.length ? rules.map((r) => renderRule(r)).join('') : '
No rules yet. New matching articles are not auto-downloaded.
'}
`; + + 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', ` +
+
`, + [{ 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 = '' + + state.meta.categories.filter((c) => c.name).map((c) => + ``).join(''); + const feedChecks = (feeds || []).map((fd) => + ``).join('') + || '
No feeds yet.
'; + openModal(rule ? 'Edit rule' : 'New rule', ` +
+
+
+
${feedChecks}
+
+
+
+ + + +
`, + [{ 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) { @@ -985,11 +1073,14 @@ function renderRule(r) { ${r.enabled ? 'enabled' : 'disabled'} ${r.useRegex ? 'regex' : ''} ${r.addPaused ? 'add paused' : ''} + + +
must contain ${f.esc(r.mustContain)}
${r.mustNotContain ? `
must not contain ${f.esc(r.mustNotContain)}
` : ''} -
→ category ${f.esc(r.assignedCategory)} · save to ${f.esc(r.savePath)}
-
feeds: ${r.affectedFeeds.join(', ')} · last match ${f.ago(r.lastMatch)}
+
→ category ${f.esc(r.assignedCategory || '—')} · save to ${f.esc(r.savePath || 'default')}
+
feeds: ${r.affectedFeeds.length ? r.affectedFeeds.join(', ') : 'all'} · last match ${r.lastMatch ? f.ago(r.lastMatch) : 'never'}
`; } @@ -1202,30 +1293,65 @@ function bindSettingsPanel(script) { /* ===================== Search view ===================== */ function renderSearchView(host) { - const plugins = state.meta.searchPlugins.map((p) => - `${f.esc(p.name)}`).join(' '); + const idx = state.meta.searchPlugins || []; + const plugins = idx.length + ? idx.map((p) => `${f.esc(p.name)}`).join(' ') + : 'none configured'; host.innerHTML = `
-
Indexers: ${plugins}
+
Indexers: ${plugins} +
${state.searchResults ? searchTable(state.searchResults) : '
Enter a query to search configured indexers
'}
`; const input = document.getElementById('searchInput'); const run = async () => { state.searchQuery = input.value.trim(); + if (!state.searchQuery) return; document.getElementById('searchResults').innerHTML = '
Searching…
'; - state.searchResults = await api.search(state.searchQuery); + try { state.searchResults = await api.search(state.searchQuery); } + catch (e) { document.getElementById('searchResults').innerHTML = '
Search failed
'; return; } document.getElementById('searchResults').innerHTML = searchTable(state.searchResults); bindSearchRows(); }; document.getElementById('searchBtn').addEventListener('click', run); + document.getElementById('manageIdxBtn').addEventListener('click', openIndexerManager); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); }); input.focus(); bindSearchRows(); } +// Manage Torznab indexers (list, add, remove). +function openIndexerManager() { + const idx = state.meta.searchPlugins || []; + const rows = idx.length ? idx.map((p) => `
+ 🔎 ${f.esc(p.name)} ${f.esc(p.url || '')} +
`).join('') + : '
No indexers yet.
'; + openModal('Torznab indexers', ` +
${rows}
+
+
+
`, + [{ 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) { if (!rows.length) return '
No results
'; return ` @@ -1241,11 +1367,11 @@ function searchTable(rows) { function bindSearchRows() { document.querySelectorAll('[data-sr]').forEach((b) => - b.addEventListener('click', async () => { + b.addEventListener('click', () => guard(async () => { 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; - })); + }, 'Failed to add torrent'))); } /* ===================== Engine/settings view ===================== */