Compare commits

...

10 commits

Author SHA1 Message Date
ookami125
d1b90cfdcb webui: fix relative timestamps (seconds, not milliseconds)
f.date/f.ago assumed JS milliseconds, but the backend emits Unix epoch
seconds everywhere (created_at, feed lastUpdate, rule lastMatch, …), so
every real timestamp rendered as ~20607 days ago (now - ~1.78e9 ms).
Treat the formatter input as seconds. Search results show the indexer's
pubDate string directly rather than mis-parsing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:25:43 -04:00
ookami125
2df3f11cf4 webui: account management UI (users + change password)
Settings now has an Account card (current user/role + change password)
and, for admins, a Users card to add/delete users, reset passwords, and
toggle admin/user roles. Login captures the role; the Users section is
admin-only. api.js gains the account/user endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:48:05 -04:00
ookami125
530390089c webui: send article/result title with RSS manual downloads
So the daemon names the torrent after the feed article or search
result instead of the temp upload filename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:59:06 -04:00
ookami125
b1f33d4194 webui: always show RSS Add button + live rule match preview
- Article Add button now shows whenever the item has any source
  (magnet, enclosure, or a plain <link>/Atom href), and the manual
  download falls back to the link URL. Fixes missing + on feeds that
  only provide a <link> to the torrent.
- The rule editor now shows a live "current matches" list that updates
  as you type the filters / toggle regex / pick feeds, mirroring the
  daemon's matcher, with grabbed items dimmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:51:08 -04:00
ookami125
0aeb039586 webui: RSS manual download / repull / rule-run controls
- Per-article Add button now sends the article key and reflects the
  grabbed state (✓ Added, disabled).
- Per-feed ⟳ repull plus a Refresh-all button.
- Per-rule ⏵ "Run now" that re-applies the rule to existing articles
  and reports how many were added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:45:49 -04:00
ookami125
ed906a3549 webui: RSS + Search tab management UI
Wire the RSS and Search tabs to the new backend:

- RSS: add/remove feeds, per-article "Add" (download), and a full
  auto-download rule editor (match/regex, must[-not]-contain, per-feed
  scope, category, save path, paused) with edit/delete.
- Search: results now add via the server-side download endpoint
  (magnet/.torrent); add a Torznab indexer manager (list/add/remove).
- api.js: feed/rule/indexer/download client methods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:01:36 -04:00
ookami125
d8c4a96e88 webui: category default download location in add modal
The add-torrent save path now shows the selected category's default
location (its own save path, else the global default), greyed and
read-only. Clicking makes it editable; an overridden path then stays
put when the category changes. Resolves the "category default location
does nothing" issue by feeding the category's save path into the add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:33:35 -04:00
ookami125
d55fc35c72 webui: edit-category modal + dropdown dedupe
Add an "Edit category…" context-menu item and modal that renames a
category and/or changes its save path (Uncategorized: path only).
Filter empty-named categories out of the add/set-category dropdowns so
the stored Uncategorized save-path entry doesn't double the option.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:29:16 -04:00
ookami125
c3985fe742 webui: float the tags dropdown over content (fixed-position)
The checkbox menu now uses position:fixed anchored under the button,
overlaying the rest of the dialog instead of expanding it. Closes on
outside click; repositions on scroll/resize.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:26:58 -04:00
ookami125
c15cecaa5f webui: tags as a checkbox dropdown on add (not a comma string)
Replace the comma-separated tags input with a checkbox dropdown of
existing tags plus inline new-tag creation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:18:29 -04:00
4 changed files with 519 additions and 44 deletions

View file

@ -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%; } .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 { 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 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 { border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; background: var(--bg-1); }
.rule.off { opacity: .55; } .rule.off { opacity: .55; }
.rule-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .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 .mbody { padding: 16px 18px; }
.modal .mfoot { padding: 12px 18px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 8px; } .modal .mfoot { padding: 12px 18px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 8px; }
.field { margin-bottom: 12px; } .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 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 { .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; 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=text]:focus, .field input[type=password]:focus, .field textarea:focus, .field select:focus,
.field input[type=file]:focus { outline: none; border-color: var(--accent); } .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; } .field textarea { min-height: 70px; font-family: var(--mono); font-size: 12px; resize: vertical; }
/* themed file picker */ /* themed file picker */

