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=<hash>, served immutable like the scripts. The smoke test checks both
pages.

Closes #19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 15:28:28 +00:00
parent 2d158a4540
commit aeb686163b
13 changed files with 1260 additions and 1097 deletions

34
web/admin.html Normal file
View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>iPodderX admin</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" data-src="app.css">
</head>
<body class="adminpage">
<!-- The server sends this page, and its script, to admins only. -->
<header id="topbar">
<a class="btn ico" href="/" title="Back to iPodderX" aria-label="Back to iPodderX" data-icon="left"></a>
<img class="logo" src="/icon.png" alt="" width="26">
<h1>Admin</h1>
<span class="grow"></span>
<nav class="tabs" id="atabs">
<a href="#server" data-t="server">Server</a>
<a href="#accounts" data-t="accounts">Accounts</a>
<a href="#log" data-t="log">Log</a>
</nav>
</header>
<main class="wrap plain" id="admin">
<section id="server" hidden></section>
<section id="accounts" hidden></section>
<section id="log" hidden></section>
</main>
<div id="toasts"></div>
<script data-src="web/src"></script>
</body>
</html>

847
web/app.css Normal file
View File

@@ -0,0 +1,847 @@
/* Inter, served by ipx itself (/inter.woff2) rather than a font CDN, so the page asks nothing of
anyone else. One variable file covers every weight used here; italics are synthesized. Classic
keeps Lucida Grande, the 2004 app's face. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
/* Palette taken from the 2004 iPodderX icon: the silver device body, the blue
screen, and the amber EQ bars. Hex values in comments are sampled straight from it. */
:root {
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
--panel:#151c27;
--panel2:#1c2431;
--raise:#25303f;
--line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
--accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */
--good:#6fbf8b;
--warn:#f49e2c; /* the amber doubles as the pending colour */
--bad:#e2705f;
--shadow:0 8px 28px rgba(6,10,16,.55);
}
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's
script sets data-mode to light or dark, working Auto out from the system, so each theme has
one block per variant here and none needs repeating under a media query. */
:root[data-theme="modern"][data-mode="light"] {
--bg:#f2f4f7;
--panel:#ffffff; /* #FFFFFF device body */
--panel2:#e9edf3;
--raise:#dde3ec;
--line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
--ink:#ffffff;
--good:#2f7d4f;
--warn:#b06f10;
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour
(#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */
:root[data-theme="dracula"] {
--bg:#282a36;
--panel:#21222c;
--panel2:#343746;
--raise:#44475a; /* selection */
--line:#414558;
--fg:#f8f8f2;
--dim:#c9cbd6;
--faint:#9ea8c7;
--accent:#bd93f9; /* purple */
--accent2:#ff79c6; /* pink */
--ink:#282a36;
--good:#50fa7b;
--warn:#ffb86c;
--bad:#ff8080;
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="dracula"][data-mode="light"] {
--bg:#fffbeb;
--panel:#f7f2df;
--panel2:#efe9d3;
--raise:#cfcfde; /* selection */
--line:#ddd6bd;
--fg:#1f1f1f;
--dim:#454137;
--faint:#635d44;
--accent:#644ac9;
--accent2:#a3144d;
--ink:#ffffff;
--good:#14710a;
--warn:#a34d14;
--bad:#b83322;
--shadow:0 8px 28px rgba(108,102,75,.18);
}
/* Material 3's baseline scheme: surface, the surface containers, outline and primary. */
:root[data-theme="material"] {
--bg:#141218; /* surface */
--panel:#1d1b20; /* surface-container-low */
--panel2:#211f26; /* surface-container */
--raise:#36343b; /* surface-container-highest */
--line:#49454f; /* outline-variant */
--fg:#e6e0e9; /* on-surface */
--dim:#cac4d0; /* on-surface-variant */
--faint:#9a95a0; /* outline, a shade up to clear AA on the containers */
--accent:#d0bcff; /* primary */
--accent2:#efb8c8; /* tertiary */
--ink:#381e72; /* on-primary */
--good:#8fd6a0;
--warn:#f2c46b;
--bad:#f2b8b5; /* error */
--shadow:0 8px 28px rgba(0,0,0,.5);
}
:root[data-theme="material"][data-mode="light"] {
--bg:#fef7ff;
--panel:#f7f2fa;
--panel2:#f3edf7;
--raise:#e6e0e9;
--line:#cac4d0;
--fg:#1d1b20;
--dim:#49454f;
--faint:#67626c; /* outline (#79747E), a shade down to clear AA on the containers */
--accent:#6750a4;
--accent2:#7d5260;
--ink:#ffffff;
--good:#2b6c3c;
--warn:#7c5800;
--bad:#b3261e;
--shadow:0 8px 28px rgba(103,80,164,.14);
}
/* Adwaita, from libadwaita's own CSS variables: view, window and sidebar backgrounds, the blue
accent, and its destructive, success and warning colours. */
:root[data-theme="adwaita"] {
--bg:#1d1d20; /* view */
--panel:#2e2e32; /* sidebar, headerbar */
--panel2:#222226; /* window */
--raise:#3a3a3f;
--line:#3f3f45;
--fg:#ffffff;
--dim:#c4c4c8;
--faint:#a0a0a7;
--accent:#81d0ff;
--accent2:#3584e4;
--ink:#1d1d20;
--good:#78e9ab;
--warn:#ffc252;
--bad:#ff938c;
--shadow:0 8px 28px rgba(0,0,0,.5);
}
:root[data-theme="adwaita"][data-mode="light"] {
--bg:#ffffff;
--panel:#ebebed;
--panel2:#fafafb;
--raise:#dcdce0;
--line:#d9d9dd;
--fg:#333338; /* rgb(0 0 6 / 80%) on white */
--dim:#57575d;
--faint:#66666c;
--accent:#0461be;
--accent2:#3584e4;
--ink:#ffffff;
--good:#00753a;
--warn:#905400;
--bad:#c30000;
--shadow:0 8px 28px rgba(0,0,6,.12);
}
/* Flat Remix, from its GTK theme's _colors.scss: base and bg, fg, the selection blue, and its
link colours, which are the selection blue taken down (light) or up (dark) until readable. */
:root[data-theme="flatremix"] {
--bg:#272a34; /* base */
--panel:#23262f; /* bg: base darkened 2% */
--panel2:#2e323d;
--raise:#363b48;
--line:#3a3f4c;
--fg:#eeeeec;
--dim:#babec8;
--faint:#959baa;
--accent:#8fb6ff; /* link */
--accent2:#fd7d00; /* warning orange */
--ink:#1a1c23;
--good:#4cc28c;
--warn:#ff9a3c;
--bad:#ff6b6b;
--shadow:0 8px 28px rgba(0,0,0,.3);
}
:root[data-theme="flatremix"][data-mode="light"] {
--bg:#fafafa; /* base */
--panel:#ffffff; /* bg */
--panel2:#f0f0f1;
--raise:#e4e5e7;
--line:#d9d9d9; /* borders: bg darkened 15% */
--fg:#22252b;
--dim:#5c616c; /* fg */
--faint:#686d78;
--accent:#0060f0; /* link */
--accent2:#fd7d00;
--ink:#ffffff;
--good:#23794f;
--warn:#a85400;
--bad:#d41919;
--shadow:0 8px 28px rgba(0,0,0,.1);
}
/* Paper: a single light palette, black on the colour of paper, colour kept for what matters.
Its blue and red are taken down a shade to clear AA on that background. */
:root[data-theme="paper"] {
--bg:#f2eede;
--panel:#ebe7d7;
--panel2:#e4e0d0;
--raise:#d8d5c7; /* highlight */
--line:#cdc9b9;
--fg:#000000;
--dim:#3d3a33;
--faint:#5a564c;
--accent:#1a5fae;
--accent2:#b58900;
--ink:#ffffff;
--good:#216609;
--warn:#795a00;
--bad:#b1331f;
--shadow:0 8px 28px rgba(0,0,0,.12);
}
/* Nordic: the Nord palette. Polar Night for the dark half, Snow Storm for the light, Frost for
the accent and Aurora for the rest. Aurora red and Frost blue are shifted until they read. */
:root[data-theme="nordic"] {
--bg:#2e3440; /* nord0 */
--panel:#3b4252; /* nord1 */
--panel2:#434c5e; /* nord2 */
--raise:#4c566a; /* nord3 */
--line:#434c5e;
--fg:#eceff4; /* nord6 */
--dim:#d8dee9; /* nord4 */
--faint:#b4bccb;
--accent:#93c9d8; /* nord8, a shade up */
--accent2:#ebcb8b; /* nord13 */
--ink:#2e3440;
--good:#b0c99b; /* nord14, a shade up */
--warn:#ebcb8b;
--bad:#f2aeb4;
--shadow:0 8px 28px rgba(0,0,0,.35);
}
:root[data-theme="nordic"][data-mode="light"] {
--bg:#eceff4; /* nord6 */
--panel:#e5e9f0; /* nord5 */
--panel2:#d8dee9; /* nord4 */
--raise:#cdd4e0;
--line:#c5cedb;
--fg:#2e3440; /* nord0 */
--dim:#3b4252; /* nord1 */
--faint:#4c566a; /* nord3 */
--accent:#3a5e8a;
--accent2:#b0674e;
--ink:#ffffff;
--good:#446632;
--warn:#7e590e;
--bad:#9c3f49;
--shadow:0 8px 28px rgba(46,52,64,.15);
}
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
:root[data-theme="classic"] {
color-scheme:light;
--bg:#ffffff;
--panel:#e7ebf1; /* the source list's pale blue-grey */
--panel2:#f2f2f2;
--raise:#d5dce7;
--line:#a9a9a9;
--fg:#000000;
--dim:#444444;
--faint:#666666;
--accent:#3875d7; /* Aqua selection blue */
--accent2:#2a5db0;
--ink:#ffffff;
--good:#237a23;
--warn:#a15f00;
--bad:#c42b1c;
--shadow:0 4px 16px rgba(0,0,0,.28);
}
*{box-sizing:border-box}
/* A rule that sets display beats the UA's [hidden], and several below do. */
[hidden]{display:none!important}
html,body{height:100%}
body{
margin:0;background:var(--bg);color:var(--fg);
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:grid;grid-template-rows:auto 1fr auto auto;overflow:hidden;
/* iOS Safari's address bar collapses and expands without firing a resize, so 100vh is
measured against whichever state happened to be current -- sized too tall while the bar
is showing, which puts the topbar (the hamburger included) under Safari's own chrome,
where a tap never reaches the page. Only a hard refresh reset it, forcing 100vh to be
recomputed. 100dvh tracks the real visible viewport as the bar moves; the 100vh above is
the fallback for a browser that does not know dvh. */
height:100vh;height:100dvh;
}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
a{color:var(--accent)}
/* Inset wherever a parent clips its overflow, or the toolbar and the feed list cut the ring off. */
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.tgroup button:focus-visible,.tgroup a:focus-visible,.feed:focus-visible,.place:focus-visible,.chev:focus-visible{outline-offset:-2px}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation:none!important;transition:none!important}
}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px}
::-webkit-scrollbar-track{background:transparent}
/* ---------- shell ---------- */
#shell{display:grid;grid-template-columns:290px 1fr;min-height:0;min-width:0;overflow:hidden}
/* [ hides the feed list. A phone slides it over the page instead, so this is for wider screens. */
@media (min-width:821px){
body.nosb #shell{grid-template-columns:minmax(0,1fr)}
body.nosb #sidebar{display:none}
}
#sidebar{
background:var(--panel);border-right:1px solid var(--line);
display:flex;flex-direction:column;min-height:0;
}
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
.brand .logo{
width:30px;height:30px;flex:none;object-fit:contain;
}
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1}
.iconbtn{
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
color:var(--dim);flex:none;
}
.iconbtn:hover{background:var(--raise);color:var(--fg)}
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
#topbar{
display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden;
background:var(--panel);border-bottom:1px solid var(--line);
}
#topbar .grow{flex:1}
#topbar #epSearch{width:260px;flex:none}
.tgroup{display:flex;flex:none;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel2)}
.tgroup button,.tgroup a{padding:5px 11px;min-width:34px;color:var(--dim);font-size:14px;line-height:1.3}
.tgroup a{display:inline-grid;place-items:center}
.tgroup button+button,.tgroup button+a{border-left:1px solid var(--line)}
.tgroup button:hover:not(:disabled),.tgroup a:hover{background:var(--raise);color:var(--fg)}
.tgroup button:disabled{opacity:.35;cursor:default}
.sidetools button,.sidefoot button{
flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 8px;font-size:12.5px;color:var(--dim);
}
.sidetools button:hover,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)}
/* Settings and the log are housekeeping: they sit under the feeds, out of the way. */
.sidefoot{
display:flex;flex-direction:column;gap:7px;padding:8px 12px;
border-top:1px solid var(--line);flex:none;
}
.sidefoot .row{display:flex;gap:6px}
.who{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--faint)}
.who span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.who button{flex:none;background:none;border:0;padding:2px 4px;color:var(--faint);text-decoration:underline}
.who button:hover{color:var(--fg)}
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.sidefoot i{font-style:normal;opacity:.75}
.searchwrap{padding:0 12px 8px}
input[type=search],input[type=text],input[type=password],input[type=number],select{
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
}
input:focus,select:focus{outline:0;border-color:var(--accent)}
/* The wider left gutter is the folder triangle's; everything in the list shifts with it, so the
feeds still line up with the places above. */
#feedlist{overflow-y:auto;padding:0 8px 12px 16px;flex:1;min-height:0}
.feed{
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
cursor:pointer;margin-bottom:1px;position:relative;
}
.feed:hover{background:var(--panel2)}
.feed.sel{background:var(--raise)}
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
.feed.child{margin-left:11px}
/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */
.feed .fpin .i{width:10px;height:10px;margin-right:5px;vertical-align:-1px;color:var(--accent)}
.feed.lastpin{margin-bottom:9px}
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
.feed.child .art{width:28px;height:28px;font-size:11px}
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
above it. The button is the row's full height and 24 px wide: a near miss used to open the
folder's page, and 16 px left the triangle cramped against the art. */
.chev{
position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;
border-radius:4px;color:var(--faint);
}
.chev:hover{color:var(--fg)}
.chev.bad,.chev.bad:hover{color:var(--bad)}
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
a child's included, which is why it moves left by the child's indent. */
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
.ferr .i{width:12px;height:12px}
.feed.child .ferr{left:-27px}
.chev .i{width:12px;height:12px;transition:transform .12s}
.chev[aria-expanded="true"] .i{transform:rotate(90deg)}
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
.childrow{
display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
border-radius:9px;cursor:pointer;background:var(--panel);
}
.childrow:hover{border-color:var(--accent)}
.childrow .art{width:32px;height:32px;font-size:12px}
.childrow .txt{flex:1;min-width:0}
.childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Currently Listening: how far into each episode you are, as a rail along the foot of its row.
The one in the player is marked as it is everywhere else, by the amber EQ bars, and its rail
and time left move as it plays. The rest stay neutral, so that one is what stands out. */
#listening .childrow{position:relative;overflow:hidden;gap:12px;padding:10px 10px 13px}
#listening .childrow .art{width:44px;height:44px;font-size:13px}
#listening .txt b{display:flex;align-items:center;gap:7px}
#listening .txt b span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#listening .txt small{display:flex;gap:12px;margin-top:3px;color:var(--faint);font-size:11.5px}
#listening .txt small .fd{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#listening .txt small .left{flex:none;color:var(--dim)}
#listening .childrow:not(.now) .eq{display:none}
#listening .rail{position:absolute;left:0;right:0;bottom:0;height:3px;background:var(--raise)}
#listening .rail i{display:block;height:100%;width:0;background:var(--faint);transition:width .25s linear}
#listening .now{border-color:color-mix(in srgb,var(--accent2) 60%,var(--line))}
#listening .now .eq,#listening .now .left{color:var(--accent2)}
#listening .now .rail i{background:var(--accent2)}
/* Quiet buttons: a filled one on every row outshouted the row that is playing. */
#listening [data-a=play]{color:var(--accent)}
#listening [data-a=remove]{color:var(--faint)}
/* Directory's filters: .tabs for what a feed is, as the item filters are, and chips for what it
is about. A picked chip is underlined in --accent2, as the download bar and the now-playing EQ
are; a .badge's fill already means unread in the sidebar. */
.dirbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px}
.dirbar .tabs{flex:none}
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0}
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent}
.chips button:hover{color:var(--fg)}
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
/* Square cover art, title underneath: podcast art is made to be known at a glance. The subscribe
button stays visible, since a button that only shows on hover cannot be reached on a phone. */
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px;margin-top:14px}
.tiles>.hint{grid-column:1/-1}
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start;cursor:pointer}
.tile .art{grid-column:1/-1;width:100%;height:auto;aspect-ratio:1;font-size:34px}
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
.tile:hover .txt b{color:var(--accent)}
.tag{
font-size:11px;font-weight:600;
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
}
.art{
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
}
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
.art.ini{background:color-mix(in srgb,var(--tint) 24%,var(--bg));color:var(--tint)}
/* A folder: its first four shows' art, as iTunes built a playlist's from its albums. */
.art.mosaic{grid-template:1fr 1fr/1fr 1fr;place-items:stretch}
.art.mosaic img{width:100%;height:100%;min-height:0;object-fit:cover;display:block}
.feed .art{width:34px;height:34px;font-size:13px}
.feed .txt{min-width:0;flex:1}
.feed .txt b{display:block;font-weight:500;font-size:13.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed .txt small{display:block;color:var(--faint);font-size:11.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.badge{
background:var(--accent2);color:var(--ink);border-radius:20px;padding:1px 7px;
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
}
.badge.zero{background:var(--raise);color:var(--faint)}
/* Counts, sizes and times that change in place should not jiggle the text around them. */
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
/* ---------- main ---------- */
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
.wrap{flex:1;min-height:0;min-width:0;display:flex;flex-direction:column}
.wrap.plain{overflow-y:auto;padding:18px 22px 40px;display:block}
#content{display:flex;flex-direction:column;min-height:0;min-width:0;flex:1}
#content>.fhead,#content>.toolbar{padding-left:20px;padding-right:20px;flex:none}
#content>.fhead{padding-top:16px}
/* Three panes, as the original had: feeds beside, items above, the item below. */
#split{
display:grid;flex:1;min-height:0;
grid-template-columns:minmax(0,1fr) 270px;
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
grid-template-areas:"list files" "grab grab" "detail detail";
}
#list{grid-area:list;overflow:auto;padding:0 14px 10px;overscroll-behavior-y:contain}
/* Pull-to-refresh's note (gestures.ts). overscroll-behavior keeps the browser's own pull, which
reloads the page, from starting on top of it. */
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
font-size:12px;color:var(--faint)}
/* The selected item's files, beside the list, as the original's Files pane was. */
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
/* Nothing selected, or an item with no files: the list takes the width. */
#split:has(>#files[hidden]){grid-template-columns:minmax(0,1fr) 0}
#files .encbox{margin-top:8px}
#files .empty{padding:24px 0}
.fhd{font-size:12px;color:var(--faint)}
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
#grab:hover{background:var(--accent)}
#detail{grid-area:detail;overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere}
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
.dmeta .btn{padding:3px 9px;font-size:12px}
#detail .encbox{margin:0 0 10px}
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
.dbody img{max-width:100%;height:auto;border-radius:6px}
.dbody a{color:var(--accent)}
.dbody pre{overflow-x:auto;background:var(--panel2);padding:10px;border-radius:8px}
.encbox{
display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px;
padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);
}
.fhead{display:flex;gap:18px;margin-bottom:18px}
.fhead .art{width:118px;height:118px;font-size:34px;box-shadow:var(--shadow)}
.fhead .meta{min-width:0;flex:1;display:flex;flex-direction:column}
.fhead h2{margin:0 0 3px;font-size:23px;line-height:1.2;overflow-wrap:anywhere}
.fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px}
.fhead .sub a{color:var(--dim);text-decoration:none}
.fhead .sub a:hover{color:var(--accent)}
.acts{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:auto}
.acts .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
.acts .btn.primary i{opacity:.9}
.acts .btn:hover{background:var(--raise)}
.acts .btn.primary:hover{background:var(--accent)}
.btn{
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 12px;font-size:13px;
}
.btn:hover{border-color:var(--accent)}
a.btn{text-decoration:none;color:inherit}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--ink);font-weight:600}
.btn.primary:hover{filter:brightness(1.08)}
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
/* An icon in place of a word; the word is in its tooltip. */
.btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center}
/* Inline in a sentence, next to a plain-word error explanation. */
.btn.tiny{padding:2px 7px;font-size:11.5px;border-radius:6px;margin-left:2px}
/* The keys ? lists. */
.keys{border-collapse:collapse;width:100%;font-size:13px}
.keys th{text-align:left;font-weight:600;padding:12px 0 4px;color:var(--fg)}
.keys td{padding:3px 0;color:var(--dim)}
.keys td:first-child{width:10em;white-space:nowrap}
kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 5px}
/* An icon (Font Awesome, embedded as SVG) in the button's own colour. */
.i{display:inline-block;width:16px;height:16px;vertical-align:-3px;flex:none;fill:currentColor}
/* A file's type as an icon, in place of the old DOWNLOADED / PENDING / audio chips: green once
it is here, red when the download failed. */
.kind{display:inline-grid;place-items:center;color:var(--dim)}
.kind.here{color:var(--good)}
.subbed{display:inline-flex;padding:0 9px;color:var(--good)}
.subbed .i{width:18px;height:18px}
.kind.bad{color:var(--bad)}
.fhead .art .i{width:22px;height:22px}
.toolbar{
display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap;
position:sticky;top:0;background:var(--bg);padding:6px 0 8px;z-index:3;
}
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px}
.tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)}
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:500}
.tabs a{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim);text-decoration:none}
.tabs a.on{background:var(--raise);color:var(--fg);font-weight:500}
/* ---------- the admin page (admin.html) ---------- */
/* One column that scrolls under the same header, rather than the app's fixed panes. */
body.adminpage{display:block;overflow:auto}
body.adminpage #topbar{position:sticky;top:0;z-index:5}
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
body.adminpage #topbar .logo{border-radius:5px}
body.adminpage #topbar > a.btn{text-decoration:none}
body.adminpage #admin{max-width:780px;margin:0 auto}
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
body.adminpage #log{max-width:none}
.urow{padding:9px 0;border-bottom:1px solid var(--line)}
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
/* ---------- items ---------- */
/* A table, as the original's was: unread, kept, title, feed, file, size, published. */
.ephead,.ep{
display:grid;gap:8px;align-items:center;
grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,160px) 56px minmax(0,72px) minmax(0,92px) 64px;
}
/* A pixel more side padding than a row's: a row has a 1px border the heading has not, and without
it every heading sat a pixel left of its column. */
.ephead{
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 11px 5px;
border-bottom:1px solid var(--line);margin-bottom:3px;
font-size:12px;color:var(--faint);
}
/* Each heading sorts by its column; the caret says which way. */
.ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none;
font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}
.ephead .hs:hover,.ephead .hs.on{color:var(--fg)}
.ephead .hs .arr{display:inline-flex}
.ephead .hs .arr .i{width:8px;height:8px}
.ephead .hs .arr.asc{transform:rotate(-90deg)}
.ephead .hs .arr.desc{transform:rotate(90deg)}
/* An icon heading is centred over its column, as the icons under it are, and its caret hangs
outside so sorting by it does not push the icon off line. */
.ephead .hs.h-ic{justify-content:center;position:relative;width:100%}
.ephead .hs.h-ic .arr{position:absolute;left:100%}
.ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
.ep>*{min-width:0}
.ep.sel{background:var(--raise);border-color:var(--line)}
.ep:hover{background:var(--panel)}
/* Amber means new: the unread dot, the badges and a download under way. Blue is for the one
primary action and links, so a count no longer looks like a button. */
/* No padding: a button keeps the browser's side padding, which left too little room for the 16px
icon, so it spilled 3px right of centre and off line with its heading. */
.ep .st,.ep .fl{width:22px;height:22px;padding:0;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent2)}
.ep .fl{color:var(--faint);font-size:13px}
.ep .fl.on{color:var(--fg)}
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
.eq i{flex:1;height:100%;background:currentColor;border-radius:1px;transform-origin:bottom}
.eq i:nth-child(1){height:60%}
.eq i:nth-child(3){height:40%}
body.playing .eq i{animation:eq .9s ease-in-out infinite alternate}
body.playing .eq i:nth-child(1){animation-delay:-.3s}
body.playing .eq i:nth-child(3){animation-delay:-.6s}
@keyframes eq{from{transform:scaleY(.35)}}
.ep .st:hover,.ep .fl:hover{background:var(--raise)}
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep.read .t{color:var(--dim);font-weight:500}
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
.ep .line:empty{display:none}
.ep .fd,.ep .size,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep .file{display:flex;gap:6px;align-items:center;position:relative;color:var(--faint);font-size:11.5px}
/* Under the icon, not beside it or on a line of its own: taking a line, it lifted the icon of
every file not yet downloaded above the downloaded ones' (issue #31). */
.ep .file .dlbar{position:absolute;left:0;right:0;bottom:-5px;margin:0}
.ep .rowacts{justify-content:flex-end}
/* One feed's own table has no need of a Feed column; All Subscriptions does. */
#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) 56px minmax(0,72px) minmax(0,92px) 64px}
#split.one .h-fd,#split.one .ep .fd{display:none}
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
.ep:hover .rowacts{opacity:1}
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
.dlbar.live{background:var(--raise)}
.dlbar i{display:block;height:100%;width:0;background:var(--accent2);transition:width .25s}
.empty{color:var(--faint);text-align:center;padding:50px 0}
#more{display:block;width:100%;margin-top:10px}
/* ---------- player ---------- */
#player{
border-top:1px solid var(--line);background:var(--panel);
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
}
#player.on{display:grid}
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
file needs to be seen, floated above the bar rather than laid into it, so an audio episode's
layout is unchanged. */
#audio{display:none}
body.has-video #audio{
display:block;position:fixed;z-index:45;right:16px;bottom:120px;
width:360px;max-width:calc(100vw - 32px);aspect-ratio:16/9;background:#000;
border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.5);
}
#pnow{display:flex;gap:11px;align-items:center;min-width:0;width:250px}
#pnow .art{width:44px;height:44px;font-size:13px}
#pnow .txt{min-width:0}
#pnow b{display:block;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#pnow .eq{color:var(--accent2);margin-right:6px}
#pnow small{color:var(--faint);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
#pmid{display:flex;flex-direction:column;gap:3px;min-width:0}
#pbtns{display:flex;gap:5px;align-items:center;justify-content:center}
#pbtns .iconbtn{width:34px;height:34px}
#pplay{
width:40px!important;height:40px!important;background:var(--fg);color:var(--bg);
border-radius:50%;
}
#pplay:hover{background:var(--accent);color:var(--ink)}
#seekrow{display:flex;gap:9px;align-items:center;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--faint)}
input[type=range]{
-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;flex:1;
background:var(--raise);cursor:pointer;
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);
}
input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radius:50%;background:var(--accent)}
#pright{display:flex;gap:8px;align-items:center}
#pright select{width:auto;padding:4px 6px;font-size:12px}
#vol{width:78px;flex:none}
/* ---------- overlays ---------- */
#modal{
position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;
place-items:center;z-index:50;padding:20px;
}
#modal.on{display:grid}
.card{
background:var(--panel);border:1px solid var(--line);border-radius:14px;
padding:20px;width:min(460px,100%);box-shadow:var(--shadow);max-height:88vh;overflow:auto;
}
.card.wide{width:min(1000px,100%)}
#logbox{
background:var(--bg);border:1px solid var(--line);border-radius:9px;padding:10px 12px;
height:min(60vh,520px);overflow:auto;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
}
#logbox .l{display:flex;gap:9px;white-space:pre-wrap;overflow-wrap:anywhere}
#logbox time{color:var(--faint);flex:none}
#logbox .lv{flex:none;width:42px;font-weight:700}
#logbox .lv.ERROR{color:var(--bad)} #logbox .lv.WARN{color:var(--warn)}
#logbox .lv.INFO{color:var(--accent)} #logbox .lv.DEBUG,#logbox .lv.TRACE{color:var(--faint)}
#logbox .tg{color:var(--faint);flex:none}
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
.logbar .grow{flex:1;min-width:120px}
.card h3{margin:0 0 14px;font-size:17px}
.field{display:grid;gap:4px;margin-bottom:12px}
.field label{font-size:12px;color:var(--dim)}
.field .hint{font-size:11.5px;color:var(--faint)}
.check{display:flex;gap:8px;align-items:center;font-size:13.5px;margin-bottom:9px}
.inline{display:flex;gap:6px;align-items:stretch}
.inline input{flex:1;min-width:0}
.inline .btn{white-space:nowrap;flex:none}
.inline select{flex:none;width:auto}
.inline input[type=number]{flex:none;width:90px}
.check input{width:16px;height:16px;accent-color:var(--accent)}
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
.toast{
background:var(--raise);border:1px solid var(--line);border-radius:9px;
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
}
.toast.bad{border-color:var(--bad);color:var(--bad)}
@keyframes in{from{opacity:0;transform:translateY(6px)}}
#burger,#dback{display:none}
/* Totals for what is showing, along the bottom, as the original's status bar. */
#status{
padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
/* The feed's own header, kept to one line so the table starts high, as it did. */
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
.fhead.slim .meta{flex:1 1 260px}
/* One line each, or a long title wraps to three and pushes the table down. */
.fhead.slim h2,.fhead.slim .sub.stat{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fhead.slim h2{font-size:17px}
.fhead.slim .sub{margin-bottom:0;font-size:12.5px}
.fhead.slim .acts{margin-top:0;flex:none}
.fhead.slim .acts .btn{padding:5px 11px;font-size:12.5px}
/* Places above the feeds: not subscriptions, but where to look. */
.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px}
.place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer}
.place:hover{background:var(--panel2)}
.place.sel{background:var(--raise)}
.place .ico{width:18px;text-align:center;color:var(--accent);flex:none}
.place b{flex:1;font-weight:500;font-size:13.5px}
@media (max-width:820px){
#shell{grid-template-columns:1fr}
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
#sidebar.open{transform:none}
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
#player{position:relative;z-index:39;padding:7px 10px;gap:8px}
/* The feed list is reachable whether or not anything is playing. */
#burger{display:grid}
#topbar{gap:6px;padding:6px 8px}
#topbar .grow,.tgroup.item{display:none}
#topbar #epSearch{width:auto;flex:1;min-width:0}
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
.fhead.slim .acts{flex:1 1 100%}
.iconbtn{width:38px;height:38px}
/* One pane at a time: the list, then the item over it. */
#split{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"list"}
/* Title and date only; the files sit above the item's text in the reader instead. */
#files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none}
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
#grab{display:none}
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px}
body.reading #detail{display:block}
#dback{display:inline-block;margin-bottom:10px}
#content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px}
#content>.fhead{padding-top:12px}
.fhead{gap:12px;margin-bottom:12px}
.fhead .art{width:64px;height:64px;font-size:20px}
.fhead h2{font-size:18px}
.fhead .sub{font-size:12px;margin-bottom:6px}
.acts{gap:6px}
.acts .btn{padding:7px 10px;font-size:12.5px}
.toolbar{gap:8px}
#list{padding:0 12px 10px}
#player{grid-template-columns:1fr auto;grid-template-areas:"now right" "mid mid";gap:2px 8px}
#pnow{grid-area:now;width:auto}
#pright{grid-area:right}
#pmid{grid-area:mid}
#pnow .art{width:38px;height:38px}
#pnow .txt small,#pright #vol{display:none}
.wrap.plain{padding:14px}
.card{padding:16px}
/* The chips get a line of their own and scroll sideways rather than wrap, so they never push
the grid down. */
.chips{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
/* A log line has no room for four columns: keep time and message, wrap as prose. */
#logbox{font-size:11.5px;padding:8px}
#logbox .l{flex-wrap:wrap;gap:6px}
#logbox .lv{width:auto}
#logbox .tg{display:none}
}
/* ---------- Classic: the 2004 Mac app ---------- */
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
for whatever is selected, red unread badges, a striped table and Lucida Grande. */
:root[data-theme="classic"] body{
font-family:"Lucida Grande","Lucida Sans Unicode","Lucida Sans",Geneva,Verdana,sans-serif;font-size:13px;
}
:root[data-theme="classic"] #topbar,
:root[data-theme="classic"] #status,
:root[data-theme="classic"] #player{background:linear-gradient(#ececec,#c9c9c9);border-color:#8e8e8e;color:#1a1a1a}
:root[data-theme="classic"] #status{text-align:center}
:root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3}
:root[data-theme="classic"] #files{background:#f4f4f4}
:root[data-theme="classic"] .tgroup{background:none;border-color:#8e8e8e;border-radius:6px}
:root[data-theme="classic"] .tgroup button+button,:root[data-theme="classic"] .tgroup button+a{border-left-color:#8e8e8e}
:root[data-theme="classic"] .tgroup button,
:root[data-theme="classic"] .tgroup a,
:root[data-theme="classic"] .btn,
:root[data-theme="classic"] .tabs{background:linear-gradient(#ffffff,#d9d9d9);border-color:#8e8e8e;color:#1a1a1a}
:root[data-theme="classic"] .tabs{border:1px solid #8e8e8e;border-radius:6px}
:root[data-theme="classic"] .tgroup button:hover:not(:disabled),
:root[data-theme="classic"] .tgroup a:hover,
:root[data-theme="classic"] .btn:hover{background:linear-gradient(#f5f9ff,#cbdbf4)}
:root[data-theme="classic"] .btn.primary,
:root[data-theme="classic"] .tabs button.on,
:root[data-theme="classic"] .feed.sel,
:root[data-theme="classic"] .place.sel{background:linear-gradient(#86b0ec,#3a73cf);border-color:#2d5eae;color:#fff}
:root[data-theme="classic"] .feed.sel .txt small,
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
:root[data-theme="classic"] #epSearch{border-radius:14px}
:root[data-theme="classic"] .badge{background:#c9302c;color:#fff}
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
:root[data-theme="classic"] .ephead,
:root[data-theme="classic"] .fhd{
background:linear-gradient(#fbfbfb,#dcdcdc);color:#1a1a1a;font-size:11px;
}
:root[data-theme="classic"] .fhd{padding:3px 6px;border:1px solid #c2c2c2}
:root[data-theme="classic"] .ephead{border-bottom-color:#a3a3a3}
:root[data-theme="classic"] .ep{border-radius:0;margin:0}
:root[data-theme="classic"] #eps .ep:nth-child(even){background:#edf3fe}
:root[data-theme="classic"] .ep .t{color:#1c4fa8}
:root[data-theme="classic"] .ep.read .t{color:#333}
:root[data-theme="classic"] #eps .ep.sel{background:#3875d7;border-color:#3875d7}
:root[data-theme="classic"] .ep.sel .t,
:root[data-theme="classic"] .ep.sel .fd,
:root[data-theme="classic"] .ep.sel .size,
:root[data-theme="classic"] .ep.sel .date,
:root[data-theme="classic"] .ep.sel .line,
:root[data-theme="classic"] .ep.sel .st,
:root[data-theme="classic"] .ep.sel .fl,
:root[data-theme="classic"] .ep.sel .file,
:root[data-theme="classic"] .ep.sel .kind{color:#fff}
/* Green and red stay green and red on the blue, just lighter so they read. */
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
:root[data-theme="classic"] .childrow{background:#fff}
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}

View File

@@ -22,8 +22,24 @@ const here = path.dirname(fileURLToPath(import.meta.url));
// scope, as the single inline script did, and code that runs at load needs what came before it.
const PAGES = {
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events'] },
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
'login.html': { script: 'login.js', src: ['login'] },
};
// The stylesheet the app and admin pages share, served and named by hash as the scripts are.
const STYLE = 'app.css';
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
/// one; the doctype only keeps it from complaining that a fragment has none.
export function buildStyle({ minify = true } = {}) {
const css = fs.readFileSync(path.join(here, STYLE), 'utf8');
if (!minify) return css;
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
return r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
}
/// The page and its script, built: { html, js, script }, where script is the file's name.
export function buildPage(name, { minify = true } = {}) {
@@ -44,10 +60,11 @@ export function buildPage(name, { minify = true } = {}) {
const page = fs.readFileSync(path.join(here, name), 'utf8');
const marker = /<script data-src="[^"]*"><\/script>/;
if (!marker.test(page)) throw new Error(`${name} has no <script data-src> to put its script in`);
const v = crypto.createHash('sha256').update(js).digest('hex').slice(0, 12);
// Where the inline script was, and a plain <script src>, so it still runs in the same place:
// after the markup it wires up, before anything else.
const out = page.replace(marker, `<script src="/${script}?v=${v}"></script>`);
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
if (!minify) return { html: out, js, script };
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'Error');
@@ -58,6 +75,7 @@ export function buildPage(name, { minify = true } = {}) {
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const out = process.argv[2] || path.join(here, 'dist');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, STYLE), buildStyle());
for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html);

View File

@@ -7,838 +7,7 @@
<title>iPodderX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>
/* Inter, served by ipx itself (/inter.woff2) rather than a font CDN, so the page asks nothing of
anyone else. One variable file covers every weight used here; italics are synthesized. Classic
keeps Lucida Grande, the 2004 app's face. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
/* Palette taken from the 2004 iPodderX icon: the silver device body, the blue
screen, and the amber EQ bars. Hex values in comments are sampled straight from it. */
:root {
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
--panel:#151c27;
--panel2:#1c2431;
--raise:#25303f;
--line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
--accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */
--good:#6fbf8b;
--warn:#f49e2c; /* the amber doubles as the pending colour */
--bad:#e2705f;
--shadow:0 8px 28px rgba(6,10,16,.55);
}
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's
script sets data-mode to light or dark, working Auto out from the system, so each theme has
one block per variant here and none needs repeating under a media query. */
:root[data-theme="modern"][data-mode="light"] {
--bg:#f2f4f7;
--panel:#ffffff; /* #FFFFFF device body */
--panel2:#e9edf3;
--raise:#dde3ec;
--line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
--ink:#ffffff;
--good:#2f7d4f;
--warn:#b06f10;
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour
(#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */
:root[data-theme="dracula"] {
--bg:#282a36;
--panel:#21222c;
--panel2:#343746;
--raise:#44475a; /* selection */
--line:#414558;
--fg:#f8f8f2;
--dim:#c9cbd6;
--faint:#9ea8c7;
--accent:#bd93f9; /* purple */
--accent2:#ff79c6; /* pink */
--ink:#282a36;
--good:#50fa7b;
--warn:#ffb86c;
--bad:#ff8080;
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="dracula"][data-mode="light"] {
--bg:#fffbeb;
--panel:#f7f2df;
--panel2:#efe9d3;
--raise:#cfcfde; /* selection */
--line:#ddd6bd;
--fg:#1f1f1f;
--dim:#454137;
--faint:#635d44;
--accent:#644ac9;
--accent2:#a3144d;
--ink:#ffffff;
--good:#14710a;
--warn:#a34d14;
--bad:#b83322;
--shadow:0 8px 28px rgba(108,102,75,.18);
}
/* Material 3's baseline scheme: surface, the surface containers, outline and primary. */
:root[data-theme="material"] {
--bg:#141218; /* surface */
--panel:#1d1b20; /* surface-container-low */
--panel2:#211f26; /* surface-container */
--raise:#36343b; /* surface-container-highest */
--line:#49454f; /* outline-variant */
--fg:#e6e0e9; /* on-surface */
--dim:#cac4d0; /* on-surface-variant */
--faint:#9a95a0; /* outline, a shade up to clear AA on the containers */
--accent:#d0bcff; /* primary */
--accent2:#efb8c8; /* tertiary */
--ink:#381e72; /* on-primary */
--good:#8fd6a0;
--warn:#f2c46b;
--bad:#f2b8b5; /* error */
--shadow:0 8px 28px rgba(0,0,0,.5);
}
:root[data-theme="material"][data-mode="light"] {
--bg:#fef7ff;
--panel:#f7f2fa;
--panel2:#f3edf7;
--raise:#e6e0e9;
--line:#cac4d0;
--fg:#1d1b20;
--dim:#49454f;
--faint:#67626c; /* outline (#79747E), a shade down to clear AA on the containers */
--accent:#6750a4;
--accent2:#7d5260;
--ink:#ffffff;
--good:#2b6c3c;
--warn:#7c5800;
--bad:#b3261e;
--shadow:0 8px 28px rgba(103,80,164,.14);
}
/* Adwaita, from libadwaita's own CSS variables: view, window and sidebar backgrounds, the blue
accent, and its destructive, success and warning colours. */
:root[data-theme="adwaita"] {
--bg:#1d1d20; /* view */
--panel:#2e2e32; /* sidebar, headerbar */
--panel2:#222226; /* window */
--raise:#3a3a3f;
--line:#3f3f45;
--fg:#ffffff;
--dim:#c4c4c8;
--faint:#a0a0a7;
--accent:#81d0ff;
--accent2:#3584e4;
--ink:#1d1d20;
--good:#78e9ab;
--warn:#ffc252;
--bad:#ff938c;
--shadow:0 8px 28px rgba(0,0,0,.5);
}
:root[data-theme="adwaita"][data-mode="light"] {
--bg:#ffffff;
--panel:#ebebed;
--panel2:#fafafb;
--raise:#dcdce0;
--line:#d9d9dd;
--fg:#333338; /* rgb(0 0 6 / 80%) on white */
--dim:#57575d;
--faint:#66666c;
--accent:#0461be;
--accent2:#3584e4;
--ink:#ffffff;
--good:#00753a;
--warn:#905400;
--bad:#c30000;
--shadow:0 8px 28px rgba(0,0,6,.12);
}
/* Flat Remix, from its GTK theme's _colors.scss: base and bg, fg, the selection blue, and its
link colours, which are the selection blue taken down (light) or up (dark) until readable. */
:root[data-theme="flatremix"] {
--bg:#272a34; /* base */
--panel:#23262f; /* bg: base darkened 2% */
--panel2:#2e323d;
--raise:#363b48;
--line:#3a3f4c;
--fg:#eeeeec;
--dim:#babec8;
--faint:#959baa;
--accent:#8fb6ff; /* link */
--accent2:#fd7d00; /* warning orange */
--ink:#1a1c23;
--good:#4cc28c;
--warn:#ff9a3c;
--bad:#ff6b6b;
--shadow:0 8px 28px rgba(0,0,0,.3);
}
:root[data-theme="flatremix"][data-mode="light"] {
--bg:#fafafa; /* base */
--panel:#ffffff; /* bg */
--panel2:#f0f0f1;
--raise:#e4e5e7;
--line:#d9d9d9; /* borders: bg darkened 15% */
--fg:#22252b;
--dim:#5c616c; /* fg */
--faint:#686d78;
--accent:#0060f0; /* link */
--accent2:#fd7d00;
--ink:#ffffff;
--good:#23794f;
--warn:#a85400;
--bad:#d41919;
--shadow:0 8px 28px rgba(0,0,0,.1);
}
/* Paper: a single light palette, black on the colour of paper, colour kept for what matters.
Its blue and red are taken down a shade to clear AA on that background. */
:root[data-theme="paper"] {
--bg:#f2eede;
--panel:#ebe7d7;
--panel2:#e4e0d0;
--raise:#d8d5c7; /* highlight */
--line:#cdc9b9;
--fg:#000000;
--dim:#3d3a33;
--faint:#5a564c;
--accent:#1a5fae;
--accent2:#b58900;
--ink:#ffffff;
--good:#216609;
--warn:#795a00;
--bad:#b1331f;
--shadow:0 8px 28px rgba(0,0,0,.12);
}
/* Nordic: the Nord palette. Polar Night for the dark half, Snow Storm for the light, Frost for
the accent and Aurora for the rest. Aurora red and Frost blue are shifted until they read. */
:root[data-theme="nordic"] {
--bg:#2e3440; /* nord0 */
--panel:#3b4252; /* nord1 */
--panel2:#434c5e; /* nord2 */
--raise:#4c566a; /* nord3 */
--line:#434c5e;
--fg:#eceff4; /* nord6 */
--dim:#d8dee9; /* nord4 */
--faint:#b4bccb;
--accent:#93c9d8; /* nord8, a shade up */
--accent2:#ebcb8b; /* nord13 */
--ink:#2e3440;
--good:#b0c99b; /* nord14, a shade up */
--warn:#ebcb8b;
--bad:#f2aeb4;
--shadow:0 8px 28px rgba(0,0,0,.35);
}
:root[data-theme="nordic"][data-mode="light"] {
--bg:#eceff4; /* nord6 */
--panel:#e5e9f0; /* nord5 */
--panel2:#d8dee9; /* nord4 */
--raise:#cdd4e0;
--line:#c5cedb;
--fg:#2e3440; /* nord0 */
--dim:#3b4252; /* nord1 */
--faint:#4c566a; /* nord3 */
--accent:#3a5e8a;
--accent2:#b0674e;
--ink:#ffffff;
--good:#446632;
--warn:#7e590e;
--bad:#9c3f49;
--shadow:0 8px 28px rgba(46,52,64,.15);
}
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
:root[data-theme="classic"] {
color-scheme:light;
--bg:#ffffff;
--panel:#e7ebf1; /* the source list's pale blue-grey */
--panel2:#f2f2f2;
--raise:#d5dce7;
--line:#a9a9a9;
--fg:#000000;
--dim:#444444;
--faint:#666666;
--accent:#3875d7; /* Aqua selection blue */
--accent2:#2a5db0;
--ink:#ffffff;
--good:#237a23;
--warn:#a15f00;
--bad:#c42b1c;
--shadow:0 4px 16px rgba(0,0,0,.28);
}
*{box-sizing:border-box}
/* A rule that sets display beats the UA's [hidden], and several below do. */
[hidden]{display:none!important}
html,body{height:100%}
body{
margin:0;background:var(--bg);color:var(--fg);
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:grid;grid-template-rows:auto 1fr auto auto;overflow:hidden;
/* iOS Safari's address bar collapses and expands without firing a resize, so 100vh is
measured against whichever state happened to be current -- sized too tall while the bar
is showing, which puts the topbar (the hamburger included) under Safari's own chrome,
where a tap never reaches the page. Only a hard refresh reset it, forcing 100vh to be
recomputed. 100dvh tracks the real visible viewport as the bar moves; the 100vh above is
the fallback for a browser that does not know dvh. */
height:100vh;height:100dvh;
}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
a{color:var(--accent)}
/* Inset wherever a parent clips its overflow, or the toolbar and the feed list cut the ring off. */
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.tgroup button:focus-visible,.feed:focus-visible,.place:focus-visible,.chev:focus-visible{outline-offset:-2px}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation:none!important;transition:none!important}
}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px}
::-webkit-scrollbar-track{background:transparent}
/* ---------- shell ---------- */
#shell{display:grid;grid-template-columns:290px 1fr;min-height:0;min-width:0;overflow:hidden}
/* [ hides the feed list. A phone slides it over the page instead, so this is for wider screens. */
@media (min-width:821px){
body.nosb #shell{grid-template-columns:minmax(0,1fr)}
body.nosb #sidebar{display:none}
}
#sidebar{
background:var(--panel);border-right:1px solid var(--line);
display:flex;flex-direction:column;min-height:0;
}
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
.brand .logo{
width:30px;height:30px;flex:none;object-fit:contain;
}
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1}
.iconbtn{
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
color:var(--dim);flex:none;
}
.iconbtn:hover{background:var(--raise);color:var(--fg)}
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
#topbar{
display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden;
background:var(--panel);border-bottom:1px solid var(--line);
}
#topbar .grow{flex:1}
#topbar #epSearch{width:260px;flex:none}
.tgroup{display:flex;flex:none;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel2)}
.tgroup button{padding:5px 11px;min-width:34px;color:var(--dim);font-size:14px;line-height:1.3}
.tgroup button+button{border-left:1px solid var(--line)}
.tgroup button:hover:not(:disabled){background:var(--raise);color:var(--fg)}
.tgroup button:disabled{opacity:.35;cursor:default}
.sidetools button,.sidefoot button{
flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 8px;font-size:12.5px;color:var(--dim);
}
.sidetools button:hover,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)}
/* Settings and the log are housekeeping: they sit under the feeds, out of the way. */
.sidefoot{
display:flex;flex-direction:column;gap:7px;padding:8px 12px;
border-top:1px solid var(--line);flex:none;
}
.sidefoot .row{display:flex;gap:6px}
.who{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--faint)}
.who span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.who button{flex:none;background:none;border:0;padding:2px 4px;color:var(--faint);text-decoration:underline}
.who button:hover{color:var(--fg)}
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.sidefoot i{font-style:normal;opacity:.75}
.searchwrap{padding:0 12px 8px}
input[type=search],input[type=text],input[type=password],input[type=number],select{
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
}
input:focus,select:focus{outline:0;border-color:var(--accent)}
/* The wider left gutter is the folder triangle's; everything in the list shifts with it, so the
feeds still line up with the places above. */
#feedlist{overflow-y:auto;padding:0 8px 12px 16px;flex:1;min-height:0}
.feed{
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
cursor:pointer;margin-bottom:1px;position:relative;
}
.feed:hover{background:var(--panel2)}
.feed.sel{background:var(--raise)}
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
.feed.child{margin-left:11px}
/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */
.feed .fpin .i{width:10px;height:10px;margin-right:5px;vertical-align:-1px;color:var(--accent)}
.feed.lastpin{margin-bottom:9px}
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
.feed.child .art{width:28px;height:28px;font-size:11px}
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
above it. The button is the row's full height and 24 px wide: a near miss used to open the
folder's page, and 16 px left the triangle cramped against the art. */
.chev{
position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;
border-radius:4px;color:var(--faint);
}
.chev:hover{color:var(--fg)}
.chev.bad,.chev.bad:hover{color:var(--bad)}
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
a child's included, which is why it moves left by the child's indent. */
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
.ferr .i{width:12px;height:12px}
.feed.child .ferr{left:-27px}
.chev .i{width:12px;height:12px;transition:transform .12s}
.chev[aria-expanded="true"] .i{transform:rotate(90deg)}
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
.childrow{
display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
border-radius:9px;cursor:pointer;background:var(--panel);
}
.childrow:hover{border-color:var(--accent)}
.childrow .art{width:32px;height:32px;font-size:12px}
.childrow .txt{flex:1;min-width:0}
.childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Currently Listening: how far into each episode you are, as a rail along the foot of its row.
The one in the player is marked as it is everywhere else, by the amber EQ bars, and its rail
and time left move as it plays. The rest stay neutral, so that one is what stands out. */
#listening .childrow{position:relative;overflow:hidden;gap:12px;padding:10px 10px 13px}
#listening .childrow .art{width:44px;height:44px;font-size:13px}
#listening .txt b{display:flex;align-items:center;gap:7px}
#listening .txt b span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#listening .txt small{display:flex;gap:12px;margin-top:3px;color:var(--faint);font-size:11.5px}
#listening .txt small .fd{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#listening .txt small .left{flex:none;color:var(--dim)}
#listening .childrow:not(.now) .eq{display:none}
#listening .rail{position:absolute;left:0;right:0;bottom:0;height:3px;background:var(--raise)}
#listening .rail i{display:block;height:100%;width:0;background:var(--faint);transition:width .25s linear}
#listening .now{border-color:color-mix(in srgb,var(--accent2) 60%,var(--line))}
#listening .now .eq,#listening .now .left{color:var(--accent2)}
#listening .now .rail i{background:var(--accent2)}
/* Quiet buttons: a filled one on every row outshouted the row that is playing. */
#listening [data-a=play]{color:var(--accent)}
#listening [data-a=remove]{color:var(--faint)}
/* Directory's filters: .tabs for what a feed is, as the item filters are, and chips for what it
is about. A picked chip is underlined in --accent2, as the download bar and the now-playing EQ
are; a .badge's fill already means unread in the sidebar. */
.dirbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px}
.dirbar .tabs{flex:none}
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0}
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent}
.chips button:hover{color:var(--fg)}
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
/* Square cover art, title underneath: podcast art is made to be known at a glance. The subscribe
button stays visible, since a button that only shows on hover cannot be reached on a phone. */
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px;margin-top:14px}
.tiles>.hint{grid-column:1/-1}
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start;cursor:pointer}
.tile .art{grid-column:1/-1;width:100%;height:auto;aspect-ratio:1;font-size:34px}
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
.tile:hover .txt b{color:var(--accent)}
.tag{
font-size:11px;font-weight:600;
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
}
.art{
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
}
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
.art.ini{background:color-mix(in srgb,var(--tint) 24%,var(--bg));color:var(--tint)}
/* A folder: its first four shows' art, as iTunes built a playlist's from its albums. */
.art.mosaic{grid-template:1fr 1fr/1fr 1fr;place-items:stretch}
.art.mosaic img{width:100%;height:100%;min-height:0;object-fit:cover;display:block}
.feed .art{width:34px;height:34px;font-size:13px}
.feed .txt{min-width:0;flex:1}
.feed .txt b{display:block;font-weight:500;font-size:13.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed .txt small{display:block;color:var(--faint);font-size:11.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.badge{
background:var(--accent2);color:var(--ink);border-radius:20px;padding:1px 7px;
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
}
.badge.zero{background:var(--raise);color:var(--faint)}
/* Counts, sizes and times that change in place should not jiggle the text around them. */
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
/* ---------- main ---------- */
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
.wrap{flex:1;min-height:0;min-width:0;display:flex;flex-direction:column}
.wrap.plain{overflow-y:auto;padding:18px 22px 40px;display:block}
#content{display:flex;flex-direction:column;min-height:0;min-width:0;flex:1}
#content>.fhead,#content>.toolbar{padding-left:20px;padding-right:20px;flex:none}
#content>.fhead{padding-top:16px}
/* Three panes, as the original had: feeds beside, items above, the item below. */
#split{
display:grid;flex:1;min-height:0;
grid-template-columns:minmax(0,1fr) 270px;
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
grid-template-areas:"list files" "grab grab" "detail detail";
}
#list{grid-area:list;overflow:auto;padding:0 14px 10px;overscroll-behavior-y:contain}
/* Pull-to-refresh's note (gestures.ts). overscroll-behavior keeps the browser's own pull, which
reloads the page, from starting on top of it. */
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
font-size:12px;color:var(--faint)}
/* The selected item's files, beside the list, as the original's Files pane was. */
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
/* Nothing selected, or an item with no files: the list takes the width. */
#split:has(>#files[hidden]){grid-template-columns:minmax(0,1fr) 0}
#files .encbox{margin-top:8px}
#files .empty{padding:24px 0}
.fhd{font-size:12px;color:var(--faint)}
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
#grab:hover{background:var(--accent)}
#detail{grid-area:detail;overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere}
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
.dmeta .btn{padding:3px 9px;font-size:12px}
#detail .encbox{margin:0 0 10px}
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
.dbody img{max-width:100%;height:auto;border-radius:6px}
.dbody a{color:var(--accent)}
.dbody pre{overflow-x:auto;background:var(--panel2);padding:10px;border-radius:8px}
.encbox{
display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px;
padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);
}
.fhead{display:flex;gap:18px;margin-bottom:18px}
.fhead .art{width:118px;height:118px;font-size:34px;box-shadow:var(--shadow)}
.fhead .meta{min-width:0;flex:1;display:flex;flex-direction:column}
.fhead h2{margin:0 0 3px;font-size:23px;line-height:1.2;overflow-wrap:anywhere}
.fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px}
.fhead .sub a{color:var(--dim);text-decoration:none}
.fhead .sub a:hover{color:var(--accent)}
.acts{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:auto}
.acts .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
.acts .btn.primary i{opacity:.9}
.acts .btn:hover{background:var(--raise)}
.acts .btn.primary:hover{background:var(--accent)}
.btn{
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 12px;font-size:13px;
}
.btn:hover{border-color:var(--accent)}
a.btn{text-decoration:none;color:inherit}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--ink);font-weight:600}
.btn.primary:hover{filter:brightness(1.08)}
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
/* An icon in place of a word; the word is in its tooltip. */
.btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center}
/* Inline in a sentence, next to a plain-word error explanation. */
.btn.tiny{padding:2px 7px;font-size:11.5px;border-radius:6px;margin-left:2px}
/* The keys ? lists. */
.keys{border-collapse:collapse;width:100%;font-size:13px}
.keys th{text-align:left;font-weight:600;padding:12px 0 4px;color:var(--fg)}
.keys td{padding:3px 0;color:var(--dim)}
.keys td:first-child{width:10em;white-space:nowrap}
kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 5px}
/* An icon (Font Awesome, embedded as SVG) in the button's own colour. */
.i{display:inline-block;width:16px;height:16px;vertical-align:-3px;flex:none;fill:currentColor}
/* A file's type as an icon, in place of the old DOWNLOADED / PENDING / audio chips: green once
it is here, red when the download failed. */
.kind{display:inline-grid;place-items:center;color:var(--dim)}
.kind.here{color:var(--good)}
.subbed{display:inline-flex;padding:0 9px;color:var(--good)}
.subbed .i{width:18px;height:18px}
.kind.bad{color:var(--bad)}
.fhead .art .i{width:22px;height:22px}
.toolbar{
display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap;
position:sticky;top:0;background:var(--bg);padding:6px 0 8px;z-index:3;
}
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px}
.tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)}
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:500}
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
/* ---------- items ---------- */
/* A table, as the original's was: unread, kept, title, feed, file, size, published. */
.ephead,.ep{
display:grid;gap:8px;align-items:center;
grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,160px) 56px minmax(0,72px) minmax(0,92px) 64px;
}
/* A pixel more side padding than a row's: a row has a 1px border the heading has not, and without
it every heading sat a pixel left of its column. */
.ephead{
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 11px 5px;
border-bottom:1px solid var(--line);margin-bottom:3px;
font-size:12px;color:var(--faint);
}
/* Each heading sorts by its column; the caret says which way. */
.ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none;
font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}
.ephead .hs:hover,.ephead .hs.on{color:var(--fg)}
.ephead .hs .arr{display:inline-flex}
.ephead .hs .arr .i{width:8px;height:8px}
.ephead .hs .arr.asc{transform:rotate(-90deg)}
.ephead .hs .arr.desc{transform:rotate(90deg)}
/* An icon heading is centred over its column, as the icons under it are, and its caret hangs
outside so sorting by it does not push the icon off line. */
.ephead .hs.h-ic{justify-content:center;position:relative;width:100%}
.ephead .hs.h-ic .arr{position:absolute;left:100%}
.ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
.ep>*{min-width:0}
.ep.sel{background:var(--raise);border-color:var(--line)}
.ep:hover{background:var(--panel)}
/* Amber means new: the unread dot, the badges and a download under way. Blue is for the one
primary action and links, so a count no longer looks like a button. */
/* No padding: a button keeps the browser's side padding, which left too little room for the 16px
icon, so it spilled 3px right of centre and off line with its heading. */
.ep .st,.ep .fl{width:22px;height:22px;padding:0;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent2)}
.ep .fl{color:var(--faint);font-size:13px}
.ep .fl.on{color:var(--fg)}
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
.eq i{flex:1;height:100%;background:currentColor;border-radius:1px;transform-origin:bottom}
.eq i:nth-child(1){height:60%}
.eq i:nth-child(3){height:40%}
body.playing .eq i{animation:eq .9s ease-in-out infinite alternate}
body.playing .eq i:nth-child(1){animation-delay:-.3s}
body.playing .eq i:nth-child(3){animation-delay:-.6s}
@keyframes eq{from{transform:scaleY(.35)}}
.ep .st:hover,.ep .fl:hover{background:var(--raise)}
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep.read .t{color:var(--dim);font-weight:500}
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
.ep .line:empty{display:none}
.ep .fd,.ep .size,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep .file{display:flex;gap:6px;align-items:center;position:relative;color:var(--faint);font-size:11.5px}
/* Under the icon, not beside it or on a line of its own: taking a line, it lifted the icon of
every file not yet downloaded above the downloaded ones' (issue #31). */
.ep .file .dlbar{position:absolute;left:0;right:0;bottom:-5px;margin:0}
.ep .rowacts{justify-content:flex-end}
/* One feed's own table has no need of a Feed column; All Subscriptions does. */
#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) 56px minmax(0,72px) minmax(0,92px) 64px}
#split.one .h-fd,#split.one .ep .fd{display:none}
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
.ep:hover .rowacts{opacity:1}
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
.dlbar.live{background:var(--raise)}
.dlbar i{display:block;height:100%;width:0;background:var(--accent2);transition:width .25s}
.empty{color:var(--faint);text-align:center;padding:50px 0}
#more{display:block;width:100%;margin-top:10px}
/* ---------- player ---------- */
#player{
border-top:1px solid var(--line);background:var(--panel);
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
}
#player.on{display:grid}
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
file needs to be seen, floated above the bar rather than laid into it, so an audio episode's
layout is unchanged. */
#audio{display:none}
body.has-video #audio{
display:block;position:fixed;z-index:45;right:16px;bottom:120px;
width:360px;max-width:calc(100vw - 32px);aspect-ratio:16/9;background:#000;
border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.5);
}
#pnow{display:flex;gap:11px;align-items:center;min-width:0;width:250px}
#pnow .art{width:44px;height:44px;font-size:13px}
#pnow .txt{min-width:0}
#pnow b{display:block;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#pnow .eq{color:var(--accent2);margin-right:6px}
#pnow small{color:var(--faint);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
#pmid{display:flex;flex-direction:column;gap:3px;min-width:0}
#pbtns{display:flex;gap:5px;align-items:center;justify-content:center}
#pbtns .iconbtn{width:34px;height:34px}
#pplay{
width:40px!important;height:40px!important;background:var(--fg);color:var(--bg);
border-radius:50%;
}
#pplay:hover{background:var(--accent);color:var(--ink)}
#seekrow{display:flex;gap:9px;align-items:center;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--faint)}
input[type=range]{
-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;flex:1;
background:var(--raise);cursor:pointer;
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);
}
input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radius:50%;background:var(--accent)}
#pright{display:flex;gap:8px;align-items:center}
#pright select{width:auto;padding:4px 6px;font-size:12px}
#vol{width:78px;flex:none}
/* ---------- overlays ---------- */
#modal{
position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;
place-items:center;z-index:50;padding:20px;
}
#modal.on{display:grid}
.card{
background:var(--panel);border:1px solid var(--line);border-radius:14px;
padding:20px;width:min(460px,100%);box-shadow:var(--shadow);max-height:88vh;overflow:auto;
}
.card.wide{width:min(1000px,100%)}
#logbox{
background:var(--bg);border:1px solid var(--line);border-radius:9px;padding:10px 12px;
height:min(60vh,520px);overflow:auto;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
}
#logbox .l{display:flex;gap:9px;white-space:pre-wrap;overflow-wrap:anywhere}
#logbox time{color:var(--faint);flex:none}
#logbox .lv{flex:none;width:42px;font-weight:700}
#logbox .lv.ERROR{color:var(--bad)} #logbox .lv.WARN{color:var(--warn)}
#logbox .lv.INFO{color:var(--accent)} #logbox .lv.DEBUG,#logbox .lv.TRACE{color:var(--faint)}
#logbox .tg{color:var(--faint);flex:none}
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
.logbar .grow{flex:1;min-width:120px}
.card h3{margin:0 0 14px;font-size:17px}
.field{display:grid;gap:4px;margin-bottom:12px}
.field label{font-size:12px;color:var(--dim)}
.field .hint{font-size:11.5px;color:var(--faint)}
.check{display:flex;gap:8px;align-items:center;font-size:13.5px;margin-bottom:9px}
.inline{display:flex;gap:6px;align-items:stretch}
.inline input{flex:1;min-width:0}
.inline .btn{white-space:nowrap;flex:none}
.inline select{flex:none;width:auto}
.inline input[type=number]{flex:none;width:90px}
.check input{width:16px;height:16px;accent-color:var(--accent)}
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
.toast{
background:var(--raise);border:1px solid var(--line);border-radius:9px;
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
}
.toast.bad{border-color:var(--bad);color:var(--bad)}
@keyframes in{from{opacity:0;transform:translateY(6px)}}
#burger,#dback{display:none}
/* Totals for what is showing, along the bottom, as the original's status bar. */
#status{
padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
/* The feed's own header, kept to one line so the table starts high, as it did. */
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
.fhead.slim .meta{flex:1 1 260px}
/* One line each, or a long title wraps to three and pushes the table down. */
.fhead.slim h2,.fhead.slim .sub.stat{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fhead.slim h2{font-size:17px}
.fhead.slim .sub{margin-bottom:0;font-size:12.5px}
.fhead.slim .acts{margin-top:0;flex:none}
.fhead.slim .acts .btn{padding:5px 11px;font-size:12.5px}
/* Places above the feeds: not subscriptions, but where to look. */
.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px}
.place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer}
.place:hover{background:var(--panel2)}
.place.sel{background:var(--raise)}
.place .ico{width:18px;text-align:center;color:var(--accent);flex:none}
.place b{flex:1;font-weight:500;font-size:13.5px}
@media (max-width:820px){
#shell{grid-template-columns:1fr}
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
#sidebar.open{transform:none}
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
#player{position:relative;z-index:39;padding:7px 10px;gap:8px}
/* The feed list is reachable whether or not anything is playing. */
#burger{display:grid}
#topbar{gap:6px;padding:6px 8px}
#topbar .grow,.tgroup.item{display:none}
#topbar #epSearch{width:auto;flex:1;min-width:0}
.tgroup button{padding:4px 8px;min-width:32px}
.fhead.slim .acts{flex:1 1 100%}
.iconbtn{width:38px;height:38px}
/* One pane at a time: the list, then the item over it. */
#split{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"list"}
/* Title and date only; the files sit above the item's text in the reader instead. */
#files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none}
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
#grab{display:none}
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px}
body.reading #detail{display:block}
#dback{display:inline-block;margin-bottom:10px}
#content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px}
#content>.fhead{padding-top:12px}
.fhead{gap:12px;margin-bottom:12px}
.fhead .art{width:64px;height:64px;font-size:20px}
.fhead h2{font-size:18px}
.fhead .sub{font-size:12px;margin-bottom:6px}
.acts{gap:6px}
.acts .btn{padding:7px 10px;font-size:12.5px}
.toolbar{gap:8px}
#list{padding:0 12px 10px}
#player{grid-template-columns:1fr auto;grid-template-areas:"now right" "mid mid";gap:2px 8px}
#pnow{grid-area:now;width:auto}
#pright{grid-area:right}
#pmid{grid-area:mid}
#pnow .art{width:38px;height:38px}
#pnow .txt small,#pright #vol{display:none}
.wrap.plain{padding:14px}
.card{padding:16px}
/* The chips get a line of their own and scroll sideways rather than wrap, so they never push
the grid down. */
.chips{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
/* A log line has no room for four columns: keep time and message, wrap as prose. */
#logbox{font-size:11.5px;padding:8px}
#logbox .l{flex-wrap:wrap;gap:6px}
#logbox .lv{width:auto}
#logbox .tg{display:none}
}
/* ---------- Classic: the 2004 Mac app ---------- */
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
for whatever is selected, red unread badges, a striped table and Lucida Grande. */
:root[data-theme="classic"] body{
font-family:"Lucida Grande","Lucida Sans Unicode","Lucida Sans",Geneva,Verdana,sans-serif;font-size:13px;
}
:root[data-theme="classic"] #topbar,
:root[data-theme="classic"] #status,
:root[data-theme="classic"] #player{background:linear-gradient(#ececec,#c9c9c9);border-color:#8e8e8e;color:#1a1a1a}
:root[data-theme="classic"] #status{text-align:center}
:root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3}
:root[data-theme="classic"] #files{background:#f4f4f4}
:root[data-theme="classic"] .tgroup{background:none;border-color:#8e8e8e;border-radius:6px}
:root[data-theme="classic"] .tgroup button+button{border-left-color:#8e8e8e}
:root[data-theme="classic"] .tgroup button,
:root[data-theme="classic"] .btn,
:root[data-theme="classic"] .tabs{background:linear-gradient(#ffffff,#d9d9d9);border-color:#8e8e8e;color:#1a1a1a}
:root[data-theme="classic"] .tabs{border:1px solid #8e8e8e;border-radius:6px}
:root[data-theme="classic"] .tgroup button:hover:not(:disabled),
:root[data-theme="classic"] .btn:hover{background:linear-gradient(#f5f9ff,#cbdbf4)}
:root[data-theme="classic"] .btn.primary,
:root[data-theme="classic"] .tabs button.on,
:root[data-theme="classic"] .feed.sel,
:root[data-theme="classic"] .place.sel{background:linear-gradient(#86b0ec,#3a73cf);border-color:#2d5eae;color:#fff}
:root[data-theme="classic"] .feed.sel .txt small,
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
:root[data-theme="classic"] #epSearch{border-radius:14px}
:root[data-theme="classic"] .badge{background:#c9302c;color:#fff}
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
:root[data-theme="classic"] .ephead,
:root[data-theme="classic"] .fhd{
background:linear-gradient(#fbfbfb,#dcdcdc);color:#1a1a1a;font-size:11px;
}
:root[data-theme="classic"] .fhd{padding:3px 6px;border:1px solid #c2c2c2}
:root[data-theme="classic"] .ephead{border-bottom-color:#a3a3a3}
:root[data-theme="classic"] .ep{border-radius:0;margin:0}
:root[data-theme="classic"] #eps .ep:nth-child(even){background:#edf3fe}
:root[data-theme="classic"] .ep .t{color:#1c4fa8}
:root[data-theme="classic"] .ep.read .t{color:#333}
:root[data-theme="classic"] #eps .ep.sel{background:#3875d7;border-color:#3875d7}
:root[data-theme="classic"] .ep.sel .t,
:root[data-theme="classic"] .ep.sel .fd,
:root[data-theme="classic"] .ep.sel .size,
:root[data-theme="classic"] .ep.sel .date,
:root[data-theme="classic"] .ep.sel .line,
:root[data-theme="classic"] .ep.sel .st,
:root[data-theme="classic"] .ep.sel .fl,
:root[data-theme="classic"] .ep.sel .file,
:root[data-theme="classic"] .ep.sel .kind{color:#fff}
/* Green and red stay green and red on the blue, just lighter so they read. */
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
:root[data-theme="classic"] .childrow{background:#fff}
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
</style>
<link rel="stylesheet" data-src="app.css">
</head>
<body>
<header id="topbar">
@@ -859,7 +28,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
<input type="search" id="epSearch" placeholder="Search items…">
<div class="tgroup" id="admintools">
<button id="prefs" title="Settings" aria-label="Settings" data-icon="settings"></button>
<button id="logs" title="Daemon and web log" aria-label="Log" data-icon="log"></button>
<a id="admin" href="/admin" title="Admin: the server's settings, accounts and the log" aria-label="Admin" data-icon="admin"></a>
</div>
</header>
<div id="shell">

204
web/src/admin.ts Normal file
View File

@@ -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<string, () => 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 = `<h2>Server</h2>
<p class="hint">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.</p>
<div class="field"><label>Check feeds every</label>
<div class="inline">
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
<select id="gunit">${unitOptions(gs.u)}</select>
</div>
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
<div class="field"><label>Max new downloads per scan, per feed</label>
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
<span class="hint">Applies to any feed that does not set its own — including every feed
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
catalogue the first time a feed is scanned.</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">
<span class="hint">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.</span></div>
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
<span class="hint">Over this, the oldest played items are deleted first. Pinned
items are never touched.</span></div>
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></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>`;
$('#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 = `<h2>Accounts</h2>
${users.map(u => `<div class="inline urow" data-id="${u.id}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
<small style="display:block;color:var(--faint)">${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.password ? '' : '<span class="tag" title="No password: signs in through the proxy">Proxy</span>'}
<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('')}
<div class="field" style="margin-top:20px"><label>Add someone</label>
<div class="inline">
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
</div>
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
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, 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 = `<h2>Log</h2>
<div class="logbar">
<div class="tabs" id="logtabs">
${Object.keys(LOG_TABS).map(t =>
`<button data-t="${t}" class="${logTab === t ? 'on' : ''}">${
{all: 'All', daemon: 'Daemon I/O', scan: 'Scans', web: 'HTTP'}[t]}</button>`).join('')}
</div>
<select id="loglevel" style="width:auto">
<option value="">All levels</option>
<option value="INFO">Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
<input type="search" id="logq" class="grow" placeholder="Filter…">
<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>
</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
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests.
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 = () => {
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 = `<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`;
}
}
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 `<div class="l"><time>${t}</time>` +
`<span class="lv" style="color:${out ? 'var(--good)' : 'var(--accent)'}">${out ? 'out' : 'in'}</span>` +
`<span>${esc(l.msg.replace(/^[<-]+\s*/, ''))}</span></div>`;
}
return `<div class="l"><time>${t}</time><span class="lv ${esc(l.level)}">${esc(l.level)}</span>` +
`<span class="tg">${esc(l.target.replace(/^ipx::?/, ''))}</span><span>${esc(l.msg)}</span></div>`;
}).join('') : '<p class="empty">Nothing matches.</p>';
if(follow) box.scrollTop = box.scrollHeight;
}
showSection();

View File

@@ -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(`<h3>Log</h3>
<div class="logbar">
<div class="tabs" id="logtabs">
${Object.keys(LOG_TABS).map(t=>
`<button data-t="${t}" class="${logTab===t?'on':''}">${
{all:'All',daemon:'Daemon I/O',scan:'Scans',web:'HTTP'}[t]}</button>`).join('')}
</div>
<select id="loglevel" style="width:auto">
<option value="">All levels</option>
<option value="INFO">Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
<input type="search" id="logq" class="grow" placeholder="Filter…">
<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" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
</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
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests.
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
what it captures.</span>`, 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=`<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`;
}
}
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 `<div class="l"><time>${t}</time>`+
`<span class="lv" style="color:${out?'var(--good)':'var(--accent)'}">${out?'out':'in'}</span>`+
`<span>${esc(l.msg.replace(/^[<-]+\s*/,''))}</span></div>`;
}
return `<div class="l"><time>${t}</time><span class="lv ${esc(l.level)}">${esc(l.level)}</span>`+
`<span class="tg">${esc(l.target.replace(/^ipx::?/,''))}</span><span>${esc(l.msg)}</span></div>`;
}).join('') : '<p class="empty">Nothing matches.</p>';
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]) =>
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).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(`<h3>Settings</h3>
<div class="field"><label>Theme</label>
@@ -339,33 +226,6 @@ async function prefsModal(){
<select id="smode">${Object.entries(MODES).map(([k,t])=>
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
<span class="hint">Auto follows your system's light/dark setting.</span></div>
<div class="field"><label>Check feeds every</label>
${admin?`<div class="inline">
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
<select id="gunit">${unitOptions(gs.u)}</select>
</div>
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span>`
:`<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its
own. Only an admin changes this.</span>`}</div>
${admin?`<div class="field"><label>Max new downloads per scan, per feed</label>
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
<span class="hint">Applies to any feed that does not set its own — including every feed
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
catalogue the first time a feed is scanned.</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">
<span class="hint">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.</span></div>
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
<span class="hint">Over this, the oldest played items are deleted first. Pinned
items are never touched.</span></div>
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>`:''}
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="field"><label>Subscriptions</label>
<div class="inline">
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
@@ -374,76 +234,15 @@ async function prefsModal(){
</div>
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
subscribes you to every feed in one.</span></div>
${admin?`<div class="field"><label>Users</label>
<div class="inline"><button class="btn ico" id="gusers" title="Manage users…" aria-label="Manage users">${ICON.users}</button></div>
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>`:''}
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="${admin?'Cancel':'Close'}" aria-label="${admin?'Cancel':'Close'}">${ICON.close}</button>
${admin?`<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button>`:''}</div>`);
<div class="field"><label>Feeds are checked every</label>
<span class="hint">${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 <a href="/admin">admin page</a>.':'Only an admin changes this.'}</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 ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
$('#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(`<h3>Users</h3>
${users.map(u=>`<div class="inline" data-id="${u.id}" style="margin-bottom:8px">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
<small style="display:block;color:var(--faint)">${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.password?'':'<span class="tag" title="No password: signs in through the proxy">Proxy</span>'}
<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('')}
<div class="field" style="margin-top:16px"><label>Add someone</label>
<div class="inline">
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
</div>
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
through the proxy. New people start with no feeds.</span></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
<button class="btn ico primary" id="uadd" title="Add" aria-label="Add">${ICON.plus}</button></div>`);
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.

View File

@@ -46,6 +46,7 @@ const ICON={
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
pause:fa('0 0 384 512','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause
alert:fa('0 0 128 512','<path fill="currentColor" d="M64 432c22.1 0 40 17.9 40 40s-17.9 40-40 40-40-17.9-40-40c0-22.1 17.9-40 40-40zM64 0c26.5 0 48 21.5 48 48 0 .6 0 1.1 0 1.7l-16 304c-.9 17-15 30.3-32 30.3S33 370.7 32 353.7L16 49.7c0-.6 0-1.1 0-1.7 0-26.5 21.5-48 48-48z"/>'), // solid/exclamation
admin:fa('0 0 576 512','<path fill="currentColor" d="M70.8-6.7c5.4-5.4 13.8-6.2 20.2-2L209.9 70.5c8.9 5.9 14.2 15.9 14.2 26.6l0 49.6 90.8 90.8c33.3-15 73.9-8.9 101.2 18.5L542.2 382.1c18.7 18.7 18.7 49.1 0 67.9l-60.1 60.1c-18.7 18.7-49.1 18.7-67.9 0L288.1 384c-27.4-27.4-33.5-67.9-18.5-101.2l-90.8-90.8-49.6 0c-10.7 0-20.7-5.3-26.6-14.2L23.4 58.9c-4.2-6.3-3.4-14.8 2-20.2L70.8-6.7zm145 303.5c-6.3 36.9 2.3 75.9 26.2 107.2l-94.9 95c-28.1 28.1-73.7 28.1-101.8 0s-28.1-73.7 0-101.8l135.4-135.5 35.2 35.1zM384.1 0c20.1 0 39.4 3.7 57.1 10.5 10 3.8 11.8 16.5 4.3 24.1L388.8 91.3c-3 3-4.7 7.1-4.7 11.3l0 41.4c0 8.8 7.2 16 16 16l41.4 0c4.2 0 8.3-1.7 11.3-4.7l56.7-56.7c7.6-7.5 20.3-5.7 24.1 4.3 6.8 17.7 10.5 37 10.5 57.1 0 43.2-17.2 82.3-45 111.1l-49.1-49.1c-33.1-33-78.5-45.7-121.1-38.4l-56.8-56.8 0-29.7-.2-5c-.8-12.4-4.4-24.3-10.5-34.9 29.4-35 73.4-57.2 122.7-57.3z"/>'), // solid/screwdriver-wrench
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // 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]) =>
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).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' : '');
}