From c15cecaa5f9f762cc22b62d63d15c7b2545bccb0 Mon Sep 17 00:00:00 2001 From: ookami125 Date: Mon, 22 Jun 2026 00:18:29 -0400 Subject: [PATCH 01/10] webui: tags as a checkbox dropdown on add (not a comma string) Replace the comma-separated tags input with a checkbox dropdown of existing tags plus inline new-tag creation. Co-Authored-By: Claude Opus 4.8 --- public/css/styles.css | 17 +++++++++++++++ public/js/app.js | 49 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/public/css/styles.css b/public/css/styles.css index bea9e79..d50e709 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -409,6 +409,23 @@ code.inline { background: var(--bg-3); border: 1px solid var(--line); border-rad .modal .mbody { padding: 16px 18px; } .modal .mfoot { padding: 12px 18px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 8px; } .field { margin-bottom: 12px; } +/* checkbox dropdown (multi-select, e.g. tags) */ +.cbdrop { position: relative; } +.cbdrop-btn { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px; + background: var(--bg-3); border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; + color: var(--txt); font-size: 13px; cursor: pointer; } +.cbdrop-btn:hover { border-color: var(--accent); } +.cbdrop-btn > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } +.cbdrop-caret { color: var(--txt-faint); flex: none; } +.cbdrop-menu { margin-top: 6px; background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; + padding: 6px; max-height: 200px; overflow: auto; } +.cbdrop-item { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 5px; + cursor: pointer; color: var(--txt); font-size: 13px; } +.cbdrop-item:hover { background: var(--bg-3); } +.cbdrop-item input { accent-color: var(--accent); } +.cbdrop-new { display: flex; gap: 6px; padding-top: 6px; margin-top: 4px; border-top: 1px solid var(--line); } +.cbdrop-new input { flex: 1; background: var(--bg-1); border: 1px solid var(--line); border-radius: 6px; + padding: 5px 8px; color: var(--txt); font-size: 12px; } .field label { display: block; color: var(--txt-dim); margin-bottom: 4px; font-size: 12px; } .field input[type=text], .field input[type=password], .field textarea, .field select { width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 7px 10px; diff --git a/public/js/app.js b/public/js/app.js index 3e0bc0b..66eedf5 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -496,6 +496,8 @@ async function confirmDelete(hashes) { function openAddModal() { const cats = '' + state.meta.categories.map((c) => ``).join(''); + const tagOpts = state.meta.tags.map((t) => + ``).join(''); openModal('Add torrent', `
@@ -504,9 +506,15 @@ function openAddModal() {
- - ${state.meta.tags.length ? `${state.meta.tags.map((t) => `` : ''}
+
+ + +
@@ -519,7 +527,7 @@ function openAddModal() { const magnet = document.getElementById('addMagnet').value.trim(); const common = { category: document.getElementById('addCat').value, - tags: document.getElementById('addTags').value.split(',').map((s) => s.trim()).filter(Boolean), + tags: [...document.querySelectorAll('#addTagsList input:checked')].map((c) => c.value), savePath: document.getElementById('addPath').value.trim(), paused: document.getElementById('addPaused').checked, seqDl: document.getElementById('addSeq').checked, @@ -536,6 +544,39 @@ function openAddModal() { closeModal(); }, 'Failed to add torrent'), }]); + // tags checkbox dropdown: toggle, live label, inline new-tag creation + (() => { + const menu = document.getElementById('addTagsMenu'); + const list = document.getElementById('addTagsList'); + const label = document.getElementById('addTagsLabel'); + const input = document.getElementById('addTagNew'); + const refresh = () => { + const sel = [...list.querySelectorAll('input:checked')].map((c) => c.value); + label.textContent = sel.length ? sel.join(', ') : 'No tags selected'; + }; + const addTag = () => { + const name = input.value.trim(); + if (!name) return; + let cb = [...list.querySelectorAll('input')].find((c) => c.value === name); + if (!cb) { + const ph = list.querySelector('.dim'); if (ph) ph.remove(); + const lbl = document.createElement('label'); + lbl.className = 'cbdrop-item'; + lbl.innerHTML = ''; + cb = lbl.querySelector('input'); + cb.value = name; + lbl.querySelector('span').textContent = name; + list.appendChild(lbl); + } + cb.checked = true; + input.value = ''; + refresh(); + }; + document.getElementById('addTagsBtn').addEventListener('click', () => { menu.hidden = !menu.hidden; }); + list.addEventListener('change', refresh); + document.getElementById('addTagAdd').addEventListener('click', addTag); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(); } }); + })(); // live readout of the chosen file(s) document.getElementById('addFile').addEventListener('change', async (e) => { const info = document.getElementById('addFileInfo'); From c3985fe742381b6ea6cfa7da00e22a2365dfe57b Mon Sep 17 00:00:00 2001 From: ookami125 Date: Mon, 22 Jun 2026 00:26:58 -0400 Subject: [PATCH 02/10] webui: float the tags dropdown over content (fixed-position) The checkbox menu now uses position:fixed anchored under the button, overlaying the rest of the dialog instead of expanding it. Closes on outside click; repositions on scroll/resize. Co-Authored-By: Claude Opus 4.8 --- public/css/styles.css | 5 +++-- public/js/app.js | 30 ++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/public/css/styles.css b/public/css/styles.css index d50e709..34feef4 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -417,8 +417,9 @@ code.inline { background: var(--bg-3); border: 1px solid var(--line); border-rad .cbdrop-btn:hover { border-color: var(--accent); } .cbdrop-btn > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } .cbdrop-caret { color: var(--txt-faint); flex: none; } -.cbdrop-menu { margin-top: 6px; background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; - padding: 6px; max-height: 200px; overflow: auto; } +.cbdrop-menu { position: fixed; z-index: 300; background: var(--bg-2); border: 1px solid var(--line); + border-radius: 8px; padding: 6px; max-height: 240px; overflow: auto; + box-shadow: 0 12px 40px rgba(0,0,0,.5); } .cbdrop-item { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 5px; cursor: pointer; color: var(--txt); font-size: 13px; } .cbdrop-item:hover { background: var(--bg-3); } diff --git a/public/js/app.js b/public/js/app.js index 66eedf5..3c0c815 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -544,8 +544,9 @@ function openAddModal() { closeModal(); }, 'Failed to add torrent'), }]); - // tags checkbox dropdown: toggle, live label, inline new-tag creation + // tags checkbox dropdown: a fixed-position menu that floats over the dialog (() => { + const btn = document.getElementById('addTagsBtn'); const menu = document.getElementById('addTagsMenu'); const list = document.getElementById('addTagsList'); const label = document.getElementById('addTagsLabel'); @@ -571,8 +572,33 @@ function openAddModal() { cb.checked = true; input.value = ''; refresh(); + place(); }; - document.getElementById('addTagsBtn').addEventListener('click', () => { menu.hidden = !menu.hidden; }); + // Anchor the floating menu under the button and keep it on-screen. + const place = () => { + const r = btn.getBoundingClientRect(); + menu.style.left = r.left + 'px'; + menu.style.width = r.width + 'px'; + menu.style.top = (r.bottom + 4) + 'px'; + menu.style.maxHeight = Math.max(120, window.innerHeight - r.bottom - 16) + 'px'; + }; + let onOutside; let onReflow; + const close = () => { + menu.hidden = true; + document.removeEventListener('mousedown', onOutside, true); + document.removeEventListener('scroll', onReflow, true); + window.removeEventListener('resize', onReflow); + }; + const open = () => { + menu.hidden = false; + place(); + onOutside = (e) => { if (!menu.contains(e.target) && !btn.contains(e.target)) close(); }; + onReflow = () => place(); + document.addEventListener('mousedown', onOutside, true); + document.addEventListener('scroll', onReflow, true); // reposition on modal scroll + window.addEventListener('resize', onReflow); + }; + btn.addEventListener('click', () => (menu.hidden ? open() : close())); list.addEventListener('change', refresh); document.getElementById('addTagAdd').addEventListener('click', addTag); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(); } }); From d55fc35c7237acbb54204a643e84dfbc9f1c8e51 Mon Sep 17 00:00:00 2001 From: ookami125 Date: Tue, 23 Jun 2026 00:29:16 -0400 Subject: [PATCH 03/10] webui: edit-category modal + dropdown dedupe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an "Edit category…" context-menu item and modal that renames a category and/or changes its save path (Uncategorized: path only). Filter empty-named categories out of the add/set-category dropdowns so the stored Uncategorized save-path entry doesn't double the option. Co-Authored-By: Claude Opus 4.8 --- public/js/api.js | 2 ++ public/js/app.js | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/public/js/api.js b/public/js/api.js index cb20e01..cde919a 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -36,6 +36,8 @@ export const api = { createCategory: (name, savePath) => jpost('/api/categories', { name, savePath }), deleteCategory: (name) => jpost('/api/categories/delete', { name }), + editCategory: (name, newName, savePath) => + jpost('/api/categories/edit', { name, newName, savePath }), createTag: (name) => jpost('/api/tags', { name }), deleteTag: (name) => jpost('/api/tags/delete', { name }), diff --git a/public/js/app.js b/public/js/app.js index 3c0c815..6c20aa6 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -264,6 +264,9 @@ function onSidebarContext(e, type, value, removable) { const items = [ { label: type === 'category' ? 'New category…' : 'New tag…', act: () => (type === 'category' ? openCreateCategory() : openCreateTag()) }, ]; + if (type === 'category') { + items.push({ label: 'Edit category…', act: () => openEditCategory(value) }); + } if (removable) { items.push({ sep: true }); items.push({ label: `Delete ${type}`, danger: true, act: () => confirmDeleteMeta(type, value) }); @@ -495,7 +498,7 @@ async function confirmDelete(hashes) { /* ---------- add torrent modal ---------- */ function openAddModal() { const cats = '' + - state.meta.categories.map((c) => ``).join(''); + state.meta.categories.filter((c) => c.name).map((c) => ``).join(''); const tagOpts = state.meta.tags.map((t) => ``).join(''); openModal('Add torrent', ` @@ -765,7 +768,7 @@ function promptCategory() { const cur = state.snapshot?.torrents?.find((t) => t.hash === [...state.selected][0])?.category || ''; const sel = (v) => (v === cur ? ' selected' : ''); const opts = `` + - state.meta.categories.map((c) => ``).join(''); + state.meta.categories.filter((c) => c.name).map((c) => ``).join(''); openModal('Set category', `

Need a new one? Use + next to “Categories” in the sidebar.

`, @@ -795,6 +798,33 @@ function openCreateCategory() { setTimeout(() => document.getElementById('catName')?.focus(), 0); } +function openEditCategory(value) { + const isUncat = value === ''; + const cur = state.meta.categories.find((c) => c.name === value); + const curPath = cur ? (cur.savePath || '') : ''; + const nameField = isUncat + ? `
` + : `
`; + openModal('Edit category', ` + ${nameField} +
+
`, + [{ label: 'Cancel', act: closeModal }, { + label: 'Save', primary: true, act: async () => { + const newName = isUncat ? '' : document.getElementById('catName').value.trim(); + if (!isUncat && !newName) return closeModal(); + const savePath = document.getElementById('catPath').value.trim(); + await api.editCategory(value, newName, savePath); + await refreshMeta(); + if (!isUncat && newName !== value && state.filter.type === 'category' && state.filter.value === value) + state.filter = { type: 'category', value: newName }; + renderView(); + closeModal(); + }, + }]); + setTimeout(() => document.getElementById('catName')?.focus(), 0); +} + function openCreateTag() { openModal('New tag', `
`, [{ label: 'Cancel', act: closeModal }, { From d8c4a96e8800d33e6b2e5bb17bbcfdfbeca421bb Mon Sep 17 00:00:00 2001 From: ookami125 Date: Tue, 23 Jun 2026 00:33:35 -0400 Subject: [PATCH 04/10] webui: category default download location in add modal The add-torrent save path now shows the selected category's default location (its own save path, else the global default), greyed and read-only. Clicking makes it editable; an overridden path then stays put when the category changes. Resolves the "category default location does nothing" issue by feeding the category's save path into the add. Co-Authored-By: Claude Opus 4.8 --- public/css/styles.css | 4 ++++ public/js/app.js | 32 +++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/public/css/styles.css b/public/css/styles.css index 34feef4..86961f6 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -433,6 +433,10 @@ code.inline { background: var(--bg-3); border: 1px solid var(--line); border-rad } .field input[type=text]:focus, .field input[type=password]:focus, .field textarea:focus, .field select:focus, .field input[type=file]:focus { outline: none; border-color: var(--accent); } +/* greyed default-location field: shows the category default until clicked */ +.field input[type=text].path-default { + color: var(--txt-faint); background: var(--bg-2); cursor: pointer; } +.field input[type=text].path-default:hover { border-color: var(--accent); } .field textarea { min-height: 70px; font-family: var(--mono); font-size: 12px; resize: vertical; } /* themed file picker */ diff --git a/public/js/app.js b/public/js/app.js index 6c20aa6..0b98681 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -496,6 +496,14 @@ async function confirmDelete(hashes) { } /* ---------- add torrent modal ---------- */ +// Default download location for a category: its own save path if set, +// otherwise the global default. +function categoryDefaultPath(catName) { + const c = state.meta.categories.find((x) => x.name === catName); + if (c && c.savePath) return c.savePath; + return state.meta.preferences.save_path || '/data/downloads'; +} + function openAddModal() { const cats = '' + state.meta.categories.filter((c) => c.name).map((c) => ``).join(''); @@ -519,7 +527,9 @@ function openAddModal() {
-
+ +
Category default — click to change.
@@ -606,6 +616,26 @@ function openAddModal() { document.getElementById('addTagAdd').addEventListener('click', addTag); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(); } }); })(); + // save path: greyed default that tracks the category until the user edits it + (() => { + const path = document.getElementById('addPath'); + const cat = document.getElementById('addCat'); + const hint = document.getElementById('addPathHint'); + let manual = false; + const enable = () => { + if (manual) return; + manual = true; + path.readOnly = false; + path.classList.remove('path-default'); + hint.textContent = "Custom location — won't change with the category."; + path.focus(); + path.select(); + }; + path.addEventListener('mousedown', (e) => { if (!manual) { e.preventDefault(); enable(); } }); + cat.addEventListener('change', () => { + if (!manual) path.value = categoryDefaultPath(cat.value); + }); + })(); // live readout of the chosen file(s) document.getElementById('addFile').addEventListener('change', async (e) => { const info = document.getElementById('addFileInfo'); From ed906a3549001cb8d8df7c9e006bf5125a5dba6c Mon Sep 17 00:00:00 2001 From: ookami125 Date: Tue, 23 Jun 2026 01:01:36 -0400 Subject: [PATCH 05/10] 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 --- public/css/styles.css | 9 +++ public/js/api.js | 7 ++ public/js/app.js | 168 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 163 insertions(+), 21 deletions(-) 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 ===================== */ From 0aeb039586d7aef011965dade3d2fc22513fdd98 Mon Sep 17 00:00:00 2001 From: ookami125 Date: Tue, 23 Jun 2026 01:45:49 -0400 Subject: [PATCH 06/10] webui: RSS manual download / repull / rule-run controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Per-article Add button now sends the article key and reflects the grabbed state (✓ Added, disabled). - Per-feed ⟳ repull plus a Refresh-all button. - Per-rule ⏵ "Run now" that re-applies the rule to existing articles and reports how many were added. Co-Authored-By: Claude Opus 4.8 --- public/js/api.js | 2 ++ public/js/app.js | 28 +++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/public/js/api.js b/public/js/api.js index 101c410..5e3cc59 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -47,6 +47,8 @@ export const api = { deleteFeed: (name) => jpost('/api/rss/delete', { name }), saveRssRule: (rule) => jpost('/api/rss/rules', rule), deleteRssRule: (name) => jpost('/api/rss/rules/delete', { name }), + runRssRule: (name) => jpost('/api/rss/rules/run', { name }), + refreshFeeds: (name) => jpost('/api/rss/refresh', name ? { name } : {}), rssDownload: (item) => jpost('/api/rss/download', item), saveIndexer: (ix) => jpost('/api/indexers', ix), deleteIndexer: (name) => jpost('/api/indexers/delete', { name }), diff --git a/public/js/app.js b/public/js/app.js index d35b826..993c113 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -962,10 +962,13 @@ async function renderRssView(host, seq) { host.innerHTML = `
-

Feeds

+

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.
'} @@ -977,7 +980,9 @@ async function renderRssView(host, seq) { ${articles.map((a, i) => `
- `).join('')} + `).join('')}
${f.esc(a.feed)}${f.esc(a.title)} ${a.size ? f.bytes(a.size) : '—'}${f.esc(a.pubDate || '')}${(a.magnet || a.torrentUrl) ? `` : ''}
${(a.magnet || a.torrentUrl) + ? `` + : ''}
` : '
No articles yet.
'}

Auto-download rules

@@ -987,6 +992,17 @@ async function renderRssView(host, seq) { document.getElementById('addFeedBtn').addEventListener('click', openAddFeed); document.getElementById('addRuleBtn').addEventListener('click', () => openRuleEditor(null, feeds)); + document.getElementById('refreshAllBtn').addEventListener('click', (e) => guard(async () => { + e.target.disabled = true; e.target.textContent = '⟳ Refreshing…'; + await api.refreshFeeds(); + renderView(); + }, 'Failed to refresh feeds')); + host.querySelectorAll('.repull-feed').forEach((b) => b.addEventListener('click', () => guard(async () => { + const name = b.closest('.feed-row').dataset.feed; + b.disabled = true; + await api.refreshFeeds(name); + renderView(); + }, 'Failed to refresh feed'))); 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'); @@ -994,11 +1010,16 @@ async function renderRssView(host, seq) { })); 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 || '' }); + await api.rssDownload({ magnet: a.magnet || '', torrentUrl: a.torrentUrl || '', key: a.key || '' }); 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-run]').forEach((b) => b.addEventListener('click', () => guard(async () => { + const res = await api.runRssRule(b.dataset.ruleRun); + toast(`Rule run: ${res.grabbed} added of ${res.matched} match(es)`, 'ok'); + renderView(); + }, 'Failed to run rule'))); host.querySelectorAll('[data-rule-del]').forEach((b) => b.addEventListener('click', async () => { await guard(() => api.deleteRssRule(b.dataset.ruleDel), 'Failed to delete rule'); renderView(); @@ -1074,6 +1095,7 @@ function renderRule(r) { ${r.useRegex ? 'regex' : ''} ${r.addPaused ? 'add paused' : ''} + From b1f33d4194a1928aaf421ce7f355390972e3c5f9 Mon Sep 17 00:00:00 2001 From: ookami125 Date: Tue, 23 Jun 2026 20:51:08 -0400 Subject: [PATCH 07/10] webui: always show RSS Add button + live rule match preview - Article Add button now shows whenever the item has any source (magnet, enclosure, or a plain /Atom href), and the manual download falls back to the link URL. Fixes missing + on feeds that only provide a to the torrent. - The rule editor now shows a live "current matches" list that updates as you type the filters / toggle regex / pick feeds, mirroring the daemon's matcher, with grabbed items dimmed. Co-Authored-By: Claude Opus 4.8 --- public/css/styles.css | 6 ++++++ public/js/app.js | 47 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/public/css/styles.css b/public/css/styles.css index a064769..40d1a09 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -304,6 +304,12 @@ table.dtbl td { padding: 3px 8px; border-bottom: 1px solid var(--line-soft); whi .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-matches { max-height: 160px; overflow: auto; border: 1px solid var(--line); + border-radius: 6px; background: var(--bg); font-size: 12px; } +.match-row { padding: 3px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; } +.match-row:last-child { border-bottom: none; } +.match-row.grabbed { opacity: .55; } .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/app.js b/public/js/app.js index 993c113..56d4204 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -980,7 +980,7 @@ async function renderRssView(host, seq) { ${articles.map((a, i) => ` ${f.esc(a.feed)}${f.esc(a.title)} ${a.size ? f.bytes(a.size) : '—'}${f.esc(a.pubDate || '')} - ${(a.magnet || a.torrentUrl) + ${(a.magnet || a.torrentUrl || a.link) ? `` : ''}`).join('')} ` : '
No articles yet.
'} @@ -1010,7 +1010,7 @@ async function renderRssView(host, seq) { })); 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 || '', key: a.key || '' }); + await api.rssDownload({ magnet: a.magnet || '', torrentUrl: a.torrentUrl || a.link || '', key: a.key || '' }); b.textContent = '✓ Added'; b.disabled = true; }, 'Failed to add torrent'))); host.querySelectorAll('[data-rule-edit]').forEach((b) => b.addEventListener('click', () => @@ -1064,7 +1064,9 @@ function openRuleEditor(rule, feeds) { - `, + +
+
`, [{ label: 'Cancel', act: closeModal }, { label: 'Save', primary: true, act: () => guard(async () => { const name = document.getElementById('rName').value.trim(); @@ -1084,9 +1086,48 @@ function openRuleEditor(rule, feeds) { renderView(); }, 'Failed to save rule'), }]); + + // Live preview of which current articles this rule matches. + const allArticles = (feeds || []).flatMap((fd) => fd.articles.map((a) => ({ title: a.title, feed: fd.name, grabbed: a.grabbed }))); + const updateMatches = () => { + const opts = { + must: document.getElementById('rMust').value.trim(), + mustNot: document.getElementById('rMustNot').value.trim(), + regex: document.getElementById('rRegex').checked, + feeds: [...document.querySelectorAll('.rule-feeds input:checked')].map((c) => c.value), + }; + const matches = allArticles.filter((a) => ruleMatchesArticle(opts, a.feed, a.title)); + document.getElementById('rMatchCount').textContent = `— ${matches.length} of ${allArticles.length} article(s)`; + const box = document.getElementById('rMatches'); + box.innerHTML = matches.length + ? matches.slice(0, 50).map((a) => `
+ ${f.esc(a.feed)} ${f.esc(a.title)}${a.grabbed ? ' (grabbed)' : ''}
`).join('') + + (matches.length > 50 ? `
…and ${matches.length - 50} more
` : '') + : '
No current articles match.
'; + }; + ['rMust', 'rMustNot'].forEach((id) => document.getElementById(id).addEventListener('input', updateMatches)); + document.getElementById('rRegex').addEventListener('change', updateMatches); + document.querySelectorAll('.rule-feeds input').forEach((c) => c.addEventListener('change', updateMatches)); + updateMatches(); setTimeout(() => document.getElementById(rule ? 'rMust' : 'rName')?.focus(), 0); } +// Client-side mirror of the daemon's rule matcher (for live preview). +function ruleMatchesArticle(opts, feedName, title) { + if (opts.feeds && opts.feeds.length && !opts.feeds.includes(feedName)) return false; + if (opts.regex) { + try { + if (opts.must && !new RegExp(opts.must, 'i').test(title)) return false; + if (opts.mustNot && new RegExp(opts.mustNot, 'i').test(title)) return false; + } catch (e) { return false; } // invalid regex matches nothing + } else { + const t = title.toLowerCase(); + if (opts.must && !t.includes(opts.must.toLowerCase())) return false; + if (opts.mustNot && t.includes(opts.mustNot.toLowerCase())) return false; + } + return true; +} + function renderRule(r) { return `
From 530390089c5d12b2940345e90bb3e9bf23e69199 Mon Sep 17 00:00:00 2001 From: ookami125 Date: Tue, 23 Jun 2026 20:59:06 -0400 Subject: [PATCH 08/10] webui: send article/result title with RSS manual downloads So the daemon names the torrent after the feed article or search result instead of the temp upload filename. Co-Authored-By: Claude Opus 4.8 --- public/js/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 56d4204..a82d633 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1010,7 +1010,7 @@ async function renderRssView(host, seq) { })); 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 || a.link || '', key: a.key || '' }); + await api.rssDownload({ title: a.title || '', magnet: a.magnet || '', torrentUrl: a.torrentUrl || a.link || '', key: a.key || '' }); b.textContent = '✓ Added'; b.disabled = true; }, 'Failed to add torrent'))); host.querySelectorAll('[data-rule-edit]').forEach((b) => b.addEventListener('click', () => @@ -1432,7 +1432,7 @@ function bindSearchRows() { document.querySelectorAll('[data-sr]').forEach((b) => b.addEventListener('click', () => guard(async () => { const r = state.searchResults[+b.dataset.sr]; - await api.rssDownload({ magnet: r.magnet || '', torrentUrl: r.torrentUrl || '' }); + await api.rssDownload({ title: r.name || '', magnet: r.magnet || '', torrentUrl: r.torrentUrl || '' }); b.textContent = '✓ Added'; b.disabled = true; }, 'Failed to add torrent'))); } From 2df3f11cf4155d0a7caac46de1263052d84c4e2d Mon Sep 17 00:00:00 2001 From: ookami125 Date: Tue, 23 Jun 2026 21:48:05 -0400 Subject: [PATCH 09/10] webui: account management UI (users + change password) Settings now has an Account card (current user/role + change password) and, for admins, a Users card to add/delete users, reset passwords, and toggle admin/user roles. Login captures the role; the Users section is admin-only. api.js gains the account/user endpoints. Co-Authored-By: Claude Opus 4.8 --- public/js/api.js | 7 +++ public/js/app.js | 121 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/public/js/api.js b/public/js/api.js index 5e3cc59..1b00a67 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -19,6 +19,13 @@ export const api = { authStatus: () => jget('/api/auth/status'), login: (username, password) => jpost('/api/login', { username, password }), logout: () => jpost('/api/logout', {}), + // account management + changePassword: (oldPassword, newPassword) => jpost('/api/account/password', { oldPassword, newPassword }), + listUsers: () => jget('/api/users'), + createUser: (username, password, role) => jpost('/api/users', { username, password, role }), + deleteUser: (username) => jpost('/api/users/delete', { username }), + setUserPassword: (username, password) => jpost('/api/users/password', { username, password }), + setUserRole: (username, role) => jpost('/api/users/role', { username, role }), plugins: () => jget('/api/plugins'), meta: () => jget('/api/meta'), diff --git a/public/js/app.js b/public/js/app.js index a82d633..59556ff 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -29,6 +29,7 @@ const state = { columns: ['name', 'size', 'progress', 'state', 'seeds', 'peers', 'dlspeed', 'upspeed', 'eta', 'ratio', 'category', 'tags', 'addedOn'], searchResults: null, searchQuery: '', + auth: { user: '', role: '' }, }; let viewRenderSeq = 0; @@ -103,11 +104,13 @@ async function loadMeta() { } async function ensureLogin() { - const auth = await api.authStatus(); - if (auth.authenticated) return; - document.getElementById('app').hidden = true; - await new Promise((resolve) => showLogin(auth, resolve)); - document.getElementById('app').hidden = false; + let auth = await api.authStatus(); + if (!auth.authenticated) { + document.getElementById('app').hidden = true; + auth = await new Promise((resolve) => showLogin(auth, resolve)); + document.getElementById('app').hidden = false; + } + state.auth = { user: auth.user || '', role: auth.role || '' }; } function showLogin(auth, done) { @@ -134,9 +137,9 @@ function showLogin(auth, done) { err.hidden = true; form.querySelector('.login-submit').disabled = true; try { - await api.login(host.querySelector('#loginUser').value.trim(), host.querySelector('#loginPass').value); + const res = await api.login(host.querySelector('#loginUser').value.trim(), host.querySelector('#loginPass').value); host.remove(); - done(); + done(res || {}); } catch { err.textContent = 'Invalid username or password'; err.hidden = false; @@ -1438,11 +1441,38 @@ function bindSearchRows() { } /* ===================== Engine/settings view ===================== */ -function renderSettingsView(host) { +async function renderSettingsView(host) { + const isAdmin = state.auth.role === 'admin'; + let users = []; + if (isAdmin) { try { users = await api.listUsers(); } catch (e) { users = []; } } + if (state.view !== 'settings') return; const p = state.meta.preferences; const card = (title, rows) => `

${title}

${rows.map(([k, v]) => `
${k}${v}
`).join('')}
`; - host.innerHTML = `
+ + const accountCard = `

Account

+
+
Signed in as${f.esc(state.auth.user)}
+
Role${f.esc(state.auth.role)}
`; + + const usersCard = isAdmin ? `

Users

+
+ + ${users.map((u) => ` + + + + `).join('')} +
UsernameRoleCreated
${f.esc(u.username)}${f.esc(u.role)}${u.createdAt ? f.ago(u.createdAt) : '—'} + + + +
` : ''; + + host.innerHTML = `
+
+ ${accountCard} + ${usersCard} ${card('Bandwidth', [ ['Global download limit', p.dl_limit ? f.rate(p.dl_limit) : '∞'], ['Global upload limit', p.up_limit ? f.rate(p.up_limit) : '∞'], @@ -1477,6 +1507,79 @@ function renderSettingsView(host) {

This is a stubbed engine view — values are read from the mock server. Wire these to a real client's API (qBittorrent WebAPI, Transmission RPC, Deluge JSON-RPC) to make them editable.

`; + + document.getElementById('changePwBtn')?.addEventListener('click', openChangePassword); + document.getElementById('addUserBtn')?.addEventListener('click', openAddUser); + host.querySelectorAll('.u-del').forEach((b) => b.addEventListener('click', () => guard(async () => { + const u = b.closest('[data-user]').dataset.user; + if (u === state.auth.user && !confirm('Delete your own account? You will be signed out.')) return; + await api.deleteUser(u); + if (u === state.auth.user) return location.reload(); + renderView(); + }, 'Failed to delete user'))); + host.querySelectorAll('.u-pw').forEach((b) => b.addEventListener('click', () => + openResetPassword(b.closest('[data-user]').dataset.user))); + host.querySelectorAll('.u-role').forEach((b) => b.addEventListener('click', () => guard(async () => { + const row = b.closest('[data-user]'); + const u = row.dataset.user; + const cur = users.find((x) => x.username === u); + await api.setUserRole(u, cur && cur.role === 'admin' ? 'user' : 'admin'); + renderView(); + }, 'Failed to change role'))); +} + +function openChangePassword() { + openModal('Change password', ` +
+
+
`, + [{ label: 'Cancel', act: closeModal }, { + label: 'Update', primary: true, act: () => guard(async () => { + const oldp = document.getElementById('cpOld').value; + const newp = document.getElementById('cpNew').value; + if (newp !== document.getElementById('cpConf').value) { toast('Passwords do not match', 'err'); return; } + if (!newp) { toast('New password is empty', 'err'); return; } + await api.changePassword(oldp, newp); + closeModal(); + toast('Password changed', 'ok'); + }, 'Failed to change password'), + }]); + setTimeout(() => document.getElementById('cpOld')?.focus(), 0); +} + +function openAddUser() { + openModal('Add user', ` +
+
+
+
`, + [{ label: 'Cancel', act: closeModal }, { + label: 'Create', primary: true, act: () => guard(async () => { + const name = document.getElementById('auName').value.trim(); + const pass = document.getElementById('auPass').value; + if (!name || !pass) { toast('Username and password required', 'err'); return; } + await api.createUser(name, pass, document.getElementById('auRole').value); + closeModal(); + renderView(); + }, 'Failed to create user'), + }]); + setTimeout(() => document.getElementById('auName')?.focus(), 0); +} + +function openResetPassword(username) { + openModal(`Reset password — ${username}`, ` +
+

${f.esc(username)} will be signed out and must use the new password.

`, + [{ label: 'Cancel', act: closeModal }, { + label: 'Reset', primary: true, act: () => guard(async () => { + const newp = document.getElementById('rpNew').value; + if (!newp) { toast('Password is empty', 'err'); return; } + await api.setUserPassword(username, newp); + closeModal(); + toast('Password reset', 'ok'); + }, 'Failed to reset password'), + }]); + setTimeout(() => document.getElementById('rpNew')?.focus(), 0); } /* ===================== status bar ===================== */ From d1b90cfdcbb51de268b98edf2dc0843b9e891323 Mon Sep 17 00:00:00 2001 From: ookami125 Date: Wed, 24 Jun 2026 01:25:43 -0400 Subject: [PATCH 10/10] webui: fix relative timestamps (seconds, not milliseconds) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f.date/f.ago assumed JS milliseconds, but the backend emits Unix epoch seconds everywhere (created_at, feed lastUpdate, rule lastMatch, …), so every real timestamp rendered as ~20607 days ago (now - ~1.78e9 ms). Treat the formatter input as seconds. Search results show the indexer's pubDate string directly rather than mis-parsing it. Co-Authored-By: Claude Opus 4.8 --- public/js/app.js | 2 +- public/js/format.js | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 59556ff..e99db2d 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1426,7 +1426,7 @@ function searchTable(rows) { ${rows.map((r, i) => ` ${f.esc(r.name)}${f.bytes(r.size)} ${r.seeds}${r.leeches} - ${f.esc(r.engine)}${f.ago(r.pubDate)} + ${f.esc(r.engine)}${r.pubDate ? f.esc(r.pubDate) : '–'} `).join('')} `; } diff --git a/public/js/format.js b/public/js/format.js index db2575c..8a8e061 100644 --- a/public/js/format.js +++ b/public/js/format.js @@ -44,15 +44,16 @@ export function ratio(r) { return r.toFixed(2); } -export function date(ms) { - if (!ms || ms < 0) return '–'; - const d = new Date(ms); +// Timestamps from the backend are Unix epoch SECONDS; 0/negative means "unset". +export function date(sec) { + if (!sec || sec < 0) return '–'; + const d = new Date(sec * 1000); return d.toLocaleString(undefined, { year: '2-digit', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } -export function ago(ms) { - if (!ms || ms < 0) return '–'; - const s = (Date.now() - ms) / 1000; +export function ago(sec) { + if (!sec || sec < 0) return '–'; + const s = Date.now() / 1000 - sec; if (s < 60) return 'just now'; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`;