diff --git a/public/css/styles.css b/public/css/styles.css
index bea9e79..40d1a09 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -295,6 +295,21 @@ 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-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; }
@@ -409,12 +424,34 @@ 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 { 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); }
+.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;
}
.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/api.js b/public/js/api.js
index cb20e01..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'),
@@ -36,11 +43,22 @@ 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 }),
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 }),
+ 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 }),
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 3e0bc0b..e99db2d 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;
@@ -264,6 +267,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) });
@@ -493,9 +499,19 @@ 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 = 'Uncategorized ' +
- state.meta.categories.map((c) => `${f.esc(c.name || 'Uncategorized')} `).join('');
+ state.meta.categories.filter((c) => c.name).map((c) => `${f.esc(c.name)} `).join('');
+ const tagOpts = state.meta.tags.map((t) =>
+ `${f.esc(t)} `).join('');
openModal('Add torrent', `
Magnet link / URL
@@ -504,11 +520,19 @@ function openAddModal() {
Category ${cats}
Tags
-
- ${state.meta.tags.length ? `${state.meta.tags.map((t) => ``).join('')} ` : ''}
+
+
+ No tags selected ▾
+
+
Save path
-
+
+ Category default — click to change.
Add paused
Sequential download
@@ -519,7 +543,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 +560,85 @@ function openAddModal() {
closeModal();
}, 'Failed to add torrent'),
}]);
+ // 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');
+ 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();
+ place();
+ };
+ // 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(); } });
+ })();
+ // 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');
@@ -698,7 +801,7 @@ function promptCategory() {
const cur = state.snapshot?.torrents?.find((t) => t.hash === [...state.selected][0])?.category || '';
const sel = (v) => (v === cur ? ' selected' : '');
const opts = `
Uncategorized ` +
- state.meta.categories.map((c) => `
${f.esc(c.name || 'Uncategorized')} `).join('');
+ state.meta.categories.filter((c) => c.name).map((c) => `
${f.esc(c.name)} `).join('');
openModal('Set category', `
Category ${opts}
Need a new one? Use + next to “Categories” in the sidebar.
`,
@@ -728,6 +831,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
+ ? `
Name
`
+ : `
Name
`;
+ openModal('Edit category', `
+ ${nameField}
+
Save path
+
`,
+ [{ 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', `
Name
`,
[{ label: 'Cancel', act: closeModal }, {
@@ -829,26 +959,176 @@ async function renderRssView(host, seq) {
host.innerHTML = '
';
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
+ ⟳ Refresh all
+ + Add feed
+ ${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
-
Feed Title Size Published
- ${feeds.flatMap((fd) => fd.articles.map((a) => `
- ${f.esc(fd.name)} ${f.esc(a.title)}
- ${f.bytes(a.size)} ${f.ago(a.date)}
- ${a.isRead ? 'read ' : '● new '} `)).join('')}
-
+
Articles
+ ${articles.length ? `
Feed Title Size Published
+ ${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.link)
+ ? `${a.grabbed ? '✓ Added' : '+ Add'} `
+ : '— '} `).join('')}
+
` : '
No articles yet.
'}
-
Auto-download rules
- ${rules.map((r) => renderRule(r)).join('')}
+
Auto-download rules + New rule
+ ${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));
+ 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');
+ renderView();
+ }));
+ host.querySelectorAll('.dl-art').forEach((b) => b.addEventListener('click', () => guard(async () => {
+ const a = articles[+b.dataset.art];
+ 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', () =>
+ 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();
+ }));
+}
+
+function openAddFeed() {
+ openModal('Add RSS feed', `
+
Name
+
Feed URL
`,
+ [{ 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 = '
— none — ' +
+ state.meta.categories.filter((c) => c.name).map((c) =>
+ `
${f.esc(c.name)} `).join('');
+ const feedChecks = (feeds || []).map((fd) =>
+ `
${f.esc(fd.name)} `).join('')
+ || '
No feeds yet.
';
+ openModal(rule ? 'Edit rule' : 'New rule', `
+
Rule name
+
Must contain
+
Must not contain
+
Apply to feeds (none = all) ${feedChecks}
+
Assign category ${catOpts}
+
Save path (blank = category/default)
+
+ Enabled
+ Use regex
+ Add paused
+
+
`,
+ [{ 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'),
+ }]);
+
+ // 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) {
@@ -858,11 +1138,15 @@ 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'}
`;
}
@@ -1075,30 +1359,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 = `
Search
-
Indexers: ${plugins}
+
Indexers: ${plugins}
+ Manage indexers
${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}
+
Name
+
Torznab API URL
+
API key
`,
+ [{ 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 `
@@ -1107,26 +1426,53 @@ 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) : '–'}
+ Add `).join('')}
`;
}
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({ title: r.name || '', magnet: r.magnet || '', torrentUrl: r.torrentUrl || '' });
b.textContent = '✓ Added'; b.disabled = true;
- }));
+ }, 'Failed to add torrent')));
}
/* ===================== 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
+ Change password
+
Signed in as ${f.esc(state.auth.user)}
+
Role ${f.esc(state.auth.role)}
`;
+
+ const usersCard = isAdmin ? `
Users
+ + Add user
+
Username Role Created
+ ${users.map((u) => `
+ ${f.esc(u.username)}
+ ${f.esc(u.role)}
+ ${u.createdAt ? f.ago(u.createdAt) : '—'}
+
+ ${u.role === 'admin' ? '▼ user' : '▲ admin'}
+ ✎
+ ✕
+ `).join('')}
+
` : '';
+
+ 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) : '∞'],
@@ -1161,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', `
+
Current password
+
New password
+
Confirm new 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', `
+
Username
+
Password
+
Role
+ user admin
`,
+ [{ 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}`, `
+
New password
+
${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 ===================== */
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`;