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 = '' + - state.meta.categories.filter((c) => c.name).map((c) => ``).join(''); - const tagOpts = state.meta.tags.map((t) => - ``).join(''); + state.meta.categories.map((c) => ``).join(''); openModal('Add torrent', `
@@ -520,19 +504,11 @@ function openAddModal() {
-
- - -
+ + ${state.meta.tags.length ? `${state.meta.tags.map((t) => `` : ''}
- -
Category default — click to change.
+
@@ -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 = `` + - state.meta.categories.filter((c) => c.name).map((c) => ``).join(''); + state.meta.categories.map((c) => ``).join(''); openModal('Set category', `

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 - ? `
` - : `
`; - 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 }, { @@ -959,176 +829,26 @@ 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.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 ? ` - ${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.
'} +

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'}
-

Auto-download rules

- ${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', ` -
-
`, - [{ 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) { @@ -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 = `
-
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…
'; - 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}
-
-
-
`, - [{ 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) => ` - + `).join('')}
${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)}
`; } 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

-
-
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} + 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', ` -
-
-
`, - [{ 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 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`;