Commitf4a1547
feat(admin): unified admin console — shared shell + single sidebar nav
feat(admin): unified admin console — shared shell + single sidebar nav Third redesign pass (ultracode, 12 agents + shell build). New src/views/admin-shell.tsx provides AdminShell: the existing Layout plus a persistent grouped left sidebar built from a single canonical ADMIN_NAV (no more two rival hubs) and a sticky page header. The nav includes every admin destination, rescuing the previously URL-only /admin/servers and /admin/billing. Fully token-based, theme-aware, no hardcoded hex. 12 admin pages migrated from bare <Layout> to <AdminShell active=...>: ops, self-host, diagnose, status, env-health, deploys, servers, security, deletions, stripe, integrations, advancement. Every page's site-admin gate()/auth check was left untouched (verified: all still gate) — this is a presentation/IA change only, no auth surface change. layout.tsx gains an "Admin" item in the user menu, shown only when user.isAdmin === true. Typecheck clean; suite 3116 pass / 4 pre-existing fails. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 files changed+392−34f4a1547f8fd3827b3e5fe6c405f97675b313b910
14 changed files+392−34
Modifiedsrc/routes/admin-advancement.tsx+3−2View fileUnifiedSplit
@@ -28,6 +28,7 @@
2828import { Hono } from "hono";
2929import { and, desc, eq, gte, sql } from "drizzle-orm";
3030import { Layout } from "../views/layout";
31import { AdminShell } from "../views/admin-shell";
3132import { softAuth } from "../middleware/auth";
3233import type { AuthEnv } from "../middleware/auth";
3334import { isSiteAdmin } from "../lib/admin";
@@ -329,7 +330,7 @@ advancement.get("/admin/advancement", async (c) => {
329330 ]);
330331
331332 return c.html(
332 <Layout title="Advancement scanner — admin" user={user}>
333 <AdminShell active="advancement" title="Advancement scanner" user={user}>
333334 <div class="adv-scan-wrap">
334335 {/* Hero */}
335336 <section class="adv-scan-hero">
@@ -546,7 +547,7 @@ advancement.get("/admin/advancement", async (c) => {
546547 </section>
547548 </div>
548549 <style dangerouslySetInnerHTML={{ __html: ADV_SCAN_CSS }} />
549 </Layout>
550 </AdminShell>
550551 );
551552});
552553
Modifiedsrc/routes/admin-deletions.tsx+3−2View fileUnifiedSplit
@@ -10,6 +10,7 @@ import { eq, isNotNull, lt } from "drizzle-orm";
1010import { db } from "../db";
1111import { users } from "../db/schema";
1212import { Layout } from "../views/layout";
13import { AdminShell } from "../views/admin-shell";
1314import { softAuth } from "../middleware/auth";
1415import type { AuthEnv } from "../middleware/auth";
1516import { isSiteAdmin } from "../lib/admin";
@@ -141,7 +142,7 @@ adminDeletions.get("/admin/deletions", async (c) => {
141142 d ? new Date(d).toLocaleString() : "—";
142143
143144 return c.html(
144 <Layout title="Admin — Account Deletions" user={user}>
145 <AdminShell active="deletions" title="Account Deletions" user={user}>
145146 <style dangerouslySetInnerHTML={{ __html: styles }} />
146147 <div class="adm-del-wrap">
147148 <div class="adm-del-hero">
@@ -242,7 +243,7 @@ adminDeletions.get("/admin/deletions", async (c) => {
242243 )}
243244 </div>
244245 </div>
245 </Layout>
246 </AdminShell>
246247 );
247248});
248249
Modifiedsrc/routes/admin-deploys-page.tsx+3−2View fileUnifiedSplit
@@ -26,6 +26,7 @@ import {
2626 platformDeploySteps,
2727} from "../db/schema-deploys";
2828import { Layout } from "../views/layout";
29import { AdminShell } from "../views/admin-shell";
2930import { softAuth } from "../middleware/auth";
3031import type { AuthEnv } from "../middleware/auth";
3132import { isSiteAdmin } from "../lib/admin";
@@ -313,7 +314,7 @@ page.get("/admin/deploys", async (c) => {
313314 }
314315
315316 return c.html(
316 <Layout title="Deploys — admin" user={user}>
317 <AdminShell active="deploys" title="Deploys" user={user}>
317318 <div class="deploys-wrap">
318319 <section class="deploys-hero">
319320 <div class="deploys-hero-orb" aria-hidden="true" />
@@ -628,7 +629,7 @@ page.get("/admin/deploys", async (c) => {
628629 The Trigger button posts to /admin/deploys/trigger then opens
629630 the modal as soon as the SSE stream sends its first event. */}
630631 {renderDeployModal(modalDeploy, modalSteps, repo)}
631 </Layout>
632 </AdminShell>
632633 );
633634});
634635
Modifiedsrc/routes/admin-diagnose.tsx+3−2View fileUnifiedSplit
@@ -35,6 +35,7 @@ import {
3535import { platformDeploys } from "../db/schema-deploys";
3636import { db } from "../db";
3737import { Layout } from "../views/layout";
38import { AdminShell } from "../views/admin-shell";
3839import { softAuth } from "../middleware/auth";
3940import type { AuthEnv } from "../middleware/auth";
4041import { isSiteAdmin } from "../lib/admin";
@@ -1164,7 +1165,7 @@ diagnose.get("/admin/diagnose", async (c) => {
11641165 const flash = c.req.query("test_email");
11651166
11661167 return c.html(
1167 <Layout title="Diagnose — admin" user={user}>
1168 <AdminShell active="diagnose" title="Diagnose" user={user}>
11681169 <div class="health-wrap">
11691170 <section class="health-hero">
11701171 <div class="health-hero-orb" aria-hidden="true" />
@@ -1308,7 +1309,7 @@ diagnose.get("/admin/diagnose", async (c) => {
13081309 </div>
13091310 </div>
13101311 <style dangerouslySetInnerHTML={{ __html: healthStyles }} />
1311 </Layout>
1312 </AdminShell>
13121313 );
13131314});
13141315
Modifiedsrc/routes/admin-env-health.tsx+3−2View fileUnifiedSplit
@@ -19,6 +19,7 @@
1919
2020import { Hono } from "hono";
2121import { Layout } from "../views/layout";
22import { AdminShell } from "../views/admin-shell";
2223import { softAuth } from "../middleware/auth";
2324import type { AuthEnv } from "../middleware/auth";
2425import { isSiteAdmin } from "../lib/admin";
@@ -284,7 +285,7 @@ envHealth.get("/admin/env-health", async (c) => {
284285 const configured = items.filter((i) => i.configured).length;
285286
286287 return c.html(
287 <Layout title="Environment health — admin" user={user}>
288 <AdminShell active="env-health" title="Environment health" user={user}>
288289 <div class="admin-envh-wrap">
289290 <section class="admin-envh-hero">
290291 <div class="admin-envh-hero-orb" aria-hidden="true" />
@@ -370,7 +371,7 @@ envHealth.get("/admin/env-health", async (c) => {
370371 </div>
371372 </div>
372373 <style dangerouslySetInnerHTML={{ __html: styles }} />
373 </Layout>
374 </AdminShell>
374375 );
375376});
376377
Modifiedsrc/routes/admin-integrations.tsx+3−2View fileUnifiedSplit
@@ -17,6 +17,7 @@
1717
1818import { Hono } from "hono";
1919import { Layout } from "../views/layout";
20import { AdminShell } from "../views/admin-shell";
2021import { softAuth } from "../middleware/auth";
2122import type { AuthEnv } from "../middleware/auth";
2223import { isSiteAdmin } from "../lib/admin";
@@ -518,7 +519,7 @@ integrations.get("/admin/integrations", async (c) => {
518519 const specText = specLines.join("\n").trimEnd();
519520
520521 return c.html(
521 <Layout title="Integrations — admin" user={user}>
522 <AdminShell active="integrations" title="Integrations" user={user}>
522523 <div class="admin-int-wrap">
523524 <section class="admin-int-hero">
524525 <div class="admin-int-hero-orb" aria-hidden="true" />
@@ -707,7 +708,7 @@ integrations.get("/admin/integrations", async (c) => {
707708 `,
708709 }}
709710 />
710 </Layout>
711 </AdminShell>
711712 );
712713});
713714
Modifiedsrc/routes/admin-ops.tsx+3−2View fileUnifiedSplit
@@ -37,6 +37,7 @@ import { db } from "../db";
3737import { apiTokens, branchProtection, repositories, users } from "../db/schema";
3838import { platformDeploys } from "../db/schema-deploys";
3939import { Layout } from "../views/layout";
40import { AdminShell } from "../views/admin-shell";
4041import { softAuth } from "../middleware/auth";
4142import type { AuthEnv } from "../middleware/auth";
4243import { isSiteAdmin } from "../lib/admin";
@@ -980,7 +981,7 @@ ops.get("/admin/ops", async (c) => {
980981 const gatetestToken = c.req.query("gatetest_token");
981982
982983 return c.html(
983 <Layout title="Operations — admin" user={user}>
984 <AdminShell active="ops" title="Operations" user={user}>
984985 <div class="ops-wrap">
985986 {/* ─── Hero ─── */}
986987 <section class="ops-hero">
@@ -1316,7 +1317,7 @@ ops.get("/admin/ops", async (c) => {
13161317 </div>
13171318 <style dangerouslySetInnerHTML={{ __html: opsStyles }} />
13181319 <script dangerouslySetInnerHTML={{ __html: opsCopyScript }} />
1319 </Layout>
1320 </AdminShell>
13201321 );
13211322});
13221323
Modifiedsrc/routes/admin-security.tsx+5−5View fileUnifiedSplit
@@ -22,7 +22,7 @@ import {
2222import { isSiteAdmin } from "../lib/admin";
2323import { softAuth } from "../middleware/auth";
2424import type { AuthEnv } from "../middleware/auth";
25import { Layout } from "../views/layout";
25import { AdminShell } from "../views/admin-shell";
2626
2727const adminSecurity = new Hono<AuthEnv>();
2828adminSecurity.use("*", softAuth);
@@ -236,7 +236,7 @@ adminSecurity.get("/admin/security", async (c) => {
236236 const lockedCount = lockedAccounts.length;
237237
238238 return c.html(
239 <Layout title="Security dashboard" user={user}>
239 <AdminShell active="security" title="Security dashboard" user={user}>
240240 <style dangerouslySetInnerHTML={{ __html: securityStyles }} />
241241 <div class="sec-page">
242242 <div class="sec-hero">
@@ -446,7 +446,7 @@ adminSecurity.get("/admin/security", async (c) => {
446446 <a href="/admin/soc2">SOC 2 Checklist</a>.
447447 </p>
448448 </div>
449 </Layout>
449 </AdminShell>
450450 );
451451});
452452
@@ -475,7 +475,7 @@ adminSecurity.get("/admin/soc2", async (c) => {
475475 );
476476
477477 return c.html(
478 <Layout title="SOC 2 Readiness" user={user}>
478 <AdminShell active="security" title="SOC 2 Readiness" user={user}>
479479 <style dangerouslySetInnerHTML={{ __html: securityStyles }} />
480480 <div class="soc2-page">
481481 <div class="soc2-hero">
@@ -694,7 +694,7 @@ adminSecurity.get("/admin/soc2", async (c) => {
694694 Contact <a href="mailto:security@gluecron.com">security@gluecron.com</a> to report issues.
695695 </div>
696696 </div>
697 </Layout>
697 </AdminShell>
698698 );
699699});
700700
Modifiedsrc/routes/admin-self-host.tsx+3−2View fileUnifiedSplit
@@ -33,6 +33,7 @@ import { db } from "../db";
3333import { repositories, users } from "../db/schema";
3434import { platformDeploys } from "../db/schema-deploys";
3535import { Layout } from "../views/layout";
36import { AdminShell } from "../views/admin-shell";
3637import { softAuth } from "../middleware/auth";
3738import type { AuthEnv } from "../middleware/auth";
3839import { isSiteAdmin } from "../lib/admin";
@@ -847,7 +848,7 @@ selfHost.get("/admin/self-host", async (c) => {
847848 repoState.exists && hookState.installed && envState.matchesExpected;
848849
849850 return c.html(
850 <Layout title="Self-host — admin" user={user}>
851 <AdminShell active="self-host" title="Self-host" user={user}>
851852 <div class="selfhost-wrap">
852853 {/* ─── Hero ─── */}
853854 <section class="selfhost-hero">
@@ -1158,7 +1159,7 @@ selfHost.get("/admin/self-host", async (c) => {
11581159 </section>
11591160 </div>
11601161 <style dangerouslySetInnerHTML={{ __html: SELFHOST_CSS }} />
1161 </Layout>
1162 </AdminShell>
11621163 );
11631164});
11641165
Modifiedsrc/routes/admin-server-targets.tsx+7−9View fileUnifiedSplit
@@ -19,6 +19,7 @@ import { eq } from "drizzle-orm";
1919import { db } from "../db";
2020import { repositories, users } from "../db/schema";
2121import { Layout } from "../views/layout";
22import { AdminShell } from "../views/admin-shell";
2223import { softAuth } from "../middleware/auth";
2324import type { AuthEnv } from "../middleware/auth";
2425import { isSiteAdmin } from "../lib/admin";
@@ -97,9 +98,8 @@ admin.get("/admin/servers", async (c) => {
9798 const err = c.req.query("err") ?? undefined;
9899
99100 return c.html(
100 <Layout title="Server targets — admin" user={user}>
101 <AdminShell active="servers" title="Server targets" user={user}>
101102 <main style={wrap}>
102 <h1 style="margin:0 0 6px;font-size:24px">Server targets</h1>
103103 <p style="margin:0 0 20px;color:#9ca3af;font-size:14px">
104104 Boxes Gluecron can SSH into. Admin-only. A push to a watched
105105 branch fires the target's deploy script with its env vars
@@ -173,7 +173,7 @@ admin.get("/admin/servers", async (c) => {
173173 )}
174174 </div>
175175 </main>
176 </Layout>
176 </AdminShell>
177177 );
178178});
179179
@@ -194,10 +194,9 @@ admin.get("/admin/servers/new", async (c) => {
194194 .limit(200);
195195
196196 return c.html(
197 <Layout title="New server target" user={user}>
197 <AdminShell active="servers" title="New server target" user={user}>
198198 <main style={wrap}>
199199 <p style="margin:0 0 6px"><a href="/admin/servers" style="color:#9ca3af;text-decoration:none">← Server targets</a></p>
200 <h1 style="margin:0 0 16px;font-size:22px">New server target</h1>
201200 <form method="post" action="/admin/servers" style={card}>
202201 <label style={label}>Name (unique identifier)</label>
203202 <input name="name" required pattern="[a-z0-9-]+" placeholder="vapron-prod-1" style={inputStyle} />
@@ -260,7 +259,7 @@ admin.get("/admin/servers/new", async (c) => {
260259 </div>
261260 </form>
262261 </main>
263 </Layout>
262 </AdminShell>
264263 );
265264});
266265
@@ -321,10 +320,9 @@ admin.get("/admin/servers/:id", async (c) => {
321320 const err = c.req.query("err") ?? undefined;
322321
323322 return c.html(
324 <Layout title={`${target.name} — server target`} user={user}>
323 <AdminShell active="servers" title={target.name} user={user}>
325324 <main style={wrap}>
326325 <p style="margin:0 0 6px"><a href="/admin/servers" style="color:#9ca3af;text-decoration:none">← Server targets</a></p>
327 <h1 style="margin:0 0 4px;font-size:22px">{target.name}</h1>
328326 <p style="margin:0 0 18px;color:#9ca3af;font-size:14px;font-family:ui-monospace,monospace">
329327 {target.sshUser}@{target.host}:{target.port} · {target.deployPath}
330328 </p>
@@ -428,7 +426,7 @@ admin.get("/admin/servers/:id", async (c) => {
428426 )}
429427 </div>
430428 </main>
431 </Layout>
429 </AdminShell>
432430 );
433431});
434432
Modifiedsrc/routes/admin-status.tsx+3−2View fileUnifiedSplit
@@ -26,6 +26,7 @@
2626
2727import { Hono } from "hono";
2828import { Layout } from "../views/layout";
29import { AdminShell } from "../views/admin-shell";
2930import { softAuth } from "../middleware/auth";
3031import type { AuthEnv } from "../middleware/auth";
3132import { isSiteAdmin } from "../lib/admin";
@@ -143,7 +144,7 @@ adminStatus.get("/admin/status", async (c) => {
143144 : "Falling.";
144145
145146 return c.html(
146 <Layout title="Synthetic monitor — admin" user={user}>
147 <AdminShell active="status" title="Synthetic monitor" user={user}>
147148 <div class="status-wrap">
148149 {/* ─── Hero ─── */}
149150 <section class="status-hero">
@@ -384,7 +385,7 @@ adminStatus.get("/admin/status", async (c) => {
384385 />
385386 </div>
386387 <style dangerouslySetInnerHTML={{ __html: STATUS_CSS }} />
387 </Layout>
388 </AdminShell>
388389 );
389390});
390391
Modifiedsrc/routes/admin-stripe.tsx+3−2View fileUnifiedSplit
@@ -16,6 +16,7 @@ import { desc, eq, ne } from "drizzle-orm";
1616import { db } from "../db";
1717import { userQuotas, users } from "../db/schema";
1818import { Layout } from "../views/layout";
19import { AdminShell } from "../views/admin-shell";
1920import { softAuth } from "../middleware/auth";
2021import type { AuthEnv } from "../middleware/auth";
2122import { isSiteAdmin } from "../lib/admin";
@@ -220,7 +221,7 @@ adminStripe.get("/admin/stripe", async (c) => {
220221 const errMsg = c.req.query("err") || "";
221222
222223 return c.html(
223 <Layout title="Admin — Stripe Sync" user={user}>
224 <AdminShell active="stripe" title="Stripe Sync" user={user}>
224225 <style dangerouslySetInnerHTML={{ __html: styles }} />
225226 <div class="adm-stripe-wrap">
226227 <div class="adm-stripe-hero">
@@ -319,7 +320,7 @@ adminStripe.get("/admin/stripe", async (c) => {
319320 </table>
320321 )}
321322 </div>
322 </Layout>
323 </AdminShell>
323324 );
324325});
325326
Addedsrc/views/admin-shell.tsx+340−0View fileUnifiedSplit
@@ -0,0 +1,340 @@
1import type { FC, PropsWithChildren } from "hono/jsx";
2import type { User } from "../db/schema";
3import { Layout } from "./layout";
4
5// ---------------------------------------------------------------------------
6// AdminShell — the single unified chrome for every /admin/* surface.
7//
8// Before this, the ~15 admin pages each rendered the plain customer `Layout`
9// with no admin navigation, so the only way between admin screens was the
10// two rival hub pages (/admin and /admin/command). AdminShell wraps `Layout`
11// (so the html shell, top nav, footer, scripts all still work) and adds a
12// persistent left sidebar driven by ONE canonical `ADMIN_NAV` array plus a
13// sticky page header (title + optional actions slot).
14//
15// This module is the single source of truth for the admin IA. Every admin
16// destination in the app lives in `ADMIN_NAV` below — nothing stays orphaned.
17// Migrate a page by replacing its `<Layout …>` wrapper with
18// `<AdminShell active="<key>" title="…" user={user}> … </AdminShell>`.
19// ---------------------------------------------------------------------------
20
21export type AdminNavKey =
22 // Overview
23 | "dashboard"
24 | "command"
25 // Operations & Health
26 | "ops"
27 | "health"
28 | "env-health"
29 | "status"
30 | "diagnose"
31 | "autopilot"
32 // Deploys & Infra
33 | "deploys"
34 | "self-host"
35 | "servers"
36 // Users & Access
37 | "users"
38 | "repos"
39 | "security"
40 | "deletions"
41 // Sign-in & SSO
42 | "google-oauth"
43 | "github-oauth"
44 | "sso"
45 // Growth & Billing
46 | "growth"
47 | "billing"
48 | "stripe"
49 | "digests"
50 | "ai-costs"
51 // Platform Config
52 | "flags"
53 | "integrations"
54 | "advancement"
55 | "connect-claude";
56
57interface AdminNavItem {
58 key: AdminNavKey;
59 href: string;
60 label: string;
61}
62
63interface AdminNavGroup {
64 label: string;
65 items: AdminNavItem[];
66}
67
68/**
69 * Canonical admin navigation. Grouped by concern; the union of both legacy
70 * hubs (/admin + /admin/command) plus the previously-orphaned /admin/servers
71 * and /admin/billing surfaces. This array is the ONLY place the admin IA is
72 * defined — add new admin pages here and every shell picks them up.
73 */
74export const ADMIN_NAV: AdminNavGroup[] = [
75 {
76 label: "Overview",
77 items: [
78 { key: "dashboard", href: "/admin", label: "Dashboard" },
79 { key: "command", href: "/admin/command", label: "Command Center" },
80 ],
81 },
82 {
83 label: "Operations & Health",
84 items: [
85 { key: "ops", href: "/admin/ops", label: "Operations" },
86 { key: "health", href: "/admin/health", label: "Health (traffic lights)" },
87 { key: "env-health", href: "/admin/env-health", label: "Env / feature health" },
88 { key: "status", href: "/admin/status", label: "Platform monitor" },
89 { key: "diagnose", href: "/admin/diagnose", label: "AI background tasks" },
90 { key: "autopilot", href: "/admin/autopilot", label: "Autopilot" },
91 ],
92 },
93 {
94 label: "Deploys & Infra",
95 items: [
96 { key: "deploys", href: "/admin/deploys", label: "Deploys" },
97 { key: "self-host", href: "/admin/self-host", label: "Self-host status" },
98 { key: "servers", href: "/admin/servers", label: "Server targets" },
99 ],
100 },
101 {
102 label: "Users & Access",
103 items: [
104 { key: "users", href: "/admin/users", label: "Manage users" },
105 { key: "repos", href: "/admin/repos", label: "Manage repos" },
106 { key: "security", href: "/admin/security", label: "Security" },
107 { key: "deletions", href: "/admin/deletions", label: "GDPR deletions" },
108 ],
109 },
110 {
111 label: "Sign-in & SSO",
112 items: [
113 { key: "google-oauth", href: "/admin/google-oauth", label: "Sign in with Google" },
114 { key: "github-oauth", href: "/admin/github-oauth", label: "Sign in with GitHub" },
115 { key: "sso", href: "/admin/sso", label: "Enterprise SSO" },
116 ],
117 },
118 {
119 label: "Growth & Billing",
120 items: [
121 { key: "growth", href: "/admin/growth", label: "User growth" },
122 { key: "billing", href: "/admin/billing", label: "Billing" },
123 { key: "stripe", href: "/admin/stripe", label: "Stripe sync" },
124 { key: "digests", href: "/admin/digests", label: "Email digests" },
125 { key: "ai-costs", href: "/admin/ai-costs", label: "AI cost breakdown" },
126 ],
127 },
128 {
129 label: "Platform Config",
130 items: [
131 { key: "flags", href: "/admin/flags", label: "Site flags" },
132 { key: "integrations", href: "/admin/integrations", label: "Integrations" },
133 { key: "advancement", href: "/admin/advancement", label: "Advancement" },
134 { key: "connect-claude", href: "/connect/claude", label: "Connect Claude" },
135 ],
136 },
137];
138
139export const AdminShell: FC<
140 PropsWithChildren<{
141 active: AdminNavKey | string;
142 title: string;
143 user?: User | null;
144 // Optional right-aligned actions slot rendered in the sticky page header.
145 actions?: any;
146 }>
147> = ({ active, title, user, actions, children }) => (
148 <Layout title={`${title} — Admin`} user={user}>
149 <style dangerouslySetInnerHTML={{ __html: adminShellStyles }} />
150 <div class="admin-shell">
151 <aside class="admin-shell-sidebar">
152 <div class="admin-shell-brand">
153 <span class="admin-shell-brand-dot" aria-hidden="true" />
154 Site admin
155 </div>
156 <nav class="adminnav" aria-label="Admin sections">
157 {ADMIN_NAV.map((group) => (
158 <div class="adminnav-group">
159 <div class="adminnav-label">{group.label}</div>
160 {group.items.map((it) => (
161 <a
162 href={it.href}
163 class={it.key === active ? "is-active" : ""}
164 aria-current={it.key === active ? "page" : undefined}
165 >
166 {it.label}
167 </a>
168 ))}
169 </div>
170 ))}
171 </nav>
172 </aside>
173 <div class="admin-shell-main">
174 <header class="admin-shell-head">
175 <h1 class="admin-shell-title">{title}</h1>
176 {actions ? (
177 <div class="admin-shell-actions">{actions}</div>
178 ) : null}
179 </header>
180 <div class="admin-shell-content">{children}</div>
181 </div>
182 </div>
183 </Layout>
184);
185
186/**
187 * Shell CSS. Follows the SettingsNav pattern (grouped sidebar, sticky, token
188 * based, theme-aware). No hardcoded hex — every colour resolves from a design
189 * token so light/dark both track automatically. Injected once per page; a
190 * duplicate identical <style> is harmless.
191 */
192export const adminShellStyles = `
193 .admin-shell {
194 display: grid;
195 grid-template-columns: 240px minmax(0, 1fr);
196 gap: var(--space-6);
197 align-items: start;
198 max-width: 1280px;
199 margin: 0 auto;
200 padding: var(--space-6) var(--space-6) var(--space-16);
201 }
202 .admin-shell-sidebar {
203 position: sticky;
204 top: var(--space-4);
205 display: flex;
206 flex-direction: column;
207 gap: var(--space-4);
208 min-width: 0;
209 }
210 .admin-shell-brand {
211 display: flex;
212 align-items: center;
213 gap: var(--space-2);
214 font-family: var(--font-mono);
215 font-size: var(--font-size-xs);
216 font-weight: 500;
217 letter-spacing: 0.12em;
218 text-transform: uppercase;
219 color: var(--text-muted);
220 padding: 0 var(--space-2);
221 }
222 .admin-shell-brand-dot {
223 width: 7px;
224 height: 7px;
225 border-radius: var(--radius-full);
226 background: var(--accent);
227 box-shadow: var(--accent-glow);
228 }
229 .adminnav {
230 display: flex;
231 flex-direction: column;
232 gap: var(--space-5);
233 }
234 .adminnav-group {
235 display: flex;
236 flex-direction: column;
237 gap: 1px;
238 }
239 .adminnav-label {
240 font-size: 11px;
241 font-weight: 700;
242 letter-spacing: 0.06em;
243 text-transform: uppercase;
244 color: var(--text-muted);
245 padding: 4px 10px;
246 margin-bottom: 2px;
247 }
248 .adminnav a {
249 display: block;
250 padding: 6px 10px;
251 border-radius: 6px;
252 font-size: 13.5px;
253 font-weight: 500;
254 color: var(--text-muted);
255 text-decoration: none;
256 white-space: nowrap;
257 overflow: hidden;
258 text-overflow: ellipsis;
259 transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
260 }
261 .adminnav a:hover {
262 color: var(--text-strong);
263 background: var(--bg-hover);
264 }
265 .adminnav a.is-active {
266 color: var(--text-strong);
267 background: var(--accent-gradient-soft);
268 box-shadow: var(--accent-glow) inset;
269 font-weight: 600;
270 }
271 .admin-shell-main {
272 min-width: 0;
273 }
274 .admin-shell-head {
275 position: sticky;
276 top: 0;
277 z-index: var(--z-sticky);
278 display: flex;
279 align-items: center;
280 justify-content: space-between;
281 gap: var(--space-4);
282 flex-wrap: wrap;
283 padding: var(--space-4) 0;
284 margin-bottom: var(--space-5);
285 border-bottom: 1px solid var(--border);
286 background: var(--bg);
287 }
288 .admin-shell-title {
289 font-family: var(--font-display);
290 font-size: var(--font-size-xl);
291 font-weight: 700;
292 letter-spacing: -0.022em;
293 color: var(--text-strong);
294 margin: 0;
295 min-width: 0;
296 }
297 .admin-shell-actions {
298 display: flex;
299 align-items: center;
300 gap: var(--space-2);
301 flex-wrap: wrap;
302 }
303 .admin-shell-content {
304 min-width: 0;
305 }
306 @media (max-width: 860px) {
307 .admin-shell {
308 display: block;
309 padding: var(--space-4);
310 }
311 .admin-shell-sidebar {
312 position: static;
313 margin-bottom: var(--space-5);
314 }
315 .adminnav {
316 flex-direction: row;
317 flex-wrap: wrap;
318 gap: var(--space-3);
319 padding: var(--space-2);
320 background: var(--bg-elevated);
321 border: 1px solid var(--border);
322 border-radius: var(--radius);
323 }
324 .adminnav-group {
325 flex-direction: row;
326 flex-wrap: wrap;
327 gap: 2px;
328 }
329 .adminnav-label {
330 display: none;
331 }
332 .adminnav a {
333 padding: 6px 12px;
334 border-radius: var(--radius-full);
335 }
336 .admin-shell-head {
337 position: static;
338 }
339 }
340`;
Modifiedsrc/views/layout.tsx+10−0View fileUnifiedSplit
@@ -289,6 +289,16 @@ export const Layout: FC<
289289 <a href={`/${user.username}`} role="menuitem" class="nav-user-item">Your profile</a>
290290 <a href="/settings" role="menuitem" class="nav-user-item">Settings</a>
291291 <a href="/settings/tokens" role="menuitem" class="nav-user-item">Access tokens</a>
292 {/* Site admins get a direct entry into the admin shell.
293 Gated on the same `isAdmin` signal the admin
294 middleware uses (user.isAdmin === true); non-admins
295 never see the link. */}
296 {(user as any).isAdmin === true && (
297 <>
298 <div class="nav-user-menu-sep" />
299 <a href="/admin" role="menuitem" class="nav-user-item">Admin</a>
300 </>
301 )}
292302 <div class="nav-user-menu-sep" />
293303 <a href="/theme/toggle" class="nav-user-item" role="menuitem">
294304 <span class="theme-icon-dark">{"☾"} Light mode</span>
295305