From aeb686163b9595a5d763da34e912efe3e0c810f7 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 18 Sep 2026 15:28:28 +0000 Subject: [PATCH] A separate admin page: server settings, accounts and the log /admin, with Server, Accounts and Log sections chosen by the URL's hash. The server sends the page and /admin.js to admins only (anyone else asking for the page goes back to the app, and the script is 403), and removes the header's link to it from everyone else's page rather than hiding it. The API keeps refusing all of it to non-admins as before. Settings becomes personal: theme, OPML import and export, and the schedule and download folder to read. The server fields, the Users dialog and the Log dialog move out of dialogs.ts into admin.ts. The CSS moves out of index.html into web/app.css, which both pages load as /app.css?v=, served immutable like the scripts. The smoke test checks both pages. Closes #19. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 + CLAUDE.md | 10 +- docs/architecture.md | 4 +- src/web.rs | 76 +++- tests/page-smoke.js | 35 +- tests/ui/app.spec.js | 47 ++- web/admin.html | 34 ++ web/app.css | 847 +++++++++++++++++++++++++++++++++++++++++++ web/build.mjs | 22 +- web/index.html | 835 +----------------------------------------- web/src/admin.ts | 204 +++++++++++ web/src/dialogs.ts | 220 +---------- web/src/util.ts | 20 + 13 files changed, 1260 insertions(+), 1097 deletions(-) create mode 100644 web/admin.html create mode 100644 web/app.css create mode 100644 web/src/admin.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eebde8..7990a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ The long form, with what was wrong before and how it was found, is in ### Changed +- The server's settings, the accounts and the log are on their own admin page, /admin, reached by + the wrench in the header. Only an admin is sent the page, its script, or the link to it. + Settings is now yours alone: your theme and your subscriptions. - A feed that fails to check gets a red exclamation mark in the feed list, in the margin where a folder's triangle sits, and its page says why, in place of a pop-up per failure that everyone saw during a scan of every feed. A folder holding a failing feed has its triangle turn red. diff --git a/CLAUDE.md b/CLAUDE.md index aa5c264..cc48253 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,11 +61,15 @@ the shell running the command and kills the session (exit 144). This has happene ## Before you touch the page -The page is markup and CSS in `web/index.html` and TypeScript in `web/src/`. `build.rs` runs +There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to +admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet, +`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files +make up each page's script. `build.rs` runs `web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and `login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page -loads its script as `/app.js?v=`: the page is served `no-cache` and the -script `immutable`, so a browser keeps the script until a deploy changes it and its name. So **every page change needs a +loads its script as `/app.js?v=`, and `/app.css` the same way: the page is +served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a +deploy changes them and their names. So **every page change needs a rebuild** before it is visible, and building needs node and `npm ci` run once. The files in `web/src` are not modules. They are one script split up, concatenated in the order diff --git a/docs/architecture.md b/docs/architecture.md index 4f953db..a5357dd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,9 @@ it to a running daemon. | `src/auth.rs` | Argon2id hashing, session tokens, header names | — | | `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — | | `src/logbuf.rs` | Ring buffer behind the UI's Log view | — | -| `web/index.html` | The page's markup and CSS | — | +| `web/index.html` | The app's markup | — | +| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — | +| `web/app.css` | The stylesheet both pages share | — | | `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — | | `web/build.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — | | `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — | diff --git a/src/web.rs b/src/web.rs index b072afc..9b3fcfc 100644 --- a/src/web.rs +++ b/src/web.rs @@ -59,6 +59,8 @@ pub fn router(state: WebState) -> Router { .route("/api/users/{id}", patch(patch_user).delete(remove_user)) .route("/api/logs", get(logs)) .route("/api/events", get(events)) + .route("/admin", get(admin_page)) + .route("/admin.js", get(admin_js)) .route("/media/{id}", get(media)) .layer(middleware::from_fn_with_state(state.clone(), auth)) // Signing in cannot require being signed in, so these sit outside the auth layer. @@ -69,6 +71,7 @@ pub fn router(state: WebState) -> Router { .route("/favicon.png", get(favicon)) .route("/apple-touch-icon.png", get(touch_icon)) .route("/app.js", get(app_js)) + .route("/app.css", get(app_css)) .route("/login.js", get(login_js)) .route("/inter.woff2", get(inter)) .layer(middleware::from_fn(access_log)) @@ -493,6 +496,32 @@ async fn app_js() -> impl IntoResponse { script(include_str!(concat!(env!("OUT_DIR"), "/app.js"))) } +/// The stylesheet the app and admin pages share, named by hash in each as the scripts are. +async fn app_css() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/css; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], + include_str!(concat!(env!("OUT_DIR"), "/app.css")), + ) +} + +/// The admin page and its script go to admins only: not just hidden from everyone else, never +/// sent. Anyone else asking for the page is sent back to the app. +async fn admin_page(State(state): State, user: crate::db::User) -> Response { + if !user.is_admin { + return Redirect::to("/").into_response(); + } + let theme = state.ctx.db.theme(user.id).unwrap_or_default(); + let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme); + ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response() +} + +async fn admin_js(user: crate::db::User) -> Response { + if !user.is_admin { + return (StatusCode::FORBIDDEN, "only an admin").into_response(); + } + script(include_str!(concat!(env!("OUT_DIR"), "/admin.js"))).into_response() +} + async fn login_js() -> impl IntoResponse { script(include_str!(concat!(env!("OUT_DIR"), "/login.js"))) } @@ -548,9 +577,9 @@ fn constant_time_eq(a: &str, b: &str) -> bool { } // Built by build.rs from web/index.html and web/src, and minified: attribute values lose their -// quotes, so LOG_BUTTON is spelled the way the minifier leaves it. +// quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them. const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html")); -const LOG_BUTTON: &str = " - +
diff --git a/web/src/admin.ts b/web/src/admin.ts new file mode 100644 index 0000000..eaf4b8b --- /dev/null +++ b/web/src/admin.ts @@ -0,0 +1,204 @@ +/* ---------------- admin page ---------------- */ +// /admin: the server's settings, the accounts, and the log, each a section chosen by the URL's +// hash so a link can go straight to one. The server sends this page and this script to admins +// only, and refuses every call below to anyone else; they were parts of Settings and the header, +// shown or hidden by the main page's script (issue #19). + +let me: {name: string} | null = null; +api('/api/me').then(u => { me = u; }).catch(() => {}); + +const SECTIONS: Record void> = {server: drawServer, accounts: drawAccounts, log: drawLogView}; + +function showSection(){ + const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server'; + for(const s of $$('#admin > section')) s.hidden = s.id !== t; + for(const a of $$('#atabs a')) a.classList.toggle('on', a.dataset.t === t); + // The log polls every two seconds while it is showing, and not otherwise. + if(t !== 'log' && logTimer){ clearInterval(logTimer); logTimer = null; } + SECTIONS[t](); +} +window.addEventListener('hashchange', showSection); + +/* ---------------- 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. 0 means unlimited, which will pull a whole back + catalogue the first time a feed is scanned.
+
+ + 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.
+
+
+
+ ${esc(g.download_dir)}
+
`; + $('#gsave').onclick = async () => { + try{ + await api('/api/settings', {method: 'PATCH', body: JSON.stringify({ + schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`, + max_new_per_check: Math.max(0, Number($('#gmax').value) || 0), + media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean), + max_total_gb: Number($('#gquota').value) || 0, + max_age_days: Number($('#gage').value) || 0})}); + toast('Settings saved'); + }catch(e){ toast(e.message, true); } + }; +} + +/* ---------------- accounts ---------------- */ +async function drawAccounts(){ + const box = $('#accounts'); + const users = await api('/api/users') || []; + box.innerHTML = `

Accounts

+ ${users.map(u => `
+
${esc(u.name)} + ${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.
+
`; + const change = async (u, opts) => { + try{ + await api(`/api/users/${u.id}`, opts); + // Demoting yourself takes this page away; go back to the app rather than stay on a page + // the server no longer answers. Only on success: a refusal's toast has to stay readable. + if(u.name === me?.name){ location.href = '/'; return; } + }catch(e){ toast(e.message, true); } + drawAccounts(); // on a refusal, this puts the checkbox back where the server left it + }; + for(const row of $$('#accounts [data-id]')){ + const u = users.find(x => String(x.id) === row.dataset.id); + $('[data-a="admin"]', row).onchange = e => + change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})}); + $('[data-a="rm"]', row).onclick = () => { + if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`)) + change(u, {method: 'DELETE'}); + }; + } + $('#uadd').onclick = async () => { + try{ + await api('/api/users', {method: 'POST', body: JSON.stringify({ + name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})}); + toast('Added'); drawAccounts(); + }catch(e){ toast(e.message, true); } // keep what was typed + }; +} + +/* ---------------- log ---------------- */ +let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all'; +// Which sources belong to each tab. "daemon" is the control protocol itself: every +// command in and every event out, whatever sent it. +const LOG_TABS = { + all: null, + daemon: t => t === 'ipx::io', + scan: t => t === 'ipx::scan', + web: t => t === 'ipx::http', +}; +const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0}; + +function drawLogView(){ + logSeq = 0; logLines = []; + $('#log').innerHTML = `

Log

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

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.`; + 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); + drawLog(); + }; + $('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); }; + $('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); }; + $('#logcopy').onclick = () => copyText(visibleLog().map(l => + `${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy')); + pollLog(); + if(!logTimer) logTimer = setInterval(pollLog, 2000); +} + +async function pollLog(){ + try{ + const r = await api(`/api/logs?after=${logSeq}&limit=500`); + if(r.lines.length){ + logLines = logLines.concat(r.lines).slice(-2000); + logSeq = r.latest; + drawLog(); + }else if(!logLines.length){ drawLog(); } + }catch(e){ + const box = $('#logbox'); + if(box) box.innerHTML = `

Lost contact with the daemon: ${esc(e.message)}

`; + } +} +function visibleLog(){ + const min = logLevel ? LEVELS[logLevel] : -1; + const tab = LOG_TABS[logTab]; + return logLines.filter(l => + (!tab || tab(l.target)) && + (LEVELS[l.level] ?? 1) >= min && + (!logFilter || (l.msg + ' ' + l.target).toLowerCase().includes(logFilter))); +} +function drawLog(){ + const box = $('#logbox'); if(!box) return; + const follow = $('#logfollow')?.checked; + const rows = visibleLog(); + box.innerHTML = rows.length ? rows.map(l => { + const t = new Date(l.ts * 1000).toLocaleTimeString(); + if(logTab === 'daemon'){ + const out = l.msg.startsWith('<-'); + return `
` + + `${out ? 'out' : 'in'}` + + `${esc(l.msg.replace(/^[<-]+\s*/, ''))}
`; + } + return `
${esc(l.level)}` + + `${esc(l.target.replace(/^ipx::?/, ''))}${esc(l.msg)}
`; + }).join('') : '

Nothing matches.

'; + if(follow) box.scrollTop = box.scrollHeight; +} + +showSection(); diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 1eee651..023f802 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -6,99 +6,8 @@ function openModal(html: string, wide?: boolean){ } function closeModal(){ $('#modal').classList.remove('on'); - if(logTimer){ clearInterval(logTimer); logTimer=null; } } -/* ---------------- log view ---------------- */ -let logTimer=null, logSeq=0, logLines=[], logFilter='', logLevel='', logTab='all'; -// Which sources belong to each tab. "daemon" is the control protocol itself: every -// command in and every event out, whatever sent it. -const LOG_TABS={ - all: null, - daemon: t=>t==='ipx::io', - scan: t=>t==='ipx::scan', - web: t=>t==='ipx::http', -}; -const LEVELS={ERROR:3,WARN:2,INFO:1,DEBUG:0,TRACE:0}; - -function logsModal(){ - logSeq=0; logLines=[]; - openModal(`

Log

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

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.`, true); - - $$('#logtabs button').forEach(b=>b.onclick=()=>{ - logTab=b.dataset.t; - $$('#logtabs button').forEach(x=>x.classList.toggle('on',x.dataset.t===logTab)); - drawLog(); - }); - $('#loglevel').onchange=e=>{ logLevel=e.target.value; drawLog(); }; - $('#logq').oninput=e=>{ logFilter=e.target.value.toLowerCase(); drawLog(); }; - $('#logcopy').onclick=()=>copyText(visibleLog().map(l=> - `${new Date(l.ts*1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy')); - - pollLog(); - logTimer=setInterval(pollLog,2000); -} - -async function pollLog(){ - try{ - const r=await api(`/api/logs?after=${logSeq}&limit=500`); - if(r.lines.length){ - logLines=logLines.concat(r.lines).slice(-2000); - logSeq=r.latest; - drawLog(); - }else if(!logLines.length){ drawLog(); } - }catch(e){ - const box=$('#logbox'); - if(box) box.innerHTML=`

Lost contact with the daemon: ${esc(e.message)}

`; - } -} -function visibleLog(){ - const min=logLevel?LEVELS[logLevel]:-1; - const tab=LOG_TABS[logTab]; - return logLines.filter(l=> - (!tab || tab(l.target)) && - (LEVELS[l.level]??1)>=min && - (!logFilter || (l.msg+' '+l.target).toLowerCase().includes(logFilter))); -} -function drawLog(){ - const box=$('#logbox'); if(!box) return; - const follow=$('#logfollow')?.checked; - const rows=visibleLog(); - box.innerHTML = rows.length ? rows.map(l=>{ - const t=new Date(l.ts*1000).toLocaleTimeString(); - if(logTab==='daemon'){ - const out=l.msg.startsWith('<-'); - return `
`+ - `${out?'out':'in'}`+ - `${esc(l.msg.replace(/^[<-]+\s*/,''))}
`; - } - return `
${esc(l.level)}`+ - `${esc(l.target.replace(/^ipx::?/,''))}${esc(l.msg)}
`; - }).join('') : '

Nothing matches.

'; - if(follow) box.scrollTop=box.scrollHeight; -} $('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); }; $('#addFeed').onclick=()=>{ @@ -295,25 +204,6 @@ function toggleGroup(id){ renderFeeds(); } let globalMax = 3; -const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']]; -const UNIT_MINS = {m:1, h:60, d:1440, w:10080}; - -/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes". -function splitEvery(m){ - if(!m) return {n:1, u:'h'}; - for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u}; - return {n:m, u:'m'}; -} -function unitOptions(sel){ - return UNITS.map(([v,l]) => - ``).join(''); -} -function everyText(m){ - if(!m) return '\u2014'; - const {n,u} = splitEvery(m); - const name = {m:'min', h:'hour', d:'day', w:'week'}[u]; - return n + ' ' + name + (u!=='m' && n!==1 ? 's' : ''); -} function due(ts){ const d = ts - Date.now()/1000; if(d <= 0) return 'due now'; @@ -322,14 +212,11 @@ function due(ts){ return 'in '+Math.round(d/86400)+'d'; } -/// Global settings. GET /api/settings is open to anyone signed in; only the PATCH, and the -/// Users screen behind it, are the operator's alone (the server refuses both from anyone -/// else). A non-admin gets the same modal minus those two parts, not no settings at all -- -/// Export/Import are theirs regardless, and seeing the schedule and quota explains why a -/// feed is polled when it is. +/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does +/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an +/// admin is sent (issue #19); this used to hold them too, shown to admins only. async function prefsModal(){ const g = await api('/api/settings'); - const gs = splitEvery(g.every_mins); const admin = !!(S.me&&S.me.admin); openModal(`

Settings

@@ -339,33 +226,6 @@ async function prefsModal(){ Auto follows your system's light/dark setting.
-
- ${admin?`
- - -
- 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.` - :`${everyText(g.every_mins)}, for every feed that does not set its - own. Only an admin changes this.`}
- ${admin?`
- - Applies to any feed that does not set its own — including every feed - inside an OPML subscription. 0 means unlimited, which will pull a whole back - catalogue the first time a feed is scanned.
-
- - 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.
-
-
`:''} -
- ${esc(g.download_dir)}
@@ -374,76 +234,15 @@ async function prefsModal(){
Export saves your subscriptions as OPML for another podcast app. Import subscribes you to every feed in one.
- ${admin?`
-
- Add and remove the people who can sign in, and choose who is an admin.
`:''} -
- ${admin?``:''}
`); +
+ ${everyText(g.every_mins)}, for every feed that does not set its own. + ${admin?'This and the rest of the server\'s settings are on the admin page.':'Only an admin changes this.'}
+
+ ${esc(g.download_dir)}
+
`); $('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); $('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $('#gopml').onclick=opmlModal; - if(!admin) return; - $('#gusers').onclick=usersModal; - $('#gsave').onclick=async()=>{ - try{ - await api('/api/settings',{method:'PATCH',body:JSON.stringify({ - schedule:`every ${Math.max(1,Number($('#gnum').value)||1)}${$('#gunit').value}`, - max_new_per_check:Math.max(0,Number($('#gmax').value)||0), - media_types:$('#gtypes').value.split(',').map(t=>t.trim()).filter(Boolean), - max_total_gb:Number($('#gquota').value)||0, - max_age_days:Number($('#gage').value)||0})}); - closeModal(); toast('Settings saved'); - await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } - }catch(e){ toast(e.message,true); } - }; -} - -// Admin only, and the server enforces that: this screen is just the way in. -async function usersModal(){ - const users = await api('/api/users') || []; - openModal(`

Users

- ${users.map(u=>`
-
${esc(u.name)} - ${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.
-
-
`); - const change=async(u,opts)=>{ - try{ - await api(`/api/users/${u.id}`,opts); - // Demoting yourself takes this screen away; reload so the page stops offering it. Only - // on success: reloading after a refusal wiped the toast that said why. - if(u.name===S.me?.name){ location.reload(); return; } - }catch(e){ toast(e.message,true); } - usersModal(); // on a refusal, this puts the checkbox back where the server left it - }; - $$('#modalCard [data-id]').forEach(row=>{ - const u=users.find(x=>String(x.id)===row.dataset.id); - $('[data-a="admin"]',row).onchange=e=> - change(u,{method:'PATCH',body:JSON.stringify({admin:e.target.checked})}); - $('[data-a="rm"]',row).onclick=()=>{ - if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`)) - change(u,{method:'DELETE'}); - }; - }); - $('#uadd').onclick=async()=>{ - try{ - await api('/api/users',{method:'POST',body:JSON.stringify({ - name:$('#uname').value, password:$('#upass').value, admin:$('#uadmin').checked})}); - toast('Added'); usersModal(); - }catch(e){ toast(e.message,true); } // keep what was typed - }; } function settingsModal(f, newUrl?: string){ @@ -582,7 +381,6 @@ api('/api/me').then(u=>{ S.me=u; $('#who').textContent=u.name+(u.admin?' · admin':''); }).catch(()=>{}); -$('#logs').onclick=logsModal; $('#feedFilter').oninput=renderFeeds; // The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a // folder. Handled keys stop here, or the player's own Space and arrows would act on them too. diff --git a/web/src/util.ts b/web/src/util.ts index 5b7ee01..f80431f 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -46,6 +46,7 @@ const ICON={ fwd:fa('0 0 512 512',''), // solid/rotate-right pause:fa('0 0 384 512',''), // solid/pause alert:fa('0 0 128 512',''), // solid/exclamation + admin:fa('0 0 576 512',''), // solid/screwdriver-wrench caret:fa('0 0 256 512',''), // solid/caret-right left:fa('0 0 512 512',''), // solid/arrow-left subbed:fa('0 0 512 512',''), // solid/circle-check @@ -156,3 +157,22 @@ const S = { }; const LIMIT = 50; +const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']]; +const UNIT_MINS = {m:1, h:60, d:1440, w:10080}; + +/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes". +function splitEvery(m){ + if(!m) return {n:1, u:'h'}; + for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u}; + return {n:m, u:'m'}; +} +function unitOptions(sel){ + return UNITS.map(([v,l]) => + ``).join(''); +} +function everyText(m){ + if(!m) return '\u2014'; + const {n,u} = splitEvery(m); + const name = {m:'min', h:'hour', d:'day', w:'week'}[u]; + return n + ' ' + name + (u!=='m' && n!==1 ? 's' : ''); +}