View file

@ -19,6 +19,13 @@ export const api = {
authStatus: () => jget('/api/auth/status'), authStatus: () => jget('/api/auth/status'),
login: (username, password) => jpost('/api/login', { username, password }), login: (username, password) => jpost('/api/login', { username, password }),
logout: () => jpost('/api/logout', {}), 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'), plugins: () => jget('/api/plugins'),
meta: () => jget('/api/meta'), meta: () => jget('/api/meta'),
@ -36,11 +43,22 @@ export const api = {
createCategory: (name, savePath) => jpost('/api/categories', { name, savePath }), createCategory: (name, savePath) => jpost('/api/categories', { name, savePath }),
deleteCategory: (name) => jpost('/api/categories/delete', { name }), deleteCategory: (name) => jpost('/api/categories/delete', { name }),
editCategory: (name, newName, savePath) =>
jpost('/api/categories/edit', { name, newName, savePath }),
createTag: (name) => jpost('/api/tags', { name }), createTag: (name) => jpost('/api/tags', { name }),
deleteTag: (name) => jpost('/api/tags/delete', { name }), deleteTag: (name) => jpost('/api/tags/delete', { name }),
rss: () => jget('/api/rss'), rss: () => jget('/api/rss'),
rssRules: () => jget('/api/rss/rules'), 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'), script: () => jget('/api/script'),
saveScript: (source) => jpost('/api/script', { source }), saveScript: (source) => jpost('/api/script', { source }),
saveScriptSettings: (settings) => jpost('/api/script/settings', { settings }), saveScriptSettings: (settings) => jpost('/api/script/settings', { settings }),

View file

@ -29,6 +29,7 @@ const state = {
columns: ['name', 'size', 'progress', 'state', 'seeds', 'peers', 'dlspeed', 'upspeed', 'eta', 'ratio', 'category', 'tags', 'addedOn'], columns: ['name', 'size', 'progress', 'state', 'seeds', 'peers', 'dlspeed', 'upspeed', 'eta', 'ratio', 'category', 'tags', 'addedOn'],
searchResults: null, searchResults: null,
searchQuery: '', searchQuery: '',
auth: { user: '', role: '' },
}; };
let viewRenderSeq = 0; let viewRenderSeq = 0;
@ -103,11 +104,13 @@ async function loadMeta() {
} }
async function ensureLogin() { async function ensureLogin() {
const auth = await api.authStatus(); let auth = await api.authStatus();
if (auth.authenticated) return; if (!auth.authenticated) {
document.getElementById('app').hidden = true; document.getElementById('app').hidden = true;
await new Promise((resolve) => showLogin(auth, resolve)); auth = await new Promise((resolve) => showLogin(auth, resolve));
document.getElementById('app').hidden = false; document.getElementById('app').hidden = false;
}
state.auth = { user: auth.user || '', role: auth.role || '' };
} }
function showLogin(auth, done) { function showLogin(auth, done) {
@ -134,9 +137,9 @@ function showLogin(auth, done) {
err.hidden = true; err.hidden = true;
form.querySelector('.login-submit').disabled = true; form.querySelector('.login-submit').disabled = true;
try { 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(); host.remove();
done(); done(res || {});
} catch { } catch {
err.textContent = 'Invalid username or password'; err.textContent = 'Invalid username or password';
err.hidden = false; err.hidden = false;
@ -264,6 +267,9 @@ function onSidebarContext(e, type, value, removable) {
const items = [ const items = [
{ label: type === 'category' ? 'New category…' : 'New tag…', act: () => (type === 'category' ? openCreateCategory() : openCreateTag()) }, { 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) { if (removable) {
items.push({ sep: true }); items.push({ sep: true });
items.push({ label: `Delete ${type}`, danger: true, act: () => confirmDeleteMeta(type, value) }); items.push({ label: `Delete ${type}`, danger: true, act: () => confirmDeleteMeta(type, value) });
@ -493,9 +499,19 @@ async function confirmDelete(hashes) {
} }
/* ---------- add torrent modal ---------- */ /* ---------- 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() { function openAddModal() {
const cats = '<option value="" selected>Uncategorized</option>' + const cats = '<option value="" selected>Uncategorized</option>' +
state.meta.categories.map((c) => `<option value="${f.esc(c.name)}">${f.esc(c.name || 'Uncategorized')}</option>`).join(''); state.meta.categories.filter((c) => c.name).map((c) => `<option value="${f.esc(c.name)}">${f.esc(c.name)}</option>`).join('');
const tagOpts = state.meta.tags.map((t) =>
`<label class="cbdrop-item"><input type="checkbox" value="${f.esc(t)}"><span>${f.esc(t)}</span></label>`).join('');
openModal('Add torrent', ` openModal('Add torrent', `
<div class="field"><label>Magnet link / URL</label> <div class="field"><label>Magnet link / URL</label>
<input type="text" id="addMagnet" placeholder="magnet:?xt=urn:btih:…" /></div> <input type="text" id="addMagnet" placeholder="magnet:?xt=urn:btih:…" /></div>
@ -504,11 +520,19 @@ function openAddModal() {
<div id="addFileInfo" class="dim" style="margin-top:6px;font-size:12px"></div></div> <div id="addFileInfo" class="dim" style="margin-top:6px;font-size:12px"></div></div>
<div class="field"><label>Category</label><select id="addCat">${cats}</select></div> <div class="field"><label>Category</label><select id="addCat">${cats}</select></div>
<div class="field"><label>Tags</label> <div class="field"><label>Tags</label>
<input type="text" id="addTags" placeholder="comma-separated, e.g. anime, airing"${ <div class="cbdrop">
state.meta.tags.length ? ` list="addTagList"` : ''} /> <button type="button" class="cbdrop-btn" id="addTagsBtn">
${state.meta.tags.length ? `<datalist id="addTagList">${state.meta.tags.map((t) => `<option value="${f.esc(t)}">`).join('')}</datalist>` : ''}</div> <span id="addTagsLabel">No tags selected</span><span class="cbdrop-caret"></span></button>
<div class="cbdrop-menu" id="addTagsMenu" hidden>
<div id="addTagsList">${tagOpts || '<div class="dim" style="padding:4px 4px 8px">No tags yet — add one below.</div>'}</div>
<div class="cbdrop-new"><input type="text" id="addTagNew" placeholder="New tag…" />
<button type="button" class="btn" id="addTagAdd">Add</button></div>
</div>
</div></div>
<div class="field"><label>Save path</label> <div class="field"><label>Save path</label>
<input type="text" id="addPath" value="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div> <input type="text" id="addPath" class="path-default" readonly title="Click to change"
value="${f.esc(categoryDefaultPath(''))}" />
<div class="dim" id="addPathHint" style="font-size:12px;margin-top:4px">Category default click to change.</div></div>
<div class="checks"> <div class="checks">
<label><input type="checkbox" id="addPaused"> Add paused</label> <label><input type="checkbox" id="addPaused"> Add paused</label>
<label><input type="checkbox" id="addSeq"> Sequential download</label> <label><input type="checkbox" id="addSeq"> Sequential download</label>
@ -519,7 +543,7 @@ function openAddModal() {
const magnet = document.getElementById('addMagnet').value.trim(); const magnet = document.getElementById('addMagnet').value.trim();
const common = { const common = {
category: document.getElementById('addCat').value, 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(), savePath: document.getElementById('addPath').value.trim(),
paused: document.getElementById('addPaused').checked, paused: document.getElementById('addPaused').checked,
seqDl: document.getElementById('addSeq').checked, seqDl: document.getElementById('addSeq').checked,
@ -536,6 +560,85 @@ function openAddModal() {
closeModal(); closeModal();
}, 'Failed to add torrent'), }, '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 = '<input type="checkbox"><span></span>';
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) // live readout of the chosen file(s)
document.getElementById('addFile').addEventListener('change', async (e) => { document.getElementById('addFile').addEventListener('change', async (e) => {
const info = document.getElementById('addFileInfo'); 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 cur = state.snapshot?.torrents?.find((t) => t.hash === [...state.selected][0])?.category || '';
const sel = (v) => (v === cur ? ' selected' : ''); const sel = (v) => (v === cur ? ' selected' : '');
const opts = `<option value=""${sel('')}>Uncategorized</option>` + const opts = `<option value=""${sel('')}>Uncategorized</option>` +
state.meta.categories.map((c) => `<option value="${f.esc(c.name)}"${sel(c.name)}>${f.esc(c.name || 'Uncategorized')}</option>`).join(''); state.meta.categories.filter((c) => c.name).map((c) => `<option value="${f.esc(c.name)}"${sel(c.name)}>${f.esc(c.name)}</option>`).join('');
openModal('Set category', ` openModal('Set category', `
<div class="field"><label>Category</label><select id="catSel">${opts}</select></div> <div class="field"><label>Category</label><select id="catSel">${opts}</select></div>
<p class="dim" style="font-size:12px">Need a new one? Use <b>+</b> next to Categories in the sidebar.</p>`, <p class="dim" style="font-size:12px">Need a new one? Use <b>+</b> next to Categories in the sidebar.</p>`,
@ -728,6 +831,33 @@ function openCreateCategory() {
setTimeout(() => document.getElementById('catName')?.focus(), 0); 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
? `<div class="field"><label>Name</label><input type="text" value="Uncategorized" disabled /></div>`
: `<div class="field"><label>Name</label><input type="text" id="catName" value="${f.esc(value)}" /></div>`;
openModal('Edit category', `
${nameField}
<div class="field"><label>Save path</label>
<input type="text" id="catPath" value="${f.esc(curPath)}" placeholder="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>`,
[{ 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() { function openCreateTag() {
openModal('New tag', `<div class="field"><label>Name</label><input type="text" id="tagName" placeholder="e.g. seed-2weeks" /></div>`, openModal('New tag', `<div class="field"><label>Name</label><input type="text" id="tagName" placeholder="e.g. seed-2weeks" /></div>`,
[{ label: 'Cancel', act: closeModal }, { [{ label: 'Cancel', act: closeModal }, {
@ -829,26 +959,176 @@ async function renderRssView(host, seq) {
host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>'; host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]); const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]);
if (seq !== viewRenderSeq || state.view !== 'rss') return; 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 = `<div class="pane"><div class="split2"> host.innerHTML = `<div class="pane"><div class="split2">
<div> <div>
<div class="card"><h3>Feeds</h3> <div class="card">
${feeds.map((fd) => `<div class="kvrow"><span style="flex:1">📡 <b>${f.esc(fd.name)}</b></span><span class="dim">${f.ago(fd.lastUpdate)}</span></div> <div class="card-head"><h3>Feeds</h3><span>
<div class="kvrow dim"><code class="inline">${f.esc(fd.url)}</code></div>`).join('<div style="height:8px"></div>')} <button class="btn" id="refreshAllBtn" title="Re-fetch all feeds now"> Refresh all</button>
<button class="btn" id="addFeedBtn"> Add feed</button></span></div>
${feeds.length ? feeds.map((fd) => `<div class="feed-row" data-feed="${f.esc(fd.name)}">
<div class="kvrow"><span style="flex:1">📡 <b>${f.esc(fd.name)}</b> <span class="dim">(${fd.articles.length})</span></span>
<span class="dim">${fd.lastUpdate ? f.ago(fd.lastUpdate) : 'not fetched yet'}</span>
<button class="iconbtn repull-feed" title="Re-fetch this feed now"></button>
<button class="iconbtn del-feed" title="Remove feed"></button></div>
<div class="kvrow dim"><code class="inline">${f.esc(fd.url)}</code></div>
</div>`).join('<div style="height:8px"></div>') : '<div class="dim">No feeds yet. Add one to start polling.</div>'}
</div> </div>
</div> </div>
<div> <div>
<div class="card"><h3>Unread articles</h3> <div class="card"><h3>Articles</h3>
<table class="dtbl"><thead><tr><th>Feed</th><th>Title</th><th class="num">Size</th><th class="num">Published</th><th></th></tr></thead><tbody> ${articles.length ? `<table class="dtbl"><thead><tr><th>Feed</th><th>Title</th><th class="num">Size</th><th class="num">Published</th><th></th></tr></thead><tbody>
${feeds.flatMap((fd) => fd.articles.map((a) => `<tr style="${a.isRead ? 'opacity:.5' : ''}"> ${articles.map((a, i) => `<tr style="${a.isRead ? 'opacity:.5' : ''}">
<td class="dim">${f.esc(fd.name)}</td><td>${f.esc(a.title)}</td> <td class="dim">${f.esc(a.feed)}</td><td>${f.esc(a.title)}</td>
<td class="num">${f.bytes(a.size)}</td><td class="num dim">${f.ago(a.date)}</td> <td class="num">${a.size ? f.bytes(a.size) : '—'}</td><td class="num dim">${f.esc(a.pubDate || '')}</td>
<td>${a.isRead ? '<span class="faint">read</span>' : '<span style="color:var(--accent)">● new</span>'}</td></tr>`)).join('')} <td>${(a.magnet || a.torrentUrl || a.link)
</tbody></table> ? `<button class="btn dl-art" data-art="${i}"${a.grabbed ? ' disabled' : ''}>${a.grabbed ? '✓ Added' : ' Add'}</button>`
: '<span class="faint">—</span>'}</td></tr>`).join('')}
</tbody></table>` : '<div class="dim">No articles yet.</div>'}
</div> </div>
<h3 style="margin:16px 0 8px">Auto-download rules</h3> <div class="card-head" style="margin-top:16px"><h3>Auto-download rules</h3><button class="btn" id="addRuleBtn"> New rule</button></div>
${rules.map((r) => renderRule(r)).join('')} ${rules.length ? rules.map((r) => renderRule(r)).join('') : '<div class="dim">No rules yet. New matching articles are not auto-downloaded.</div>'}
</div> </div>
</div></div>`; </div></div>`;
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', `
<div class="field"><label>Name</label><input type="text" id="feedName" placeholder="e.g. EZTV" /></div>
<div class="field"><label>Feed URL</label><input type="text" id="feedUrl" placeholder="https:///rss.xml" /></div>`,
[{ 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 = '<option value="">— none —</option>' +
state.meta.categories.filter((c) => c.name).map((c) =>
`<option value="${f.esc(c.name)}"${c.name === r.assignedCategory ? ' selected' : ''}>${f.esc(c.name)}</option>`).join('');
const feedChecks = (feeds || []).map((fd) =>
`<label class="cbdrop-item"><input type="checkbox" value="${f.esc(fd.name)}"${r.affectedFeeds.includes(fd.name) ? ' checked' : ''}><span>${f.esc(fd.name)}</span></label>`).join('')
|| '<div class="dim" style="padding:4px">No feeds yet.</div>';
openModal(rule ? 'Edit rule' : 'New rule', `
<div class="field"><label>Rule name</label><input type="text" id="rName" value="${f.esc(r.name)}" ${rule ? 'readonly' : ''} placeholder="e.g. My show 1080p" /></div>
<div class="field"><label>Must contain</label><input type="text" id="rMust" value="${f.esc(r.mustContain)}" placeholder="title substring or regex" /></div>
<div class="field"><label>Must not contain</label><input type="text" id="rMustNot" value="${f.esc(r.mustNotContain)}" placeholder="optional" /></div>
<div class="field"><label>Apply to feeds (none = all)</label><div class="rule-feeds">${feedChecks}</div></div>
<div class="field"><label>Assign category</label><select id="rCat">${catOpts}</select></div>
<div class="field"><label>Save path (blank = category/default)</label><input type="text" id="rPath" value="${f.esc(r.savePath)}" placeholder="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>
<div class="checks">
<label><input type="checkbox" id="rEnabled" ${r.enabled ? 'checked' : ''}> Enabled</label>
<label><input type="checkbox" id="rRegex" ${r.useRegex ? 'checked' : ''}> Use regex</label>
<label><input type="checkbox" id="rPaused" ${r.addPaused ? 'checked' : ''}> Add paused</label>
</div>
<div class="field" style="margin-top:6px"><label>Current matches <span id="rMatchCount" class="dim"></span></label>
<div id="rMatches" class="rule-matches"></div></div>`,
[{ 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) => `<div class="match-row${a.grabbed ? ' grabbed' : ''}">
<span class="dim">${f.esc(a.feed)}</span> ${f.esc(a.title)}${a.grabbed ? ' <span class="faint">(grabbed)</span>' : ''}</div>`).join('')
+ (matches.length > 50 ? `<div class="dim" style="padding:4px">…and ${matches.length - 50} more</div>` : '')
: '<div class="dim" style="padding:4px">No current articles match.</div>';
};
['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) { function renderRule(r) {
@ -858,11 +1138,15 @@ function renderRule(r) {
<span class="pill ${r.enabled ? 'on' : ''}">${r.enabled ? 'enabled' : 'disabled'}</span> <span class="pill ${r.enabled ? 'on' : ''}">${r.enabled ? 'enabled' : 'disabled'}</span>
${r.useRegex ? '<span class="pill">regex</span>' : ''} ${r.useRegex ? '<span class="pill">regex</span>' : ''}
${r.addPaused ? '<span class="pill paused">add paused</span>' : ''} ${r.addPaused ? '<span class="pill paused">add paused</span>' : ''}
<span style="flex:1"></span>
<button class="iconbtn" data-rule-run="${f.esc(r.name)}" title="Run now against existing articles"></button>
<button class="iconbtn" data-rule-edit="${f.esc(r.name)}" title="Edit rule"></button>
<button class="iconbtn" data-rule-del="${f.esc(r.name)}" title="Delete rule"></button>
</div> </div>
<div class="kvrow">must contain <code class="inline">${f.esc(r.mustContain)}</code></div> <div class="kvrow">must contain <code class="inline">${f.esc(r.mustContain)}</code></div>
${r.mustNotContain ? `<div class="kvrow">must not contain <code class="inline">${f.esc(r.mustNotContain)}</code></div>` : ''} ${r.mustNotContain ? `<div class="kvrow">must not contain <code class="inline">${f.esc(r.mustNotContain)}</code></div>` : ''}
<div class="kvrow"> category <b>${f.esc(r.assignedCategory)}</b> · save to <b>${f.esc(r.savePath)}</b></div> <div class="kvrow"> category <b>${f.esc(r.assignedCategory || '—')}</b> · save to <b>${f.esc(r.savePath || 'default')}</b></div>
<div class="kvrow dim">feeds: ${r.affectedFeeds.join(', ')} · last match ${f.ago(r.lastMatch)}</div> <div class="kvrow dim">feeds: ${r.affectedFeeds.length ? r.affectedFeeds.join(', ') : 'all'} · last match ${r.lastMatch ? f.ago(r.lastMatch) : 'never'}</div>
</div>`; </div>`;
} }
@ -1075,30 +1359,65 @@ function bindSettingsPanel(script) {
/* ===================== Search view ===================== */ /* ===================== Search view ===================== */
function renderSearchView(host) { function renderSearchView(host) {
const plugins = state.meta.searchPlugins.map((p) => const idx = state.meta.searchPlugins || [];
`<span class="pill ${p.enabled ? 'on' : ''}">${f.esc(p.name)}</span>`).join(' '); const plugins = idx.length
? idx.map((p) => `<span class="pill ${p.enabled ? 'on' : ''}">${f.esc(p.name)}</span>`).join(' ')
: '<span class="dim">none configured</span>';
host.innerHTML = `<div class="pane"> host.innerHTML = `<div class="pane">
<div class="search-bar"> <div class="search-bar">
<input type="text" id="searchInput" placeholder="Search indexers… e.g. debian, sintel, dataset" value="${f.esc(state.searchQuery)}" /> <input type="text" id="searchInput" placeholder="Search indexers… e.g. debian, sintel, dataset" value="${f.esc(state.searchQuery)}" />
<button class="btn primary" id="searchBtn">Search</button> <button class="btn primary" id="searchBtn">Search</button>
</div> </div>
<div class="kvrow" style="margin-bottom:12px">Indexers: ${plugins}</div> <div class="kvrow" style="margin-bottom:12px">Indexers: ${plugins}
<span style="flex:1"></span><button class="btn" id="manageIdxBtn">Manage indexers</button></div>
<div id="searchResults">${state.searchResults ? searchTable(state.searchResults) : '<div class="empty" style="height:200px">Enter a query to search configured indexers</div>'}</div> <div id="searchResults">${state.searchResults ? searchTable(state.searchResults) : '<div class="empty" style="height:200px">Enter a query to search configured indexers</div>'}</div>
</div>`; </div>`;
const input = document.getElementById('searchInput'); const input = document.getElementById('searchInput');
const run = async () => { const run = async () => {
state.searchQuery = input.value.trim(); state.searchQuery = input.value.trim();
if (!state.searchQuery) return;
document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Searching…</div>'; document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Searching…</div>';
state.searchResults = await api.search(state.searchQuery); try { state.searchResults = await api.search(state.searchQuery); }
catch (e) { document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Search failed</div>'; return; }
document.getElementById('searchResults').innerHTML = searchTable(state.searchResults); document.getElementById('searchResults').innerHTML = searchTable(state.searchResults);
bindSearchRows(); bindSearchRows();
}; };
document.getElementById('searchBtn').addEventListener('click', run); document.getElementById('searchBtn').addEventListener('click', run);
document.getElementById('manageIdxBtn').addEventListener('click', openIndexerManager);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
input.focus(); input.focus();
bindSearchRows(); bindSearchRows();
} }
// Manage Torznab indexers (list, add, remove).
function openIndexerManager() {
const idx = state.meta.searchPlugins || [];
const rows = idx.length ? idx.map((p) => `<div class="kvrow" data-idx="${f.esc(p.name)}">
<span style="flex:1">🔎 <b>${f.esc(p.name)}</b> <code class="inline">${f.esc(p.url || '')}</code></span>
<button class="iconbtn del-idx" title="Remove"></button></div>`).join('')
: '<div class="dim">No indexers yet.</div>';
openModal('Torznab indexers', `
<div id="idxList" style="margin-bottom:12px">${rows}</div>
<div class="field"><label>Name</label><input type="text" id="ixName" placeholder="e.g. Jackett/Prowlarr indexer" /></div>
<div class="field"><label>Torznab API URL</label><input type="text" id="ixUrl" placeholder="https://host/api/v2.0/indexers/.../results/torznab/api" /></div>
<div class="field"><label>API key</label><input type="text" id="ixKey" placeholder="optional" /></div>`,
[{ 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) { function searchTable(rows) {
if (!rows.length) return '<div class="empty" style="height:160px">No results</div>'; if (!rows.length) return '<div class="empty" style="height:160px">No results</div>';
return `<table class="dtbl"><thead><tr> return `<table class="dtbl"><thead><tr>
@ -1107,26 +1426,53 @@ function searchTable(rows) {
${rows.map((r, i) => `<tr> ${rows.map((r, i) => `<tr>
<td>${f.esc(r.name)}</td><td class="num">${f.bytes(r.size)}</td> <td>${f.esc(r.name)}</td><td class="num">${f.bytes(r.size)}</td>
<td class="num" style="color:var(--up)">${r.seeds}</td><td class="num dim">${r.leeches}</td> <td class="num" style="color:var(--up)">${r.seeds}</td><td class="num dim">${r.leeches}</td>
<td class="dim">${f.esc(r.engine)}</td><td class="num dim">${f.ago(r.pubDate)}</td> <td class="dim">${f.esc(r.engine)}</td><td class="num dim">${r.pubDate ? f.esc(r.pubDate) : ''}</td>
<td><button class="btn" data-sr="${i}"> Add</button></td></tr>`).join('')} <td><button class="btn" data-sr="${i}"> Add</button></td></tr>`).join('')}
</tbody></table>`; </tbody></table>`;
} }
function bindSearchRows() { function bindSearchRows() {
document.querySelectorAll('[data-sr]').forEach((b) => document.querySelectorAll('[data-sr]').forEach((b) =>
b.addEventListener('click', async () => { b.addEventListener('click', () => guard(async () => {
const r = state.searchResults[+b.dataset.sr]; 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; b.textContent = '✓ Added'; b.disabled = true;
})); }, 'Failed to add torrent')));
} }
/* ===================== Engine/settings view ===================== */ /* ===================== 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 p = state.meta.preferences;
const card = (title, rows) => `<div class="card"><h3>${title}</h3>${rows.map(([k, v]) => const card = (title, rows) => `<div class="card"><h3>${title}</h3>${rows.map(([k, v]) =>
`<div class="prop"><span class="k">${k}</span><span class="v mono">${v}</span></div>`).join('')}</div>`; `<div class="prop"><span class="k">${k}</span><span class="v mono">${v}</span></div>`).join('')}</div>`;
host.innerHTML = `<div class="pane"><div class="settings-grid">
const accountCard = `<div class="card"><div class="card-head"><h3>Account</h3>
<button class="btn" id="changePwBtn">Change password</button></div>
<div class="prop"><span class="k">Signed in as</span><span class="v mono">${f.esc(state.auth.user)}</span></div>
<div class="prop"><span class="k">Role</span><span class="v mono">${f.esc(state.auth.role)}</span></div></div>`;
const usersCard = isAdmin ? `<div class="card"><div class="card-head"><h3>Users</h3>
<button class="btn" id="addUserBtn"> Add user</button></div>
<table class="dtbl"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead><tbody>
${users.map((u) => `<tr data-user="${f.esc(u.username)}">
<td>${f.esc(u.username)}</td>
<td><span class="pill ${u.role === 'admin' ? 'on' : ''}">${f.esc(u.role)}</span></td>
<td class="dim">${u.createdAt ? f.ago(u.createdAt) : '—'}</td>
<td style="white-space:nowrap">
<button class="iconbtn u-role" title="Toggle admin/user">${u.role === 'admin' ? '▼ user' : '▲ admin'}</button>
<button class="iconbtn u-pw" title="Reset password"></button>
<button class="iconbtn u-del" title="Delete user"></button>
</td></tr>`).join('')}
</tbody></table></div>` : '';
host.innerHTML = `<div class="pane">
<div class="settings-grid">
${accountCard}
${usersCard}
${card('Bandwidth', [ ${card('Bandwidth', [
['Global download limit', p.dl_limit ? f.rate(p.dl_limit) : '∞'], ['Global download limit', p.dl_limit ? f.rate(p.dl_limit) : '∞'],
['Global upload limit', p.up_limit ? f.rate(p.up_limit) : '∞'], ['Global upload limit', p.up_limit ? f.rate(p.up_limit) : '∞'],
@ -1161,6 +1507,79 @@ function renderSettingsView(host) {
</div> </div>
<p class="dim" style="margin-top:14px">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.</p> <p class="dim" style="margin-top:14px">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.</p>
</div>`; </div>`;
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', `
<div class="field"><label>Current password</label><input type="password" id="cpOld" autocomplete="current-password" /></div>
<div class="field"><label>New password</label><input type="password" id="cpNew" autocomplete="new-password" /></div>
<div class="field"><label>Confirm new password</label><input type="password" id="cpConf" autocomplete="new-password" /></div>`,
[{ 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', `
<div class="field"><label>Username</label><input type="text" id="auName" placeholder="letters, digits, . _ -" /></div>
<div class="field"><label>Password</label><input type="password" id="auPass" autocomplete="new-password" /></div>
<div class="field"><label>Role</label>
<select id="auRole"><option value="user" selected>user</option><option value="admin">admin</option></select></div>`,
[{ 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}`, `
<div class="field"><label>New password</label><input type="password" id="rpNew" autocomplete="new-password" /></div>
<p class="dim" style="font-size:12px">${f.esc(username)} will be signed out and must use the new password.</p>`,
[{ 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 ===================== */ /* ===================== status bar ===================== */

View file

@ -44,15 +44,16 @@ export function ratio(r) {
return r.toFixed(2); return r.toFixed(2);
} }
export function date(ms) { // Timestamps from the backend are Unix epoch SECONDS; 0/negative means "unset".
if (!ms || ms < 0) return ''; export function date(sec) {
const d = new Date(ms); 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' }); return d.toLocaleString(undefined, { year: '2-digit', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
} }
export function ago(ms) { export function ago(sec) {
if (!ms || ms < 0) return ''; if (!sec || sec < 0) return '';
const s = (Date.now() - ms) / 1000; const s = Date.now() / 1000 - sec;
if (s < 60) return 'just now'; if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`;