diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md
index fb9a38e..777bf04 100644
--- a/web/CHANGELOG.md
+++ b/web/CHANGELOG.md
@@ -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).
- 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
- 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).
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}
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.
-TYPESAFE_KEY), so each person uses their own.'}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.
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.
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.
+${esc(g.download_dir)}${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.'}
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 = () => { $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.
-Asking the directory…
Asking the directory…
+ ${asect('Connection', `Whoever runs the directory makes one for each server, and can revoke it.
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${esc(why(e))}
`; return; } if(!box.isConnected) return; - box.innerHTML = `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 => `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 => `No server is sharing yet.
'} -Feeds removed from the directory. No snapshot brings them back until they are unblocked.
- ${a.blocked.length ? a.blocked.map(b => `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 => `Nothing is blocked.
'}`; + : 'Nothing is blocked.
'}`); box.onclick = async (ev: MouseEvent) => { const b = (ev.target as Element).closestAt least 8 characters. Empty for someone who signs in through the proxy.
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: RecordLoading…
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);