From c6dcb7f34e16a9c14b3c103368e9635d9f8d1400 Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 7 Nov 2026 17:07:19 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA --- web/CHANGELOG.md | 1 + web/app.css | 47 ++++++++++-- web/src/admin.ts | 183 +++++++++++++++++++++++------------------------ 3 files changed, 134 insertions(+), 97 deletions(-) diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index edea008..bb4c9a0 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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). - An item's title and text take four fifths of the reading pane's width, no longer running to its far edge (#360). ### Added diff --git a/web/app.css b/web/app.css index 639f464..dbd9e80 100644 --- a/web/app.css +++ b/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 script marks an admin's from the last visit before the first frame. */ html:not([data-admin]) #topbar a#admin{display:none} -body.adminpage #admin{max-width:780px;margin:0 auto} -body.adminpage #admin h2{font-size:18px;margin:0 0 12px} -/* The directory's Servers and Blocked follow its Save and each other's rows (#206). */ -body.adminpage #dadmin h2{margin-top:32px} +/* Sections laid out as a feed's settings are (#290, #414): .fsec's name beside its controls, + above them when the column is narrow, which the container query on #admin decides. */ +body.adminpage #admin{max-width:780px;margin:0 auto;container-type:inline-size} +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} -.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} /* ---------- items ---------- */ diff --git a/web/src/admin.ts b/web/src/admin.ts index 44f1920..c86ceed 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -19,53 +19,53 @@ function 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) => `

${title}

${body}
`; +const aswitch = (input: string, text: string) => ``; +/// A number with its unit after it, rather than in the label. +const anum = (id: string, label: string, value: number, unit: string, attrs = '') => + `
+
${unit}
`; + /* ---------------- server ---------------- */ async function drawServer(){ const box = $('#server'); const g = await api('/api/settings'); const gs = splitEvery(g.every_mins); box.innerHTML = `

Server

-

These apply to everyone. Each person's own choices, such as keywords or how - many items a feed downloads for them, are in that feed's settings.

-
-
- - -
- Applies to every feed that does not set its own. A feed's suggested - interval (its ttl) is still honoured when it asks to be polled less often.
-
- - 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. 0 means - every episode, the whole back catalogue.
-
- - Anything else is still listed and can be downloaded by hand — blog feeds - put article images in enclosures, and those are not worth keeping. Empty takes everything.
-
- - Over this, the oldest played items are deleted first. Pinned - items are never touched.
-
-
-
- - Show and episode artwork is kept here once shown, so it loads from iPX - instead of each publisher. Over this, what has gone longest unshown is dropped first.
-
- ${g.typesafe_server_key - ? '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.' - : 'This server has no TypeSafe key (TYPESAFE_KEY), so each person uses their own.'}
-
- - How far back a new Signal reads. Each item read is a call to TypeSafe, so a - longer look back costs more, once for each new Signal. Editing a Signal reads again only what - it had picked out.
-
- ${esc(g.download_dir)}
-
`; +

These apply to everyone. Each person's own choices, such as keywords or how many + items a feed downloads for them, are in that feed's settings.

+ ${asect('Feeds', `
+
+
+

For every feed that does not set its own. A feed that asks to be checked less + often (its ttl) still is.

+ ${anum('gmax', 'Download the newest', g.max_new_per_check, 'per feed', ' min="0" max="999"')} +

For every feed that does not set its own, those inside an OPML subscription too. + Older ones stay listed, to download by hand. 0 downloads every episode.

+
+ +

Anything else is listed, to download by hand: blog feeds put article images in + enclosures. Empty takes everything.

`)} + ${asect('Storage', `${anum('gquota', 'Disk quota', g.max_total_gb, 'GB', ' min="0" step="0.5"')} +

0 has no limit. Over it, the oldest played items go first; pinned ones never do.

+ ${anum('gage', 'Delete items after', g.max_age_days, 'days', ' min="0"')} +

0 keeps them.

+ ${anum('gart', 'Artwork kept', g.art_cache_mb, 'MB', ' min="0"')} +

Artwork is kept once shown, so it loads from iPX rather than each publisher. 0 keeps none.

+
Download folder + ${esc(g.download_dir)}
`)} + ${asect('Signals', `${aswitch(``, + 'On this server\'s key, for everyone')} +

${g.typesafe_server_key + ? '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.' + : 'This server has no TypeSafe key (TYPESAFE_KEY), so each person uses their own.'}

+ ${anum('gsigdays', 'A new Signal looks back', g.signal_days, 'days', ' min="1" max="365"')} +

Each item read is a call to TypeSafe, so a longer look back costs more, once for each new Signal.

`)} +
`; + // Save waits for a change, so it says whether there is one. + box.oninput = () => { $('#gsave').disabled = false; }; $('#gsave').onclick = async () => { try{ 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. ...($('#gsig').disabled ? {} : {signals_on_server: $('#gsig').checked})})}); toast('Settings saved'); + $('#gsave').disabled = true; }catch(e){ toast(why(e), true); } }; } @@ -92,20 +93,20 @@ async function drawDirectorySettings(){ const box = $('#directory'); const g = await api('/api/settings'); box.innerHTML = `

Directory

-

The Directory shows podcasts from a directory service that iPX servers share. +

The Directory shows podcasts from a directory service that iPX servers share. Without one, people add feeds by their address.

-
-
-
-
- ${g.directory_token_set ? `` : ''}
- Whoever runs the directory makes one for each server, and can revoke it.
-
- Sends the directory the feeds people here subscribe to, and how many of them do, every - half hour. Never a private feed: one marked private, one with a login, or one whose address carries a key.
-

Asking the directory…

-
+

Asking the directory…

+ ${asect('Connection', `
+
+
+
+ ${g.directory_token_set ? `` : ''}
+

Whoever runs the directory makes one for each server, and can revoke it.

+ ${aswitch(``, 'Share this server\'s public feeds')} +

Every half hour, the feeds people here subscribe to and how many do. Never a + private feed: one marked private, one with a login, or one whose address carries a key.

+
`)}
`; const save = async (patch: Record, said: string) => { try{ @@ -153,21 +154,18 @@ async function drawDirectoryAdmin(){ let a: DirAdmin; try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `

${esc(why(e))}

`; return; } if(!box.isConnected) return; - box.innerHTML = `

Servers

-

Each server sharing with the directory, by its token's name. Dropping one removes its - feeds and counts until it next shares; to keep it out, revoke its token in the directory's config.

- ${a.instances.length ? a.instances.map(i => `
-
${esc(i.name)} - ${plural(i.feeds, 'feed')} · ${plural(i.subscribers, 'subscriber')}
+ box.innerHTML = asect('Servers', `

Each server sharing with the directory, by its token's + name. Dropping one removes its feeds and counts until it next shares; to keep it out, revoke its + token in the directory's config.

+ ${a.instances.length ? a.instances.map(i => `
+
${esc(i.name)}${plural(i.feeds, 'feed')} · ${plural(i.subscribers, 'subscriber')}
`).join('') - : '

No server is sharing yet.

'} -

Blocked

-

Feeds removed from the directory. No snapshot brings them back until they are unblocked.

- ${a.blocked.length ? a.blocked.map(b => `
-
${esc(b.title || 'Untitled')} - Removed ${dateOf(b.at)}
+ : '

No server is sharing yet.

'}`) + + asect('Blocked', `

Feeds removed from the directory. No snapshot brings them back until they are unblocked.

+ ${a.blocked.length ? a.blocked.map(b => `
+
${esc(b.title || 'Untitled')}Removed ${dateOf(b.at)}
`).join('') - : '

Nothing is blocked.

'}`; + : '

Nothing is blocked.

'}`); box.onclick = async (ev: MouseEvent) => { const b = (ev.target as Element).closest('[data-a]'); if(!b) return; @@ -192,22 +190,20 @@ async function drawAccounts(){ const box = $('#accounts'); const users: Account[] = await api('/api/users') || []; box.innerHTML = `

Accounts

- ${users.map(u => `
-
${esc(u.name)} - ${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${ + ${asect('People', users.map(u => `
+
${esc(u.name)}${u.password ? '' : ' Proxy'} + ${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${ u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}
- ${u.password ? '' : 'Proxy'} - -
`).join('')} -
-
- - -
- - At least 8 characters. Leave the password empty for someone who signs in - through the proxy. New people start with no feeds.
-
`; + ${aswitch(``, 'Admin')} +
`).join(''))} + ${asect('Add someone', `
+
+
+ +

At least 8 characters. Empty for someone who signs in through the proxy.

+ ${aswitch('', 'Admin')} +

New people start with no feeds.

+
`)}`; const change = async (u: Account, opts: RequestInit) => { try{ await api(`/api/users/${u.id}`, opts); @@ -245,32 +241,35 @@ const LOG_TABS: Record boolean)|null> = { scan: t => t === 'ipx::scan', web: t => t === 'ipx::http', }; +// Said on each tab, rather than in a paragraph under the log. +const LOG_TAB_SAYS: Record = { + all: 'Everything', daemon: 'The control protocol: every command in and every event out', + scan: 'Feed and download activity', web: 'Web requests', +}; const LEVELS: Record = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0}; function drawLogView(){ logSeq = 0; logLines = []; - $('#log').innerHTML = `

Log

-
+ $('#log').innerHTML = `

Log

${Object.keys(LOG_TABS).map(t => - ``).join('')} -
- - ${searchBox('','grow')} + ${searchBox('','grow')}

Loading…

- Daemon I/O is the control protocol itself — every command in and - every event out. Scans is feed and download activity, HTTP is web requests. - The buffer keeps debug detail even when the terminal does not; IPX_UI_LOG changes - what it captures.`; +

Debug lines are kept here even when the container's log leaves them out; + IPX_UI_LOG changes what is kept.

`; for(const b of $$('#logtabs button')) b.onclick = () => { logTab = b.dataset.t!; for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);