diff --git a/public/css/styles.css b/public/css/styles.css
index 40d1a09..bea9e79 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -295,21 +295,6 @@ 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; }
@@ -424,34 +409,12 @@ 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 1b00a67..cb20e01 100644
--- a/public/js/api.js
+++ b/public/js/api.js
@@ -19,13 +19,6 @@ 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'),
@@ -43,22 +36,11 @@ 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 e99db2d..3e0bc0b 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -29,7 +29,6 @@ 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;
@@ -104,13 +103,11 @@ async function loadMeta() {
}
async function ensureLogin() {
- 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 || '' };
+ 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;
}
function showLogin(auth, done) {
@@ -137,9 +134,9 @@ function showLogin(auth, done) {
err.hidden = true;
form.querySelector('.login-submit').disabled = true;
try {
- const res = await api.login(host.querySelector('#loginUser').value.trim(), host.querySelector('#loginPass').value);
+ await api.login(host.querySelector('#loginUser').value.trim(), host.querySelector('#loginPass').value);
host.remove();
- done(res || {});
+ done();
} catch {
err.textContent = 'Invalid username or password';
err.hidden = false;
@@ -267,9 +264,6 @@ 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) });
@@ -499,19 +493,9 @@ 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.filter((c) => c.name).map((c) => `${f.esc(c.name)} `).join('');
- const tagOpts = state.meta.tags.map((t) =>
- `${f.esc(t)} `).join('');
+ state.meta.categories.map((c) => `${f.esc(c.name || 'Uncategorized')} `).join('');
openModal('Add torrent', `
Magnet link / URL
@@ -520,19 +504,11 @@ function openAddModal() {
Category ${cats}
Tags
-
-
- No tags selected ▾
-
-
+
+ ${state.meta.tags.length ? `${state.meta.tags.map((t) => ``).join('')} ` : ''}
Save path
-
-
Category default — click to change.
+
Add paused
Sequential download
@@ -543,7 +519,7 @@ function openAddModal() {
const magnet = document.getElementById('addMagnet').value.trim();
const common = {
category: document.getElementById('addCat').value,
- tags: [...document.querySelectorAll('#addTagsList input:checked')].map((c) => c.value),
+ tags: document.getElementById('addTags').value.split(',').map((s) => s.trim()).filter(Boolean),
savePath: document.getElementById('addPath').value.trim(),
paused: document.getElementById('addPaused').checked,
seqDl: document.getElementById('addSeq').checked,
@@ -560,85 +536,6 @@ 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');
@@ -801,7 +698,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.filter((c) => c.name).map((c) => `
${f.esc(c.name)} `).join('');
+ state.meta.categories.map((c) => `
${f.esc(c.name || 'Uncategorized')} `).join('');
openModal('Set category', `
Category ${opts}
Need a new one? Use + next to “Categories” in the sidebar.
`,
@@ -831,33 +728,6 @@ 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 }, {
@@ -959,176 +829,26 @@ 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
- ⟳ 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.
'}
+
Feeds
+ ${feeds.map((fd) => `
📡 ${f.esc(fd.name)} ${f.ago(fd.lastUpdate)}
+
${f.esc(fd.url)}
`).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.
'}
+
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('')}
+
-
Auto-download rules + New rule
- ${rules.length ? rules.map((r) => renderRule(r)).join('') : '
No rules yet. New matching articles are not auto-downloaded.
'}
+
Auto-download rules
+ ${rules.map((r) => renderRule(r)).join('')}
`;
-
- 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) {
@@ -1138,15 +858,11 @@ 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 || 'default')}
-
feeds: ${r.affectedFeeds.length ? r.affectedFeeds.join(', ') : 'all'} · last match ${r.lastMatch ? f.ago(r.lastMatch) : 'never'}
+
→ category ${f.esc(r.assignedCategory)} · save to ${f.esc(r.savePath)}
+
feeds: ${r.affectedFeeds.join(', ')} · last match ${f.ago(r.lastMatch)}
`;
}
@@ -1359,65 +1075,30 @@ function bindSettingsPanel(script) {
/* ===================== Search view ===================== */
function renderSearchView(host) {
- const idx = state.meta.searchPlugins || [];
- const plugins = idx.length
- ? idx.map((p) => `
${f.esc(p.name)} `).join(' ')
- : '
none configured ';
+ const plugins = state.meta.searchPlugins.map((p) =>
+ `
${f.esc(p.name)} `).join(' ');
host.innerHTML = `
Search
-
Indexers: ${plugins}
- Manage indexers
+
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…
';
- try { state.searchResults = await api.search(state.searchQuery); }
- catch (e) { document.getElementById('searchResults').innerHTML = '
Search failed
'; return; }
+ state.searchResults = await api.search(state.searchQuery);
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 `
@@ -1426,53 +1107,26 @@ function searchTable(rows) {
${rows.map((r, i) => `
${f.esc(r.name)} ${f.bytes(r.size)}
${r.seeds} ${r.leeches}
- ${f.esc(r.engine)} ${r.pubDate ? f.esc(r.pubDate) : '–'}
+ ${f.esc(r.engine)} ${f.ago(r.pubDate)}
+ Add `).join('')}
`;
}
function bindSearchRows() {
document.querySelectorAll('[data-sr]').forEach((b) =>
- b.addEventListener('click', () => guard(async () => {
+ b.addEventListener('click', async () => {
const r = state.searchResults[+b.dataset.sr];
- await api.rssDownload({ title: r.name || '', magnet: r.magnet || '', torrentUrl: r.torrentUrl || '' });
+ await api.add({ name: r.name });
b.textContent = '✓ Added'; b.disabled = true;
- }, 'Failed to add torrent')));
+ }));
}
/* ===================== Engine/settings view ===================== */
-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;
+function renderSettingsView(host) {
const p = state.meta.preferences;
const card = (title, rows) => `
${title} ${rows.map(([k, v]) =>
`
${k} ${v}
`).join('')}
`;
-
- 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}
+ host.innerHTML = `
${card('Bandwidth', [
['Global download limit', p.dl_limit ? f.rate(p.dl_limit) : '∞'],
['Global upload limit', p.up_limit ? f.rate(p.up_limit) : '∞'],
@@ -1507,79 +1161,6 @@ async 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 8a8e061..db2575c 100644
--- a/public/js/format.js
+++ b/public/js/format.js
@@ -44,16 +44,15 @@ export function ratio(r) {
return r.toFixed(2);
}
-// 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);
+export function date(ms) {
+ if (!ms || ms < 0) return '–';
+ const d = new Date(ms);
return d.toLocaleString(undefined, { year: '2-digit', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
-export function ago(sec) {
- if (!sec || sec < 0) return '–';
- const s = Date.now() / 1000 - sec;
+export function ago(ms) {
+ if (!ms || ms < 0) return '–';
+ const s = (Date.now() - ms) / 1000;
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`;