Compare commits
No commits in common. "d1b90cfdcbb51de268b98edf2dc0843b9e891323" and "54ac49fa33b1c138fc470bf8dea1e887fc456c2b" have entirely different histories.
d1b90cfdcb
...
54ac49fa33
4 changed files with 44 additions and 519 deletions
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
491
public/js/app.js
491
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) {
|
||||
const auth = await api.authStatus();
|
||||
if (auth.authenticated) return;
|
||||
document.getElementById('app').hidden = true;
|
||||
auth = await new Promise((resolve) => showLogin(auth, resolve));
|
||||
await new Promise((resolve) => showLogin(auth, resolve));
|
||||
document.getElementById('app').hidden = false;
|
||||
}
|
||||
state.auth = { user: auth.user || '', role: auth.role || '' };
|
||||
}
|
||||
|
||||
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 = '<option value="" selected>Uncategorized</option>' +
|
||||
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('');
|
||||
state.meta.categories.map((c) => `<option value="${f.esc(c.name)}">${f.esc(c.name || 'Uncategorized')}</option>`).join('');
|
||||
openModal('Add torrent', `
|
||||
<div class="field"><label>Magnet link / URL</label>
|
||||
<input type="text" id="addMagnet" placeholder="magnet:?xt=urn:btih:…" /></div>
|
||||
|
|
@ -520,19 +504,11 @@ function openAddModal() {
|
|||
<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>Tags</label>
|
||||
<div class="cbdrop">
|
||||
<button type="button" class="cbdrop-btn" id="addTagsBtn">
|
||||
<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>
|
||||
<input type="text" id="addTags" placeholder="comma-separated, e.g. anime, airing"${
|
||||
state.meta.tags.length ? ` list="addTagList"` : ''} />
|
||||
${state.meta.tags.length ? `<datalist id="addTagList">${state.meta.tags.map((t) => `<option value="${f.esc(t)}">`).join('')}</datalist>` : ''}</div>
|
||||
<div class="field"><label>Save path</label>
|
||||
<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>
|
||||
<input type="text" id="addPath" value="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>
|
||||
<div class="checks">
|
||||
<label><input type="checkbox" id="addPaused"> Add paused</label>
|
||||
<label><input type="checkbox" id="addSeq"> Sequential download</label>
|
||||
|
|
@ -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 = '<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)
|
||||
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 = `<option value=""${sel('')}>Uncategorized</option>` +
|
||||
state.meta.categories.filter((c) => c.name).map((c) => `<option value="${f.esc(c.name)}"${sel(c.name)}>${f.esc(c.name)}</option>`).join('');
|
||||
state.meta.categories.map((c) => `<option value="${f.esc(c.name)}"${sel(c.name)}>${f.esc(c.name || 'Uncategorized')}</option>`).join('');
|
||||
openModal('Set category', `
|
||||
<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>`,
|
||||
|
|
@ -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
|
||||
? `<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() {
|
||||
openModal('New tag', `<div class="field"><label>Name</label><input type="text" id="tagName" placeholder="e.g. seed-2weeks" /></div>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
|
|
@ -959,176 +829,26 @@ async function renderRssView(host, seq) {
|
|||
host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
|
||||
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 = `<div class="pane"><div class="split2">
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Feeds</h3><span>
|
||||
<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 class="card"><h3>Feeds</h3>
|
||||
${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="kvrow dim"><code class="inline">${f.esc(fd.url)}</code></div>`).join('<div style="height:8px"></div>')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card"><h3>Articles</h3>
|
||||
${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>
|
||||
${articles.map((a, i) => `<tr style="${a.isRead ? 'opacity:.5' : ''}">
|
||||
<td class="dim">${f.esc(a.feed)}</td><td>${f.esc(a.title)}</td>
|
||||
<td class="num">${a.size ? f.bytes(a.size) : '—'}</td><td class="num dim">${f.esc(a.pubDate || '')}</td>
|
||||
<td>${(a.magnet || a.torrentUrl || a.link)
|
||||
? `<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 class="card"><h3>Unread 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>
|
||||
${feeds.flatMap((fd) => fd.articles.map((a) => `<tr style="${a.isRead ? 'opacity:.5' : ''}">
|
||||
<td class="dim">${f.esc(fd.name)}</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>${a.isRead ? '<span class="faint">read</span>' : '<span style="color:var(--accent)">● new</span>'}</td></tr>`)).join('')}
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div class="card-head" style="margin-top:16px"><h3>Auto-download rules</h3><button class="btn" id="addRuleBtn">+ New rule</button></div>
|
||||
${rules.length ? rules.map((r) => renderRule(r)).join('') : '<div class="dim">No rules yet. New matching articles are not auto-downloaded.</div>'}
|
||||
<h3 style="margin:16px 0 8px">Auto-download rules</h3>
|
||||
${rules.map((r) => renderRule(r)).join('')}
|
||||
</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) {
|
||||
|
|
@ -1138,15 +858,11 @@ function renderRule(r) {
|
|||
<span class="pill ${r.enabled ? 'on' : ''}">${r.enabled ? 'enabled' : 'disabled'}</span>
|
||||
${r.useRegex ? '<span class="pill">regex</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 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>` : ''}
|
||||
<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.length ? r.affectedFeeds.join(', ') : 'all'} · last match ${r.lastMatch ? f.ago(r.lastMatch) : 'never'}</div>
|
||||
<div class="kvrow">→ category <b>${f.esc(r.assignedCategory)}</b> · save to <b>${f.esc(r.savePath)}</b></div>
|
||||
<div class="kvrow dim">feeds: ${r.affectedFeeds.join(', ')} · last match ${f.ago(r.lastMatch)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -1359,65 +1075,30 @@ function bindSettingsPanel(script) {
|
|||
|
||||
/* ===================== Search view ===================== */
|
||||
function renderSearchView(host) {
|
||||
const idx = state.meta.searchPlugins || [];
|
||||
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>';
|
||||
const plugins = state.meta.searchPlugins.map((p) =>
|
||||
`<span class="pill ${p.enabled ? 'on' : ''}">${f.esc(p.name)}</span>`).join(' ');
|
||||
host.innerHTML = `<div class="pane">
|
||||
<div class="search-bar">
|
||||
<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>
|
||||
</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 class="kvrow" style="margin-bottom:12px">Indexers: ${plugins}</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>`;
|
||||
const input = document.getElementById('searchInput');
|
||||
const run = async () => {
|
||||
state.searchQuery = input.value.trim();
|
||||
if (!state.searchQuery) return;
|
||||
document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Searching…</div>';
|
||||
try { state.searchResults = await api.search(state.searchQuery); }
|
||||
catch (e) { document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Search failed</div>'; 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) => `<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) {
|
||||
if (!rows.length) return '<div class="empty" style="height:160px">No results</div>';
|
||||
return `<table class="dtbl"><thead><tr>
|
||||
|
|
@ -1426,53 +1107,26 @@ function searchTable(rows) {
|
|||
${rows.map((r, i) => `<tr>
|
||||
<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="dim">${f.esc(r.engine)}</td><td class="num dim">${r.pubDate ? f.esc(r.pubDate) : '–'}</td>
|
||||
<td class="dim">${f.esc(r.engine)}</td><td class="num dim">${f.ago(r.pubDate)}</td>
|
||||
<td><button class="btn" data-sr="${i}">+ Add</button></td></tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
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) => `<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>`;
|
||||
|
||||
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}
|
||||
host.innerHTML = `<div class="pane"><div class="settings-grid">
|
||||
${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) {
|
|||
</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>
|
||||
</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 ===================== */
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue