Blame · Line-by-line history
admin-shell.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| f4a1547 | 1 | import type { FC, PropsWithChildren } from "hono/jsx"; |
| 2 | import type { User } from "../db/schema"; | |
| 3 | import { 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 | ||
| 21 | export 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 | ||
| 57 | interface AdminNavItem { | |
| 58 | key: AdminNavKey; | |
| 59 | href: string; | |
| 60 | label: string; | |
| 61 | } | |
| 62 | ||
| 63 | interface 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 | */ | |
| 74 | export 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 | ||
| 139 | export 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 | */ | |
| 192 | export 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 | `; |