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 = '' + - 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', `
@@ -504,11 +520,19 @@ function openAddModal() {
- - ${state.meta.tags.length ? `${state.meta.tags.map((t) => `` : ''}
+
+ + +
-
+ +
Category default — click to change.
@@ -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 = `` + - 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.

`, @@ -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 + ? `
` + : `
`; + 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 }, { @@ -829,26 +959,176 @@ 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 || a.link) + ? `` + : ''}
` : '
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)); + 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', ` +
+
`, + [{ 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'), + }]); + + // 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 = `
-
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 ` @@ -1107,26 +1426,53 @@ function searchTable(rows) { ${rows.map((r, i) => ` - + `).join('')}
${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) : '–'}
`; } 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

+
+
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) : '∞'], @@ -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', ` +
+
+
`, + [{ 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 ===================== */ 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`;