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

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