Add the admin page (phase 3)

services/admin is a client of the content service's HTTP API, served on
the tailnet only. It renders on the server with plain forms and no client
scripts, and holds the admin token so the browser never sees it.

- Review queue: reasons, line diff against the live version, accept,
  reject, and reject with the live version put back into Obsidian
- Notes and drafts: search, preview, fields, backlinks, publish toggle
- History with compare and roll back
- Quick capture for a status or a Library item (optionally both)
- Store: orders open or closed per category, open slots and stock
- Overview: counts, last scan and error, scan now, sync job health
- Same-origin check on every post, strict CSP, sanitized previews

The content service gains /api/status, sync run reports (/api/syncs) and
lookup of a note by name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 19:59:21 +00:00
parent 3f646048a2
commit 32cf13a5cb
23 changed files with 1474 additions and 6 deletions
+79
View File
@@ -0,0 +1,79 @@
/* The admin page follows the site's all-dark brand, kept plain: it is a tool. */
:root {
--sky: #0b111c;
--midnight: #131c2b;
--panel: #182335;
--line: #2a3a52;
--text: #f4f2ed;
--muted: #a9b4c2;
--teal: #14a39a;
--teal-bright: #2bc2b7;
--gold: #c9a24a;
--gold-bright: #d8b35a;
--red: #f0857a;
--add: #12382f;
--remove: #45202a;
color-scheme: dark;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--sky); color: var(--text); font: 15px/1.55 Montserrat, system-ui, sans-serif; }
a { color: var(--teal-bright); text-underline-offset: 3px; }
h1, h2, h3 { font-family: Cinzel, Georgia, serif; font-weight: 600; letter-spacing: 0.02em; }
h1 { font-size: 1.6rem; margin: 0 0 4px; }
h2 { font-size: 1.15rem; margin: 28px 0 10px; color: var(--gold-bright); }
code, pre, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
header.top { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 18px; padding: 12px 20px; background: var(--midnight); border-bottom: 1px solid var(--gold); }
header.top .brand { font-family: Cinzel, serif; color: var(--gold-bright); text-decoration: none; margin-right: 8px; }
header.top nav { display: flex; flex-wrap: wrap; gap: 4px 14px; }
header.top nav a { color: var(--text); text-decoration: none; padding: 4px 0; border-bottom: 2px solid transparent; }
header.top nav a[aria-current="page"] { border-color: var(--teal); }
.count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 10px; background: var(--gold); color: var(--midnight); font-size: 12px; font-weight: 700; text-align: center; }
main { max-width: 1100px; margin: 0 auto; padding: 24px 16px 64px; }
.lede { color: var(--muted); margin: 0 0 18px; }
.flash { padding: 10px 14px; border-left: 3px solid var(--teal); background: var(--panel); margin-bottom: 18px; }
.flash.err { border-color: var(--red); }
.flash ul { margin: 6px 0 0; padding-left: 20px; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px; margin-bottom: 16px; }
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.stat { font-size: 1.9rem; font-family: Cinzel, serif; color: var(--gold-bright); }
.muted { color: var(--muted); }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: 13px; }
.table-wrap { overflow-x: auto; }
.pill { display: inline-block; padding: 1px 10px; border-radius: 999px; font-size: 12px; border: 1px solid var(--line); white-space: nowrap; }
.pill.live { border-color: var(--teal); color: var(--teal-bright); }
.pill.draft { border-color: var(--gold); color: var(--gold-bright); }
.pill.bad { border-color: var(--red); color: var(--red); }
.reason { margin: 4px 0; }
.reason code { color: var(--gold-bright); }
form.inline { display: inline; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 14px 0; }
button, .button { font: inherit; cursor: pointer; min-height: 36px; padding: 6px 18px; border-radius: 999px; border: 1px solid var(--gold); background: transparent; color: var(--text); text-decoration: none; display: inline-flex; align-items: center; }
button.primary { background: var(--teal); border-color: var(--teal); color: var(--midnight); font-weight: 600; }
button.danger { border-color: var(--red); color: var(--red); }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
label { display: block; margin: 10px 0 4px; font-size: 13px; color: var(--muted); }
label.check { display: flex; gap: 8px; align-items: center; color: var(--text); font-size: 15px; }
input, select, textarea { font: inherit; width: 100%; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--line); background: var(--sky); color: var(--text); }
input[type="checkbox"] { width: auto; }
input:focus, select:focus, textarea:focus { border-color: var(--teal); }
.row { display: grid; gap: 0 14px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
.filters { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; margin-bottom: 16px; }
.filters > * { flex: 1 1 160px; }
.filters button { flex: 0 0 auto; }
.diff { background: var(--sky); border: 1px solid var(--line); border-radius: 10px; overflow-x: auto; padding: 8px 0; }
.diff div { white-space: pre-wrap; word-break: break-word; padding: 0 12px; font-family: ui-monospace, monospace; font-size: 13px; }
.diff .add { background: var(--add); }
.diff .add::before { content: "+ "; color: var(--teal-bright); }
.diff .remove { background: var(--remove); }
.diff .remove::before { content: "- "; color: var(--red); }
.diff .same::before { content: " "; }
.diff .skip { color: var(--muted); font-style: italic; }
.preview { background: var(--midnight); border: 1px solid var(--gold); border-radius: 10px; padding: 8px 22px; }
.preview img { max-width: 100%; }
dl.fields { display: grid; grid-template-columns: minmax(110px, max-content) 1fr; gap: 6px 16px; margin: 0; }
dl.fields dt { color: var(--muted); }
dl.fields dd { margin: 0; word-break: break-word; }
dl.fields pre { margin: 0; white-space: pre-wrap; }
input.num { width: 90px; }