Admin page laid out as a feed's settings are (#414)
Prototype for approval on the issue; not to be merged until then. The page had grown one captioned box at a time: at 390 px the tabs ran off the edge and hid Log, Server was eleven full-width boxes (1150 px wide on a desktop for a one-digit number) with units and the meaning of 0 in the labels, and Directory's Save sat between its settings and the lists below. Each tab is now named parts in .fsec, the layout #290 gave a feed's settings, with its switches, numbers with their unit after the box, Server's Save sticky and greyed until a change, the tabs in their own row on a phone, the Proxy tag by the account's name, and the log's explanation moved into each source tab's tooltip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
This commit is contained in:
@@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- iPX opens and reads without a connection: the lists it has fetched (All Subscriptions, Unread and the feeds it preloads or you opened) are kept on the device with the pictures in their unread items, up to 200 MB of them, and items read or flagged and places in an episode while offline are sent when the connection is back. Signing out clears it all (#389).
|
- iPX opens and reads without a connection: the lists it has fetched (All Subscriptions, Unread and the feeds it preloads or you opened) are kept on the device with the pictures in their unread items, up to 200 MB of them, and items read or flagged and places in an episode while offline are sent when the connection is back. Signing out clears it all (#389).
|
||||||
- An open page, or the home-screen app, takes up a new version of iPX by itself once it reconnects after a deploy, back on the item you had open; while something is playing, a dialog is open or a search is typed, it offers a reload instead (#388).
|
- An open page, or the home-screen app, takes up a new version of iPX by itself once it reconnects after a deploy, back on the item you had open; while something is playing, a dialog is open or a search is typed, it offers a reload instead (#388).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The admin page is laid out as a feed's settings are: each tab in named parts, numbers with their unit beside them, switches for on and off, Server's Save always in reach, and on a phone the tabs in a row of their own so all four fit (#414).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Swiping, or j, through more than 50 items no longer stops at the 50th until a reload: the next ones are fetched as you near the end, and Load more on the Unread tab no longer skips as many unread items as you had read (#383).
|
- Swiping, or j, through more than 50 items no longer stops at the 50th until a reload: the next ones are fetched as you near the end, and Load more on the Unread tab no longer skips as many unread items as you had read (#383).
|
||||||
- On the Unread tab, a short list read through no longer asks the server for more that is not there, or shows a count of 0 items beside the items still on screen (#385).
|
- On the Unread tab, a short list read through no longer asks the server for more that is not there, or shows a count of 0 items beside the items still on screen (#385).
|
||||||
|
|||||||
47
web/app.css
47
web/app.css
@@ -962,12 +962,49 @@ body.adminpage #topbar > a.btn{text-decoration:none}
|
|||||||
/* The link to Admin, for admins only: the page is the same for everyone now (#203), and the head
|
/* The link to Admin, for admins only: the page is the same for everyone now (#203), and the head
|
||||||
script marks an admin's from the last visit before the first frame. */
|
script marks an admin's from the last visit before the first frame. */
|
||||||
html:not([data-admin]) #topbar a#admin{display:none}
|
html:not([data-admin]) #topbar a#admin{display:none}
|
||||||
body.adminpage #admin{max-width:780px;margin:0 auto}
|
/* Sections laid out as a feed's settings are (#290, #414): .fsec's name beside its controls,
|
||||||
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
above them when the column is narrow, which the container query on #admin decides. */
|
||||||
/* The directory's Servers and Blocked follow its Save and each other's rows (#206). */
|
body.adminpage #admin{max-width:780px;margin:0 auto;container-type:inline-size}
|
||||||
body.adminpage #dadmin h2{margin-top:32px}
|
body.adminpage #admin h2{font-size:22px;margin:0 0 6px;letter-spacing:-.01em}
|
||||||
|
body.adminpage #admin h2+.cap{margin-bottom:14px}
|
||||||
|
body.adminpage #admin .fsec:last-of-type{border-bottom:1px solid var(--line)}
|
||||||
body.adminpage #log{max-width:none}
|
body.adminpage #log{max-width:none}
|
||||||
.urow{padding:9px 0;border-bottom:1px solid var(--line)}
|
/* A setting's caption belongs to the field above it, not halfway to the next. */
|
||||||
|
#admin .fsecin>.ffield:has(+.cap){margin-bottom:0}
|
||||||
|
#admin .fsecin>.cap{margin-bottom:6px}
|
||||||
|
#admin .unit{align-self:center;color:var(--dim);font-size:14px}
|
||||||
|
#admin .apath{font-size:12.5px;color:var(--dim);overflow-wrap:anywhere}
|
||||||
|
#admin .cardacts{margin-top:8px}
|
||||||
|
/* Save stays on screen at the foot of a long section, as a feed's settings keep theirs (#167). */
|
||||||
|
.afoot{position:sticky;bottom:0;display:flex;justify-content:flex-end;padding-top:12px;padding-bottom:calc(12px + var(--safe-b));margin-top:4px;background:var(--bg)}
|
||||||
|
.afoot .btn:disabled{opacity:.45;cursor:default}
|
||||||
|
/* How the directory answers, above what sets it up. */
|
||||||
|
.astatus{margin:0 0 14px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;font-size:13.5px;line-height:1.5}
|
||||||
|
.urow{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--line)}
|
||||||
|
.urow:last-child{border-bottom:0}
|
||||||
|
.urow .uwho{flex:1;min-width:0}
|
||||||
|
.urow .uwho b{display:block;overflow-wrap:anywhere}
|
||||||
|
.urow .uwho .tag{margin-left:4px;vertical-align:1px}
|
||||||
|
.urow .uwho small{display:block;color:var(--faint);font-size:12.5px}
|
||||||
|
.urow .switch{min-height:0;gap:8px;font-size:13px;font-weight:400;color:var(--dim)}
|
||||||
|
.loghead{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px}
|
||||||
|
.loghead h2{margin:0!important}
|
||||||
|
.loghead .tabs button{white-space:nowrap}
|
||||||
|
.logbar select{width:auto}
|
||||||
|
body.adminpage #logbox{height:calc(100dvh - 230px);min-height:240px}
|
||||||
|
#log>.cap{margin-top:8px}
|
||||||
|
/* On a phone the tabs take a row of their own under the title, where all four fit. */
|
||||||
|
@media (max-width:600px){
|
||||||
|
body.adminpage #topbar{flex-wrap:wrap;row-gap:8px}
|
||||||
|
body.adminpage #atabs{order:9;flex:1 1 100%}
|
||||||
|
body.adminpage #atabs a{flex:1;text-align:center}
|
||||||
|
.loghead .tabs{flex:1 1 100%}
|
||||||
|
.loghead .tabs button{flex:1;padding:5px 4px}
|
||||||
|
.logbar select{flex:1}
|
||||||
|
body.adminpage #logbox{height:calc(100dvh - 300px)}
|
||||||
|
#logbox .l{display:grid;grid-template-columns:auto 1fr;gap:0 8px;padding:5px 0;border-bottom:1px solid var(--line)}
|
||||||
|
#logbox .l>span:last-child{grid-column:1/-1}
|
||||||
|
}
|
||||||
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
|
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
|
||||||
|
|
||||||
/* ---------- items ---------- */
|
/* ---------- items ---------- */
|
||||||
|
|||||||
173
web/src/admin.ts
173
web/src/admin.ts
@@ -19,53 +19,53 @@ function showSection(){
|
|||||||
}
|
}
|
||||||
window.addEventListener('hashchange', showSection);
|
window.addEventListener('hashchange', showSection);
|
||||||
|
|
||||||
|
// The parts of each section named in a column beside their controls, and on or off drawn as a
|
||||||
|
// switch, as a feed's settings are (#290, #414): the page had grown one captioned box at a time.
|
||||||
|
const asect = (title: string, body: string) => `<section class="fsec"><h4>${title}</h4><div class="fsecin">${body}</div></section>`;
|
||||||
|
const aswitch = (input: string, text: string) => `<label class="switch"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
|
||||||
|
/// A number with its unit after it, rather than in the label.
|
||||||
|
const anum = (id: string, label: string, value: number, unit: string, attrs = '') =>
|
||||||
|
`<div class="ffield"><label class="flabel" for="${id}">${label}</label>
|
||||||
|
<div class="inline"><input type="number" id="${id}" value="${value}"${attrs}><span class="unit">${unit}</span></div></div>`;
|
||||||
|
|
||||||
/* ---------------- server ---------------- */
|
/* ---------------- server ---------------- */
|
||||||
async function drawServer(){
|
async function drawServer(){
|
||||||
const box = $('#server');
|
const box = $('#server');
|
||||||
const g = await api('/api/settings');
|
const g = await api('/api/settings');
|
||||||
const gs = splitEvery(g.every_mins);
|
const gs = splitEvery(g.every_mins);
|
||||||
box.innerHTML = `<h2>Server</h2>
|
box.innerHTML = `<h2>Server</h2>
|
||||||
<p class="hint">These apply to everyone. Each person's own choices, such as keywords or how
|
<p class="cap">These apply to everyone. Each person's own choices, such as keywords or how many
|
||||||
many items a feed downloads for them, are in that feed's settings.</p>
|
items a feed downloads for them, are in that feed's settings.</p>
|
||||||
<div class="field"><label>Check feeds every</label>
|
${asect('Feeds', `<div class="ffield"><label class="flabel" for="gnum">Check every</label>
|
||||||
<div class="inline">
|
<div class="inline"><input type="number" id="gnum" min="1" max="999" value="${gs.n}">
|
||||||
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
|
<select id="gunit" aria-label="Unit">${unitOptions(gs.u)}</select></div>
|
||||||
<select id="gunit">${unitOptions(gs.u)}</select>
|
<p class="cap">For every feed that does not set its own. A feed that asks to be checked less
|
||||||
</div>
|
often (its <b>ttl</b>) still is.</p></div>
|
||||||
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
|
${anum('gmax', 'Download the newest', g.max_new_per_check, 'per feed', ' min="0" max="999"')}
|
||||||
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
|
<p class="cap">For every feed that does not set its own, those inside an OPML subscription too.
|
||||||
<div class="field"><label>Newest episodes to download, per feed</label>
|
Older ones stay listed, to download by hand. 0 downloads every episode.</p>
|
||||||
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
|
<div class="ffield"><label class="flabel" for="gtypes">Media types to download</label>
|
||||||
<span class="hint">Applies to any feed that does not set its own — including every feed
|
|
||||||
inside an OPML subscription. Older episodes stay listed to download by hand. <b>0 means
|
|
||||||
every episode</b>, the whole back catalogue.</span></div>
|
|
||||||
<div class="field"><label>Download these media types automatically</label>
|
|
||||||
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
|
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
|
||||||
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
|
<p class="cap">Anything else is listed, to download by hand: blog feeds put article images in
|
||||||
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
|
enclosures. Empty takes everything.</p></div>`)}
|
||||||
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
|
${asect('Storage', `${anum('gquota', 'Disk quota', g.max_total_gb, 'GB', ' min="0" step="0.5"')}
|
||||||
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
|
<p class="cap">0 has no limit. Over it, the oldest played items go first; pinned ones never do.</p>
|
||||||
<span class="hint">Over this, the oldest played items are deleted first. Pinned
|
${anum('gage', 'Delete items after', g.max_age_days, 'days', ' min="0"')}
|
||||||
items are never touched.</span></div>
|
<p class="cap">0 keeps them.</p>
|
||||||
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
${anum('gart', 'Artwork kept', g.art_cache_mb, 'MB', ' min="0"')}
|
||||||
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
<p class="cap">Artwork is kept once shown, so it loads from iPX rather than each publisher. 0 keeps none.</p>
|
||||||
<div class="field"><label>Artwork kept (MB, 0 = none)</label>
|
<div class="ffield"><span class="flabel">Download folder</span>
|
||||||
<input type="number" id="gart" min="0" value="${g.art_cache_mb}">
|
<code class="apath">${esc(g.download_dir)}</code></div>`)}
|
||||||
<span class="hint">Show and episode artwork is kept here once shown, so it loads from iPX
|
${asect('Signals', `${aswitch(`<input type="checkbox" role="switch" id="gsig"${g.signals_on_server ? ' checked' : ''}${g.typesafe_server_key ? '' : ' disabled'}>`,
|
||||||
instead of each publisher. Over this, what has gone longest unshown is dropped first.</span></div>
|
'On this server\'s key, for everyone')}
|
||||||
<div class="field"><label class="check"><input type="checkbox" id="gsig"${g.signals_on_server ? ' checked' : ''}${g.typesafe_server_key ? '' : ' disabled'}>
|
<p class="cap">${g.typesafe_server_key
|
||||||
Signals for everyone, on this server's TypeSafe key</label>
|
? 'On, every item read for a Signal is a call on this server\'s TypeSafe key. Off, each person turns Signals on in their own Settings, with their own key.'
|
||||||
<span class="hint">${g.typesafe_server_key
|
: 'This server has no TypeSafe key (<code>TYPESAFE_KEY</code>), so each person uses their own.'}</p>
|
||||||
? 'On, everyone has Signals and every item read for one is a call on this server\'s key. Off, each person turns Signals on in their own Settings with a key of their own.'
|
${anum('gsigdays', 'A new Signal looks back', g.signal_days, 'days', ' min="1" max="365"')}
|
||||||
: 'This server has no TypeSafe key (<code>TYPESAFE_KEY</code>), so each person uses their own.'}</span></div>
|
<p class="cap">Each item read is a call to TypeSafe, so a longer look back costs more, once for each new Signal.</p>`)}
|
||||||
<div class="field"><label for="gsigdays">Signals look back (days)</label>
|
<div class="afoot"><button class="btn primary" id="gsave" disabled>${ICON.check} Save</button></div>`;
|
||||||
<input type="number" id="gsigdays" min="1" max="365" value="${g.signal_days}">
|
// Save waits for a change, so it says whether there is one.
|
||||||
<span class="hint">How far back a new Signal reads. Each item read is a call to TypeSafe, so a
|
box.oninput = () => { $<HTMLButtonElement>('#gsave').disabled = false; };
|
||||||
longer look back costs more, once for each new Signal. Editing a Signal reads again only what
|
|
||||||
it had picked out.</span></div>
|
|
||||||
<div class="field"><label>Download folder</label>
|
|
||||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
|
||||||
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
|
||||||
$<HTMLButtonElement>('#gsave').onclick = async () => {
|
$<HTMLButtonElement>('#gsave').onclick = async () => {
|
||||||
try{
|
try{
|
||||||
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
|
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
|
||||||
@@ -79,6 +79,7 @@ async function drawServer(){
|
|||||||
// Left alone while the server has no key to offer, rather than switched off.
|
// Left alone while the server has no key to offer, rather than switched off.
|
||||||
...($<HTMLInputElement>('#gsig').disabled ? {} : {signals_on_server: $<HTMLInputElement>('#gsig').checked})})});
|
...($<HTMLInputElement>('#gsig').disabled ? {} : {signals_on_server: $<HTMLInputElement>('#gsig').checked})})});
|
||||||
toast('Settings saved');
|
toast('Settings saved');
|
||||||
|
$<HTMLButtonElement>('#gsave').disabled = true;
|
||||||
}catch(e){ toast(why(e), true); }
|
}catch(e){ toast(why(e), true); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -92,20 +93,20 @@ async function drawDirectorySettings(){
|
|||||||
const box = $('#directory');
|
const box = $('#directory');
|
||||||
const g = await api('/api/settings');
|
const g = await api('/api/settings');
|
||||||
box.innerHTML = `<h2>Directory</h2>
|
box.innerHTML = `<h2>Directory</h2>
|
||||||
<p class="hint">The Directory shows podcasts from a directory service that iPX servers share.
|
<p class="cap">The Directory shows podcasts from a directory service that iPX servers share.
|
||||||
Without one, people add feeds by their address.</p>
|
Without one, people add feeds by their address.</p>
|
||||||
<div class="field"><label for="durl">Directory address</label>
|
<p class="astatus" id="dstatus" role="status">Asking the directory…</p>
|
||||||
|
${asect('Connection', `<div class="ffield"><label class="flabel" for="durl">Address</label>
|
||||||
<input type="text" inputmode="url" id="durl" value="${esc(g.directory_url)}" placeholder="https://directory.example.net" spellcheck="false"></div>
|
<input type="text" inputmode="url" id="durl" value="${esc(g.directory_url)}" placeholder="https://directory.example.net" spellcheck="false"></div>
|
||||||
<div class="field"><label for="dtoken">Access token</label>
|
<div class="ffield"><label class="flabel" for="dtoken">Access token</label>
|
||||||
<div class="inline"><input type="password" id="dtoken" autocomplete="off" spellcheck="false"
|
<div class="inline"><input type="password" id="dtoken" autocomplete="off" spellcheck="false"
|
||||||
placeholder="${g.directory_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token the directory gave this server'}">
|
placeholder="${g.directory_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token the directory gave this server'}">
|
||||||
${g.directory_token_set ? `<button type="button" class="btn" id="dclear">Clear</button>` : ''}</div>
|
${g.directory_token_set ? `<button type="button" class="btn" id="dclear">Clear</button>` : ''}</div>
|
||||||
<span class="hint">Whoever runs the directory makes one for each server, and can revoke it.</span></div>
|
<p class="cap">Whoever runs the directory makes one for each server, and can revoke it.</p></div>
|
||||||
<div class="field"><label class="check"><input type="checkbox" id="dshare"${g.directory_share ? ' checked' : ''}> Share this server's public feeds</label>
|
${aswitch(`<input type="checkbox" role="switch" id="dshare"${g.directory_share ? ' checked' : ''}>`, 'Share this server\'s public feeds')}
|
||||||
<span class="hint">Sends the directory the feeds people here subscribe to, and how many of them do, every
|
<p class="cap">Every half hour, the feeds people here subscribe to and how many do. Never a
|
||||||
half hour. Never a private feed: one marked private, one with a login, or one whose address carries a key.</span></div>
|
private feed: one marked private, one with a login, or one whose address carries a key.</p>
|
||||||
<div class="field"><label>Status</label><p class="hint" id="dstatus">Asking the directory…</p></div>
|
<div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>`)}
|
||||||
<div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>
|
|
||||||
<div id="dadmin"></div>`;
|
<div id="dadmin"></div>`;
|
||||||
const save = async (patch: Record<string, unknown>, said: string) => {
|
const save = async (patch: Record<string, unknown>, said: string) => {
|
||||||
try{
|
try{
|
||||||
@@ -153,21 +154,18 @@ async function drawDirectoryAdmin(){
|
|||||||
let a: DirAdmin;
|
let a: DirAdmin;
|
||||||
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(why(e))}</p>`; return; }
|
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(why(e))}</p>`; return; }
|
||||||
if(!box.isConnected) return;
|
if(!box.isConnected) return;
|
||||||
box.innerHTML = `<h2>Servers</h2>
|
box.innerHTML = asect('Servers', `<p class="cap">Each server sharing with the directory, by its token's
|
||||||
<p class="hint">Each server sharing with the directory, by its token's name. Dropping one removes its
|
name. Dropping one removes its feeds and counts until it next shares; to keep it out, revoke its
|
||||||
feeds and counts until it next shares; to keep it out, revoke its token in the directory's config.</p>
|
token in the directory's config.</p>
|
||||||
${a.instances.length ? a.instances.map(i => `<div class="inline urow" data-name="${esc(i.name)}">
|
${a.instances.length ? a.instances.map(i => `<div class="urow" data-name="${esc(i.name)}">
|
||||||
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(i.name)}</b>
|
<div class="uwho"><b>${esc(i.name)}</b><small>${plural(i.feeds, 'feed')} · ${plural(i.subscribers, 'subscriber')}</small></div>
|
||||||
<small style="display:block;color:var(--faint)">${plural(i.feeds, 'feed')} · ${plural(i.subscribers, 'subscriber')}</small></div>
|
|
||||||
<button class="btn ico danger" data-a="drop" title="Drop ${esc(i.name)}'s feeds" aria-label="Drop ${esc(i.name)}'s feeds">${ICON.trash}</button></div>`).join('')
|
<button class="btn ico danger" data-a="drop" title="Drop ${esc(i.name)}'s feeds" aria-label="Drop ${esc(i.name)}'s feeds">${ICON.trash}</button></div>`).join('')
|
||||||
: '<p class="hint">No server is sharing yet.</p>'}
|
: '<p class="cap">No server is sharing yet.</p>'}`) +
|
||||||
<h2>Blocked</h2>
|
asect('Blocked', `<p class="cap">Feeds removed from the directory. No snapshot brings them back until they are unblocked.</p>
|
||||||
<p class="hint">Feeds removed from the directory. No snapshot brings them back until they are unblocked.</p>
|
${a.blocked.length ? a.blocked.map(b => `<div class="urow" data-id="${esc(b.id)}">
|
||||||
${a.blocked.length ? a.blocked.map(b => `<div class="inline urow" data-id="${esc(b.id)}">
|
<div class="uwho"><b>${esc(b.title || 'Untitled')}</b><small>Removed ${dateOf(b.at)}</small></div>
|
||||||
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(b.title || 'Untitled')}</b>
|
|
||||||
<small style="display:block;color:var(--faint)">Removed ${dateOf(b.at)}</small></div>
|
|
||||||
<button class="btn" data-a="unblock">Unblock</button></div>`).join('')
|
<button class="btn" data-a="unblock">Unblock</button></div>`).join('')
|
||||||
: '<p class="hint">Nothing is blocked.</p>'}`;
|
: '<p class="cap">Nothing is blocked.</p>'}`);
|
||||||
box.onclick = async (ev: MouseEvent) => {
|
box.onclick = async (ev: MouseEvent) => {
|
||||||
const b = (ev.target as Element).closest<HTMLElement>('[data-a]');
|
const b = (ev.target as Element).closest<HTMLElement>('[data-a]');
|
||||||
if(!b) return;
|
if(!b) return;
|
||||||
@@ -192,22 +190,20 @@ async function drawAccounts(){
|
|||||||
const box = $('#accounts');
|
const box = $('#accounts');
|
||||||
const users: Account[] = await api('/api/users') || [];
|
const users: Account[] = await api('/api/users') || [];
|
||||||
box.innerHTML = `<h2>Accounts</h2>
|
box.innerHTML = `<h2>Accounts</h2>
|
||||||
${users.map(u => `<div class="inline urow" data-id="${u.id}">
|
${asect('People', users.map(u => `<div class="urow" data-id="${u.id}">
|
||||||
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
|
<div class="uwho"><b>${esc(u.name)}${u.password ? '' : ' <span class="tag" title="No password: signs in through the proxy">Proxy</span>'}</b>
|
||||||
<small style="display:block;color:var(--faint)">${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${
|
<small>${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${
|
||||||
u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}</small></div>
|
u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}</small></div>
|
||||||
${u.password ? '' : '<span class="tag" title="No password: signs in through the proxy">Proxy</span>'}
|
${aswitch(`<input type="checkbox" role="switch" data-a="admin" ${u.admin ? 'checked' : ''}>`, 'Admin')}
|
||||||
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin ? 'checked' : ''}> Admin</label>
|
<button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join(''))}
|
||||||
<button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join('')}
|
${asect('Add someone', `<div class="ffield"><label class="flabel" for="uname">Name</label>
|
||||||
<div class="field" style="margin-top:20px"><label>Add someone</label>
|
<input type="text" id="uname" autocomplete="off" spellcheck="false"></div>
|
||||||
<div class="inline">
|
<div class="ffield"><label class="flabel" for="upass">Password</label>
|
||||||
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
|
<input type="password" id="upass" autocomplete="new-password">
|
||||||
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
|
<p class="cap">At least 8 characters. Empty for someone who signs in through the proxy.</p></div>
|
||||||
</div>
|
${aswitch('<input type="checkbox" role="switch" id="uadmin">', 'Admin')}
|
||||||
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
|
<p class="cap">New people start with no feeds.</p>
|
||||||
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
|
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`)}`;
|
||||||
through the proxy. New people start with no feeds.</span></div>
|
|
||||||
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
|
|
||||||
const change = async (u: Account, opts: RequestInit) => {
|
const change = async (u: Account, opts: RequestInit) => {
|
||||||
try{
|
try{
|
||||||
await api(`/api/users/${u.id}`, opts);
|
await api(`/api/users/${u.id}`, opts);
|
||||||
@@ -245,32 +241,35 @@ const LOG_TABS: Record<string, ((t: string) => boolean)|null> = {
|
|||||||
scan: t => t === 'ipx::scan',
|
scan: t => t === 'ipx::scan',
|
||||||
web: t => t === 'ipx::http',
|
web: t => t === 'ipx::http',
|
||||||
};
|
};
|
||||||
|
// Said on each tab, rather than in a paragraph under the log.
|
||||||
|
const LOG_TAB_SAYS: Record<string, string> = {
|
||||||
|
all: 'Everything', daemon: 'The control protocol: every command in and every event out',
|
||||||
|
scan: 'Feed and download activity', web: 'Web requests',
|
||||||
|
};
|
||||||
const LEVELS: Record<string,number> = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
|
const LEVELS: Record<string,number> = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
|
||||||
|
|
||||||
function drawLogView(){
|
function drawLogView(){
|
||||||
logSeq = 0; logLines = [];
|
logSeq = 0; logLines = [];
|
||||||
$('#log').innerHTML = `<h2>Log</h2>
|
$('#log').innerHTML = `<div class="loghead"><h2>Log</h2>
|
||||||
<div class="logbar">
|
|
||||||
<div class="tabs" id="logtabs">
|
<div class="tabs" id="logtabs">
|
||||||
${Object.keys(LOG_TABS).map(t =>
|
${Object.keys(LOG_TABS).map(t =>
|
||||||
`<button data-t="${t}" class="${logTab === t ? 'on' : ''}">${
|
`<button data-t="${t}" class="${logTab === t ? 'on' : ''}" title="${LOG_TAB_SAYS[t]}">${
|
||||||
{all: 'All', daemon: 'Daemon I/O', scan: 'Scans', web: 'HTTP'}[t]}</button>`).join('')}
|
{all: 'All', daemon: 'Daemon I/O', scan: 'Scans', web: 'HTTP'}[t]}</button>`).join('')}
|
||||||
</div>
|
</div></div>
|
||||||
<select id="loglevel" style="width:auto">
|
<div class="logbar">
|
||||||
|
<select id="loglevel" aria-label="Level">
|
||||||
<option value="">All levels</option>
|
<option value="">All levels</option>
|
||||||
<option value="INFO">Info and above</option>
|
<option value="INFO">Info and above</option>
|
||||||
<option value="WARN">Warnings and errors</option>
|
<option value="WARN">Warnings and errors</option>
|
||||||
<option value="ERROR">Errors only</option>
|
<option value="ERROR">Errors only</option>
|
||||||
</select>
|
</select>
|
||||||
${searchBox('<input type="search" id="logq" placeholder="Filter…">','grow')}
|
${searchBox('<input type="search" id="logq" placeholder="Filter…" aria-label="Filter">','grow')}
|
||||||
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
|
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
|
||||||
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
|
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="logbox"><p class="empty">Loading…</p></div>
|
<div id="logbox"><p class="empty">Loading…</p></div>
|
||||||
<span class="hint"><b>Daemon I/O</b> is the control protocol itself — every command in and
|
<p class="cap">Debug lines are kept here even when the container's log leaves them out;
|
||||||
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests.
|
<b>IPX_UI_LOG</b> changes what is kept.</p>`;
|
||||||
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
|
|
||||||
what it captures.</span>`;
|
|
||||||
for(const b of $$('#logtabs button')) b.onclick = () => {
|
for(const b of $$('#logtabs button')) b.onclick = () => {
|
||||||
logTab = b.dataset.t!;
|
logTab = b.dataset.t!;
|
||||||
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
|
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
|
||||||
|
|||||||
Reference in New Issue
Block a user