Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

admin.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.

admin.tsxBlame4428 lines · 1 contributor
8f50ed0Claude1/**
2 * Block F3 — Site admin panel.
3 *
4 * GET /admin — dashboard (counts + recent users)
5 * GET /admin/users — user list + search
6 * POST /admin/users/:id/admin — toggle site-admin flag
7 * GET /admin/repos — repo list (including private)
8 * POST /admin/repos/:id/delete — nuclear delete (audit-logged)
9 * GET /admin/flags — site flags CRUD
10 * POST /admin/flags — set flag
11 *
12 * All routes gated by `isSiteAdmin`. First registered user is the bootstrap
13 * admin. Site banner + registration lock are surfaced to the rest of the app
14 * via `getFlag`.
07f4b70Claude15 *
16 * Visual polish (parallel session 3.I): adopts the 2026 design language —
17 * gradient-hairline hero, animated orb, stat cards with display font, and a
18 * polished action grid with inline-SVG glyphs. Logic, auth gates, redirects,
19 * and audit emissions are unchanged from the pre-polish version. All scoped
20 * CSS classes are prefixed `.admin-`.
8f50ed0Claude21 */
22
23import { Hono } from "hono";
b218e63Claude24import { and, desc, eq, gte, ilike, or, sql } from "drizzle-orm";
8f50ed0Claude25import { db } from "../db";
b218e63Claude26import { aiCostEvents, auditLog, repositories, users } from "../db/schema";
8f50ed0Claude27import { Layout } from "../views/layout";
5618f9aClaude28import { softAuth } from "../middleware/auth";
8f50ed0Claude29import type { AuthEnv } from "../middleware/auth";
30import {
31 grantSiteAdmin,
32 isSiteAdmin,
33 KNOWN_FLAGS,
34 listFlags,
35 listSiteAdmins,
36 revokeSiteAdmin,
37 setFlag,
38} from "../lib/admin";
39import { audit } from "../lib/notify";
08420cdClaude40import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
8e9f1d9Claude41import {
42 getLastTick,
43 getTickCount,
44 runAutopilotTick,
45} from "../lib/autopilot";
988380aClaude46import { ensureDemoContent, DEMO_USERNAME } from "../lib/demo-seed";
8f50ed0Claude47
48const admin = new Hono<AuthEnv>();
49admin.use("*", softAuth);
50
07f4b70Claude51/* ─────────────────────────────────────────────────────────────────────────
52 * Scoped CSS — every class prefixed `.admin-` so the block cannot bleed
53 * into other surfaces. Pattern mirrors dashboard-hero (commit a004c46),
54 * repo-home (commit 544d842), and settings polish (commit 98eb360).
55 * ───────────────────────────────────────────────────────────────────── */
56const adminStyles = `
eed4684Claude57 .admin-wrap { max-width: 1680px; margin: 0 auto; }
07f4b70Claude58
59 /* ─── Hero (main dashboard) ─── */
60 .admin-hero {
61 position: relative;
62 margin-bottom: var(--space-6);
63 padding: var(--space-5) var(--space-6);
64 background: var(--bg-elevated);
65 border: 1px solid var(--border);
66 border-radius: 16px;
67 overflow: hidden;
68 }
69 .admin-hero::before {
70 content: '';
71 position: absolute;
72 top: 0; left: 0; right: 0;
73 height: 2px;
74 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
75 opacity: 0.7;
76 pointer-events: none;
77 }
78 .admin-hero-bg {
79 position: absolute;
80 inset: -20% -10% auto auto;
81 width: 380px; height: 380px;
82 pointer-events: none;
83 z-index: 0;
84 }
85 .admin-hero-orb {
86 position: absolute;
87 inset: 0;
88 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
89 filter: blur(80px);
90 opacity: 0.7;
91 animation: adminHeroOrb 14s ease-in-out infinite;
92 }
93 @keyframes adminHeroOrb {
94 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
95 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
96 }
97 @media (prefers-reduced-motion: reduce) {
98 .admin-hero-orb { animation: none; }
99 }
100 .admin-hero-inner {
101 position: relative;
102 z-index: 1;
103 max-width: 720px;
104 }
105 .admin-hero-eyebrow {
106 font-size: 12px;
107 color: var(--text-muted);
108 margin-bottom: var(--space-2);
109 letter-spacing: 0.02em;
110 text-transform: none;
111 display: inline-flex;
112 align-items: center;
113 gap: 8px;
114 }
115 .admin-hero-eyebrow .admin-shield {
116 display: inline-flex;
117 align-items: center;
118 justify-content: center;
119 width: 18px; height: 18px;
120 border-radius: 6px;
121 background: rgba(140,109,255,0.14);
122 color: #b69dff;
123 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
124 }
125 .admin-hero-eyebrow .admin-who {
126 color: var(--accent);
127 font-weight: 600;
128 }
129 .admin-hero-title {
130 font-size: clamp(28px, 4vw, 40px);
131 font-family: var(--font-display);
132 font-weight: 800;
133 letter-spacing: -0.028em;
134 line-height: 1.05;
135 margin: 0 0 var(--space-2);
136 color: var(--text-strong);
137 }
138 .admin-hero-title .gradient-text,
139 .admin-hero-title-grad {
140 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
141 -webkit-background-clip: text;
142 background-clip: text;
143 -webkit-text-fill-color: transparent;
144 color: transparent;
145 }
146 .admin-hero-sub {
147 font-size: 15px;
148 color: var(--text-muted);
149 margin: 0;
150 line-height: 1.5;
151 max-width: 620px;
152 }
153
154 /* ─── Section hero (sub-pages) ─── */
155 .admin-sec-hero {
156 position: relative;
157 margin-bottom: var(--space-5);
158 padding: var(--space-4) var(--space-5);
159 background: var(--bg-elevated);
160 border: 1px solid var(--border);
161 border-radius: 14px;
162 overflow: hidden;
163 display: flex;
164 align-items: flex-end;
165 justify-content: space-between;
166 gap: var(--space-4);
167 flex-wrap: wrap;
168 }
169 .admin-sec-hero::before {
170 content: '';
171 position: absolute;
172 top: 0; left: 0; right: 0;
173 height: 2px;
174 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
175 opacity: 0.6;
176 pointer-events: none;
177 }
178 .admin-sec-hero-text { flex: 1; min-width: 240px; }
179 .admin-sec-eyebrow {
180 font-size: 11px;
181 font-weight: 600;
182 letter-spacing: 0.08em;
183 text-transform: uppercase;
184 color: var(--accent);
185 margin-bottom: 6px;
186 }
187 .admin-sec-title {
188 font-family: var(--font-display);
189 font-size: clamp(22px, 2.8vw, 28px);
190 font-weight: 800;
191 letter-spacing: -0.022em;
192 line-height: 1.1;
193 margin: 0 0 4px;
194 color: var(--text-strong);
195 }
196 .admin-sec-sub {
197 font-size: 13.5px;
198 color: var(--text-muted);
199 margin: 0;
200 line-height: 1.5;
201 max-width: 620px;
202 }
203 .admin-sec-hero-actions {
204 display: flex;
205 gap: var(--space-2);
206 flex-wrap: wrap;
207 }
208
209 /* ─── Banners ─── */
210 .admin-banner {
211 margin-bottom: var(--space-4);
212 padding: 10px 14px;
213 border-radius: 10px;
214 font-size: 13.5px;
215 border: 1px solid var(--border);
216 background: rgba(255,255,255,0.025);
217 color: var(--text);
218 }
219 .admin-banner.is-error {
220 border-color: rgba(248,113,113,0.40);
221 background: rgba(248,113,113,0.08);
222 color: #fecaca;
223 }
224 .admin-banner.is-ok {
225 border-color: rgba(52,211,153,0.40);
226 background: rgba(52,211,153,0.08);
227 color: #bbf7d0;
228 }
229
230 /* ─── Stat grid ─── */
231 .admin-stat-grid {
232 display: grid;
233 grid-template-columns: repeat(3, 1fr);
234 gap: var(--space-3);
235 margin-bottom: var(--space-5);
236 }
237 @media (max-width: 720px) {
238 .admin-stat-grid { grid-template-columns: 1fr; }
f1dc7c7Claude239 .admin-hero { padding: var(--space-4); }
240 .admin-actions { grid-template-columns: 1fr; }
241 .admin-action { min-height: 44px; padding: 14px; }
242 .admin-list-row { flex-direction: column; align-items: stretch; padding: 14px; }
243 .admin-search { flex-direction: column; align-items: stretch; }
244 .admin-search .admin-input { width: 100%; }
245 .admin-card-body { padding: var(--space-4); }
246 .admin-card-foot { padding: var(--space-3) var(--space-4); justify-content: flex-start; }
247 .admin-ap-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
07f4b70Claude248 }
249 .admin-stat {
250 position: relative;
251 padding: var(--space-4) var(--space-4);
252 background: var(--bg-elevated);
253 border: 1px solid var(--border);
254 border-radius: 14px;
255 overflow: hidden;
256 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
257 }
258 .admin-stat::after {
259 content: '';
260 position: absolute;
261 inset: 0;
262 border-radius: inherit;
263 background: linear-gradient(135deg, rgba(140,109,255,0.05), rgba(54,197,214,0.04));
264 opacity: 0;
265 pointer-events: none;
266 transition: opacity 200ms ease;
267 }
268 .admin-stat:hover {
269 transform: translateY(-2px);
270 border-color: var(--border-strong);
271 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
272 }
273 .admin-stat:hover::after { opacity: 1; }
274 .admin-stat-head {
275 display: flex;
276 align-items: center;
277 justify-content: space-between;
278 margin-bottom: var(--space-2);
279 position: relative;
280 z-index: 1;
281 }
282 .admin-stat-label {
283 font-size: 11px;
284 font-weight: 600;
285 letter-spacing: 0.08em;
286 text-transform: uppercase;
287 color: var(--text-muted);
288 }
289 .admin-stat-icon {
290 display: inline-flex;
291 align-items: center;
292 justify-content: center;
293 width: 26px; height: 26px;
294 border-radius: 8px;
295 background: rgba(140,109,255,0.12);
296 color: #b69dff;
297 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
298 }
299 .admin-stat-value {
300 position: relative;
301 z-index: 1;
302 font-family: var(--font-display);
303 font-size: 36px;
304 font-weight: 800;
305 letter-spacing: -0.028em;
306 line-height: 1;
307 color: var(--text-strong);
308 }
309 .admin-stat-hint {
310 margin-top: 6px;
311 font-size: 12px;
312 color: var(--text-faint);
313 position: relative;
314 z-index: 1;
315 }
316
317 /* ─── Action grid ─── */
318 .admin-actions {
319 display: grid;
320 grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
321 gap: var(--space-2);
322 margin-bottom: var(--space-6);
323 }
324 .admin-action {
325 display: flex;
326 align-items: center;
327 gap: 10px;
328 padding: 12px 14px;
329 background: var(--bg-elevated);
330 border: 1px solid var(--border);
331 border-radius: 12px;
332 color: var(--text);
333 text-decoration: none;
334 font-size: 13.5px;
335 font-weight: 500;
336 line-height: 1.25;
337 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
338 cursor: pointer;
339 text-align: left;
340 font-family: inherit;
341 }
342 .admin-action:hover {
343 border-color: var(--border-strong);
344 background: rgba(255,255,255,0.025);
345 transform: translateY(-1px);
346 }
347 .admin-action:focus-visible {
348 outline: none;
349 border-color: var(--border-focus);
350 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
351 }
352 .admin-action .admin-action-icon {
353 display: inline-flex;
354 align-items: center;
355 justify-content: center;
356 width: 30px; height: 30px;
357 border-radius: 8px;
358 background: rgba(255,255,255,0.03);
359 color: var(--text-muted);
360 flex-shrink: 0;
361 box-shadow: inset 0 0 0 1px var(--border);
362 }
363 .admin-action.is-primary {
364 border-color: rgba(140,109,255,0.35);
365 background: linear-gradient(135deg, rgba(140,109,255,0.14), rgba(54,197,214,0.10));
366 color: var(--text-strong);
367 }
368 .admin-action.is-primary:hover {
369 border-color: rgba(140,109,255,0.55);
370 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
371 }
372 .admin-action.is-primary .admin-action-icon {
373 background: rgba(140,109,255,0.18);
374 color: #c5b3ff;
375 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
376 }
377 .admin-action-form { display: contents; }
378
379 /* ─── Section header (h3 replacement) ─── */
380 .admin-h3 {
381 display: flex;
382 align-items: baseline;
383 justify-content: space-between;
384 gap: var(--space-3);
385 margin: var(--space-5) 0 var(--space-3);
386 }
387 .admin-h3 h3 {
388 font-family: var(--font-display);
389 font-size: 16px;
390 font-weight: 700;
391 letter-spacing: -0.014em;
392 margin: 0;
393 color: var(--text-strong);
394 }
395 .admin-h3-meta {
396 font-size: 12px;
397 color: var(--text-muted);
398 }
399
400 /* ─── Lists / cards ─── */
401 .admin-list {
402 background: var(--bg-elevated);
403 border: 1px solid var(--border);
404 border-radius: 14px;
405 overflow: hidden;
406 }
407 .admin-list-row {
408 display: flex;
409 align-items: center;
410 justify-content: space-between;
411 gap: var(--space-3);
412 padding: 12px 16px;
413 border-bottom: 1px solid var(--border-subtle);
414 transition: background 120ms ease;
415 }
416 .admin-list-row:last-child { border-bottom: none; }
417 .admin-list-row:hover { background: rgba(255,255,255,0.018); }
418 .admin-list-empty {
419 padding: var(--space-5);
420 text-align: center;
421 color: var(--text-muted);
422 font-size: 13.5px;
423 }
424 .admin-list-main { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; }
425 .admin-avatar {
426 width: 30px; height: 30px;
427 border-radius: 9999px;
428 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.22));
429 color: var(--text-strong);
430 display: inline-flex;
431 align-items: center;
432 justify-content: center;
433 font-size: 12px;
434 font-weight: 700;
435 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
436 flex-shrink: 0;
437 text-transform: uppercase;
438 }
439 .admin-avatar.is-admin {
440 background: linear-gradient(135deg, rgba(140,109,255,0.50), rgba(54,197,214,0.35));
441 color: #fff;
442 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.55), 0 0 12px rgba(140,109,255,0.25);
443 }
444 .admin-row-text { min-width: 0; }
445 .admin-row-title {
446 font-size: 14px;
447 font-weight: 600;
448 color: var(--text-strong);
449 text-decoration: none;
450 }
451 .admin-row-title:hover { color: var(--accent-hover); }
452 .admin-row-sub {
453 margin-top: 2px;
454 font-size: 12px;
455 color: var(--text-muted);
456 display: flex;
457 gap: 8px;
458 flex-wrap: wrap;
459 align-items: center;
460 }
461 .admin-pill {
462 display: inline-flex;
463 align-items: center;
464 gap: 4px;
465 padding: 2px 8px;
466 border-radius: 9999px;
467 font-size: 10.5px;
468 font-weight: 600;
469 letter-spacing: 0.04em;
470 text-transform: uppercase;
471 }
472 .admin-pill.is-admin {
473 background: rgba(140,109,255,0.16);
474 color: #c5b3ff;
475 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
476 }
477 .admin-pill.is-private {
478 background: rgba(251,191,36,0.10);
479 color: #fde68a;
480 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
481 }
482 .admin-pill.is-public {
483 background: rgba(255,255,255,0.04);
484 color: var(--text-muted);
485 box-shadow: inset 0 0 0 1px var(--border);
486 }
487 .admin-pill.is-on {
488 background: rgba(52,211,153,0.14);
489 color: #6ee7b7;
490 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
491 }
492 .admin-pill.is-off {
493 background: rgba(255,255,255,0.04);
494 color: var(--text-muted);
495 box-shadow: inset 0 0 0 1px var(--border);
496 }
497 .admin-pill .dot {
498 width: 6px; height: 6px;
499 border-radius: 9999px;
500 background: currentColor;
501 }
502
503 /* ─── Inline forms / search ─── */
504 .admin-search {
505 display: flex;
506 gap: 8px;
507 flex-wrap: wrap;
508 align-items: center;
509 margin-bottom: var(--space-4);
510 }
511 .admin-input {
512 width: 320px;
513 max-width: 100%;
514 padding: 9px 12px;
515 font-size: 14px;
516 color: var(--text);
517 background: var(--bg);
518 border: 1px solid var(--border-strong);
519 border-radius: 8px;
520 outline: none;
521 font-family: var(--font-sans);
522 transition: border-color 120ms ease, box-shadow 120ms ease;
523 }
524 .admin-input:focus {
525 border-color: var(--border-focus);
526 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
527 }
528
529 /* ─── Flags form ─── */
530 .admin-card {
531 background: var(--bg-elevated);
532 border: 1px solid var(--border);
533 border-radius: 14px;
534 overflow: hidden;
535 }
536 .admin-card-body { padding: var(--space-5); }
537 .admin-card-foot {
538 padding: var(--space-3) var(--space-5);
539 border-top: 1px solid var(--border);
540 background: rgba(255,255,255,0.012);
541 display: flex;
542 justify-content: flex-end;
543 gap: var(--space-2);
544 align-items: center;
545 flex-wrap: wrap;
546 }
547 .admin-card-foot .admin-foot-hint {
548 margin-right: auto;
549 font-size: 12.5px;
550 color: var(--text-muted);
551 }
552 .admin-field { margin-bottom: var(--space-4); }
553 .admin-field:last-child { margin-bottom: 0; }
554 .admin-field label {
555 display: block;
556 font-family: var(--font-mono);
557 font-size: 12.5px;
558 font-weight: 600;
559 color: var(--text-strong);
560 margin-bottom: 6px;
561 letter-spacing: -0.005em;
562 }
563 .admin-field .admin-input-mono {
564 width: 100%;
565 padding: 9px 12px;
566 font-size: 13.5px;
567 color: var(--text);
568 background: var(--bg);
569 border: 1px solid var(--border-strong);
570 border-radius: 8px;
571 outline: none;
572 font-family: var(--font-mono);
573 transition: border-color 120ms ease, box-shadow 120ms ease;
574 }
575 .admin-field .admin-input-mono:focus {
576 border-color: var(--border-focus);
577 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
578 }
579 .admin-field-hint {
580 font-size: 11.5px;
581 color: var(--text-muted);
582 margin-top: 6px;
583 line-height: 1.45;
584 }
585 .admin-field-hint code {
586 font-family: var(--font-mono);
587 font-size: 11.5px;
588 background: var(--bg-tertiary);
589 padding: 1px 5px;
590 border-radius: 4px;
591 }
592
593 /* ─── Digest forms ─── */
594 .admin-digest-row {
595 display: flex;
596 gap: 8px;
597 align-items: center;
598 flex-wrap: wrap;
599 }
600
601 /* ─── Autopilot specific ─── */
602 .admin-ap-table {
603 width: 100%;
604 border-collapse: collapse;
605 background: var(--bg-elevated);
606 border: 1px solid var(--border);
607 border-radius: 14px;
608 overflow: hidden;
609 }
610 .admin-ap-table thead th {
611 text-align: left;
612 font-size: 11px;
613 font-weight: 600;
614 letter-spacing: 0.08em;
615 text-transform: uppercase;
616 color: var(--text-muted);
617 padding: 10px 14px;
618 background: rgba(255,255,255,0.015);
619 border-bottom: 1px solid var(--border);
620 }
621 .admin-ap-table tbody td {
622 padding: 10px 14px;
623 border-bottom: 1px solid var(--border-subtle);
624 font-size: 13px;
625 color: var(--text);
626 vertical-align: top;
627 }
628 .admin-ap-table tbody tr:last-child td { border-bottom: none; }
629 .admin-ap-table code {
630 font-family: var(--font-mono);
631 font-size: 12px;
632 color: var(--text-strong);
633 }
634 .admin-ap-status-ok { color: var(--green); font-weight: 600; }
635 .admin-ap-status-fail { color: var(--red); font-weight: 600; }
636 .admin-ap-empty {
637 padding: var(--space-5);
638 text-align: center;
639 color: var(--text-muted);
640 font-size: 13.5px;
641 background: var(--bg-elevated);
642 border: 1px dashed var(--border);
643 border-radius: 14px;
644 }
645 .admin-ap-foot {
646 margin-top: var(--space-5);
647 padding: var(--space-3) var(--space-4);
648 border: 1px solid var(--border-subtle);
649 background: rgba(255,255,255,0.015);
650 border-radius: 10px;
651 color: var(--text-muted);
652 font-size: 12.5px;
653 }
654 .admin-ap-foot code {
655 font-family: var(--font-mono);
656 font-size: 12px;
657 background: var(--bg-tertiary);
658 padding: 1px 5px;
659 border-radius: 4px;
660 color: var(--text);
661 }
662
663 /* ─── Misc ─── */
664 .admin-403 {
665 max-width: 540px;
666 margin: var(--space-12) auto;
667 padding: var(--space-6);
668 text-align: center;
669 background: var(--bg-elevated);
670 border: 1px solid var(--border);
671 border-radius: 16px;
672 }
673 .admin-403 h2 {
674 font-family: var(--font-display);
675 font-size: 22px;
676 margin: 0 0 8px;
677 color: var(--text-strong);
678 }
679 .admin-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
680`;
681
8929744Claude682/* ─────────────────────────────────────────────────────────────────────────
683 * Per-sub-page scoped CSS — each handler gets its own namespace so they
684 * cannot bleed into each other or back into the shared `.admin-*` panel.
685 *
686 * .adm-users-* /admin/users
687 * .adm-repos-* /admin/repos
688 * .adm-flags-* /admin/flags
689 * .adm-digests-* /admin/digests
690 * .adm-autopilot-* /admin/autopilot
691 *
692 * All five mirror the 2026 design language from /admin and /admin/ops:
693 * - gradient hairline (::before)
694 * - animated radial-gradient orb
695 * - clamp() display headline + gradient-text span
696 * - eyebrow + subtitle
697 * - cards with avatar/icon + mono IDs + action buttons
698 * - filter pills / search bar
699 * - empty state with orb
700 * ───────────────────────────────────────────────────────────────────── */
701const admUsersStyles = `
eed4684Claude702 .adm-users-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude703
704 /* Hero */
705 .adm-users-hero {
706 position: relative;
707 margin-bottom: var(--space-5);
708 padding: var(--space-5) var(--space-6);
709 background: var(--bg-elevated);
710 border: 1px solid var(--border);
711 border-radius: 16px;
712 overflow: hidden;
713 }
714 .adm-users-hero::before {
715 content: '';
716 position: absolute;
717 top: 0; left: 0; right: 0;
718 height: 2px;
719 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
720 opacity: 0.7;
721 pointer-events: none;
722 }
723 .adm-users-hero-orb {
724 position: absolute;
725 inset: -20% -10% auto auto;
726 width: 380px; height: 380px;
727 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
728 filter: blur(80px);
729 opacity: 0.7;
730 pointer-events: none;
731 z-index: 0;
732 animation: admUsersOrb 14s ease-in-out infinite;
733 }
734 @keyframes admUsersOrb {
735 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
736 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
737 }
738 @media (prefers-reduced-motion: reduce) {
739 .adm-users-hero-orb { animation: none; }
740 }
741 .adm-users-hero-inner {
742 position: relative;
743 z-index: 1;
744 display: flex;
745 align-items: flex-end;
746 justify-content: space-between;
747 gap: var(--space-4);
748 flex-wrap: wrap;
749 }
750 .adm-users-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
751 .adm-users-eyebrow {
752 font-size: 12px;
753 color: var(--text-muted);
754 margin-bottom: var(--space-2);
755 letter-spacing: 0.02em;
756 display: inline-flex;
757 align-items: center;
758 gap: 8px;
759 }
760 .adm-users-eyebrow-pill {
761 display: inline-flex;
762 align-items: center;
763 justify-content: center;
764 width: 22px; height: 22px;
765 border-radius: 6px;
766 background: rgba(140,109,255,0.14);
767 color: #b69dff;
768 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
769 }
770 .adm-users-title {
771 font-size: clamp(28px, 4vw, 40px);
772 font-family: var(--font-display);
773 font-weight: 800;
774 letter-spacing: -0.028em;
775 line-height: 1.05;
776 margin: 0 0 var(--space-2);
777 color: var(--text-strong);
778 }
779 .adm-users-title-grad {
780 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
781 -webkit-background-clip: text;
782 background-clip: text;
783 -webkit-text-fill-color: transparent;
784 color: transparent;
785 }
786 .adm-users-sub {
787 font-size: 15px;
788 color: var(--text-muted);
789 margin: 0;
790 line-height: 1.5;
791 max-width: 620px;
792 }
793 .adm-users-back {
794 display: inline-flex;
795 align-items: center;
796 gap: 6px;
797 padding: 7px 12px;
798 font-size: 12.5px;
799 color: var(--text-muted);
800 background: rgba(255,255,255,0.02);
801 border: 1px solid var(--border);
802 border-radius: 8px;
803 text-decoration: none;
804 font-weight: 500;
805 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
806 }
807 .adm-users-back:hover {
808 border-color: var(--border-strong);
809 color: var(--text-strong);
810 background: rgba(255,255,255,0.04);
811 }
812
813 /* Filter bar */
814 .adm-users-filterbar {
815 display: flex;
816 gap: var(--space-3);
817 align-items: center;
818 justify-content: space-between;
819 margin-bottom: var(--space-4);
820 flex-wrap: wrap;
821 }
822 .adm-users-search {
823 position: relative;
824 display: flex;
825 align-items: center;
826 gap: 8px;
827 flex: 1;
828 min-width: 280px;
829 }
830 .adm-users-search-ico {
831 position: absolute;
832 left: 12px;
833 top: 50%;
834 transform: translateY(-50%);
835 color: var(--text-muted);
836 pointer-events: none;
837 display: inline-flex;
838 }
839 .adm-users-input {
840 flex: 1;
841 width: 100%;
842 padding: 10px 12px 10px 36px;
843 font-size: 14px;
844 color: var(--text);
845 background: var(--bg);
846 border: 1px solid var(--border-strong);
847 border-radius: 10px;
848 outline: none;
849 font-family: var(--font-sans);
850 transition: border-color 120ms ease, box-shadow 120ms ease;
851 }
852 .adm-users-input:focus {
853 border-color: var(--border-focus);
854 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
855 }
856 .adm-users-pills {
857 display: inline-flex;
858 gap: 6px;
859 flex-wrap: wrap;
860 }
861 .adm-users-pill {
862 display: inline-flex;
863 align-items: center;
864 gap: 6px;
865 padding: 4px 10px;
866 border-radius: 9999px;
867 font-size: 11.5px;
868 font-weight: 600;
869 background: rgba(255,255,255,0.04);
870 color: var(--text-muted);
871 box-shadow: inset 0 0 0 1px var(--border);
872 }
873 .adm-users-pill .dot {
874 width: 6px; height: 6px;
875 border-radius: 9999px;
876 background: currentColor;
877 }
878 .adm-users-pill.is-admin {
879 background: rgba(140,109,255,0.16);
880 color: #c5b3ff;
881 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
882 }
883
884 /* Buttons */
885 .adm-users-btn {
886 display: inline-flex;
887 align-items: center;
888 gap: 6px;
889 padding: 8px 14px;
890 border-radius: 8px;
891 font-size: 13px;
892 font-weight: 600;
893 text-decoration: none;
894 border: 1px solid var(--border-strong);
895 background: rgba(255,255,255,0.02);
896 color: var(--text);
897 cursor: pointer;
898 font: inherit;
899 font-weight: 600;
900 line-height: 1;
901 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
902 }
903 .adm-users-btn:hover { border-color: rgba(140,109,255,0.45); background: rgba(140,109,255,0.06); color: var(--text-strong); }
904 .adm-users-btn-ghost { background: transparent; color: var(--text-muted); border-color: var(--border); }
905 .adm-users-btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.03); border-color: var(--border-strong); }
906 .adm-users-btn-primary {
907 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
908 color: #fff;
909 border-color: transparent;
910 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
911 }
912 .adm-users-btn-primary:hover { color: #fff; transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55); }
913 .adm-users-btn-danger {
914 background: transparent;
915 color: #fca5a5;
916 border-color: rgba(248,113,113,0.40);
917 }
918 .adm-users-btn-danger:hover {
919 background: rgba(248,113,113,0.08);
920 border-color: rgba(248,113,113,0.70);
921 color: #fecaca;
922 }
923
924 /* Card grid */
925 .adm-users-grid {
926 display: grid;
927 grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
928 gap: var(--space-3);
929 }
930 .adm-users-card {
931 position: relative;
932 padding: var(--space-4);
933 background: var(--bg-elevated);
934 border: 1px solid var(--border);
935 border-radius: 14px;
936 display: flex;
937 flex-direction: column;
938 gap: var(--space-3);
939 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
940 }
941 .adm-users-card:hover {
942 transform: translateY(-2px);
943 border-color: var(--border-strong);
944 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
945 }
946 .adm-users-card.is-admin {
947 border-color: rgba(140,109,255,0.35);
948 background:
949 linear-gradient(180deg, rgba(140,109,255,0.04), transparent 60%),
950 var(--bg-elevated);
951 }
952 .adm-users-card-head {
953 display: flex;
954 align-items: center;
955 gap: 12px;
956 }
957 .adm-users-avatar {
958 width: 38px; height: 38px;
959 border-radius: 9999px;
960 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.22));
961 color: var(--text-strong);
962 display: inline-flex;
963 align-items: center;
964 justify-content: center;
965 font-size: 14px;
966 font-weight: 700;
967 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
968 flex-shrink: 0;
969 text-transform: uppercase;
970 }
971 .adm-users-avatar.is-admin {
972 background: linear-gradient(135deg, rgba(140,109,255,0.50), rgba(54,197,214,0.35));
973 color: #fff;
974 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.55), 0 0 12px rgba(140,109,255,0.25);
975 }
976 .adm-users-card-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
977 .adm-users-card-name {
978 font-size: 14.5px;
979 font-weight: 700;
980 color: var(--text-strong);
981 text-decoration: none;
982 letter-spacing: -0.005em;
983 }
984 .adm-users-card-name:hover { color: var(--accent-hover, var(--accent)); }
985 .adm-users-card-mono {
986 font-family: var(--font-mono);
987 font-size: 11px;
988 color: var(--text-muted);
989 background: rgba(255,255,255,0.03);
990 padding: 1px 6px;
991 border-radius: 4px;
992 border: 1px solid var(--border-subtle);
993 width: fit-content;
994 }
995 .adm-users-card-meta {
996 display: flex;
997 flex-direction: column;
998 gap: 6px;
999 font-size: 12.5px;
1000 }
1001 .adm-users-meta-item { display: flex; gap: 8px; align-items: baseline; min-width: 0; }
1002 .adm-users-meta-key {
1003 font-family: var(--font-mono);
1004 font-size: 10.5px;
1005 text-transform: uppercase;
1006 letter-spacing: 0.06em;
1007 color: var(--text-muted);
1008 flex-shrink: 0;
1009 width: 50px;
1010 }
1011 .adm-users-meta-val { color: var(--text); word-break: break-all; min-width: 0; }
1012 .adm-users-card-actions {
1013 display: flex;
1014 gap: 8px;
1015 flex-wrap: wrap;
1016 margin-top: auto;
1017 }
1018 .adm-users-card-actions form { margin: 0; }
1019
1020 /* Empty state */
1021 .adm-users-empty {
1022 position: relative;
1023 padding: var(--space-12) var(--space-6);
1024 border: 1px dashed var(--border);
1025 border-radius: 16px;
1026 background: var(--bg-elevated);
1027 text-align: center;
1028 overflow: hidden;
1029 }
1030 .adm-users-empty-orb {
1031 position: absolute;
1032 inset: 50% auto auto 50%;
1033 transform: translate(-50%, -50%);
1034 width: 320px; height: 320px;
1035 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
1036 filter: blur(60px);
1037 pointer-events: none;
1038 z-index: 0;
1039 }
1040 .adm-users-empty-inner { position: relative; z-index: 1; }
1041 .adm-users-empty-icon {
1042 display: inline-flex;
1043 align-items: center;
1044 justify-content: center;
1045 width: 56px; height: 56px;
1046 border-radius: 16px;
1047 background: rgba(140,109,255,0.10);
1048 color: #b69dff;
1049 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1050 margin-bottom: var(--space-3);
1051 }
1052 .adm-users-empty-icon svg { width: 24px; height: 24px; }
1053 .adm-users-empty-title {
1054 font-family: var(--font-display);
1055 font-size: 20px;
1056 font-weight: 700;
1057 letter-spacing: -0.015em;
1058 color: var(--text-strong);
1059 margin-bottom: 6px;
1060 }
1061 .adm-users-empty-sub {
1062 color: var(--text-muted);
1063 font-size: 13.5px;
1064 line-height: 1.5;
1065 }
1066 .adm-users-empty-sub code {
1067 font-family: var(--font-mono);
1068 font-size: 12px;
1069 background: var(--bg-tertiary);
1070 padding: 1px 6px;
1071 border-radius: 4px;
1072 color: var(--text);
1073 }
f1dc7c7Claude1074
1075 @media (max-width: 720px) {
1076 .adm-users-wrap { padding: var(--space-4) var(--space-3); }
1077 .adm-users-hero { padding: var(--space-4); }
1078 .adm-users-grid { grid-template-columns: 1fr; }
1079 }
8929744Claude1080`;
1081
1082const admReposStyles = `
eed4684Claude1083 .adm-repos-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1084
1085 .adm-repos-hero {
1086 position: relative;
1087 margin-bottom: var(--space-5);
1088 padding: var(--space-5) var(--space-6);
1089 background: var(--bg-elevated);
1090 border: 1px solid var(--border);
1091 border-radius: 16px;
1092 overflow: hidden;
1093 }
1094 .adm-repos-hero::before {
1095 content: '';
1096 position: absolute;
1097 top: 0; left: 0; right: 0;
1098 height: 2px;
1099 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1100 opacity: 0.7;
1101 pointer-events: none;
1102 }
1103 .adm-repos-hero-orb {
1104 position: absolute;
1105 inset: -20% -10% auto auto;
1106 width: 380px; height: 380px;
1107 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
1108 filter: blur(80px);
1109 opacity: 0.7;
1110 pointer-events: none;
1111 z-index: 0;
1112 animation: admReposOrb 14s ease-in-out infinite;
1113 }
1114 @keyframes admReposOrb {
1115 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1116 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1117 }
1118 @media (prefers-reduced-motion: reduce) {
1119 .adm-repos-hero-orb { animation: none; }
1120 }
1121 .adm-repos-hero-inner {
1122 position: relative;
1123 z-index: 1;
1124 display: flex;
1125 align-items: flex-end;
1126 justify-content: space-between;
1127 gap: var(--space-4);
1128 flex-wrap: wrap;
1129 }
1130 .adm-repos-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1131 .adm-repos-eyebrow {
1132 font-size: 12px;
1133 color: var(--text-muted);
1134 margin-bottom: var(--space-2);
1135 letter-spacing: 0.02em;
1136 display: inline-flex;
1137 align-items: center;
1138 gap: 8px;
1139 }
1140 .adm-repos-eyebrow-pill {
1141 display: inline-flex;
1142 align-items: center;
1143 justify-content: center;
1144 width: 22px; height: 22px;
1145 border-radius: 6px;
1146 background: rgba(140,109,255,0.14);
1147 color: #b69dff;
1148 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
1149 }
1150 .adm-repos-title {
1151 font-size: clamp(28px, 4vw, 40px);
1152 font-family: var(--font-display);
1153 font-weight: 800;
1154 letter-spacing: -0.028em;
1155 line-height: 1.05;
1156 margin: 0 0 var(--space-2);
1157 color: var(--text-strong);
1158 }
1159 .adm-repos-title-grad {
1160 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
1161 -webkit-background-clip: text;
1162 background-clip: text;
1163 -webkit-text-fill-color: transparent;
1164 color: transparent;
1165 }
1166 .adm-repos-sub {
1167 font-size: 15px;
1168 color: var(--text-muted);
1169 margin: 0;
1170 line-height: 1.5;
1171 max-width: 620px;
1172 }
1173 .adm-repos-back {
1174 display: inline-flex;
1175 align-items: center;
1176 gap: 6px;
1177 padding: 7px 12px;
1178 font-size: 12.5px;
1179 color: var(--text-muted);
1180 background: rgba(255,255,255,0.02);
1181 border: 1px solid var(--border);
1182 border-radius: 8px;
1183 text-decoration: none;
1184 font-weight: 500;
1185 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1186 }
1187 .adm-repos-back:hover {
1188 border-color: var(--border-strong);
1189 color: var(--text-strong);
1190 background: rgba(255,255,255,0.04);
1191 }
1192
1193 .adm-repos-pills {
1194 display: inline-flex;
1195 gap: 6px;
1196 flex-wrap: wrap;
1197 margin-bottom: var(--space-4);
1198 }
1199 .adm-repos-pill {
1200 display: inline-flex;
1201 align-items: center;
1202 gap: 6px;
1203 padding: 4px 10px;
1204 border-radius: 9999px;
1205 font-size: 11.5px;
1206 font-weight: 600;
1207 background: rgba(255,255,255,0.04);
1208 color: var(--text-muted);
1209 box-shadow: inset 0 0 0 1px var(--border);
1210 }
1211 .adm-repos-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
1212 .adm-repos-pill.is-private {
1213 background: rgba(251,191,36,0.10);
1214 color: #fde68a;
1215 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
1216 }
1217 .adm-repos-pill.is-public {
1218 background: rgba(52,211,153,0.10);
1219 color: #86efac;
1220 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28);
1221 }
1222
1223 .adm-repos-btn {
1224 display: inline-flex;
1225 align-items: center;
1226 gap: 6px;
1227 padding: 8px 14px;
1228 border-radius: 8px;
1229 font-size: 13px;
1230 font-weight: 600;
1231 text-decoration: none;
1232 border: 1px solid var(--border-strong);
1233 background: rgba(255,255,255,0.02);
1234 color: var(--text);
1235 cursor: pointer;
1236 font: inherit;
1237 line-height: 1;
1238 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
1239 }
1240 .adm-repos-btn:hover { border-color: rgba(140,109,255,0.45); background: rgba(140,109,255,0.06); color: var(--text-strong); }
1241 .adm-repos-btn-ghost { background: transparent; color: var(--text-muted); border-color: var(--border); }
1242 .adm-repos-btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.03); border-color: var(--border-strong); }
1243 .adm-repos-btn-danger {
1244 background: transparent;
1245 color: #fca5a5;
1246 border-color: rgba(248,113,113,0.40);
1247 }
1248 .adm-repos-btn-danger:hover {
1249 background: rgba(248,113,113,0.08);
1250 border-color: rgba(248,113,113,0.70);
1251 color: #fecaca;
1252 }
1253
1254 .adm-repos-grid {
1255 display: grid;
1256 grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
1257 gap: var(--space-3);
1258 }
1259 .adm-repos-card {
1260 position: relative;
1261 padding: var(--space-4);
1262 background: var(--bg-elevated);
1263 border: 1px solid var(--border);
1264 border-radius: 14px;
1265 display: flex;
1266 flex-direction: column;
1267 gap: var(--space-3);
1268 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
1269 }
1270 .adm-repos-card:hover {
1271 transform: translateY(-2px);
1272 border-color: var(--border-strong);
1273 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
1274 }
1275 .adm-repos-card-head {
1276 display: flex;
1277 align-items: center;
1278 gap: 12px;
1279 }
1280 .adm-repos-icon {
1281 width: 38px; height: 38px;
1282 border-radius: 10px;
1283 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.12));
1284 color: #b69dff;
1285 display: inline-flex;
1286 align-items: center;
1287 justify-content: center;
1288 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1289 flex-shrink: 0;
1290 }
1291 .adm-repos-icon svg { width: 18px; height: 18px; }
1292 .adm-repos-card-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
1293 .adm-repos-card-name {
1294 font-size: 14.5px;
1295 font-weight: 700;
1296 color: var(--text-strong);
1297 text-decoration: none;
1298 letter-spacing: -0.005em;
1299 word-break: break-all;
1300 }
1301 .adm-repos-card-name:hover { color: var(--accent-hover, var(--accent)); }
1302 .adm-repos-card-mono {
1303 font-family: var(--font-mono);
1304 font-size: 11px;
1305 color: var(--text-muted);
1306 background: rgba(255,255,255,0.03);
1307 padding: 1px 6px;
1308 border-radius: 4px;
1309 border: 1px solid var(--border-subtle);
1310 width: fit-content;
1311 }
1312 .adm-repos-card-meta {
1313 display: flex;
1314 gap: var(--space-3);
1315 flex-wrap: wrap;
1316 font-size: 12.5px;
1317 color: var(--text-muted);
1318 }
1319 .adm-repos-meta-item {
1320 display: inline-flex;
1321 align-items: center;
1322 gap: 5px;
1323 }
1324 .adm-repos-meta-item svg { width: 13px; height: 13px; color: var(--text-muted); }
1325 .adm-repos-card-actions {
1326 display: flex;
1327 gap: 8px;
1328 flex-wrap: wrap;
1329 margin-top: auto;
1330 }
1331 .adm-repos-card-actions form { margin: 0; }
1332
1333 .adm-repos-empty {
1334 position: relative;
1335 padding: var(--space-12) var(--space-6);
1336 border: 1px dashed var(--border);
1337 border-radius: 16px;
1338 background: var(--bg-elevated);
1339 text-align: center;
1340 overflow: hidden;
1341 }
1342 .adm-repos-empty-orb {
1343 position: absolute;
1344 inset: 50% auto auto 50%;
1345 transform: translate(-50%, -50%);
1346 width: 320px; height: 320px;
1347 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
1348 filter: blur(60px);
1349 pointer-events: none;
1350 z-index: 0;
1351 }
1352 .adm-repos-empty-inner { position: relative; z-index: 1; }
1353 .adm-repos-empty-icon {
1354 display: inline-flex;
1355 align-items: center;
1356 justify-content: center;
1357 width: 56px; height: 56px;
1358 border-radius: 16px;
1359 background: rgba(140,109,255,0.10);
1360 color: #b69dff;
1361 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1362 margin-bottom: var(--space-3);
1363 }
1364 .adm-repos-empty-icon svg { width: 24px; height: 24px; }
1365 .adm-repos-empty-title {
1366 font-family: var(--font-display);
1367 font-size: 20px;
1368 font-weight: 700;
1369 letter-spacing: -0.015em;
1370 color: var(--text-strong);
1371 margin-bottom: 6px;
1372 }
1373 .adm-repos-empty-sub {
1374 color: var(--text-muted);
1375 font-size: 13.5px;
1376 line-height: 1.5;
1377 }
f1dc7c7Claude1378
1379 @media (max-width: 720px) {
1380 .adm-repos-wrap { padding: var(--space-4) var(--space-3); }
1381 .adm-repos-hero { padding: var(--space-4); }
1382 .adm-repos-grid { grid-template-columns: 1fr; }
1383 }
8929744Claude1384`;
1385
1386const admFlagsStyles = `
eed4684Claude1387 .adm-flags-wrap { max-width: 1200px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1388
1389 .adm-flags-hero {
1390 position: relative;
1391 margin-bottom: var(--space-5);
1392 padding: var(--space-5) var(--space-6);
1393 background: var(--bg-elevated);
1394 border: 1px solid var(--border);
1395 border-radius: 16px;
1396 overflow: hidden;
1397 }
1398 .adm-flags-hero::before {
1399 content: '';
1400 position: absolute;
1401 top: 0; left: 0; right: 0;
1402 height: 2px;
1403 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1404 opacity: 0.7;
1405 pointer-events: none;
1406 }
1407 .adm-flags-hero-orb {
1408 position: absolute;
1409 inset: -20% -10% auto auto;
1410 width: 380px; height: 380px;
1411 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
1412 filter: blur(80px);
1413 opacity: 0.7;
1414 pointer-events: none;
1415 z-index: 0;
1416 animation: admFlagsOrb 14s ease-in-out infinite;
1417 }
1418 @keyframes admFlagsOrb {
1419 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1420 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1421 }
1422 @media (prefers-reduced-motion: reduce) {
1423 .adm-flags-hero-orb { animation: none; }
1424 }
1425 .adm-flags-hero-inner {
1426 position: relative;
1427 z-index: 1;
1428 display: flex;
1429 align-items: flex-end;
1430 justify-content: space-between;
1431 gap: var(--space-4);
1432 flex-wrap: wrap;
1433 }
1434 .adm-flags-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1435 .adm-flags-eyebrow {
1436 font-size: 12px;
1437 color: var(--text-muted);
1438 margin-bottom: var(--space-2);
1439 letter-spacing: 0.02em;
1440 display: inline-flex;
1441 align-items: center;
1442 gap: 8px;
1443 }
1444 .adm-flags-eyebrow-pill {
1445 display: inline-flex;
1446 align-items: center;
1447 justify-content: center;
1448 width: 22px; height: 22px;
1449 border-radius: 6px;
1450 background: rgba(140,109,255,0.14);
1451 color: #b69dff;
1452 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
1453 }
1454 .adm-flags-title {
1455 font-size: clamp(28px, 4vw, 40px);
1456 font-family: var(--font-display);
1457 font-weight: 800;
1458 letter-spacing: -0.028em;
1459 line-height: 1.05;
1460 margin: 0 0 var(--space-2);
1461 color: var(--text-strong);
1462 }
1463 .adm-flags-title-grad {
1464 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
1465 -webkit-background-clip: text;
1466 background-clip: text;
1467 -webkit-text-fill-color: transparent;
1468 color: transparent;
1469 }
1470 .adm-flags-sub {
1471 font-size: 15px;
1472 color: var(--text-muted);
1473 margin: 0;
1474 line-height: 1.5;
1475 max-width: 620px;
1476 }
1477 .adm-flags-sub code {
1478 font-family: var(--font-mono);
1479 font-size: 13px;
1480 background: var(--bg-tertiary);
1481 padding: 1px 5px;
1482 border-radius: 4px;
1483 color: var(--text);
1484 }
1485 .adm-flags-back {
1486 display: inline-flex;
1487 align-items: center;
1488 gap: 6px;
1489 padding: 7px 12px;
1490 font-size: 12.5px;
1491 color: var(--text-muted);
1492 background: rgba(255,255,255,0.02);
1493 border: 1px solid var(--border);
1494 border-radius: 8px;
1495 text-decoration: none;
1496 font-weight: 500;
1497 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1498 }
1499 .adm-flags-back:hover {
1500 border-color: var(--border-strong);
1501 color: var(--text-strong);
1502 background: rgba(255,255,255,0.04);
1503 }
1504
1505 .adm-flags-card {
1506 background: var(--bg-elevated);
1507 border: 1px solid var(--border);
1508 border-radius: 14px;
1509 overflow: hidden;
1510 }
1511 .adm-flags-card-body { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
1512 .adm-flags-card-foot {
1513 padding: var(--space-3) var(--space-5);
1514 border-top: 1px solid var(--border);
1515 background: rgba(255,255,255,0.012);
1516 display: flex;
1517 justify-content: flex-end;
1518 gap: var(--space-2);
1519 align-items: center;
1520 flex-wrap: wrap;
1521 }
1522 .adm-flags-foot-hint {
1523 margin-right: auto;
1524 font-size: 12.5px;
1525 color: var(--text-muted);
1526 }
1527
1528 .adm-flags-field {
1529 display: flex;
1530 flex-direction: column;
1531 gap: 6px;
1532 padding: var(--space-3);
1533 border: 1px solid var(--border-subtle);
1534 border-radius: 12px;
1535 background: rgba(255,255,255,0.015);
1536 transition: border-color 120ms ease, background 120ms ease;
1537 }
1538 .adm-flags-field:hover { border-color: var(--border); background: rgba(255,255,255,0.025); }
1539 .adm-flags-field-head {
1540 display: flex;
1541 align-items: center;
1542 gap: 8px;
1543 }
1544 .adm-flags-key {
1545 font-family: var(--font-mono);
1546 font-size: 12.5px;
1547 font-weight: 600;
1548 color: var(--text-strong);
1549 letter-spacing: -0.005em;
1550 }
1551 .adm-flags-mono {
1552 font-family: var(--font-mono);
1553 font-size: 10.5px;
1554 color: var(--text-muted);
1555 background: rgba(255,255,255,0.04);
1556 padding: 1px 6px;
1557 border-radius: 4px;
1558 border: 1px solid var(--border-subtle);
1559 }
1560 .adm-flags-input {
1561 width: 100%;
1562 padding: 9px 12px;
1563 font-size: 13.5px;
1564 color: var(--text);
1565 background: var(--bg);
1566 border: 1px solid var(--border-strong);
1567 border-radius: 8px;
1568 outline: none;
1569 font-family: var(--font-mono);
1570 transition: border-color 120ms ease, box-shadow 120ms ease;
1571 box-sizing: border-box;
1572 }
1573 .adm-flags-input:focus {
1574 border-color: var(--border-focus);
1575 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1576 }
1577 .adm-flags-hint {
1578 font-size: 11.5px;
1579 color: var(--text-muted);
1580 margin-top: 2px;
1581 line-height: 1.45;
1582 }
1583 .adm-flags-hint code {
1584 font-family: var(--font-mono);
1585 font-size: 11.5px;
1586 background: var(--bg-tertiary);
1587 padding: 1px 5px;
1588 border-radius: 4px;
1589 color: var(--text);
1590 }
1591
1592 .adm-flags-btn {
1593 display: inline-flex;
1594 align-items: center;
1595 gap: 6px;
1596 padding: 9px 16px;
1597 border-radius: 10px;
1598 font-size: 13px;
1599 font-weight: 600;
1600 text-decoration: none;
1601 border: 1px solid transparent;
1602 cursor: pointer;
1603 font: inherit;
1604 line-height: 1;
1605 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
1606 }
1607 .adm-flags-btn-primary {
1608 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1609 color: #fff;
1610 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
1611 }
1612 .adm-flags-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55); }
f1dc7c7Claude1613
1614 @media (max-width: 720px) {
1615 .adm-flags-wrap { padding: var(--space-4) var(--space-3); }
1616 .adm-flags-hero { padding: var(--space-4); }
1617 }
8929744Claude1618`;
1619
1620const admDigestsStyles = `
eed4684Claude1621 .adm-digests-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1622
1623 .adm-digests-hero {
1624 position: relative;
1625 margin-bottom: var(--space-5);
1626 padding: var(--space-5) var(--space-6);
1627 background: var(--bg-elevated);
1628 border: 1px solid var(--border);
1629 border-radius: 16px;
1630 overflow: hidden;
1631 }
1632 .adm-digests-hero::before {
1633 content: '';
1634 position: absolute;
1635 top: 0; left: 0; right: 0;
1636 height: 2px;
1637 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1638 opacity: 0.7;
1639 pointer-events: none;
1640 }
1641 .adm-digests-hero-orb {
1642 position: absolute;
1643 inset: -20% -10% auto auto;
1644 width: 380px; height: 380px;
1645 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
1646 filter: blur(80px);
1647 opacity: 0.7;
1648 pointer-events: none;
1649 z-index: 0;
1650 animation: admDigestsOrb 14s ease-in-out infinite;
1651 }
1652 @keyframes admDigestsOrb {
1653 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1654 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1655 }
1656 @media (prefers-reduced-motion: reduce) {
1657 .adm-digests-hero-orb { animation: none; }
1658 }
1659 .adm-digests-hero-inner {
1660 position: relative;
1661 z-index: 1;
1662 display: flex;
1663 align-items: flex-end;
1664 justify-content: space-between;
1665 gap: var(--space-4);
1666 flex-wrap: wrap;
1667 }
1668 .adm-digests-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1669 .adm-digests-eyebrow {
1670 font-size: 12px;
1671 color: var(--text-muted);
1672 margin-bottom: var(--space-2);
1673 letter-spacing: 0.02em;
1674 display: inline-flex;
1675 align-items: center;
1676 gap: 8px;
1677 }
1678 .adm-digests-eyebrow-pill {
1679 display: inline-flex;
1680 align-items: center;
1681 justify-content: center;
1682 width: 22px; height: 22px;
1683 border-radius: 6px;
1684 background: rgba(140,109,255,0.14);
1685 color: #b69dff;
1686 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
1687 }
1688 .adm-digests-title {
1689 font-size: clamp(28px, 4vw, 40px);
1690 font-family: var(--font-display);
1691 font-weight: 800;
1692 letter-spacing: -0.028em;
1693 line-height: 1.05;
1694 margin: 0 0 var(--space-2);
1695 color: var(--text-strong);
1696 }
1697 .adm-digests-title-grad {
1698 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
1699 -webkit-background-clip: text;
1700 background-clip: text;
1701 -webkit-text-fill-color: transparent;
1702 color: transparent;
1703 }
1704 .adm-digests-sub {
1705 font-size: 15px;
1706 color: var(--text-muted);
1707 margin: 0;
1708 line-height: 1.5;
1709 max-width: 620px;
1710 }
1711 .adm-digests-back {
1712 display: inline-flex;
1713 align-items: center;
1714 gap: 6px;
1715 padding: 7px 12px;
1716 font-size: 12.5px;
1717 color: var(--text-muted);
1718 background: rgba(255,255,255,0.02);
1719 border: 1px solid var(--border);
1720 border-radius: 8px;
1721 text-decoration: none;
1722 font-weight: 500;
1723 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1724 }
1725 .adm-digests-back:hover {
1726 border-color: var(--border-strong);
1727 color: var(--text-strong);
1728 background: rgba(255,255,255,0.04);
1729 }
1730
1731 .adm-digests-banner {
1732 margin-bottom: var(--space-4);
1733 padding: 10px 14px;
1734 border-radius: 10px;
1735 font-size: 13.5px;
1736 border: 1px solid var(--border);
1737 background: rgba(255,255,255,0.025);
1738 color: var(--text);
1739 }
1740 .adm-digests-banner.is-ok {
1741 border-color: rgba(52,211,153,0.40);
1742 background: rgba(52,211,153,0.08);
1743 color: #bbf7d0;
1744 }
1745 .adm-digests-banner.is-error {
1746 border-color: rgba(248,113,113,0.40);
1747 background: rgba(248,113,113,0.08);
1748 color: #fecaca;
1749 }
1750
1751 .adm-digests-pills {
1752 display: inline-flex;
1753 gap: 6px;
1754 flex-wrap: wrap;
1755 margin-bottom: var(--space-4);
1756 }
1757 .adm-digests-pill {
1758 display: inline-flex;
1759 align-items: center;
1760 gap: 6px;
1761 padding: 4px 10px;
1762 border-radius: 9999px;
1763 font-size: 11.5px;
1764 font-weight: 600;
1765 background: rgba(255,255,255,0.04);
1766 color: var(--text-muted);
1767 box-shadow: inset 0 0 0 1px var(--border);
1768 }
1769 .adm-digests-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
1770 .adm-digests-pill.is-on {
1771 background: rgba(52,211,153,0.14);
1772 color: #6ee7b7;
1773 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
1774 }
1775
1776 .adm-digests-section {
1777 margin-bottom: var(--space-5);
1778 background: var(--bg-elevated);
1779 border: 1px solid var(--border);
1780 border-radius: 14px;
1781 overflow: hidden;
1782 }
1783 .adm-digests-section-head {
1784 padding: var(--space-4) var(--space-5);
1785 border-bottom: 1px solid var(--border);
1786 display: flex;
1787 align-items: center;
1788 gap: 10px;
1789 flex-wrap: wrap;
1790 }
1791 .adm-digests-section-icon {
1792 display: inline-flex;
1793 align-items: center;
1794 justify-content: center;
1795 width: 26px; height: 26px;
1796 border-radius: 8px;
1797 background: rgba(140,109,255,0.12);
1798 color: #b69dff;
1799 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1800 flex-shrink: 0;
1801 }
1802 .adm-digests-section-title {
1803 margin: 0;
1804 font-family: var(--font-display);
1805 font-size: 17px;
1806 font-weight: 700;
1807 letter-spacing: -0.018em;
1808 color: var(--text-strong);
1809 }
1810 .adm-digests-section-sub { margin: 4px 0 0; font-size: 12.5px; color: var(--text-muted); }
1811 .adm-digests-section-body { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
1812
1813 .adm-digests-input {
1814 width: 100%;
1815 padding: 9px 12px;
1816 font-size: 13.5px;
1817 color: var(--text);
1818 background: var(--bg);
1819 border: 1px solid var(--border-strong);
1820 border-radius: 8px;
1821 outline: none;
1822 font-family: var(--font-mono);
1823 transition: border-color 120ms ease, box-shadow 120ms ease;
1824 box-sizing: border-box;
1825 max-width: 280px;
1826 }
1827 .adm-digests-input:focus {
1828 border-color: var(--border-focus);
1829 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1830 }
1831 .adm-digests-form-row {
1832 display: flex;
1833 gap: 8px;
1834 align-items: center;
1835 flex-wrap: wrap;
1836 }
1837
1838 .adm-digests-btn {
1839 display: inline-flex;
1840 align-items: center;
1841 gap: 6px;
1842 padding: 9px 16px;
1843 border-radius: 10px;
1844 font-size: 13px;
1845 font-weight: 600;
1846 text-decoration: none;
1847 border: 1px solid var(--border-strong);
1848 background: rgba(255,255,255,0.02);
1849 color: var(--text);
1850 cursor: pointer;
1851 font: inherit;
1852 line-height: 1;
1853 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
1854 }
1855 .adm-digests-btn:hover { border-color: rgba(140,109,255,0.45); background: rgba(140,109,255,0.06); color: var(--text-strong); }
1856 .adm-digests-btn-primary {
1857 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1858 color: #fff;
1859 border-color: transparent;
1860 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
1861 }
1862 .adm-digests-btn-primary:hover { color: #fff; transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55); }
1863
1864 .adm-digests-section-divider {
1865 border-top: 1px solid var(--border-subtle);
1866 padding-top: var(--space-3);
1867 margin-top: var(--space-2);
1868 }
1869 .adm-digests-divider-hint {
1870 font-size: 12.5px;
1871 color: var(--text-muted);
1872 margin-bottom: 8px;
1873 }
1874
1875 .adm-digests-h3 {
1876 display: flex;
1877 align-items: baseline;
1878 justify-content: space-between;
1879 gap: var(--space-3);
1880 margin: var(--space-5) 0 var(--space-3);
1881 }
1882 .adm-digests-h3 h3 {
1883 font-family: var(--font-display);
1884 font-size: 16px;
1885 font-weight: 700;
1886 letter-spacing: -0.014em;
1887 margin: 0;
1888 color: var(--text-strong);
1889 }
1890 .adm-digests-h3-meta { font-size: 12px; color: var(--text-muted); }
1891
1892 .adm-digests-grid {
1893 display: grid;
1894 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1895 gap: var(--space-3);
1896 }
1897 .adm-digests-card {
1898 padding: var(--space-3) var(--space-4);
1899 background: var(--bg-elevated);
1900 border: 1px solid var(--border);
1901 border-radius: 12px;
1902 display: flex;
1903 align-items: center;
1904 gap: 12px;
1905 transition: transform 160ms ease, border-color 160ms ease;
1906 }
1907 .adm-digests-card:hover { transform: translateY(-1px); border-color: var(--border-strong); }
1908 .adm-digests-avatar {
1909 width: 36px; height: 36px;
1910 border-radius: 9999px;
1911 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.22));
1912 color: var(--text-strong);
1913 display: inline-flex;
1914 align-items: center;
1915 justify-content: center;
1916 font-size: 13px;
1917 font-weight: 700;
1918 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
1919 flex-shrink: 0;
1920 text-transform: uppercase;
1921 }
1922 .adm-digests-card-text { min-width: 0; flex: 1; }
1923 .adm-digests-card-name {
1924 font-size: 13.5px;
1925 font-weight: 600;
1926 color: var(--text-strong);
1927 text-decoration: none;
1928 }
1929 .adm-digests-card-name:hover { color: var(--accent-hover, var(--accent)); }
1930 .adm-digests-card-sent {
1931 margin-top: 2px;
1932 font-size: 11.5px;
1933 color: var(--text-muted);
1934 font-family: var(--font-mono);
1935 }
1936
1937 .adm-digests-empty {
1938 position: relative;
1939 padding: var(--space-12) var(--space-6);
1940 border: 1px dashed var(--border);
1941 border-radius: 16px;
1942 background: var(--bg-elevated);
1943 text-align: center;
1944 overflow: hidden;
1945 }
1946 .adm-digests-empty-orb {
1947 position: absolute;
1948 inset: 50% auto auto 50%;
1949 transform: translate(-50%, -50%);
1950 width: 320px; height: 320px;
1951 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
1952 filter: blur(60px);
1953 pointer-events: none;
1954 z-index: 0;
1955 }
1956 .adm-digests-empty-inner { position: relative; z-index: 1; }
1957 .adm-digests-empty-icon {
1958 display: inline-flex;
1959 align-items: center;
1960 justify-content: center;
1961 width: 56px; height: 56px;
1962 border-radius: 16px;
1963 background: rgba(140,109,255,0.10);
1964 color: #b69dff;
1965 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1966 margin-bottom: var(--space-3);
1967 }
1968 .adm-digests-empty-icon svg { width: 24px; height: 24px; }
1969 .adm-digests-empty-title {
1970 font-family: var(--font-display);
1971 font-size: 20px;
1972 font-weight: 700;
1973 letter-spacing: -0.015em;
1974 color: var(--text-strong);
1975 margin-bottom: 6px;
1976 }
1977 .adm-digests-empty-sub {
1978 color: var(--text-muted);
1979 font-size: 13.5px;
1980 line-height: 1.5;
1981 }
f1dc7c7Claude1982
1983 @media (max-width: 720px) {
1984 .adm-digests-wrap { padding: var(--space-4) var(--space-3); }
1985 .adm-digests-hero { padding: var(--space-4); }
1986 .adm-digests-grid { grid-template-columns: 1fr; }
1987 }
8929744Claude1988`;
1989
1990const admAutopilotStyles = `
eed4684Claude1991 .adm-autopilot-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1992
1993 .adm-autopilot-hero {
1994 position: relative;
1995 margin-bottom: var(--space-5);
1996 padding: var(--space-5) var(--space-6);
1997 background: var(--bg-elevated);
1998 border: 1px solid var(--border);
1999 border-radius: 16px;
2000 overflow: hidden;
2001 }
2002 .adm-autopilot-hero::before {
2003 content: '';
2004 position: absolute;
2005 top: 0; left: 0; right: 0;
2006 height: 2px;
2007 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2008 opacity: 0.7;
2009 pointer-events: none;
2010 }
2011 .adm-autopilot-hero-orb {
2012 position: absolute;
2013 inset: -20% -10% auto auto;
2014 width: 380px; height: 380px;
2015 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
2016 filter: blur(80px);
2017 opacity: 0.7;
2018 pointer-events: none;
2019 z-index: 0;
2020 animation: admAutopilotOrb 14s ease-in-out infinite;
2021 }
2022 @keyframes admAutopilotOrb {
2023 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
2024 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
2025 }
2026 @media (prefers-reduced-motion: reduce) {
2027 .adm-autopilot-hero-orb { animation: none; }
2028 }
2029 .adm-autopilot-hero-inner {
2030 position: relative;
2031 z-index: 1;
2032 display: flex;
2033 align-items: flex-end;
2034 justify-content: space-between;
2035 gap: var(--space-4);
2036 flex-wrap: wrap;
2037 }
2038 .adm-autopilot-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
2039 .adm-autopilot-eyebrow {
2040 font-size: 12px;
2041 color: var(--text-muted);
2042 margin-bottom: var(--space-2);
2043 letter-spacing: 0.02em;
2044 display: inline-flex;
2045 align-items: center;
2046 gap: 8px;
2047 }
2048 .adm-autopilot-eyebrow-pill {
2049 display: inline-flex;
2050 align-items: center;
2051 justify-content: center;
2052 width: 22px; height: 22px;
2053 border-radius: 6px;
2054 background: rgba(140,109,255,0.14);
2055 color: #b69dff;
2056 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
2057 }
2058 .adm-autopilot-title {
2059 font-size: clamp(28px, 4vw, 40px);
2060 font-family: var(--font-display);
2061 font-weight: 800;
2062 letter-spacing: -0.028em;
2063 line-height: 1.05;
2064 margin: 0 0 var(--space-2);
2065 color: var(--text-strong);
2066 }
2067 .adm-autopilot-title-grad {
2068 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2069 -webkit-background-clip: text;
2070 background-clip: text;
2071 -webkit-text-fill-color: transparent;
2072 color: transparent;
2073 }
2074 .adm-autopilot-sub {
2075 font-size: 15px;
2076 color: var(--text-muted);
2077 margin: 0;
2078 line-height: 1.5;
2079 max-width: 620px;
2080 }
2081 .adm-autopilot-back {
2082 display: inline-flex;
2083 align-items: center;
2084 gap: 6px;
2085 padding: 7px 12px;
2086 font-size: 12.5px;
2087 color: var(--text-muted);
2088 background: rgba(255,255,255,0.02);
2089 border: 1px solid var(--border);
2090 border-radius: 8px;
2091 text-decoration: none;
2092 font-weight: 500;
2093 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
2094 }
2095 .adm-autopilot-back:hover {
2096 border-color: var(--border-strong);
2097 color: var(--text-strong);
2098 background: rgba(255,255,255,0.04);
2099 }
2100
2101 .adm-autopilot-banner {
2102 margin-bottom: var(--space-4);
2103 padding: 10px 14px;
2104 border-radius: 10px;
2105 font-size: 13.5px;
2106 border: 1px solid var(--border);
2107 background: rgba(255,255,255,0.025);
2108 color: var(--text);
2109 }
2110 .adm-autopilot-banner.is-ok {
2111 border-color: rgba(52,211,153,0.40);
2112 background: rgba(52,211,153,0.08);
2113 color: #bbf7d0;
2114 }
2115 .adm-autopilot-banner.is-error {
2116 border-color: rgba(248,113,113,0.40);
2117 background: rgba(248,113,113,0.08);
2118 color: #fecaca;
2119 }
2120
2121 .adm-autopilot-statgrid {
2122 display: grid;
2123 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2124 gap: var(--space-3);
2125 margin-bottom: var(--space-5);
2126 }
2127 .adm-autopilot-stat {
2128 position: relative;
2129 padding: var(--space-4);
2130 background: var(--bg-elevated);
2131 border: 1px solid var(--border);
2132 border-radius: 14px;
2133 overflow: hidden;
2134 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
2135 }
2136 .adm-autopilot-stat:hover {
2137 transform: translateY(-2px);
2138 border-color: var(--border-strong);
2139 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
2140 }
2141 .adm-autopilot-stat-head {
2142 display: flex;
2143 align-items: center;
2144 justify-content: space-between;
2145 margin-bottom: var(--space-2);
2146 }
2147 .adm-autopilot-stat-label {
2148 font-size: 11px;
2149 font-weight: 600;
2150 letter-spacing: 0.08em;
2151 text-transform: uppercase;
2152 color: var(--text-muted);
2153 }
2154 .adm-autopilot-stat-icon {
2155 display: inline-flex;
2156 align-items: center;
2157 justify-content: center;
2158 width: 26px; height: 26px;
2159 border-radius: 8px;
2160 background: rgba(140,109,255,0.12);
2161 color: #b69dff;
2162 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
2163 }
2164 .adm-autopilot-stat-value {
2165 font-family: var(--font-display);
2166 font-size: 32px;
2167 font-weight: 800;
2168 letter-spacing: -0.028em;
2169 line-height: 1;
2170 color: var(--text-strong);
2171 }
2172 .adm-autopilot-stat-value.is-mono {
2173 font-family: var(--font-mono);
2174 font-size: 13px;
2175 line-height: 1.3;
2176 word-break: break-all;
2177 }
2178 .adm-autopilot-stat-hint { margin-top: 6px; font-size: 12px; color: var(--text-muted); }
2179 .adm-autopilot-pill {
2180 display: inline-flex;
2181 align-items: center;
2182 gap: 6px;
2183 padding: 2px 8px;
2184 border-radius: 9999px;
2185 font-size: 10.5px;
2186 font-weight: 600;
2187 letter-spacing: 0.04em;
2188 text-transform: uppercase;
2189 }
2190 .adm-autopilot-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
2191 .adm-autopilot-pill.is-on {
2192 background: rgba(52,211,153,0.14);
2193 color: #6ee7b7;
2194 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
2195 }
2196 .adm-autopilot-pill.is-off {
2197 background: rgba(255,255,255,0.04);
2198 color: var(--text-muted);
2199 box-shadow: inset 0 0 0 1px var(--border);
2200 }
2201
2202 .adm-autopilot-actions {
2203 display: flex;
2204 align-items: center;
2205 gap: 12px;
2206 flex-wrap: wrap;
2207 margin-bottom: var(--space-5);
2208 }
2209 .adm-autopilot-actions form { margin: 0; }
2210 .adm-autopilot-action-hint {
2211 color: var(--text-muted);
2212 font-size: 13px;
2213 }
2214
2215 .adm-autopilot-btn {
2216 display: inline-flex;
2217 align-items: center;
2218 gap: 6px;
2219 padding: 9px 16px;
2220 border-radius: 10px;
2221 font-size: 13px;
2222 font-weight: 600;
2223 text-decoration: none;
2224 border: 1px solid transparent;
2225 cursor: pointer;
2226 font: inherit;
2227 line-height: 1;
2228 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
2229 }
2230 .adm-autopilot-btn-primary {
2231 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
2232 color: #fff;
2233 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
2234 }
2235 .adm-autopilot-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55); }
2236
2237 .adm-autopilot-h3 {
2238 display: flex;
2239 align-items: baseline;
2240 justify-content: space-between;
2241 gap: var(--space-3);
2242 margin: 0 0 var(--space-3);
2243 }
2244 .adm-autopilot-h3 h3 {
2245 font-family: var(--font-display);
2246 font-size: 16px;
2247 font-weight: 700;
2248 letter-spacing: -0.014em;
2249 margin: 0;
2250 color: var(--text-strong);
2251 }
2252 .adm-autopilot-h3-meta { font-size: 12px; color: var(--text-muted); }
2253
2254 .adm-autopilot-tasks {
2255 display: grid;
2256 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
2257 gap: var(--space-3);
2258 }
2259 .adm-autopilot-task {
2260 padding: var(--space-3) var(--space-4);
2261 background: var(--bg-elevated);
2262 border: 1px solid var(--border);
2263 border-radius: 12px;
2264 display: flex;
2265 flex-direction: column;
2266 gap: 8px;
2267 transition: transform 160ms ease, border-color 160ms ease;
2268 }
2269 .adm-autopilot-task:hover { transform: translateY(-1px); border-color: var(--border-strong); }
2270 .adm-autopilot-task.is-ok { border-color: rgba(52,211,153,0.28); }
2271 .adm-autopilot-task.is-fail { border-color: rgba(248,113,113,0.32); }
2272 .adm-autopilot-task-head {
2273 display: flex;
2274 align-items: center;
2275 gap: 10px;
2276 }
2277 .adm-autopilot-task-light {
2278 flex-shrink: 0;
2279 width: 10px; height: 10px;
2280 border-radius: 9999px;
2281 background: #6b7280;
2282 box-shadow: 0 0 0 3px rgba(107,114,128,0.16);
2283 }
2284 .adm-autopilot-task-light.is-ok {
2285 background: #34d399;
2286 box-shadow: 0 0 0 3px rgba(52,211,153,0.22), 0 0 8px rgba(52,211,153,0.45);
2287 }
2288 .adm-autopilot-task-light.is-fail {
2289 background: #f87171;
2290 box-shadow: 0 0 0 3px rgba(248,113,113,0.22), 0 0 10px rgba(248,113,113,0.50);
2291 animation: admApPulse 1.8s ease-in-out infinite;
2292 }
2293 @keyframes admApPulse {
2294 0%, 100% { opacity: 1; transform: scale(1); }
2295 50% { opacity: 0.7; transform: scale(0.92); }
2296 }
2297 @media (prefers-reduced-motion: reduce) {
2298 .adm-autopilot-task-light.is-fail { animation: none; }
2299 }
2300 .adm-autopilot-task-name {
2301 font-family: var(--font-mono);
2302 font-size: 12.5px;
2303 font-weight: 600;
2304 color: var(--text-strong);
2305 word-break: break-word;
2306 flex: 1;
2307 min-width: 0;
2308 }
2309 .adm-autopilot-task-status {
2310 display: inline-flex;
2311 align-items: center;
2312 gap: 4px;
2313 padding: 2px 8px;
2314 border-radius: 9999px;
2315 font-size: 10.5px;
2316 font-weight: 600;
2317 letter-spacing: 0.04em;
2318 text-transform: uppercase;
2319 flex-shrink: 0;
2320 }
2321 .adm-autopilot-task-status.is-ok {
2322 background: rgba(52,211,153,0.14);
2323 color: #6ee7b7;
2324 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
2325 }
2326 .adm-autopilot-task-status.is-fail {
2327 background: rgba(248,113,113,0.12);
2328 color: #fecaca;
2329 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
2330 }
2331 .adm-autopilot-task-meta {
2332 display: flex;
2333 align-items: center;
2334 justify-content: space-between;
2335 gap: 8px;
2336 font-size: 11.5px;
2337 color: var(--text-muted);
2338 font-family: var(--font-mono);
2339 }
2340 .adm-autopilot-task-err {
2341 font-size: 11.5px;
2342 color: #fecaca;
2343 line-height: 1.5;
2344 background: rgba(248,113,113,0.06);
2345 border: 1px solid rgba(248,113,113,0.20);
2346 padding: 6px 8px;
2347 border-radius: 6px;
2348 word-break: break-word;
2349 }
2350
2351 .adm-autopilot-empty {
2352 position: relative;
2353 padding: var(--space-12) var(--space-6);
2354 border: 1px dashed var(--border);
2355 border-radius: 16px;
2356 background: var(--bg-elevated);
2357 text-align: center;
2358 overflow: hidden;
2359 }
2360 .adm-autopilot-empty-orb {
2361 position: absolute;
2362 inset: 50% auto auto 50%;
2363 transform: translate(-50%, -50%);
2364 width: 320px; height: 320px;
2365 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
2366 filter: blur(60px);
2367 pointer-events: none;
2368 z-index: 0;
2369 }
2370 .adm-autopilot-empty-inner { position: relative; z-index: 1; }
2371 .adm-autopilot-empty-icon {
2372 display: inline-flex;
2373 align-items: center;
2374 justify-content: center;
2375 width: 56px; height: 56px;
2376 border-radius: 16px;
2377 background: rgba(140,109,255,0.10);
2378 color: #b69dff;
2379 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
2380 margin-bottom: var(--space-3);
2381 }
2382 .adm-autopilot-empty-icon svg { width: 24px; height: 24px; }
2383 .adm-autopilot-empty-title {
2384 font-family: var(--font-display);
2385 font-size: 20px;
2386 font-weight: 700;
2387 letter-spacing: -0.015em;
2388 color: var(--text-strong);
2389 margin-bottom: 6px;
2390 }
2391 .adm-autopilot-empty-sub {
2392 color: var(--text-muted);
2393 font-size: 13.5px;
2394 line-height: 1.5;
2395 }
2396
2397 .adm-autopilot-foot {
2398 margin-top: var(--space-5);
2399 padding: var(--space-3) var(--space-4);
2400 border: 1px solid var(--border-subtle);
2401 background: rgba(255,255,255,0.015);
2402 border-radius: 10px;
2403 color: var(--text-muted);
2404 font-size: 12.5px;
2405 }
2406 .adm-autopilot-foot code {
2407 font-family: var(--font-mono);
2408 font-size: 12px;
2409 background: var(--bg-tertiary);
2410 padding: 1px 5px;
2411 border-radius: 4px;
2412 color: var(--text);
2413 }
f1dc7c7Claude2414
2415 @media (max-width: 720px) {
2416 .adm-autopilot-wrap { padding: var(--space-4) var(--space-3); }
2417 .adm-autopilot-hero { padding: var(--space-4); }
2418 .adm-autopilot-statgrid { grid-template-columns: 1fr 1fr; }
2419 .adm-autopilot-tasks { grid-template-columns: 1fr; }
2420 }
8929744Claude2421`;
2422
b218e63Claude2423const admAnalyticsStyles = `
2424 .adm-analytics-wrap { max-width: 1400px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
2425
2426 .adm-analytics-hero {
2427 position: relative;
2428 margin-bottom: var(--space-5);
2429 padding: var(--space-5) var(--space-6);
2430 background: var(--bg-elevated);
2431 border: 1px solid var(--border);
2432 border-radius: 16px;
2433 overflow: hidden;
2434 }
2435 .adm-analytics-hero::before {
2436 content: '';
2437 position: absolute;
2438 top: 0; left: 0; right: 0;
2439 height: 2px;
2440 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2441 opacity: 0.7;
2442 pointer-events: none;
2443 }
2444 .adm-analytics-hero-orb {
2445 position: absolute;
2446 inset: -20% -10% auto auto;
2447 width: 380px; height: 380px;
2448 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
2449 filter: blur(80px);
2450 opacity: 0.7;
2451 pointer-events: none;
2452 z-index: 0;
2453 animation: admAnalyticsOrb 14s ease-in-out infinite;
2454 }
2455 @keyframes admAnalyticsOrb {
2456 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
2457 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
2458 }
2459 @media (prefers-reduced-motion: reduce) { .adm-analytics-hero-orb { animation: none; } }
2460 .adm-analytics-hero-inner {
2461 position: relative; z-index: 1;
2462 display: flex; align-items: flex-end; justify-content: space-between;
2463 gap: var(--space-4); flex-wrap: wrap;
2464 }
2465 .adm-analytics-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
2466 .adm-analytics-eyebrow {
2467 font-size: 12px; color: var(--text-muted); margin-bottom: var(--space-2);
2468 letter-spacing: 0.02em; display: inline-flex; align-items: center; gap: 8px;
2469 }
2470 .adm-analytics-eyebrow-pill {
2471 display: inline-flex; align-items: center; justify-content: center;
2472 width: 22px; height: 22px; border-radius: 6px;
2473 background: rgba(140,109,255,0.14); color: #b69dff;
2474 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
2475 }
2476 .adm-analytics-title {
2477 font-size: clamp(28px, 4vw, 36px); font-family: var(--font-display);
2478 font-weight: 800; letter-spacing: -0.028em; line-height: 1.05;
2479 margin: 0 0 var(--space-2); color: var(--text-strong);
2480 }
2481 .adm-analytics-title-grad {
2482 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2483 -webkit-background-clip: text; background-clip: text;
2484 -webkit-text-fill-color: transparent; color: transparent;
2485 }
2486 .adm-analytics-sub { font-size: 14px; color: var(--text-muted); margin: 0; line-height: 1.5; }
2487 .adm-analytics-back {
2488 display: inline-flex; align-items: center; gap: 6px;
2489 padding: 7px 12px; font-size: 12.5px; color: var(--text-muted);
2490 background: rgba(255,255,255,0.02); border: 1px solid var(--border);
2491 border-radius: 8px; text-decoration: none; font-weight: 500;
2492 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
2493 }
2494 .adm-analytics-back:hover { border-color: var(--border-strong); color: var(--text-strong); background: rgba(255,255,255,0.04); }
2495
2496 .adm-analytics-statgrid {
2497 display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
2498 gap: var(--space-3); margin-bottom: var(--space-5);
2499 }
2500 .adm-analytics-stat {
2501 padding: var(--space-4); background: var(--bg-elevated);
2502 border: 1px solid var(--border); border-radius: 14px; overflow: hidden;
2503 }
2504 .adm-analytics-stat-label {
2505 font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
2506 text-transform: uppercase; color: var(--text-muted); margin-bottom: 8px;
2507 }
2508 .adm-analytics-stat-value {
2509 font-family: var(--font-display); font-size: 28px; font-weight: 800;
2510 letter-spacing: -0.024em; line-height: 1; color: var(--text-strong);
2511 }
2512 .adm-analytics-stat-hint { margin-top: 6px; font-size: 12px; color: var(--text-faint); }
2513
2514 .adm-analytics-h3 {
2515 display: flex; align-items: baseline; justify-content: space-between;
2516 gap: var(--space-3); margin: var(--space-5) 0 var(--space-3);
2517 }
2518 .adm-analytics-h3 h3 {
2519 font-family: var(--font-display); font-size: 16px; font-weight: 700;
2520 letter-spacing: -0.014em; margin: 0; color: var(--text-strong);
2521 }
2522 .adm-analytics-h3-meta { font-size: 12px; color: var(--text-muted); }
2523
2524 .adm-analytics-table {
2525 width: 100%; border-collapse: collapse;
2526 background: var(--bg-elevated); border: 1px solid var(--border);
2527 border-radius: 14px; overflow: hidden;
2528 }
2529 .adm-analytics-table thead th {
2530 text-align: left; font-size: 11px; font-weight: 600;
2531 letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted);
2532 padding: 10px 16px; background: rgba(255,255,255,0.015);
2533 border-bottom: 1px solid var(--border);
2534 }
2535 .adm-analytics-table thead th:not(:first-child) { text-align: right; }
2536 .adm-analytics-table tbody td {
2537 padding: 10px 16px; border-bottom: 1px solid var(--border-subtle);
2538 font-size: 13px; color: var(--text); vertical-align: middle;
2539 }
2540 .adm-analytics-table tbody td:not(:first-child) { text-align: right; font-family: var(--font-mono); font-size: 12px; }
2541 .adm-analytics-table tbody tr:last-child td { border-bottom: none; }
2542 .adm-analytics-table tbody tr:hover td { background: rgba(255,255,255,0.018); }
2543 .adm-analytics-table code { font-family: var(--font-mono); font-size: 12px; color: var(--text-strong); }
2544 .adm-analytics-empty {
2545 padding: var(--space-6); text-align: center; color: var(--text-muted);
2546 font-size: 13.5px; background: var(--bg-elevated);
2547 border: 1px dashed var(--border); border-radius: 14px;
2548 }
2549
2550 /* bar chart cells */
2551 .adm-analytics-bar-cell { display: flex; align-items: center; gap: 8px; }
2552 .adm-analytics-bar-track {
2553 flex: 1; height: 6px; background: var(--bg-tertiary);
2554 border-radius: 9999px; overflow: hidden; min-width: 60px;
2555 }
2556 .adm-analytics-bar-fill {
2557 height: 100%; border-radius: 9999px;
2558 background: linear-gradient(90deg, #8c6dff, #36c5d6);
2559 }
2560 .adm-analytics-bar-label { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
2561
2562 .adm-analytics-pill {
2563 display: inline-flex; align-items: center; gap: 4px;
2564 padding: 2px 8px; border-radius: 9999px; font-size: 10.5px;
2565 font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
2566 background: rgba(140,109,255,0.12); color: #c5b3ff;
2567 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
2568 }
2569 .adm-analytics-pill.is-ok { background: rgba(52,211,153,0.12); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28); }
2570 .adm-analytics-pill.is-err { background: rgba(248,113,113,0.12); color: #fecaca; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.28); }
2571
2572 @media (max-width: 720px) {
2573 .adm-analytics-wrap { padding: var(--space-4) var(--space-3); }
2574 .adm-analytics-hero { padding: var(--space-4); }
2575 .adm-analytics-statgrid { grid-template-columns: 1fr 1fr; }
2576 .adm-analytics-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
2577 }
2578`;
2579
07f4b70Claude2580/** Inline-SVG icons (no external deps). Stroke-based, currentColor. */
2581const Icons = {
2582 shield: (
2583 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2584 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
2585 </svg>
2586 ),
2587 users: (
2588 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2589 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
2590 <circle cx="9" cy="7" r="4" />
2591 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
2592 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
2593 </svg>
2594 ),
2595 repo: (
2596 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2597 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
2598 <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
2599 </svg>
2600 ),
2601 starShield: (
2602 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2603 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
2604 <path d="m9 12 2 2 4-4" />
2605 </svg>
2606 ),
2607 ops: (
2608 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2609 <circle cx="12" cy="12" r="3" />
2610 <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
2611 </svg>
2612 ),
2613 pulse: (
2614 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2615 <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
2616 </svg>
2617 ),
2618 flag: (
2619 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2620 <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
2621 <line x1="4" y1="22" x2="4" y2="15" />
2622 </svg>
2623 ),
2624 mail: (
2625 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2626 <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
2627 <polyline points="22,6 12,13 2,6" />
2628 </svg>
2629 ),
2630 google: (
2631 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2632 <circle cx="12" cy="12" r="10" />
2633 <path d="M12 8v8" /><path d="M8 12h8" />
2634 </svg>
2635 ),
2636 github: (
2637 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
2638 <path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.69-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.68-1.28-1.68-1.05-.72.08-.71.08-.71 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.74.4-1.26.73-1.55-2.55-.29-5.23-1.28-5.23-5.69 0-1.26.45-2.29 1.19-3.1-.12-.29-.51-1.47.11-3.06 0 0 .97-.31 3.18 1.18a11 11 0 0 1 2.9-.39c.98 0 1.97.13 2.9.39 2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.77.11 3.06.74.81 1.19 1.84 1.19 3.1 0 4.42-2.69 5.39-5.25 5.68.41.35.78 1.04.78 2.1 0 1.52-.01 2.74-.01 3.11 0 .31.21.68.8.56C20.21 21.39 23.5 17.08 23.5 12 23.5 5.65 18.35.5 12 .5z" />
2639 </svg>
2640 ),
2641 sso: (
2642 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2643 <rect x="3" y="11" width="18" height="11" rx="2" />
2644 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
2645 </svg>
2646 ),
2647 bot: (
2648 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2649 <rect x="3" y="7" width="18" height="13" rx="2" />
2650 <circle cx="9" cy="13" r="1" />
2651 <circle cx="15" cy="13" r="1" />
2652 <path d="M12 3v4" />
2653 </svg>
2654 ),
2655 refresh: (
2656 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2657 <polyline points="23 4 23 10 17 10" />
2658 <polyline points="1 20 1 14 7 14" />
2659 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
2660 <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" />
2661 </svg>
2662 ),
2663 arrowLeft: (
2664 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2665 <line x1="19" y1="12" x2="5" y2="12" />
2666 <polyline points="12 19 5 12 12 5" />
2667 </svg>
2668 ),
b218e63Claude2669 dollarSign: (
2670 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2671 <line x1="12" y1="1" x2="12" y2="23" />
2672 <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
2673 </svg>
2674 ),
2675 trendingUp: (
2676 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2677 <polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
2678 <polyline points="17 6 23 6 23 12" />
2679 </svg>
2680 ),
509c376Claude2681 key: (
2682 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2683 <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
2684 </svg>
2685 ),
07f4b70Claude2686};
2687
2688/** First-letter avatar helper. */
2689function initials(s: string): string {
2690 return (s || "?").trim().charAt(0).toUpperCase() || "?";
2691}
2692
8f50ed0Claude2693async function gate(c: any): Promise<{ user: any } | Response> {
2694 const user = c.get("user");
2695 if (!user) return c.redirect("/login?next=/admin");
2696 if (!(await isSiteAdmin(user.id))) {
2697 return c.html(
2698 <Layout title="Forbidden" user={user}>
07f4b70Claude2699 <div class="admin-403">
8f50ed0Claude2700 <h2>403 — Not a site admin</h2>
2701 <p>You don't have permission to view this page.</p>
2702 </div>
07f4b70Claude2703 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude2704 </Layout>,
2705 403
2706 );
2707 }
2708 return { user };
2709}
2710
2711admin.get("/admin", async (c) => {
2712 const g = await gate(c);
2713 if (g instanceof Response) return g;
2714 const { user } = g;
2715
2716 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
2717 const [rc] = await db
2718 .select({ n: sql<number>`count(*)::int` })
2719 .from(repositories);
2720
2721 const recent = await db
2722 .select({
2723 id: users.id,
2724 username: users.username,
2725 createdAt: users.createdAt,
2726 })
2727 .from(users)
2728 .orderBy(desc(users.createdAt))
2729 .limit(10);
2730
2731 const admins = await listSiteAdmins();
2732
988380aClaude2733 const msg = c.req.query("result") || c.req.query("error");
2734 const isErr = !!c.req.query("error");
2735
07f4b70Claude2736 const userCount = Number(uc?.n || 0);
2737 const repoCount = Number(rc?.n || 0);
2738 const adminCount = admins.length;
2739
8f50ed0Claude2740 return c.html(
2741 <Layout title="Admin — Gluecron" user={user}>
07f4b70Claude2742 <div class="admin-wrap">
2743 <section class="admin-hero">
2744 <div class="admin-hero-bg" aria-hidden="true">
2745 <div class="admin-hero-orb" />
2746 </div>
2747 <div class="admin-hero-inner">
2748 <div class="admin-hero-eyebrow">
2749 <span class="admin-shield" aria-hidden="true">{Icons.shield}</span>
2750 Site administration ·{" "}
2751 <span class="admin-who">{user.username}</span>
2752 </div>
2753 <h2 class="admin-hero-title">
2754 <span class="admin-hero-title-grad">Site admin</span>.
2755 </h2>
2756 <p class="admin-hero-sub">
2757 {userCount} user{userCount === 1 ? "" : "s"} ·{" "}
2758 {repoCount} repo{repoCount === 1 ? "" : "s"} ·{" "}
2759 {adminCount} site admin{adminCount === 1 ? "" : "s"}.{" "}
2760 Operations, flags, digests, and autopilot — all in one place.
2761 </p>
2762 </div>
2763 </section>
8f50ed0Claude2764
07f4b70Claude2765 {msg && (
2766 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
2767 {decodeURIComponent(msg)}
2768 </div>
2769 )}
988380aClaude2770
07f4b70Claude2771 <div class="admin-stat-grid">
2772 <div class="admin-stat">
2773 <div class="admin-stat-head">
2774 <span class="admin-stat-label">Users</span>
2775 <span class="admin-stat-icon">{Icons.users}</span>
2776 </div>
2777 <div class="admin-stat-value">{userCount}</div>
2778 <div class="admin-stat-hint">Registered accounts</div>
8f50ed0Claude2779 </div>
07f4b70Claude2780 <div class="admin-stat">
2781 <div class="admin-stat-head">
2782 <span class="admin-stat-label">Repos</span>
2783 <span class="admin-stat-icon">{Icons.repo}</span>
2784 </div>
2785 <div class="admin-stat-value">{repoCount}</div>
2786 <div class="admin-stat-hint">Public + private</div>
8f50ed0Claude2787 </div>
07f4b70Claude2788 <div class="admin-stat">
2789 <div class="admin-stat-head">
2790 <span class="admin-stat-label">Admins</span>
2791 <span class="admin-stat-icon">{Icons.starShield}</span>
2792 </div>
2793 <div class="admin-stat-value">{adminCount}</div>
2794 <div class="admin-stat-hint">Site admins</div>
8f50ed0Claude2795 </div>
2796 </div>
2797
07f4b70Claude2798 <div class="admin-actions">
2799 <a href="/admin/ops" class="admin-action is-primary">
2800 <span class="admin-action-icon">{Icons.ops}</span>
2801 Operations
2802 </a>
509c376Claude2803 <a href="/admin/integrations" class="admin-action is-primary">
2804 <span class="admin-action-icon">{Icons.key}</span>
2805 Integrations
2806 </a>
cf793f9Claude2807 <a href="/admin/health" class="admin-action is-primary">
07f4b70Claude2808 <span class="admin-action-icon">{Icons.pulse}</span>
cf793f9Claude2809 Health (traffic lights)
2810 </a>
2811 <a href="/admin/deploys" class="admin-action is-primary">
2812 <span class="admin-action-icon">{Icons.ops}</span>
2813 Deploys
2814 </a>
2815 <a href="/admin/diagnose" class="admin-action">
2816 <span class="admin-action-icon">{Icons.pulse}</span>
2817 Diagnose
2818 </a>
2819 <a href="/admin/self-host" class="admin-action">
2820 <span class="admin-action-icon">{Icons.ops}</span>
2821 Self-host status
2822 </a>
2823 <a href="/admin/status" class="admin-action">
2824 <span class="admin-action-icon">{Icons.pulse}</span>
2825 Live activity stream
07f4b70Claude2826 </a>
2827 <a href="/admin/users" class="admin-action">
2828 <span class="admin-action-icon">{Icons.users}</span>
2829 Manage users
2830 </a>
2831 <a href="/admin/repos" class="admin-action">
2832 <span class="admin-action-icon">{Icons.repo}</span>
2833 Manage repos
2834 </a>
2835 <a href="/admin/flags" class="admin-action">
2836 <span class="admin-action-icon">{Icons.flag}</span>
2837 Site flags
2838 </a>
2839 <a href="/admin/digests" class="admin-action">
2840 <span class="admin-action-icon">{Icons.mail}</span>
2841 Email digests
2842 </a>
2843 <a href="/admin/google-oauth" class="admin-action">
2844 <span class="admin-action-icon">{Icons.google}</span>
2845 Sign in with Google
2846 </a>
2847 <a href="/admin/github-oauth" class="admin-action">
2848 <span class="admin-action-icon">{Icons.github}</span>
2849 Sign in with GitHub
2850 </a>
2851 <a href="/admin/sso" class="admin-action">
2852 <span class="admin-action-icon">{Icons.sso}</span>
2853 Enterprise SSO
2854 </a>
b218e63Claude2855 <a href="/admin/ai-costs" class="admin-action is-primary">
2856 <span class="admin-action-icon">{Icons.dollarSign}</span>
2857 AI cost breakdown
2858 </a>
2859 <a href="/admin/growth" class="admin-action is-primary">
2860 <span class="admin-action-icon">{Icons.trendingUp}</span>
2861 User growth
2862 </a>
c6018a5Claude2863 <a href="/admin/autopilot" class="admin-action" title="CI healer, patch generator, proactive monitor, AI build tasks">
07f4b70Claude2864 <span class="admin-action-icon">{Icons.bot}</span>
2865 Autopilot
2866 </a>
c6018a5Claude2867 <a href="/admin/diagnose" class="admin-action" title="Live status of the AI CI healer, patch generator, and proactive monitor">
2868 <span class="admin-action-icon">{Icons.bot}</span>
2869 AI background tasks
2870 </a>
662ce86Claude2871 <a href="/connect/claude" class="admin-action is-primary">
2872 <span class="admin-action-icon">{Icons.bot}</span>
2873 Connect Claude
2874 </a>
07f4b70Claude2875 <form
2876 method="post"
2877 action="/admin/demo/reseed"
2878 class="admin-action-form"
2879 >
2880 <button
2881 class="admin-action"
2882 type="submit"
2883 title="Idempotently (re)create demo user + 3 sample repos"
2884 >
2885 <span class="admin-action-icon">{Icons.refresh}</span>
2886 Reseed demo
2887 </button>
2888 </form>
2889 </div>
8f50ed0Claude2890
07f4b70Claude2891 <div class="admin-h3">
2892 <h3>Recent signups</h3>
2893 <span class="admin-h3-meta">
2894 {recent.length} most-recent
2895 </span>
2896 </div>
2897 <div class="admin-list" style="margin-bottom:20px">
2898 {recent.length === 0 ? (
2899 <div class="admin-list-empty">No users yet.</div>
2900 ) : (
2901 recent.map((u) => (
2902 <div class="admin-list-row">
2903 <div class="admin-list-main">
2904 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
2905 <div class="admin-row-text">
2906 <a href={`/${u.username}`} class="admin-row-title">
2907 {u.username}
2908 </a>
2909 <div class="admin-row-sub">
2910 <span>Joined</span>
2911 <span>
2912 {u.createdAt
2913 ? new Date(u.createdAt as unknown as string).toLocaleString()
2914 : ""}
2915 </span>
2916 </div>
2917 </div>
2918 </div>
2919 </div>
2920 ))
2921 )}
2922 </div>
8f50ed0Claude2923
07f4b70Claude2924 <div class="admin-h3">
2925 <h3>Site admins</h3>
2926 <span class="admin-h3-meta">
2927 {adminCount} active
2928 </span>
2929 </div>
2930 <div class="admin-list">
2931 {admins.length === 0 ? (
2932 <div class="admin-list-empty">
2933 No admins (bootstrap mode — oldest user is admin).
8f50ed0Claude2934 </div>
07f4b70Claude2935 ) : (
2936 admins.map((a) => (
2937 <div class="admin-list-row">
2938 <div class="admin-list-main">
2939 <span class="admin-avatar is-admin" aria-hidden="true">{initials(a.username)}</span>
2940 <div class="admin-row-text">
2941 <a href={`/${a.username}`} class="admin-row-title">
2942 {a.username}
2943 </a>
2944 <div class="admin-row-sub">
2945 <span class="admin-pill is-admin">
2946 <span class="dot" aria-hidden="true" /> Site admin
2947 </span>
2948 <span>
2949 Granted{" "}
2950 {a.grantedAt
2951 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
2952 : "—"}
2953 </span>
2954 </div>
2955 </div>
2956 </div>
2957 </div>
2958 ))
2959 )}
2960 </div>
8f50ed0Claude2961 </div>
07f4b70Claude2962 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude2963 </Layout>
2964 );
2965});
2966
2967// ----- Users -----
2968
2969admin.get("/admin/users", async (c) => {
2970 const g = await gate(c);
2971 if (g instanceof Response) return g;
2972 const { user } = g;
2973 const q = c.req.query("q") || "";
2974 const rows = await db
2975 .select({
2976 id: users.id,
2977 username: users.username,
2978 email: users.email,
2979 createdAt: users.createdAt,
2980 })
2981 .from(users)
2982 .where(
2983 q
2984 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
2985 : sql`1=1`
2986 )
2987 .orderBy(desc(users.createdAt))
2988 .limit(200);
2989
2990 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
8929744Claude2991 const adminCount = rows.filter((u) => adminIds.has(u.id)).length;
8f50ed0Claude2992
2993 return c.html(
2994 <Layout title="Admin — Users" user={user}>
8929744Claude2995 <div class="adm-users-wrap">
2996 <section class="adm-users-hero">
2997 <div class="adm-users-hero-orb" aria-hidden="true" />
2998 <div class="adm-users-hero-inner">
2999 <div class="adm-users-hero-text">
3000 <div class="adm-users-eyebrow">
3001 <span class="adm-users-eyebrow-pill" aria-hidden="true">{Icons.users}</span>
3002 Site admin · Users
3003 </div>
3004 <h1 class="adm-users-title">
3005 <span class="adm-users-title-grad">Users</span>.
3006 </h1>
3007 <p class="adm-users-sub">
3008 Search, audit, and grant or revoke the site-admin flag.
3009 Showing up to 200 accounts ordered by signup recency.
3010 </p>
3011 </div>
3012 <a href="/admin" class="adm-users-back">
07f4b70Claude3013 {Icons.arrowLeft} Back
3014 </a>
3015 </div>
3016 </section>
3017
8929744Claude3018 <div class="adm-users-filterbar">
3019 <form method="get" action="/admin/users" class="adm-users-search">
3020 <span class="adm-users-search-ico" aria-hidden="true">
3021 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
3022 <circle cx="11" cy="11" r="8" />
3023 <line x1="21" y1="21" x2="16.65" y2="16.65" />
3024 </svg>
3025 </span>
3026 <input
3027 type="text"
3028 name="q"
3029 value={q}
3030 placeholder="Search username or email"
3031 aria-label="Search username or email"
3032 class="adm-users-input"
3033 />
3034 <button type="submit" class="adm-users-btn">Search</button>
3035 {q && (
3036 <a href="/admin/users" class="adm-users-btn adm-users-btn-ghost">Clear</a>
3037 )}
3038 </form>
3039 <div class="adm-users-pills">
3040 <span class="adm-users-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
3041 <span class="adm-users-pill is-admin"><span class="dot" aria-hidden="true" />{adminCount} admin{adminCount === 1 ? "" : "s"}</span>
3042 </div>
3043 </div>
07f4b70Claude3044
8929744Claude3045 {rows.length === 0 ? (
3046 <div class="adm-users-empty">
3047 <div class="adm-users-empty-orb" aria-hidden="true" />
3048 <div class="adm-users-empty-inner">
3049 <div class="adm-users-empty-icon" aria-hidden="true">{Icons.users}</div>
3050 <div class="adm-users-empty-title">No users found</div>
3051 <div class="adm-users-empty-sub">
3052 {q ? <>No accounts match <code>{q}</code>. Try a different query.</> : "There are no registered accounts yet."}
3053 </div>
3054 </div>
3055 </div>
3056 ) : (
3057 <div class="adm-users-grid">
3058 {rows.map((u) => {
07f4b70Claude3059 const isAdmin = adminIds.has(u.id);
3060 return (
8929744Claude3061 <div class={"adm-users-card" + (isAdmin ? " is-admin" : "")}>
3062 <div class="adm-users-card-head">
3063 <span class={"adm-users-avatar" + (isAdmin ? " is-admin" : "")} aria-hidden="true">
07f4b70Claude3064 {initials(u.username)}
8f50ed0Claude3065 </span>
8929744Claude3066 <div class="adm-users-card-id">
3067 <a href={`/${u.username}`} class="adm-users-card-name">{u.username}</a>
3068 <code class="adm-users-card-mono" title={u.id}>{u.id.slice(0, 8)}</code>
07f4b70Claude3069 </div>
8929744Claude3070 {isAdmin && (
3071 <span class="adm-users-pill is-admin" style="margin-left:auto"><span class="dot" aria-hidden="true" />Admin</span>
3072 )}
3073 </div>
3074 <div class="adm-users-card-meta">
3075 <span class="adm-users-meta-item">
3076 <span class="adm-users-meta-key">Email</span>
3077 <span class="adm-users-meta-val">{u.email}</span>
3078 </span>
3079 {u.createdAt && (
3080 <span class="adm-users-meta-item">
3081 <span class="adm-users-meta-key">Joined</span>
3082 <span class="adm-users-meta-val">
3083 {new Date(u.createdAt as unknown as string).toLocaleDateString()}
3084 </span>
3085 </span>
3086 )}
3087 </div>
3088 <div class="adm-users-card-actions">
3089 <form
3090 method="post"
3091 action={`/admin/users/${u.id}/admin`}
3092 onsubmit={
3093 isAdmin
3094 ? "return confirm('Revoke site admin?')"
3095 : "return confirm('Grant site admin?')"
3096 }
3097 >
3098 <button
3099 type="submit"
3100 class={"adm-users-btn " + (isAdmin ? "adm-users-btn-danger" : "adm-users-btn-primary")}
3101 >
3102 {isAdmin ? "Revoke admin" : "Grant admin"}
3103 </button>
3104 </form>
3105 <a href={`/${u.username}`} class="adm-users-btn adm-users-btn-ghost">
3106 View profile
3107 </a>
07f4b70Claude3108 </div>
8f50ed0Claude3109 </div>
07f4b70Claude3110 );
8929744Claude3111 })}
3112 </div>
3113 )}
8f50ed0Claude3114 </div>
07f4b70Claude3115 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3116 <style dangerouslySetInnerHTML={{ __html: admUsersStyles }} />
8f50ed0Claude3117 </Layout>
3118 );
3119});
3120
3121admin.post("/admin/users/:id/admin", async (c) => {
3122 const g = await gate(c);
3123 if (g instanceof Response) return g;
3124 const { user } = g;
3125 const id = c.req.param("id");
3126 const admins = await listSiteAdmins();
3127 const isAlready = admins.some((a) => a.userId === id);
3128 if (isAlready) {
3129 await revokeSiteAdmin(id);
3130 await audit({
3131 userId: user.id,
3132 action: "site_admin.revoke",
3133 targetType: "user",
3134 targetId: id,
3135 });
3136 } else {
3137 await grantSiteAdmin(id, user.id);
3138 await audit({
3139 userId: user.id,
3140 action: "site_admin.grant",
3141 targetType: "user",
3142 targetId: id,
3143 });
3144 }
3145 return c.redirect("/admin/users");
3146});
3147
3148// ----- Repos -----
3149
3150admin.get("/admin/repos", async (c) => {
3151 const g = await gate(c);
3152 if (g instanceof Response) return g;
3153 const { user } = g;
3154 const rows = await db
3155 .select({
3156 id: repositories.id,
3157 name: repositories.name,
3158 ownerUsername: users.username,
0316dbbClaude3159 isPrivate: repositories.isPrivate,
8f50ed0Claude3160 createdAt: repositories.createdAt,
3161 starCount: repositories.starCount,
3162 })
3163 .from(repositories)
3164 .innerJoin(users, eq(repositories.ownerId, users.id))
3165 .orderBy(desc(repositories.createdAt))
3166 .limit(200);
3167
8929744Claude3168 const privateCount = rows.filter((r) => r.isPrivate).length;
3169 const publicCount = rows.length - privateCount;
3170
8f50ed0Claude3171 return c.html(
3172 <Layout title="Admin — Repos" user={user}>
8929744Claude3173 <div class="adm-repos-wrap">
3174 <section class="adm-repos-hero">
3175 <div class="adm-repos-hero-orb" aria-hidden="true" />
3176 <div class="adm-repos-hero-inner">
3177 <div class="adm-repos-hero-text">
3178 <div class="adm-repos-eyebrow">
3179 <span class="adm-repos-eyebrow-pill" aria-hidden="true">{Icons.repo}</span>
3180 Site admin · Repositories
3181 </div>
3182 <h1 class="adm-repos-title">
3183 <span class="adm-repos-title-grad">Repositories</span>.
3184 </h1>
3185 <p class="adm-repos-sub">
3186 Every repository on the platform — public and private.
3187 Delete is irreversible and audit-logged.
3188 </p>
3189 </div>
3190 <a href="/admin" class="adm-repos-back">
07f4b70Claude3191 {Icons.arrowLeft} Back
3192 </a>
3193 </div>
3194 </section>
3195
8929744Claude3196 <div class="adm-repos-pills">
3197 <span class="adm-repos-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
3198 <span class="adm-repos-pill is-public"><span class="dot" aria-hidden="true" />{publicCount} public</span>
3199 <span class="adm-repos-pill is-private"><span class="dot" aria-hidden="true" />{privateCount} private</span>
3200 </div>
3201
3202 {rows.length === 0 ? (
3203 <div class="adm-repos-empty">
3204 <div class="adm-repos-empty-orb" aria-hidden="true" />
3205 <div class="adm-repos-empty-inner">
3206 <div class="adm-repos-empty-icon" aria-hidden="true">{Icons.repo}</div>
3207 <div class="adm-repos-empty-title">No repositories yet</div>
3208 <div class="adm-repos-empty-sub">
3209 When users create their first repos, they'll appear here.
3210 </div>
3211 </div>
3212 </div>
3213 ) : (
3214 <div class="adm-repos-grid">
3215 {rows.map((r) => (
3216 <div class="adm-repos-card">
3217 <div class="adm-repos-card-head">
3218 <span class="adm-repos-icon" aria-hidden="true">{Icons.repo}</span>
3219 <div class="adm-repos-card-title">
07f4b70Claude3220 <a
3221 href={`/${r.ownerUsername}/${r.name}`}
8929744Claude3222 class="adm-repos-card-name"
07f4b70Claude3223 >
3224 {r.ownerUsername}/{r.name}
3225 </a>
8929744Claude3226 <code class="adm-repos-card-mono" title={r.id}>{r.id.slice(0, 8)}</code>
07f4b70Claude3227 </div>
8929744Claude3228 <span
3229 class={
3230 "adm-repos-pill " +
3231 (r.isPrivate ? "is-private" : "is-public")
3232 }
3233 style="margin-left:auto"
3234 >
3235 <span class="dot" aria-hidden="true" />
3236 {r.isPrivate ? "private" : "public"}
3237 </span>
3238 </div>
3239 <div class="adm-repos-card-meta">
3240 <span class="adm-repos-meta-item">
3241 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3242 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
3243 </svg>
3244 {r.starCount} star{r.starCount === 1 ? "" : "s"}
3245 </span>
3246 {r.createdAt && (
3247 <span class="adm-repos-meta-item">
3248 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3249 <rect x="3" y="4" width="18" height="18" rx="2" />
3250 <line x1="16" y1="2" x2="16" y2="6" />
3251 <line x1="8" y1="2" x2="8" y2="6" />
3252 <line x1="3" y1="10" x2="21" y2="10" />
3253 </svg>
3254 {new Date(r.createdAt as unknown as string).toLocaleDateString()}
3255 </span>
3256 )}
3257 </div>
3258 <div class="adm-repos-card-actions">
3259 <a href={`/${r.ownerUsername}/${r.name}`} class="adm-repos-btn adm-repos-btn-ghost">
3260 Open repo
3261 </a>
3262 <form
3263 method="post"
3264 action={`/admin/repos/${r.id}/delete`}
3265 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
3266 >
3267 <button type="submit" class="adm-repos-btn adm-repos-btn-danger">
3268 Delete
3269 </button>
3270 </form>
8f50ed0Claude3271 </div>
3272 </div>
8929744Claude3273 ))}
3274 </div>
3275 )}
8f50ed0Claude3276 </div>
07f4b70Claude3277 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3278 <style dangerouslySetInnerHTML={{ __html: admReposStyles }} />
8f50ed0Claude3279 </Layout>
3280 );
3281});
3282
3283admin.post("/admin/repos/:id/delete", async (c) => {
3284 const g = await gate(c);
3285 if (g instanceof Response) return g;
3286 const { user } = g;
3287 const id = c.req.param("id");
3288 try {
3289 await db.delete(repositories).where(eq(repositories.id, id));
3290 } catch (err) {
3291 console.error("[admin] repo delete:", err);
3292 }
3293 await audit({
3294 userId: user.id,
3295 action: "admin.repo.delete",
3296 targetType: "repository",
3297 targetId: id,
3298 });
3299 return c.redirect("/admin/repos");
3300});
3301
3302// ----- Flags -----
3303
3304admin.get("/admin/flags", async (c) => {
3305 const g = await gate(c);
3306 if (g instanceof Response) return g;
3307 const { user } = g;
3308
3309 const existing = await listFlags();
3310 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
3311 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3312
3313 return c.html(
3314 <Layout title="Admin — Flags" user={user}>
8929744Claude3315 <div class="adm-flags-wrap">
3316 <section class="adm-flags-hero">
3317 <div class="adm-flags-hero-orb" aria-hidden="true" />
3318 <div class="adm-flags-hero-inner">
3319 <div class="adm-flags-hero-text">
3320 <div class="adm-flags-eyebrow">
3321 <span class="adm-flags-eyebrow-pill" aria-hidden="true">{Icons.flag}</span>
3322 Site admin · Feature flags
3323 </div>
3324 <h1 class="adm-flags-title">
3325 <span class="adm-flags-title-grad">Site flags</span>.
3326 </h1>
3327 <p class="adm-flags-sub">
3328 Runtime feature flags surfaced to the rest of the app via{" "}
3329 <code>getFlag()</code> — registration lock, site banner, read-only mode, and more.
3330 </p>
3331 </div>
3332 <a href="/admin" class="adm-flags-back">
07f4b70Claude3333 {Icons.arrowLeft} Back
3334 </a>
3335 </div>
3336 </section>
3337
8929744Claude3338 <form method="post" action="/admin/flags" class="adm-flags-card">
3339 <div class="adm-flags-card-body">
07f4b70Claude3340 {keys.map((k) => {
3341 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
8929744Claude3342 const isOverridden =
3343 existingMap.has(k) && existingMap.get(k) !== (KNOWN_FLAGS as any)[k];
07f4b70Claude3344 return (
8929744Claude3345 <div class="adm-flags-field">
3346 <div class="adm-flags-field-head">
3347 <label for={`flag-${k}`} class="adm-flags-key">{k}</label>
3348 {isOverridden && (
3349 <span class="adm-flags-mono">overridden</span>
3350 )}
3351 </div>
07f4b70Claude3352 <input
8929744Claude3353 id={`flag-${k}`}
07f4b70Claude3354 type="text"
3355 name={k}
3356 value={current}
3357 aria-label={k}
8929744Claude3358 class="adm-flags-input"
07f4b70Claude3359 />
8929744Claude3360 <div class="adm-flags-hint">
07f4b70Claude3361 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
3362 </div>
3363 </div>
3364 );
3365 })}
3366 </div>
8929744Claude3367 <div class="adm-flags-card-foot">
3368 <span class="adm-flags-foot-hint">
07f4b70Claude3369 Saved values overwrite the defaults at runtime.
3370 </span>
8929744Claude3371 <button type="submit" class="adm-flags-btn adm-flags-btn-primary">
3372 Save changes
07f4b70Claude3373 </button>
3374 </div>
3375 </form>
8f50ed0Claude3376 </div>
07f4b70Claude3377 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3378 <style dangerouslySetInnerHTML={{ __html: admFlagsStyles }} />
8f50ed0Claude3379 </Layout>
3380 );
3381});
3382
3383admin.post("/admin/flags", async (c) => {
3384 const g = await gate(c);
3385 if (g instanceof Response) return g;
3386 const { user } = g;
3387 const body = await c.req.parseBody();
3388 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3389 for (const k of keys) {
3390 const v = String(body[k] ?? "");
3391 await setFlag(k, v, user.id);
3392 }
3393 await audit({ userId: user.id, action: "admin.flags.save" });
3394 return c.redirect("/admin/flags");
3395});
3396
08420cdClaude3397// ----- Email digests (Block I7) -----
3398
3399admin.get("/admin/digests", async (c) => {
3400 const g = await gate(c);
3401 if (g instanceof Response) return g;
3402 const { user } = g;
3403
3404 const [optedRow] = await db
3405 .select({ n: sql<number>`count(*)::int` })
3406 .from(users)
3407 .where(eq(users.notifyEmailDigestWeekly, true));
3408 const opted = Number(optedRow?.n || 0);
3409
3410 const recentlySent = await db
3411 .select({
3412 id: users.id,
3413 username: users.username,
3414 lastDigestSentAt: users.lastDigestSentAt,
3415 })
3416 .from(users)
3417 .where(sql`${users.lastDigestSentAt} is not null`)
3418 .orderBy(desc(users.lastDigestSentAt))
3419 .limit(20);
3420
3421 const result = c.req.query("result");
3422 const error = c.req.query("error");
3423
3424 return c.html(
3425 <Layout title="Admin — Digests" user={user}>
8929744Claude3426 <div class="adm-digests-wrap">
3427 <section class="adm-digests-hero">
3428 <div class="adm-digests-hero-orb" aria-hidden="true" />
3429 <div class="adm-digests-hero-inner">
3430 <div class="adm-digests-hero-text">
3431 <div class="adm-digests-eyebrow">
3432 <span class="adm-digests-eyebrow-pill" aria-hidden="true">{Icons.mail}</span>
3433 Site admin · Email
3434 </div>
3435 <h1 class="adm-digests-title">
3436 <span class="adm-digests-title-grad">Email digests</span>.
3437 </h1>
3438 <p class="adm-digests-sub">
3439 Manually trigger the weekly digest for every opted-in user
3440 or preview the email for a single account.
3441 </p>
3442 </div>
3443 <a href="/admin" class="adm-digests-back">
07f4b70Claude3444 {Icons.arrowLeft} Back
3445 </a>
3446 </div>
3447 </section>
08420cdClaude3448
07f4b70Claude3449 {result && (
8929744Claude3450 <div class="adm-digests-banner is-ok">{decodeURIComponent(result)}</div>
07f4b70Claude3451 )}
3452 {error && (
8929744Claude3453 <div class="adm-digests-banner is-error">{decodeURIComponent(error)}</div>
07f4b70Claude3454 )}
08420cdClaude3455
8929744Claude3456 <div class="adm-digests-pills">
3457 <span class="adm-digests-pill is-on"><span class="dot" aria-hidden="true" />{opted} opted-in</span>
3458 <span class="adm-digests-pill"><span class="dot" aria-hidden="true" />{recentlySent.length} recent</span>
3459 </div>
3460
3461 <section class="adm-digests-section">
3462 <header class="adm-digests-section-head">
3463 <span class="adm-digests-section-icon" aria-hidden="true">{Icons.mail}</span>
3464 <div>
3465 <h3 class="adm-digests-section-title">Send digests</h3>
3466 <p class="adm-digests-section-sub">
3467 {opted} user{opted === 1 ? "" : "s"} subscribed to the weekly digest.
3468 </p>
08420cdClaude3469 </div>
8929744Claude3470 </header>
3471 <div class="adm-digests-section-body">
3472 <form method="post" action="/admin/digests/run">
07f4b70Claude3473 <button
3474 type="submit"
8929744Claude3475 class="adm-digests-btn adm-digests-btn-primary"
07f4b70Claude3476 onclick="return confirm('Send weekly digest to all opted-in users now?')"
3477 >
8929744Claude3478 {Icons.mail}
07f4b70Claude3479 Send digests now
3480 </button>
3481 </form>
8929744Claude3482 <div class="adm-digests-section-divider">
3483 <div class="adm-digests-divider-hint">
07f4b70Claude3484 Preview / one-off — send the digest to a single user.
3485 </div>
3486 <form
3487 method="post"
3488 action="/admin/digests/preview"
8929744Claude3489 class="adm-digests-form-row"
07f4b70Claude3490 >
3491 <input
3492 type="text"
3493 name="username"
3494 placeholder="username"
3495 required
3496 aria-label="Username"
8929744Claude3497 class="adm-digests-input"
07f4b70Claude3498 />
8929744Claude3499 <button type="submit" class="adm-digests-btn">
07f4b70Claude3500 Send to one user
3501 </button>
3502 </form>
3503 </div>
3504 </div>
8929744Claude3505 </section>
07f4b70Claude3506
8929744Claude3507 <div class="adm-digests-h3">
07f4b70Claude3508 <h3>Recently sent</h3>
8929744Claude3509 <span class="adm-digests-h3-meta">last {recentlySent.length}</span>
07f4b70Claude3510 </div>
8929744Claude3511 {recentlySent.length === 0 ? (
3512 <div class="adm-digests-empty">
3513 <div class="adm-digests-empty-orb" aria-hidden="true" />
3514 <div class="adm-digests-empty-inner">
3515 <div class="adm-digests-empty-icon" aria-hidden="true">{Icons.mail}</div>
3516 <div class="adm-digests-empty-title">No digests sent yet</div>
3517 <div class="adm-digests-empty-sub">
3518 When the weekly digest fires, sent recipients will appear here.
3519 </div>
3520 </div>
3521 </div>
3522 ) : (
3523 <div class="adm-digests-grid">
3524 {recentlySent.map((u) => (
3525 <div class="adm-digests-card">
3526 <span class="adm-digests-avatar" aria-hidden="true">{initials(u.username)}</span>
3527 <div class="adm-digests-card-text">
3528 <a href={`/${u.username}`} class="adm-digests-card-name">{u.username}</a>
3529 <div class="adm-digests-card-sent">
3530 sent {u.lastDigestSentAt
3531 ? new Date(u.lastDigestSentAt as unknown as string).toLocaleString()
3532 : "—"}
07f4b70Claude3533 </div>
3534 </div>
3535 </div>
8929744Claude3536 ))}
3537 </div>
3538 )}
08420cdClaude3539 </div>
07f4b70Claude3540 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3541 <style dangerouslySetInnerHTML={{ __html: admDigestsStyles }} />
08420cdClaude3542 </Layout>
3543 );
3544});
3545
3546admin.post("/admin/digests/run", async (c) => {
3547 const g = await gate(c);
3548 if (g instanceof Response) return g;
3549 const { user } = g;
3550 const results = await sendDigestsToAll();
3551 const sent = results.filter((r) => r.ok).length;
3552 const skipped = results.length - sent;
3553 await audit({
3554 userId: user.id,
3555 action: "admin.digests.run",
3556 metadata: { sent, skipped, total: results.length },
3557 });
3558 return c.redirect(
3559 `/admin/digests?result=${encodeURIComponent(
3560 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
3561 )}`
3562 );
3563});
3564
3565admin.post("/admin/digests/preview", async (c) => {
3566 const g = await gate(c);
3567 if (g instanceof Response) return g;
3568 const { user } = g;
3569 const body = await c.req.parseBody();
3570 const username = String(body.username || "").trim();
3571 if (!username) {
3572 return c.redirect("/admin/digests?error=Username+required");
3573 }
3574 const [target] = await db
3575 .select({ id: users.id, username: users.username })
3576 .from(users)
3577 .where(eq(users.username, username))
3578 .limit(1);
3579 if (!target) {
3580 return c.redirect("/admin/digests?error=User+not+found");
3581 }
3582 const result = await sendDigestForUser(target.id);
3583 await audit({
3584 userId: user.id,
3585 action: "admin.digests.preview",
3586 targetType: "user",
3587 targetId: target.id,
3588 metadata: {
3589 ok: result.ok,
3590 skipped: "skipped" in result ? result.skipped : null,
3591 },
3592 });
3593 if (result.ok) {
3594 return c.redirect(
3595 `/admin/digests?result=${encodeURIComponent(
3596 `Digest sent to ${target.username}.`
3597 )}`
3598 );
3599 }
3600 return c.redirect(
3601 `/admin/digests?error=${encodeURIComponent(
3602 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
3603 )}`
3604 );
3605});
3606
b5dd694Claude3607const AUTOPILOT_TASK_CATALOG = [
3608 { name: "mirror-sync", desc: "Pull-sync all overdue repo mirrors" },
3609 { name: "merge-queue", desc: "Advance serialised merge queues" },
3610 { name: "weekly-digest", desc: "Send opt-in activity email digests" },
3611 { name: "advisory-rescan", desc: "Re-evaluate security advisories against deps" },
3612 { name: "wait-timer-release", desc: "Release expired deployment wait-timers" },
3613 { name: "scheduled-workflows", desc: "Fire cron-triggered workflow runs" },
3614 { name: "auto-merge-sweep", desc: "AI-gated auto-merge: close eligible PRs" },
3615 { name: "ai-build-from-issues", desc: "Dispatch ai:build issues → draft PRs" },
3616 { name: "sleep-mode-digest", desc: "Send AI-hours-saved digest emails" },
44ed968Claude3617 { name: "preview-expiry", desc: "Expire stale branch-preview rows past their TTL" },
b5dd694Claude3618] as const;
3619
8e9f1d9Claude3620admin.get("/admin/autopilot", async (c) => {
3621 const g = await gate(c);
3622 if (g instanceof Response) return g;
3623 const { user } = g;
3624 const tick = getLastTick();
3625 const total = getTickCount();
3626 const disabled = process.env.AUTOPILOT_DISABLED === "1";
3627 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
3628 const intervalMs =
3629 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
3630 ? Number(intervalRaw)
3631 : 5 * 60 * 1000;
3632 const msg = c.req.query("result") || c.req.query("error");
3633 const isErr = !!c.req.query("error");
3634 return c.html(
3635 <Layout title="Autopilot — admin" user={user}>
8929744Claude3636 <div class="adm-autopilot-wrap">
3637 <section class="adm-autopilot-hero">
3638 <div class="adm-autopilot-hero-orb" aria-hidden="true" />
3639 <div class="adm-autopilot-hero-inner">
3640 <div class="adm-autopilot-hero-text">
3641 <div class="adm-autopilot-eyebrow">
3642 <span class="adm-autopilot-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
3643 Site admin · Maintenance loop
3644 </div>
3645 <h1 class="adm-autopilot-title">
3646 <span class="adm-autopilot-title-grad">Autopilot</span>.
3647 </h1>
3648 <p class="adm-autopilot-sub">
3649 Periodic platform-maintenance loop — mirror sync, merge-queue
c315551Claude3650 progress, weekly digests, advisory rescans, wait-timer release,
3651 scheduled workflows (cron), AI-gated auto-merge sweep, and
3652 ai:build issue → PR dispatch.
8929744Claude3653 </p>
3654 </div>
3655 <a href="/admin" class="adm-autopilot-back">
07f4b70Claude3656 {Icons.arrowLeft} Back
3657 </a>
3658 </div>
3659 </section>
3660
8e9f1d9Claude3661 {msg && (
8929744Claude3662 <div class={"adm-autopilot-banner " + (isErr ? "is-error" : "is-ok")}>
8e9f1d9Claude3663 {decodeURIComponent(msg)}
3664 </div>
3665 )}
07f4b70Claude3666
8929744Claude3667 <div class="adm-autopilot-statgrid">
3668 <div class="adm-autopilot-stat">
3669 <div class="adm-autopilot-stat-head">
3670 <span class="adm-autopilot-stat-label">Status</span>
3671 <span class={"adm-autopilot-pill " + (disabled ? "is-off" : "is-on")}>
07f4b70Claude3672 <span class="dot" aria-hidden="true" />
3673 {disabled ? "disabled" : "running"}
3674 </span>
3675 </div>
8929744Claude3676 <div class="adm-autopilot-stat-value" style="font-size:22px">
8e9f1d9Claude3677 {disabled ? "disabled" : "running"}
3678 </div>
8929744Claude3679 <div class="adm-autopilot-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
8e9f1d9Claude3680 </div>
8929744Claude3681 <div class="adm-autopilot-stat">
3682 <div class="adm-autopilot-stat-head">
3683 <span class="adm-autopilot-stat-label">Interval</span>
3684 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.refresh}</span>
07f4b70Claude3685 </div>
8929744Claude3686 <div class="adm-autopilot-stat-value">{Math.round(intervalMs / 1000)}s</div>
3687 <div class="adm-autopilot-stat-hint">between ticks</div>
8e9f1d9Claude3688 </div>
8929744Claude3689 <div class="adm-autopilot-stat">
3690 <div class="adm-autopilot-stat-head">
3691 <span class="adm-autopilot-stat-label">Ticks this process</span>
3692 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.pulse}</span>
07f4b70Claude3693 </div>
8929744Claude3694 <div class="adm-autopilot-stat-value">{total}</div>
3695 <div class="adm-autopilot-stat-hint">since boot</div>
8e9f1d9Claude3696 </div>
8929744Claude3697 <div class="adm-autopilot-stat">
3698 <div class="adm-autopilot-stat-head">
3699 <span class="adm-autopilot-stat-label">Last tick</span>
3700 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.bot}</span>
07f4b70Claude3701 </div>
8929744Claude3702 <div class="adm-autopilot-stat-value is-mono">
8e9f1d9Claude3703 {tick ? tick.finishedAt : "never"}
3704 </div>
3705 </div>
3706 </div>
07f4b70Claude3707
8929744Claude3708 <div class="adm-autopilot-actions">
3709 <form method="post" action="/admin/autopilot/run">
3710 <button class="adm-autopilot-btn adm-autopilot-btn-primary" type="submit">
3711 {Icons.bot}
3712 Run tick now
3713 </button>
3714 </form>
3715 <span class="adm-autopilot-action-hint">
8e9f1d9Claude3716 Executes all sub-tasks synchronously and records the result.
3717 </span>
8929744Claude3718 </div>
07f4b70Claude3719
8929744Claude3720 <div class="adm-autopilot-h3">
07f4b70Claude3721 <h3>Last tick tasks</h3>
3722 {tick && (
8929744Claude3723 <span class="adm-autopilot-h3-meta">
07f4b70Claude3724 {tick.tasks.filter((t) => t.ok).length}/{tick.tasks.length} ok
3725 </span>
3726 )}
3727 </div>
8e9f1d9Claude3728 {tick ? (
8929744Claude3729 <div class="adm-autopilot-tasks">
3730 {tick.tasks.map((t) => (
3731 <div class={"adm-autopilot-task " + (t.ok ? "is-ok" : "is-fail")}>
3732 <div class="adm-autopilot-task-head">
3733 <span
3734 class={"adm-autopilot-task-light " + (t.ok ? "is-ok" : "is-fail")}
3735 aria-label={t.ok ? "ok" : "failed"}
3736 />
3737 <span class="adm-autopilot-task-name">{t.name}</span>
3738 <span class={"adm-autopilot-task-status " + (t.ok ? "is-ok" : "is-fail")}>
8e9f1d9Claude3739 {t.ok ? "ok" : "failed"}
8929744Claude3740 </span>
3741 </div>
3742 <div class="adm-autopilot-task-meta">
3743 <span>duration</span>
3744 <span>{t.durationMs}ms</span>
3745 </div>
3746 {t.error && (
3747 <div class="adm-autopilot-task-err">{t.error}</div>
3748 )}
3749 </div>
3750 ))}
3751 </div>
8e9f1d9Claude3752 ) : (
8929744Claude3753 <div class="adm-autopilot-empty">
3754 <div class="adm-autopilot-empty-orb" aria-hidden="true" />
3755 <div class="adm-autopilot-empty-inner">
3756 <div class="adm-autopilot-empty-icon" aria-hidden="true">{Icons.bot}</div>
3757 <div class="adm-autopilot-empty-title">No ticks yet</div>
3758 <div class="adm-autopilot-empty-sub">
3759 The first tick fires after the interval elapses. Click "Run tick now" to fire one immediately.
3760 </div>
3761 </div>
07f4b70Claude3762 </div>
8e9f1d9Claude3763 )}
b5dd694Claude3764 <div class="adm-autopilot-h3" style="margin-top:28px">
3765 <h3>Configured tasks</h3>
44ed968Claude3766 <span class="adm-autopilot-h3-meta">10 tasks · runs every tick</span>
b5dd694Claude3767 </div>
3768 <div class="adm-autopilot-tasks">
3769 {AUTOPILOT_TASK_CATALOG.map((t) => {
3770 const last = tick?.tasks.find((r) => r.name === t.name);
3771 return (
3772 <div class={"adm-autopilot-task " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}>
3773 <div class="adm-autopilot-task-head">
3774 <span
3775 class={"adm-autopilot-task-light " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}
3776 aria-label={last ? (last.ok ? "ok" : "failed") : "not yet run"}
3777 />
3778 <span class="adm-autopilot-task-name">{t.name}</span>
3779 {last && (
3780 <span class={"adm-autopilot-task-status " + (last.ok ? "is-ok" : "is-fail")}>
3781 {last.ok ? `ok · ${last.durationMs}ms` : "failed"}
3782 </span>
3783 )}
3784 </div>
3785 <div class="adm-autopilot-task-meta">
3786 <span>{t.desc}</span>
3787 </div>
3788 {last?.error && <div class="adm-autopilot-task-err">{last.error}</div>}
3789 </div>
3790 );
3791 })}
3792 </div>
3793
8929744Claude3794 <p class="adm-autopilot-foot">
8e9f1d9Claude3795 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
3796 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
3797 </p>
3798 </div>
07f4b70Claude3799 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3800 <style dangerouslySetInnerHTML={{ __html: admAutopilotStyles }} />
8e9f1d9Claude3801 </Layout>
3802 );
3803});
3804
988380aClaude3805admin.post("/admin/demo/reseed", async (c) => {
3806 const g = await gate(c);
3807 if (g instanceof Response) return g;
3808 const { user } = g;
3809 try {
3810 const result = await ensureDemoContent({ force: true });
3811 const summary = `Demo reseed: user=${result.created.user ? "created" : "existed"}, repos=${result.created.repos.length}, issues=${result.created.issues}, prs=${result.created.prs}${result.errors.length ? `, errors=${result.errors.length}` : ""}`;
3812 await audit({
3813 userId: user.id,
3814 action: "admin.demo.reseed",
3815 targetType: "user",
3816 targetId: result.demoUser?.id ?? "demo",
3817 metadata: {
3818 createdUser: result.created.user,
3819 createdRepos: result.created.repos,
3820 createdIssues: result.created.issues,
3821 createdPrs: result.created.prs,
3822 errors: result.errors.slice(0, 5),
3823 },
3824 });
3825 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
3826 } catch (err) {
3827 const message = err instanceof Error ? err.message : String(err);
3828 return c.redirect(
3829 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
3830 );
3831 }
3832});
3833
3834// Public jump-to-demo — redirects to the first demo repo if present,
3835// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
3836admin.get("/demo", (c) => {
3837 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
3838});
3839
8e9f1d9Claude3840admin.post("/admin/autopilot/run", async (c) => {
3841 const g = await gate(c);
3842 if (g instanceof Response) return g;
3843 const { user } = g;
3844 let summary = "";
3845 try {
3846 const result = await runAutopilotTick();
3847 const ok = result.tasks.filter((t) => t.ok).length;
3848 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
3849 await audit({
3850 userId: user.id,
3851 action: "admin.autopilot.run",
3852 targetType: "system",
3853 targetId: "autopilot",
3854 metadata: { ok, total: result.tasks.length },
3855 });
3856 return c.redirect(
3857 `/admin/autopilot?result=${encodeURIComponent(summary)}`
3858 );
3859 } catch (err) {
3860 const message = err instanceof Error ? err.message : String(err);
3861 return c.redirect(
3862 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
3863 );
3864 }
3865});
3866
b218e63Claude3867// ─── AI Cost Breakdown (/admin/ai-costs) ─────────────────────────────────────
3868
3869admin.get("/admin/ai-costs", async (c) => {
3870 const g = await gate(c);
3871 if (g instanceof Response) return g;
3872 const { user } = g;
3873
3874 // Start of current month (UTC)
3875 const now = new Date();
3876 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
3877
3878 // Total spend this month
3879 const [totalRow] = await db
3880 .select({ total: sql<number>`coalesce(sum(cents_estimate), 0)::int` })
3881 .from(aiCostEvents)
3882 .where(gte(aiCostEvents.occurredAt, monthStart));
3883 const totalCents = Number(totalRow?.total ?? 0);
3884
3885 // Breakdown by category
3886 const byCategory = await db
3887 .select({
3888 category: aiCostEvents.category,
3889 cents: sql<number>`sum(cents_estimate)::int`,
3890 calls: sql<number>`count(*)::int`,
3891 })
3892 .from(aiCostEvents)
3893 .where(gte(aiCostEvents.occurredAt, monthStart))
3894 .groupBy(aiCostEvents.category)
3895 .orderBy(sql`sum(cents_estimate) desc`);
3896
3897 // Top 10 spenders (join users)
3898 const topSpenders = await db
3899 .select({
3900 username: users.username,
3901 cents: sql<number>`sum(${aiCostEvents.centsEstimate})::int`,
3902 calls: sql<number>`count(*)::int`,
3903 })
3904 .from(aiCostEvents)
3905 .innerJoin(users, eq(aiCostEvents.ownerUserId, users.id))
3906 .where(gte(aiCostEvents.occurredAt, monthStart))
3907 .groupBy(users.username)
3908 .orderBy(sql`sum(${aiCostEvents.centsEstimate}) desc`)
3909 .limit(10);
3910
3911 const maxCents = Math.max(1, ...byCategory.map((r) => Number(r.cents)));
3912 const maxSpenderCents = Math.max(1, ...topSpenders.map((r) => Number(r.cents)));
3913
3914 function fmtCents(c: number): string {
3915 if (c >= 100) return `$${(c / 100).toFixed(2)}`;
3916 return `${c}¢`;
3917 }
3918
3919 const monthName = now.toLocaleString("en-US", { month: "long", year: "numeric", timeZone: "UTC" });
3920
3921 return c.html(
3922 <Layout title="Admin — AI Costs" user={user}>
3923 <div class="adm-analytics-wrap">
3924 <section class="adm-analytics-hero">
3925 <div class="adm-analytics-hero-orb" aria-hidden="true" />
3926 <div class="adm-analytics-hero-inner">
3927 <div class="adm-analytics-hero-text">
3928 <div class="adm-analytics-eyebrow">
3929 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.dollarSign}</span>
3930 Site admin · Analytics
3931 </div>
3932 <h1 class="adm-analytics-title">
3933 <span class="adm-analytics-title-grad">AI cost breakdown</span>.
3934 </h1>
3935 <p class="adm-analytics-sub">
3936 Per-call AI spend for {monthName} — by feature category and top spenders.
3937 </p>
3938 </div>
3939 <a href="/admin" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
3940 </div>
3941 </section>
3942
3943 <div class="adm-analytics-statgrid">
3944 <div class="adm-analytics-stat">
3945 <div class="adm-analytics-stat-label">Total spend this month</div>
3946 <div class="adm-analytics-stat-value">{fmtCents(totalCents)}</div>
3947 <div class="adm-analytics-stat-hint">{monthName}</div>
3948 </div>
3949 <div class="adm-analytics-stat">
3950 <div class="adm-analytics-stat-label">Categories active</div>
3951 <div class="adm-analytics-stat-value">{byCategory.length}</div>
3952 <div class="adm-analytics-stat-hint">feature buckets with spend</div>
3953 </div>
3954 <div class="adm-analytics-stat">
3955 <div class="adm-analytics-stat-label">Total AI calls</div>
3956 <div class="adm-analytics-stat-value">
3957 {byCategory.reduce((s, r) => s + Number(r.calls), 0)}
3958 </div>
3959 <div class="adm-analytics-stat-hint">recorded events</div>
3960 </div>
3961 </div>
3962
3963 <div class="adm-analytics-h3">
3964 <h3>By category</h3>
3965 <span class="adm-analytics-h3-meta">{monthName}</span>
3966 </div>
3967 {byCategory.length === 0 ? (
3968 <div class="adm-analytics-empty">No AI cost events recorded this month.</div>
3969 ) : (
3970 <table class="adm-analytics-table">
3971 <thead>
3972 <tr>
3973 <th>Category</th>
3974 <th>Spend</th>
3975 <th>Calls</th>
3976 <th style="width:200px">Distribution</th>
3977 </tr>
3978 </thead>
3979 <tbody>
3980 {byCategory.map((row) => {
3981 const pct = Math.round((Number(row.cents) / maxCents) * 100);
3982 return (
3983 <tr>
3984 <td><code>{row.category ?? "other"}</code></td>
3985 <td>{fmtCents(Number(row.cents))}</td>
3986 <td>{Number(row.calls).toLocaleString()}</td>
3987 <td>
3988 <div class="adm-analytics-bar-cell">
3989 <div class="adm-analytics-bar-track">
3990 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
3991 </div>
3992 <span class="adm-analytics-bar-label">{pct}%</span>
3993 </div>
3994 </td>
3995 </tr>
3996 );
3997 })}
3998 </tbody>
3999 </table>
4000 )}
4001
4002 <div class="adm-analytics-h3">
4003 <h3>Top 10 spenders</h3>
4004 <span class="adm-analytics-h3-meta">{monthName}</span>
4005 </div>
4006 {topSpenders.length === 0 ? (
4007 <div class="adm-analytics-empty">No per-user spend recorded this month.</div>
4008 ) : (
4009 <table class="adm-analytics-table">
4010 <thead>
4011 <tr>
4012 <th>User</th>
4013 <th>Spend</th>
4014 <th>Calls</th>
4015 <th style="width:200px">Share</th>
4016 </tr>
4017 </thead>
4018 <tbody>
4019 {topSpenders.map((row, i) => {
4020 const pct = Math.round((Number(row.cents) / maxSpenderCents) * 100);
4021 return (
4022 <tr>
4023 <td>
4024 <a href={`/${row.username}`} style="color:var(--accent);text-decoration:none;font-weight:600">
4025 #{i + 1} {row.username}
4026 </a>
4027 </td>
4028 <td>{fmtCents(Number(row.cents))}</td>
4029 <td>{Number(row.calls).toLocaleString()}</td>
4030 <td>
4031 <div class="adm-analytics-bar-cell">
4032 <div class="adm-analytics-bar-track">
4033 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4034 </div>
4035 <span class="adm-analytics-bar-label">{pct}%</span>
4036 </div>
4037 </td>
4038 </tr>
4039 );
4040 })}
4041 </tbody>
4042 </table>
4043 )}
4044 </div>
4045 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4046 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4047 </Layout>
4048 );
4049});
4050
4051// ─── Autopilot Health (/admin/autopilot/health) ──────────────────────────────
4052
4053const ALL_AUTOPILOT_TASKS = [
4054 "mirror-sync",
4055 "merge-queue",
4056 "weekly-digest",
4057 "advisory-rescan",
4058 "wait-timer-release",
4059 "scheduled-workflows",
4060 "auto-merge-sweep",
4061 "ai-build-from-issues",
4062 "sleep-mode-digest",
4063 "stale-pr-sweep",
4064] as const;
4065
4066admin.get("/admin/autopilot/health", async (c) => {
4067 const g = await gate(c);
4068 if (g instanceof Response) return g;
4069 const { user } = g;
4070
4071 const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
4072
4073 // Pull the last 200 audit log rows that match autopilot task patterns
4074 const logRows = await db
4075 .select({
4076 action: auditLog.action,
4077 createdAt: auditLog.createdAt,
4078 metadata: auditLog.metadata,
4079 })
4080 .from(auditLog)
4081 .where(gte(auditLog.createdAt, since24h))
4082 .orderBy(desc(auditLog.createdAt))
4083 .limit(500);
4084
4085 // Build per-task health from the last autopilot tick (in-memory)
4086 const tick = getLastTick();
4087
4088 type TaskHealth = {
4089 name: string;
4090 lastRun: string | null;
4091 lastDurationMs: number | null;
4092 lastOk: boolean | null;
4093 lastError: string | null;
4094 successCount24h: number;
4095 errorCount24h: number;
4096 };
4097
4098 const health: TaskHealth[] = ALL_AUTOPILOT_TASKS.map((taskName) => {
4099 // Count audit events in last 24h that look like this task
4100 const successCount24h = logRows.filter(
4101 (r) => r.action === `autopilot.task.ok.${taskName}`
4102 ).length;
4103 const errorCount24h = logRows.filter(
4104 (r) => r.action === `autopilot.task.error.${taskName}`
4105 ).length;
4106
4107 // Get last tick result for this task (in-memory)
4108 const last = tick?.tasks.find((t) => t.name === taskName);
4109
4110 return {
4111 name: taskName,
4112 lastRun: tick?.finishedAt ?? null,
4113 lastDurationMs: last?.durationMs ?? null,
4114 lastOk: last?.ok ?? null,
4115 lastError: last?.error ?? null,
4116 successCount24h,
4117 errorCount24h,
4118 };
4119 });
4120
4121 const total = getTickCount();
4122 const disabled = process.env.AUTOPILOT_DISABLED === "1";
4123
4124 return c.html(
4125 <Layout title="Autopilot Health — admin" user={user}>
4126 <div class="adm-analytics-wrap">
4127 <section class="adm-analytics-hero">
4128 <div class="adm-analytics-hero-orb" aria-hidden="true" />
4129 <div class="adm-analytics-hero-inner">
4130 <div class="adm-analytics-hero-text">
4131 <div class="adm-analytics-eyebrow">
4132 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
4133 Site admin · Autopilot
4134 </div>
4135 <h1 class="adm-analytics-title">
4136 <span class="adm-analytics-title-grad">Autopilot health</span>.
4137 </h1>
4138 <p class="adm-analytics-sub">
4139 Last-tick status, duration, and 24h success/error counts for each autopilot task.
4140 </p>
4141 </div>
4142 <a href="/admin/autopilot" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4143 </div>
4144 </section>
4145
4146 <div class="adm-analytics-statgrid">
4147 <div class="adm-analytics-stat">
4148 <div class="adm-analytics-stat-label">Status</div>
4149 <div class="adm-analytics-stat-value" style="font-size:22px">{disabled ? "disabled" : "running"}</div>
4150 <div class="adm-analytics-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
4151 </div>
4152 <div class="adm-analytics-stat">
4153 <div class="adm-analytics-stat-label">Ticks (this process)</div>
4154 <div class="adm-analytics-stat-value">{total}</div>
4155 <div class="adm-analytics-stat-hint">since boot</div>
4156 </div>
4157 <div class="adm-analytics-stat">
4158 <div class="adm-analytics-stat-label">Last tick</div>
4159 <div class="adm-analytics-stat-value" style="font-size:14px;font-family:var(--font-mono);line-height:1.3">
4160 {tick?.finishedAt ?? "—"}
4161 </div>
4162 </div>
4163 <div class="adm-analytics-stat">
4164 <div class="adm-analytics-stat-label">Tasks OK (last tick)</div>
4165 <div class="adm-analytics-stat-value">
4166 {tick ? `${tick.tasks.filter((t) => t.ok).length}/${tick.tasks.length}` : "—"}
4167 </div>
4168 </div>
4169 </div>
4170
4171 <div class="adm-analytics-h3">
4172 <h3>Per-task health</h3>
4173 <span class="adm-analytics-h3-meta">last tick + 24h audit window</span>
4174 </div>
4175 <table class="adm-analytics-table">
4176 <thead>
4177 <tr>
4178 <th>Task</th>
4179 <th>Last status</th>
4180 <th>Duration (last)</th>
4181 <th>OK (24h)</th>
4182 <th>Errors (24h)</th>
4183 </tr>
4184 </thead>
4185 <tbody>
4186 {health.map((t) => (
4187 <tr>
4188 <td><code>{t.name}</code></td>
4189 <td>
4190 {t.lastOk === null ? (
4191 <span style="color:var(--text-muted)">—</span>
4192 ) : t.lastOk ? (
4193 <span class="adm-analytics-pill is-ok">ok</span>
4194 ) : (
4195 <span class="adm-analytics-pill is-err" title={t.lastError ?? ""}>failed</span>
4196 )}
4197 </td>
4198 <td>{t.lastDurationMs !== null ? `${t.lastDurationMs}ms` : "—"}</td>
4199 <td>
4200 {t.successCount24h > 0 ? (
4201 <span class="adm-analytics-pill is-ok">{t.successCount24h}</span>
4202 ) : (
4203 <span style="color:var(--text-muted)">—</span>
4204 )}
4205 </td>
4206 <td>
4207 {t.errorCount24h > 0 ? (
4208 <span class="adm-analytics-pill is-err">{t.errorCount24h}</span>
4209 ) : (
4210 <span style="color:var(--text-muted)">0</span>
4211 )}
4212 </td>
4213 </tr>
4214 ))}
4215 </tbody>
4216 </table>
4217
4218 {tick?.tasks.some((t) => !t.ok && t.error) && (
4219 <>
4220 <div class="adm-analytics-h3" style="margin-top:28px">
4221 <h3>Last-tick errors</h3>
4222 </div>
4223 {tick.tasks.filter((t) => !t.ok && t.error).map((t) => (
4224 <div style="margin-bottom:12px;padding:12px 14px;background:rgba(248,113,113,0.06);border:1px solid rgba(248,113,113,0.20);border-radius:10px;font-size:12.5px">
4225 <code style="color:#fecaca;font-weight:600">{t.name}</code>
4226 <div style="margin-top:6px;color:#fecaca;line-height:1.5;word-break:break-word">{t.error}</div>
4227 </div>
4228 ))}
4229 </>
4230 )}
4231
4232 <p style="margin-top:24px;font-size:12.5px;color:var(--text-muted)">
4233 The 24h OK/error counts reflect <code style="font-family:var(--font-mono);font-size:11.5px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">audit_log</code> rows written by autopilot task wrappers. If those rows are absent, counts will show — (not yet instrumented).
4234 </p>
4235 </div>
4236 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4237 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4238 </Layout>
4239 );
4240});
4241
4242// ─── User Growth Chart (/admin/growth) ───────────────────────────────────────
4243
4244admin.get("/admin/growth", async (c) => {
4245 const g = await gate(c);
4246 if (g instanceof Response) return g;
4247 const { user } = g;
4248
4249 const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
4250
4251 // Daily signups last 30 days
4252 const dailySignups = await db
4253 .select({
4254 day: sql<string>`date_trunc('day', created_at)::date::text`,
4255 count: sql<number>`count(*)::int`,
4256 })
4257 .from(users)
4258 .where(gte(users.createdAt, since30d))
4259 .groupBy(sql`date_trunc('day', created_at)`)
4260 .orderBy(sql`date_trunc('day', created_at)`);
4261
4262 // Activation rate: users who created at least 1 repo (last 30d signups)
4263 const activatedRows = await db
4264 .select({
4265 userId: users.id,
4266 })
4267 .from(users)
4268 .innerJoin(repositories, eq(repositories.ownerId, users.id))
4269 .where(gte(users.createdAt, since30d))
4270 .groupBy(users.id);
4271
4272 // Total signups in window
4273 const totalSignups = dailySignups.reduce((s, r) => s + Number(r.count), 0);
4274 const activated = activatedRows.length;
4275 const activationRate = totalSignups > 0 ? Math.round((activated / totalSignups) * 100) : 0;
4276
4277 // All-time user count
4278 const [allTimeRow] = await db
4279 .select({ n: sql<number>`count(*)::int` })
4280 .from(users);
4281 const allTime = Number(allTimeRow?.n ?? 0);
4282
4283 // Build a 30-slot array (fill missing days with 0)
4284 const dayMap = new Map<string, number>();
4285 dailySignups.forEach((r) => dayMap.set(r.day, Number(r.count)));
4286
4287 const slots: { label: string; count: number }[] = [];
4288 for (let i = 29; i >= 0; i--) {
4289 const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
4290 const key = d.toISOString().slice(0, 10);
4291 const label = d.toLocaleString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
4292 slots.push({ label, count: dayMap.get(key) ?? 0 });
4293 }
4294
4295 const maxCount = Math.max(1, ...slots.map((s) => s.count));
4296
4297 return c.html(
4298 <Layout title="Admin — User Growth" user={user}>
4299 <div class="adm-analytics-wrap">
4300 <section class="adm-analytics-hero">
4301 <div class="adm-analytics-hero-orb" aria-hidden="true" />
4302 <div class="adm-analytics-hero-inner">
4303 <div class="adm-analytics-hero-text">
4304 <div class="adm-analytics-eyebrow">
4305 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.trendingUp}</span>
4306 Site admin · Analytics
4307 </div>
4308 <h1 class="adm-analytics-title">
4309 <span class="adm-analytics-title-grad">User growth</span>.
4310 </h1>
4311 <p class="adm-analytics-sub">
4312 Daily signups and activation rate over the last 30 days.
4313 </p>
4314 </div>
4315 <a href="/admin" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4316 </div>
4317 </section>
4318
4319 <div class="adm-analytics-statgrid">
4320 <div class="adm-analytics-stat">
4321 <div class="adm-analytics-stat-label">Total users</div>
4322 <div class="adm-analytics-stat-value">{allTime}</div>
4323 <div class="adm-analytics-stat-hint">all time</div>
4324 </div>
4325 <div class="adm-analytics-stat">
4326 <div class="adm-analytics-stat-label">New signups (30d)</div>
4327 <div class="adm-analytics-stat-value">{totalSignups}</div>
4328 <div class="adm-analytics-stat-hint">last 30 days</div>
4329 </div>
4330 <div class="adm-analytics-stat">
4331 <div class="adm-analytics-stat-label">Activation rate (30d)</div>
4332 <div class="adm-analytics-stat-value">{activationRate}%</div>
4333 <div class="adm-analytics-stat-hint">created ≥1 repo</div>
4334 </div>
4335 <div class="adm-analytics-stat">
4336 <div class="adm-analytics-stat-label">Activated users (30d)</div>
4337 <div class="adm-analytics-stat-value">{activated}</div>
4338 <div class="adm-analytics-stat-hint">out of {totalSignups} new</div>
4339 </div>
4340 </div>
4341
4342 <div class="adm-analytics-h3">
4343 <h3>Daily signups — last 30 days</h3>
4344 <span class="adm-analytics-h3-meta">peak: {maxCount}</span>
4345 </div>
4346
4347 {totalSignups === 0 ? (
4348 <div class="adm-analytics-empty">No signups in the last 30 days.</div>
4349 ) : (
4350 <table class="adm-analytics-table">
4351 <thead>
4352 <tr>
4353 <th>Date</th>
4354 <th>Signups</th>
4355 <th style="width:280px">Bar</th>
4356 </tr>
4357 </thead>
4358 <tbody>
4359 {slots.filter((s) => s.count > 0 || true).map((s) => {
4360 const pct = Math.round((s.count / maxCount) * 100);
4361 return (
4362 <tr>
4363 <td style="font-family:var(--font-mono);font-size:12px;color:var(--text)">{s.label}</td>
4364 <td>{s.count}</td>
4365 <td>
4366 {s.count > 0 ? (
4367 <div class="adm-analytics-bar-cell">
4368 <div class="adm-analytics-bar-track">
4369 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4370 </div>
4371 <span class="adm-analytics-bar-label">{s.count}</span>
4372 </div>
4373 ) : (
4374 <span style="color:var(--text-faint);font-size:11px">—</span>
4375 )}
4376 </td>
4377 </tr>
4378 );
4379 })}
4380 </tbody>
4381 </table>
4382 )}
4383
4384 <div class="adm-analytics-h3" style="margin-top:28px">
4385 <h3>Activation breakdown</h3>
4386 <span class="adm-analytics-h3-meta">30d cohort</span>
4387 </div>
4388 <table class="adm-analytics-table" style="max-width:500px">
4389 <thead>
4390 <tr>
4391 <th>Segment</th>
4392 <th>Count</th>
4393 <th style="width:200px">Rate</th>
4394 </tr>
4395 </thead>
4396 <tbody>
4397 <tr>
4398 <td>New signups (30d)</td>
4399 <td>{totalSignups}</td>
4400 <td>100%</td>
4401 </tr>
4402 <tr>
4403 <td>Activated (created ≥1 repo)</td>
4404 <td>{activated}</td>
4405 <td>
4406 <div class="adm-analytics-bar-cell">
4407 <div class="adm-analytics-bar-track">
4408 <div class="adm-analytics-bar-fill" style={`width:${activationRate}%`} />
4409 </div>
4410 <span class="adm-analytics-bar-label">{activationRate}%</span>
4411 </div>
4412 </td>
4413 </tr>
4414 <tr>
4415 <td>Not activated</td>
4416 <td>{totalSignups - activated}</td>
4417 <td>{100 - activationRate}%</td>
4418 </tr>
4419 </tbody>
4420 </table>
4421 </div>
4422 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4423 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4424 </Layout>
4425 );
4426});
4427
8f50ed0Claude4428export default admin;