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}
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);