Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitf0b5874unknown_key

polish(community): 2026 cards + stat grids for discussions + traffic + org-insights

Claude committed on May 25, 2026Parent: 304ce2a
9 files changed+59361230f0b587433879538b145fbfb0e5dfaa052f59b8a4
9 changed files+5936−1230
Modifiedsrc/routes/billing.tsx+817−249View fileUnifiedSplit
Large file (1,121 lines). Load full file
Modifiedsrc/routes/discussions.tsx+500−77View fileUnifiedSplit
44 * Similar to GitHub Discussions: categorised, pinnable, answer-able threads
55 * that sit alongside issues but are conversational (Q&A, ideas, announcements).
66 *
7 * 2026 polish: gradient-hairline hero + radial orb + thread cards with
8 * author chip + reply count + recent timestamp + category chip. Every class
9 * prefixed `.disc-` so this surface doesn't bleed into the rest of the repo
10 * polish. All data fetches, queries, and POST handlers preserved exactly.
11 *
712 * Never throws — all DB paths wrapped in try/catch; callers see a 500-like
813 * shell page or a redirect on any failure.
914 */
3742
3843const discussionRoutes = new Hono<AuthEnv>();
3944
45/* ─────────────────────────────────────────────────────────────────────────
46 * Scoped CSS — every class prefixed `.disc-` so this surface can't bleed
47 * into the wider repo polish. Mirrors the gradient-hairline hero + radial
48 * orb + card patterns from `insights.tsx` and `error-page.tsx`.
49 * ───────────────────────────────────────────────────────────────────── */
50const styles = `
51 .disc-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
52
53 .disc-hero {
54 position: relative;
55 margin-bottom: var(--space-5);
56 padding: var(--space-5) var(--space-6);
57 background: var(--bg-elevated);
58 border: 1px solid var(--border);
59 border-radius: 16px;
60 overflow: hidden;
61 }
62 .disc-hero::before {
63 content: '';
64 position: absolute;
65 top: 0; left: 0; right: 0;
66 height: 2px;
67 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
68 opacity: 0.75;
69 pointer-events: none;
70 }
71 .disc-hero-orb {
72 position: absolute;
73 inset: -30% -15% auto auto;
74 width: 460px; height: 460px;
75 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
76 filter: blur(80px);
77 opacity: 0.75;
78 pointer-events: none;
79 z-index: 0;
80 }
81 .disc-hero-inner {
82 position: relative;
83 z-index: 1;
84 display: flex;
85 align-items: flex-start;
86 justify-content: space-between;
87 gap: var(--space-4);
88 flex-wrap: wrap;
89 }
90 .disc-hero-text { max-width: 720px; }
91 .disc-eyebrow {
92 display: inline-flex;
93 align-items: center;
94 gap: 8px;
95 text-transform: uppercase;
96 font-family: var(--font-mono);
97 font-size: 11px;
98 letter-spacing: 0.18em;
99 color: var(--text-muted);
100 font-weight: 600;
101 margin-bottom: 14px;
102 }
103 .disc-eyebrow-dot {
104 width: 8px; height: 8px;
105 border-radius: 9999px;
106 background: linear-gradient(135deg, #8c6dff, #36c5d6);
107 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
108 }
109 .disc-title {
110 font-family: var(--font-display);
111 font-size: clamp(28px, 4vw, 40px);
112 font-weight: 800;
113 letter-spacing: -0.028em;
114 line-height: 1.05;
115 margin: 0 0 var(--space-2);
116 color: var(--text-strong);
117 }
118 .disc-title-grad {
119 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
120 -webkit-background-clip: text;
121 background-clip: text;
122 -webkit-text-fill-color: transparent;
123 color: transparent;
124 }
125 .disc-sub {
126 font-size: 15px;
127 color: var(--text-muted);
128 margin: 0;
129 line-height: 1.55;
130 }
131
132 /* Primary CTA — gradient pill that matches the hero stripe. */
133 .disc-cta {
134 display: inline-flex;
135 align-items: center;
136 justify-content: center;
137 padding: 10px 18px;
138 border-radius: 10px;
139 font-size: 14px;
140 font-weight: 600;
141 text-decoration: none;
142 border: 1px solid transparent;
143 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
144 color: #fff;
145 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
146 transition: transform 120ms ease, box-shadow 120ms ease;
147 white-space: nowrap;
148 }
149 .disc-cta:hover {
150 transform: translateY(-1px);
151 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
152 color: #fff;
153 text-decoration: none;
154 }
155
156 /* Category filter chips */
157 .disc-filters {
158 display: flex;
159 flex-wrap: wrap;
160 gap: 8px;
161 margin: 0 0 var(--space-4);
162 }
163 .disc-chip {
164 display: inline-flex;
165 align-items: center;
166 gap: 6px;
167 padding: 6px 12px;
168 border-radius: 9999px;
169 border: 1px solid var(--border);
170 background: var(--bg-elevated);
171 color: var(--text-muted);
172 font-size: 12.5px;
173 font-weight: 600;
174 text-decoration: none;
175 text-transform: lowercase;
176 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
177 }
178 .disc-chip:hover { border-color: rgba(140,109,255,0.45); color: var(--text-strong); text-decoration: none; }
179 .disc-chip.is-active {
180 color: #fff;
181 background: linear-gradient(135deg, rgba(140,109,255,0.85), rgba(54,197,214,0.85));
182 border-color: rgba(140,109,255,0.55);
183 }
184
185 /* Thread card list */
186 .disc-list {
187 display: flex;
188 flex-direction: column;
189 gap: var(--space-2);
190 margin-bottom: var(--space-5);
191 }
192 .disc-card {
193 position: relative;
194 background: var(--bg-elevated);
195 border: 1px solid var(--border);
196 border-radius: 12px;
197 padding: var(--space-3) var(--space-4);
198 transition: border-color 120ms ease, transform 120ms ease;
199 }
200 .disc-card:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
201 .disc-card.is-pinned { border-color: rgba(140,109,255,0.32); }
202
203 .disc-card-row {
204 display: flex;
205 gap: 14px;
206 align-items: flex-start;
207 }
208 .disc-avatar {
209 flex: none;
210 width: 36px; height: 36px;
211 border-radius: 9999px;
212 background: linear-gradient(135deg, #8c6dff, #36c5d6);
213 color: #fff;
214 display: flex;
215 align-items: center;
216 justify-content: center;
217 font-family: var(--font-display);
218 font-size: 14px;
219 font-weight: 700;
220 text-transform: uppercase;
221 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18);
222 }
223 .disc-card-main { flex: 1; min-width: 0; }
224 .disc-card-title {
225 font-family: var(--font-display);
226 font-size: 15px;
227 font-weight: 700;
228 color: var(--text-strong);
229 letter-spacing: -0.005em;
230 line-height: 1.3;
231 margin: 0 0 4px;
232 word-break: break-word;
233 }
234 .disc-card-title a { color: inherit; text-decoration: none; }
235 .disc-card-title a:hover { color: var(--accent); }
236 .disc-card-sub {
237 font-size: 12.5px;
238 color: var(--text-muted);
239 line-height: 1.5;
240 margin: 0;
241 display: flex;
242 flex-wrap: wrap;
243 gap: 6px 10px;
244 align-items: center;
245 }
246 .disc-card-sub a { color: var(--text); text-decoration: none; }
247 .disc-card-sub a:hover { color: var(--text-strong); }
248 .disc-num {
249 font-family: var(--font-mono);
250 font-size: 12px;
251 color: var(--text-muted);
252 }
253
254 .disc-tag {
255 display: inline-flex;
256 align-items: center;
257 gap: 4px;
258 padding: 1px 8px;
259 border-radius: 9999px;
260 font-size: 10.5px;
261 font-weight: 700;
262 letter-spacing: 0.06em;
263 text-transform: uppercase;
264 background: rgba(140,109,255,0.10);
265 color: #b69dff;
266 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
267 }
268 .disc-tag.is-pinned { background: rgba(252,211,77,0.10); color: #fcd34d; box-shadow: inset 0 0 0 1px rgba(252,211,77,0.32); }
269 .disc-tag.is-closed { background: rgba(248,113,113,0.10); color: #fca5a5; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32); }
270 .disc-tag.is-locked { background: rgba(148,163,184,0.10); color: #cbd5e1; box-shadow: inset 0 0 0 1px rgba(148,163,184,0.32); }
271
272 .disc-card-meta {
273 flex: none;
274 text-align: right;
275 font-size: 12px;
276 color: var(--text-muted);
277 font-variant-numeric: tabular-nums;
278 display: flex;
279 flex-direction: column;
280 align-items: flex-end;
281 gap: 4px;
282 min-width: 80px;
283 }
284 .disc-card-meta .replies {
285 font-family: var(--font-display);
286 font-size: 16px;
287 font-weight: 700;
288 color: var(--text-strong);
289 line-height: 1;
290 }
291 .disc-card-meta .replies-label {
292 font-size: 10.5px;
293 text-transform: uppercase;
294 letter-spacing: 0.12em;
295 font-weight: 700;
296 }
297
298 /* Empty state — dashed orb card */
299 .disc-empty {
300 position: relative;
301 overflow: hidden;
302 text-align: center;
303 padding: var(--space-6) var(--space-4);
304 border: 1px dashed var(--border-strong, var(--border));
305 border-radius: 16px;
306 background: rgba(255,255,255,0.012);
307 color: var(--text-muted);
308 margin-bottom: var(--space-5);
309 }
310 .disc-empty::before {
311 content: '';
312 position: absolute;
313 inset: -40% -20% auto auto;
314 width: 320px; height: 320px;
315 background: radial-gradient(circle, rgba(140,109,255,0.14), rgba(54,197,214,0.06) 45%, transparent 70%);
316 filter: blur(60px);
317 pointer-events: none;
318 }
319 .disc-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
320 .disc-empty strong {
321 display: block;
322 font-family: var(--font-display);
323 font-size: 18px;
324 font-weight: 700;
325 color: var(--text-strong);
326 margin: 0;
327 }
328 .disc-empty p { font-size: 13px; margin: 0; max-width: 420px; }
329
330 /* New-discussion form polish (preserves form action + field names) */
331 .disc-form-card {
332 background: var(--bg-elevated);
333 border: 1px solid var(--border);
334 border-radius: 14px;
335 padding: var(--space-4) var(--space-5);
336 margin-top: var(--space-4);
337 }
338 .disc-form { display: flex; flex-direction: column; gap: 12px; }
339 .disc-input,
340 .disc-textarea,
341 .disc-select {
342 width: 100%;
343 padding: 10px 12px;
344 font-size: 14px;
345 color: var(--text);
346 background: var(--bg);
347 border: 1px solid var(--border-strong);
348 border-radius: 10px;
349 outline: none;
350 font-family: inherit;
351 box-sizing: border-box;
352 transition: border-color 120ms ease, box-shadow 120ms ease;
353 }
354 .disc-textarea { font-family: var(--font-mono); font-size: 13px; line-height: 1.55; }
355 .disc-input:focus,
356 .disc-textarea:focus,
357 .disc-select:focus {
358 border-color: var(--border-focus, rgba(140,109,255,0.55));
359 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
360 }
361`;
362
363function relTime(d: Date | string | null | undefined): string {
364 if (!d) return "";
365 const t = typeof d === "string" ? new Date(d).getTime() : d.getTime();
366 if (!Number.isFinite(t)) return "";
367 const diff = (Date.now() - t) / 1000;
368 if (diff < 60) return "just now";
369 if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
370 if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
371 if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`;
372 if (diff < 86400 * 30) return `${Math.floor(diff / (86400 * 7))}w ago`;
373 return new Date(t).toLocaleDateString();
374}
375
376function initials(name: string): string {
377 if (!name) return "?";
378 return name.slice(0, 2);
379}
380
40381async function resolveRepo(ownerName: string, repoName: string) {
41382 try {
42383 const [owner] = await db
116457 Discussions
117458 </a>
118459 </div>
119 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
120 <div style="display: flex; gap: 8px;">
460
461 <div class="disc-wrap">
462 <section class="disc-hero">
463 <div class="disc-hero-orb" aria-hidden="true" />
464 <div class="disc-hero-inner">
465 <div class="disc-hero-text">
466 <div class="disc-eyebrow">
467 <span class="disc-eyebrow-dot" aria-hidden="true" />
468 Discussions · {ownerName}/{repoName}
469 </div>
470 <h2 class="disc-title">
471 <span class="disc-title-grad">Talk it out.</span>
472 </h2>
473 <p class="disc-sub">
474 Q&amp;A, ideas, announcements, and show-and-tell — the
475 conversational space alongside issues and PRs.
476 </p>
477 </div>
478 {user && (
479 <a
480 href={`/${ownerName}/${repoName}/discussions/new`}
481 class="disc-cta"
482 >
483 + New discussion
484 </a>
485 )}
486 </div>
487 </section>
488
489 <div class="disc-filters" aria-label="Filter by category">
121490 <a
122491 href={`/${ownerName}/${repoName}/discussions`}
123 class={!category ? "active" : ""}
124 style="padding: 4px 10px; border-radius: 6px;"
492 class={"disc-chip" + (!category ? " is-active" : "")}
125493 >
126 All
494 all
127495 </a>
128496 {CATEGORIES.map((cat) => (
129497 <a
130498 href={`/${ownerName}/${repoName}/discussions?category=${cat}`}
131 class={cat === category ? "active" : ""}
132 style="padding: 4px 10px; border-radius: 6px;"
499 class={"disc-chip" + (cat === category ? " is-active" : "")}
133500 >
134501 {cat}
135502 </a>
136503 ))}
137504 </div>
138 {user && (
139 <a
140 href={`/${ownerName}/${repoName}/discussions/new`}
141 class="btn btn-primary"
142 >
143 New discussion
144 </a>
505
506 {rows.length === 0 ? (
507 <div class="disc-empty">
508 <div class="disc-empty-inner">
509 <strong>No discussions yet</strong>
510 <p>
511 Start a thread to ask a question, float an idea, or share an
512 announcement with the community.
513 </p>
514 {user && (
515 <a
516 href={`/${ownerName}/${repoName}/discussions/new`}
517 class="disc-cta"
518 style="margin-top:6px"
519 >
520 + Start a discussion
521 </a>
522 )}
523 </div>
524 </div>
525 ) : (
526 <div class="disc-list">
527 {rows.map((r) => {
528 const replies = Number(r.commentCount || 0);
529 const updated = r.d.updatedAt || r.d.createdAt;
530 return (
531 <article
532 class={"disc-card" + (r.d.pinned ? " is-pinned" : "")}
533 >
534 <div class="disc-card-row">
535 <div
536 class="disc-avatar"
537 aria-label={`@${r.author.username}`}
538 >
539 {initials(r.author.username)}
540 </div>
541 <div class="disc-card-main">
542 <h3 class="disc-card-title">
543 <a
544 href={`/${ownerName}/${repoName}/discussions/${r.d.number}`}
545 >
546 {r.d.title}
547 </a>
548 </h3>
549 <div class="disc-card-sub">
550 <span class="disc-num">#{r.d.number}</span>
551 <span>by @{r.author.username}</span>
552 {updated && <span>· active {relTime(updated)}</span>}
553 <span class="disc-tag">{r.d.category}</span>
554 {r.d.pinned && (
555 <span class="disc-tag is-pinned">pinned</span>
556 )}
557 {r.d.state === "closed" && (
558 <span class="disc-tag is-closed">closed</span>
559 )}
560 {r.d.locked && (
561 <span class="disc-tag is-locked">locked</span>
562 )}
563 </div>
564 </div>
565 <div class="disc-card-meta">
566 <span class="replies">{replies}</span>
567 <span class="replies-label">
568 {replies === 1 ? "reply" : "replies"}
569 </span>
570 </div>
571 </div>
572 </article>
573 );
574 })}
575 </div>
145576 )}
146577 </div>
147 {rows.length === 0 ? (
148 <div class="empty-state">
149 <p>No discussions yet.</p>
150 </div>
151 ) : (
152 <table class="file-table">
153 <tbody>
154 {rows.map((r) => (
155 <tr>
156 <td style="width: 40px; color: var(--text-muted);">
157 #{r.d.number}
158 </td>
159 <td>
160 {r.d.pinned && <span class="badge">📌 Pinned</span>}{" "}
161 <a
162 href={`/${ownerName}/${repoName}/discussions/${r.d.number}`}
163 >
164 <strong>{r.d.title}</strong>
165 </a>{" "}
166 <span class="badge">{r.d.category}</span>
167 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
168 by @{r.author.username}
169 {r.d.state === "closed" ? " · closed" : ""}
170 {r.d.locked ? " · locked" : ""}
171 </div>
172 </td>
173 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
174 💬 {r.commentCount}
175 </td>
176 </tr>
177 ))}
178 </tbody>
179 </table>
180 )}
578 <style dangerouslySetInnerHTML={{ __html: styles }} />
181579 </Layout>
182580 );
183581});
194592 return c.html(
195593 <Layout title="New discussion" user={user}>
196594 <RepoHeader owner={ownerName} repo={repoName} />
197 <h2 style="margin-top: 20px;">Start a discussion</h2>
198 <form
199 method="post"
200 action={`/${ownerName}/${repoName}/discussions`}
201 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
202 >
203 <input
204 type="text"
205 name="title"
206 placeholder="Title"
207 required
208 aria-label="Discussion title"
209 style="padding: 8px;"
210 />
211 <select name="category" style="padding: 8px;">
212 {CATEGORIES.map((c) => (
213 <option value={c}>{c}</option>
214 ))}
215 </select>
216 <textarea
217 name="body"
218 rows={10}
219 placeholder="Write your post (markdown supported)"
220 style="padding: 8px; font-family: inherit;"
221 ></textarea>
222 <button type="submit" class="btn btn-primary">
223 Start discussion
224 </button>
225 </form>
595 <div class="disc-wrap">
596 <section class="disc-hero">
597 <div class="disc-hero-orb" aria-hidden="true" />
598 <div class="disc-hero-inner">
599 <div class="disc-hero-text">
600 <div class="disc-eyebrow">
601 <span class="disc-eyebrow-dot" aria-hidden="true" />
602 New discussion · {ownerName}/{repoName}
603 </div>
604 <h2 class="disc-title">
605 <span class="disc-title-grad">Start a thread.</span>
606 </h2>
607 <p class="disc-sub">
608 Pick a category, add a clear title, and tell folks what's on
609 your mind. Markdown supported.
610 </p>
611 </div>
612 </div>
613 </section>
614
615 <div class="disc-form-card">
616 <form
617 method="post"
618 action={`/${ownerName}/${repoName}/discussions`}
619 class="disc-form"
620 >
621 <input
622 type="text"
623 name="title"
624 placeholder="Title"
625 required
626 aria-label="Discussion title"
627 class="disc-input"
628 />
629 <select name="category" class="disc-select">
630 {CATEGORIES.map((c) => (
631 <option value={c}>{c}</option>
632 ))}
633 </select>
634 <textarea
635 name="body"
636 rows={10}
637 placeholder="Write your post (markdown supported)"
638 class="disc-textarea"
639 ></textarea>
640 <div>
641 <button type="submit" class="disc-cta">
642 Start discussion
643 </button>
644 </div>
645 </form>
646 </div>
647 </div>
648 <style dangerouslySetInnerHTML={{ __html: styles }} />
226649 </Layout>
227650 );
228651 }
Modifiedsrc/routes/oauth.tsx+680−94View fileUnifiedSplit
99 * POST /settings/authorizations/:appId/revoke user-initiated revoke
1010 *
1111 * Developer-facing app management lives in `src/routes/developer-apps.tsx`.
12 *
13 * Visual polish (2026): gradient hairline + orb hero + app card + scope chips
14 * + gradient Authorize CTA. Scoped under `.oauth-*` so it can't bleed into
15 * other pages. Every OAuth flow, redirect URI, state/nonce, and token
16 * issuance path is preserved EXACTLY — this is security-critical.
1217 */
1318
1419import { Hono } from "hono";
4853oauth.use("/settings/authorizations", requireAuth);
4954oauth.use("/settings/authorizations/*", requireAuth);
5055
56// --- scope explainer copy (human-readable consent text) --------------------
57//
58// Plain-English description for each scope chip on the consent screen. The
59// keys mirror SUPPORTED_SCOPES; unknown scopes fall back to a generic line.
60const SCOPE_DESCRIPTIONS: Record<string, { label: string; explain: string }> = {
61 "read:user": {
62 label: "Read your profile",
63 explain: "username, avatar, public email — never your password",
64 },
65 "read:repo": {
66 label: "Read your repositories",
67 explain: "code, branches, commits, file contents",
68 },
69 "write:repo": {
70 label: "Write to your repositories",
71 explain: "push commits, create branches, edit files",
72 },
73 "read:org": {
74 label: "Read your organisations",
75 explain: "membership, teams, org-owned repo list",
76 },
77 "write:org": {
78 label: "Manage organisations",
79 explain: "invite members, change settings, edit teams",
80 },
81 "read:issue": {
82 label: "Read issues",
83 explain: "issue threads, comments, labels, assignees",
84 },
85 "write:issue": {
86 label: "Create + edit issues",
87 explain: "open, comment, close, label, assign",
88 },
89 "read:pr": {
90 label: "Read pull requests",
91 explain: "PR diffs, comments, reviews, status",
92 },
93 "write:pr": {
94 label: "Create + edit pull requests",
95 explain: "open, review, comment, merge, close",
96 },
97};
98
99function describeScope(s: string): { label: string; explain: string } {
100 return (
101 SCOPE_DESCRIPTIONS[s] ?? {
102 label: s,
103 explain: "custom scope — see the app's documentation",
104 }
105 );
106}
107
108// --- scoped styles ----------------------------------------------------------
109//
110// Every class prefixed `.oauth-` so the consent screen / authorizations list
111// can't bleed into the wider app polish. Mirrors the gradient-hairline hero +
112// orb pattern from admin-integrations.tsx and error-page.tsx.
113const oauthStyles = `
114 .oauth-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
115
116 /* ─── Hero ─── */
117 .oauth-hero {
118 position: relative;
119 margin-bottom: var(--space-5);
120 padding: var(--space-5) var(--space-6);
121 background: var(--bg-elevated);
122 border: 1px solid var(--border);
123 border-radius: 16px;
124 overflow: hidden;
125 }
126 .oauth-hero::before {
127 content: '';
128 position: absolute;
129 top: 0; left: 0; right: 0;
130 height: 2px;
131 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
132 opacity: 0.7;
133 pointer-events: none;
134 }
135 .oauth-hero-orb {
136 position: absolute;
137 inset: -20% -10% auto auto;
138 width: 380px; height: 380px;
139 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
140 filter: blur(80px);
141 opacity: 0.7;
142 pointer-events: none;
143 z-index: 0;
144 }
145 .oauth-hero-inner { position: relative; z-index: 1; max-width: 680px; }
146 .oauth-eyebrow {
147 font-size: 11px;
148 text-transform: uppercase;
149 letter-spacing: 0.18em;
150 color: var(--text-muted);
151 margin-bottom: var(--space-2);
152 display: inline-flex;
153 align-items: center;
154 gap: 8px;
155 font-family: var(--font-mono);
156 font-weight: 600;
157 }
158 .oauth-eyebrow-dot {
159 width: 8px; height: 8px;
160 border-radius: 9999px;
161 background: linear-gradient(135deg, #8c6dff, #36c5d6);
162 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
163 }
164 .oauth-title {
165 font-size: clamp(26px, 3.5vw, 36px);
166 font-family: var(--font-display);
167 font-weight: 800;
168 letter-spacing: -0.026em;
169 line-height: 1.08;
170 margin: 0 0 var(--space-2);
171 color: var(--text-strong);
172 }
173 .oauth-title-grad {
174 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
175 -webkit-background-clip: text;
176 background-clip: text;
177 -webkit-text-fill-color: transparent;
178 color: transparent;
179 }
180 .oauth-sub {
181 font-size: 14.5px;
182 color: var(--text-muted);
183 margin: 0;
184 line-height: 1.55;
185 }
186 .oauth-sub code {
187 font-family: var(--font-mono);
188 font-size: 12.5px;
189 background: var(--bg-tertiary);
190 padding: 1px 5px;
191 border-radius: 4px;
192 color: var(--text);
193 }
194
195 /* ─── Banners ─── */
196 .oauth-banner {
197 margin-bottom: var(--space-4);
198 padding: 10px 14px;
199 border-radius: 10px;
200 font-size: 13.5px;
201 border: 1px solid var(--border);
202 background: rgba(255,255,255,0.025);
203 color: var(--text);
204 }
205 .oauth-banner.is-ok {
206 border-color: rgba(52,211,153,0.40);
207 background: rgba(52,211,153,0.08);
208 color: #bbf7d0;
209 }
210 .oauth-banner.is-error {
211 border-color: rgba(248,113,113,0.40);
212 background: rgba(248,113,113,0.08);
213 color: #fecaca;
214 }
215
216 /* ─── App identity card (consent) ─── */
217 .oauth-appcard {
218 display: flex;
219 gap: var(--space-3);
220 align-items: flex-start;
221 padding: var(--space-4) var(--space-5);
222 background: var(--bg-elevated);
223 border: 1px solid var(--border);
224 border-radius: 14px;
225 margin-bottom: var(--space-4);
226 }
227 .oauth-applogo {
228 flex-shrink: 0;
229 width: 56px;
230 height: 56px;
231 border-radius: 12px;
232 background: linear-gradient(135deg, rgba(140,109,255,0.22), rgba(54,197,214,0.16));
233 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
234 display: flex;
235 align-items: center;
236 justify-content: center;
237 font-family: var(--font-display);
238 font-size: 22px;
239 font-weight: 800;
240 color: #e9d5ff;
241 letter-spacing: -0.02em;
242 text-transform: uppercase;
243 }
244 .oauth-appmeta { flex: 1; min-width: 0; }
245 .oauth-appname {
246 margin: 0 0 4px;
247 font-family: var(--font-display);
248 font-size: 18px;
249 font-weight: 700;
250 letter-spacing: -0.018em;
251 color: var(--text-strong);
252 word-break: break-word;
253 }
254 .oauth-appname code {
255 font-family: var(--font-mono);
256 font-size: 12.5px;
257 color: var(--accent);
258 background: rgba(140,109,255,0.08);
259 padding: 1px 6px;
260 border-radius: 4px;
261 margin-left: 6px;
262 font-weight: 500;
263 }
264 .oauth-appdesc {
265 margin: 0;
266 font-size: 13px;
267 color: var(--text-muted);
268 line-height: 1.5;
269 }
270
271 /* ─── Section card ─── */
272 .oauth-section {
273 margin-bottom: var(--space-4);
274 background: var(--bg-elevated);
275 border: 1px solid var(--border);
276 border-radius: 14px;
277 overflow: hidden;
278 }
279 .oauth-section-head {
280 padding: var(--space-3) var(--space-5);
281 border-bottom: 1px solid var(--border);
282 display: flex;
283 align-items: center;
284 justify-content: space-between;
285 gap: var(--space-2);
286 flex-wrap: wrap;
287 }
288 .oauth-section-title {
289 margin: 0;
290 font-family: var(--font-display);
291 font-size: 14px;
292 font-weight: 700;
293 letter-spacing: 0.04em;
294 text-transform: uppercase;
295 color: var(--text-strong);
296 }
297 .oauth-section-count {
298 font-family: var(--font-mono);
299 font-size: 12px;
300 color: var(--text-muted);
301 }
302 .oauth-section-body { padding: var(--space-4) var(--space-5); }
303
304 /* ─── Scope chips ─── */
305 .oauth-scopes {
306 list-style: none;
307 padding: 0;
308 margin: 0;
309 display: flex;
310 flex-direction: column;
311 gap: var(--space-2);
312 }
313 .oauth-scope {
314 display: flex;
315 align-items: flex-start;
316 gap: 12px;
317 padding: 10px 12px;
318 border: 1px solid var(--border);
319 border-radius: 10px;
320 background: rgba(255,255,255,0.02);
321 }
322 .oauth-scope-chip {
323 display: inline-flex;
324 align-items: center;
325 gap: 6px;
326 padding: 3px 10px;
327 border-radius: 9999px;
328 font-family: var(--font-mono);
329 font-size: 11.5px;
330 font-weight: 600;
331 color: #e9d5ff;
332 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.10));
333 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
334 flex-shrink: 0;
335 margin-top: 2px;
336 white-space: nowrap;
337 }
338 .oauth-scope-chip[data-write="1"] {
339 color: #fde68a;
340 background: linear-gradient(135deg, rgba(251,191,36,0.18), rgba(248,113,113,0.10));
341 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.36);
342 }
343 .oauth-scope-text { flex: 1; min-width: 0; }
344 .oauth-scope-label {
345 font-size: 13.5px;
346 font-weight: 600;
347 color: var(--text-strong);
348 line-height: 1.3;
349 }
350 .oauth-scope-explain {
351 margin-top: 2px;
352 font-size: 12.5px;
353 color: var(--text-muted);
354 line-height: 1.45;
355 }
356 .oauth-scope-empty {
357 margin: 0;
358 font-size: 13px;
359 color: var(--text-muted);
360 font-style: italic;
361 }
362
363 /* ─── Actions row ─── */
364 .oauth-actions {
365 display: flex;
366 gap: 10px;
367 flex-wrap: wrap;
368 align-items: center;
369 margin-top: var(--space-4);
370 }
371 .oauth-actions form { margin: 0; }
372 .oauth-btn {
373 display: inline-flex;
374 align-items: center;
375 justify-content: center;
376 gap: 8px;
377 padding: 13px 24px;
378 border-radius: 12px;
379 font-size: 14.5px;
380 font-weight: 700;
381 text-decoration: none;
382 border: 1px solid transparent;
383 cursor: pointer;
384 font: inherit;
385 line-height: 1;
386 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
387 }
388 .oauth-btn-primary {
389 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
390 color: #ffffff;
391 box-shadow: 0 8px 22px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
392 min-width: 180px;
393 }
394 .oauth-btn-primary:hover {
395 transform: translateY(-1px);
396 box-shadow: 0 12px 28px -8px rgba(140,109,255,0.65), inset 0 1px 0 rgba(255,255,255,0.22);
397 color: #ffffff;
398 text-decoration: none;
399 }
400 .oauth-btn-ghost {
401 background: transparent;
402 color: var(--text);
403 border-color: var(--border-strong);
404 padding: 13px 20px;
405 }
406 .oauth-btn-ghost:hover {
407 background: rgba(255,255,255,0.04);
408 border-color: rgba(140,109,255,0.45);
409 color: var(--text-strong);
410 text-decoration: none;
411 }
412 .oauth-btn-danger-sm {
413 display: inline-flex;
414 align-items: center;
415 gap: 6px;
416 padding: 6px 12px;
417 border-radius: 8px;
418 font-size: 12.5px;
419 font-weight: 600;
420 border: 1px solid rgba(248,113,113,0.35);
421 background: transparent;
422 color: #fca5a5;
423 cursor: pointer;
424 font: inherit;
425 line-height: 1;
426 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
427 }
428 .oauth-btn-danger-sm:hover {
429 border-color: rgba(248,113,113,0.70);
430 background: rgba(248,113,113,0.08);
431 color: #fecaca;
432 }
433
434 /* ─── Explainer ─── */
435 .oauth-explainer {
436 margin-top: var(--space-4);
437 padding: var(--space-3) var(--space-4);
438 background: rgba(140,109,255,0.04);
439 border: 1px dashed rgba(140,109,255,0.22);
440 border-radius: 12px;
441 font-size: 12.5px;
442 color: var(--text-muted);
443 line-height: 1.55;
444 }
445 .oauth-explainer strong { color: var(--text-strong); display: block; margin-bottom: 4px; font-size: 13px; }
446 .oauth-explainer a { color: var(--accent); text-decoration: none; }
447 .oauth-explainer a:hover { text-decoration: underline; }
448 .oauth-explainer ul { margin: 6px 0 0; padding-left: 20px; }
449 .oauth-explainer li { margin: 2px 0; }
450
451 /* ─── Authorizations list ─── */
452 .oauth-breadcrumb {
453 font-size: 13px;
454 color: var(--text-muted);
455 margin-bottom: var(--space-3);
456 display: flex;
457 gap: 8px;
458 align-items: center;
459 flex-wrap: wrap;
460 }
461 .oauth-breadcrumb a { color: var(--accent); text-decoration: none; }
462 .oauth-breadcrumb a:hover { text-decoration: underline; }
463 .oauth-breadcrumb span.sep { color: var(--text-muted); }
464
465 .oauth-grant-list {
466 list-style: none;
467 padding: 0;
468 margin: 0;
469 display: flex;
470 flex-direction: column;
471 gap: var(--space-2);
472 }
473 .oauth-grant {
474 display: flex;
475 align-items: center;
476 justify-content: space-between;
477 gap: var(--space-3);
478 padding: 14px 16px;
479 background: var(--bg-elevated);
480 border: 1px solid var(--border);
481 border-radius: 12px;
482 flex-wrap: wrap;
483 }
484 .oauth-grant-info { flex: 1; min-width: 240px; }
485 .oauth-grant-name {
486 font-family: var(--font-display);
487 font-size: 15px;
488 font-weight: 700;
489 color: var(--text-strong);
490 margin: 0 0 4px;
491 letter-spacing: -0.01em;
492 }
493 .oauth-grant-meta {
494 font-size: 12px;
495 color: var(--text-muted);
496 line-height: 1.5;
497 }
498 .oauth-grant-meta code {
499 font-family: var(--font-mono);
500 font-size: 11.5px;
501 background: rgba(255,255,255,0.04);
502 border: 1px solid var(--border);
503 padding: 1px 6px;
504 border-radius: 4px;
505 color: var(--text);
506 }
507 .oauth-empty {
508 padding: 22px 18px;
509 text-align: center;
510 color: var(--text-muted);
511 font-size: 13.5px;
512 background: var(--bg-elevated);
513 border: 1px dashed var(--border);
514 border-radius: 12px;
515 }
516
517 /* ─── Error sub-page ─── */
518 .oauth-error-page {
519 max-width: 540px;
520 margin: var(--space-12) auto;
521 padding: var(--space-6);
522 text-align: center;
523 background: var(--bg-elevated);
524 border: 1px solid var(--border);
525 border-radius: 16px;
526 }
527 .oauth-error-page h2 {
528 font-family: var(--font-display);
529 font-size: 22px;
530 margin: 0 0 8px;
531 color: var(--text-strong);
532 }
533 .oauth-error-page p { color: var(--text-muted); margin: 0 0 16px; font-size: 14px; }
534`;
535
51536// --- helpers ----------------------------------------------------------------
52537
53538function appendQuery(url: string, params: Record<string, string | undefined>) {
64549function errorPage(title: string, message: string, user: User | null) {
65550 return (
66551 <Layout title={title} user={user}>
67 <div class="empty-state">
68 <h2>{title}</h2>
69 <p>{message}</p>
70 <a href="/" style="margin-top: 12px; display: inline-block">
71 Go home
72 </a>
552 <div class="oauth-wrap">
553 <div class="oauth-error-page">
554 <h2>{title}</h2>
555 <p>{message}</p>
556 <a href="/" class="oauth-btn oauth-btn-ghost" style="padding: 10px 18px">
557 Go home
558 </a>
559 </div>
73560 </div>
561 <style dangerouslySetInnerHTML={{ __html: oauthStyles }} />
74562 </Layout>
75563 );
76564}
128616 return timingSafeEqual(hash, app.clientSecretHash);
129617}
130618
619function isWriteScope(s: string): boolean {
620 return s.startsWith("write:");
621}
622
131623// --- GET /oauth/authorize ---------------------------------------------------
132624
133625oauth.get("/oauth/authorize", async (c) => {
199691 /* non-fatal */
200692 }
201693
694 const initial = (app.name || "?").trim().charAt(0).toUpperCase() || "?";
695
202696 return c.html(
203697 <Layout title="Authorize application" user={user}>
204 <div class="auth-container">
205 <h2>Authorize {app.name}</h2>
206 <p style="color: var(--text-muted); font-size: 13px">
207 <strong>{app.name}</strong> by <code>{ownerName}</code> wants
208 access to your gluecron account as <strong>{user.username}</strong>.
209 </p>
210 {app.description && (
211 <p style="color: var(--text-muted); font-size: 13px">
212 {app.description}
213 </p>
214 )}
215 <div
216 style="border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; margin: 16px 0; background: var(--bg-secondary)"
217 >
218 <strong>Requested scopes</strong>
219 {scopes.length === 0 ? (
220 <p style="color: var(--text-muted); font-size: 12px; margin: 8px 0 0">
221 No scopes — this app will only be able to identify you.
698 <div class="oauth-wrap">
699 <section class="oauth-hero">
700 <div class="oauth-hero-orb" aria-hidden="true" />
701 <div class="oauth-hero-inner">
702 <div class="oauth-eyebrow">
703 <span class="oauth-eyebrow-dot" aria-hidden="true" />
704 OAuth · Authorize an application
705 </div>
706 <h1 class="oauth-title">
707 <span class="oauth-title-grad">Authorize {app.name}?</span>
708 </h1>
709 <p class="oauth-sub">
710 Signed in as <code>{user.username}</code>. Review what this app is
711 asking for, then approve or cancel.
222712 </p>
223 ) : (
224 <ul style="margin: 8px 0 0 16px; font-size: 13px">
225 {scopes.map((s) => (
226 <li>
227 <code>{s}</code>
228 </li>
229 ))}
230 </ul>
231 )}
232 </div>
233 <p style="color: var(--text-muted); font-size: 12px">
234 You can revoke access at any time from{" "}
235 <a href="/settings/authorizations">Authorized applications</a>.
236 </p>
713 </div>
714 </section>
715
716 <section class="oauth-appcard" aria-label="Application identity">
717 <div class="oauth-applogo" aria-hidden="true">{initial}</div>
718 <div class="oauth-appmeta">
719 <h2 class="oauth-appname">
720 {app.name}
721 <code>by {ownerName}</code>
722 </h2>
723 {app.description ? (
724 <p class="oauth-appdesc">{app.description}</p>
725 ) : (
726 <p class="oauth-appdesc" style="font-style: italic">
727 No description provided by the developer.
728 </p>
729 )}
730 </div>
731 </section>
732
733 <section class="oauth-section" aria-label="Requested scopes">
734 <header class="oauth-section-head">
735 <h3 class="oauth-section-title">Requested permissions</h3>
736 <span class="oauth-section-count">
737 {scopes.length} {scopes.length === 1 ? "scope" : "scopes"}
738 </span>
739 </header>
740 <div class="oauth-section-body">
741 {scopes.length === 0 ? (
742 <p class="oauth-scope-empty">
743 No scopes — this app will only be able to identify you (your
744 username + public profile).
745 </p>
746 ) : (
747 <ul class="oauth-scopes">
748 {scopes.map((s) => {
749 const meta = describeScope(s);
750 return (
751 <li class="oauth-scope">
752 <span
753 class="oauth-scope-chip"
754 data-write={isWriteScope(s) ? "1" : "0"}
755 >
756 <code>{s}</code>
757 </span>
758 <div class="oauth-scope-text">
759 <div class="oauth-scope-label">{meta.label}</div>
760 <div class="oauth-scope-explain">{meta.explain}</div>
761 </div>
762 </li>
763 );
764 })}
765 </ul>
766 )}
767 </div>
768 </section>
769
237770 <form method="post" action="/oauth/authorize/decision">
238771 <input type="hidden" name="client_id" value={clientId} />
239772 <input type="hidden" name="redirect_uri" value={redirectUri} />
246779 name="code_challenge_method"
247780 value={codeChallengeMethod}
248781 />
249 <div style="display: flex; gap: 8px">
250 <button type="submit" name="decision" value="approve" class="btn btn-primary">
251 Authorize
782 <div class="oauth-actions">
783 <button
784 type="submit"
785 name="decision"
786 value="approve"
787 class="oauth-btn oauth-btn-primary"
788 >
789 Authorize {app.name}
252790 </button>
253 <button type="submit" name="decision" value="deny" class="btn">
254 Cancel
791 <button
792 type="submit"
793 name="decision"
794 value="deny"
795 class="oauth-btn oauth-btn-ghost"
796 >
797 Deny
255798 </button>
256799 </div>
257800 </form>
801
802 <div class="oauth-explainer">
803 <strong>What does {app.name} get?</strong>
804 <ul>
805 <li>
806 A scoped access token that expires in{" "}
807 {Math.round(ACCESS_TOKEN_TTL_MS / 60000)} minutes (auto-refreshed for{" "}
808 up to {Math.round(REFRESH_TOKEN_TTL_MS / (24 * 60 * 60 * 1000))} days).
809 </li>
810 <li>
811 The scopes listed above — nothing else. It never sees your password
812 or your other personal access tokens.
813 </li>
814 <li>
815 Revoke at any time from{" "}
816 <a href="/settings/authorizations">Authorized applications</a> —
817 every access + refresh token for this app is invalidated immediately.
818 </li>
819 </ul>
820 </div>
258821 </div>
822 <style dangerouslySetInnerHTML={{ __html: oauthStyles }} />
259823 </Layout>
260824 );
261825});
6811245
6821246 return c.html(
6831247 <Layout title="Authorized applications" user={user}>
684 <div class="settings-container">
685 <div class="breadcrumb">
1248 <div class="oauth-wrap">
1249 <div class="oauth-breadcrumb">
6861250 <a href="/settings">settings</a>
687 <span>/</span>
1251 <span class="sep">/</span>
6881252 <span>authorized applications</span>
6891253 </div>
690 <h2>Authorized applications</h2>
691 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
692 {success && (
693 <div class="auth-success">{decodeURIComponent(success)}</div>
694 )}
695 <p style="color: var(--text-muted); font-size: 13px">
696 Apps that have been granted access to your gluecron account.
697 Revoking immediately invalidates every access + refresh token
698 issued to that app for your user.
699 </p>
700 <div
701 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-top: 16px"
702 >
703 {grouped.length === 0 ? (
704 <div
705 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
706 >
707 No authorized applications.
1254
1255 <section class="oauth-hero">
1256 <div class="oauth-hero-orb" aria-hidden="true" />
1257 <div class="oauth-hero-inner">
1258 <div class="oauth-eyebrow">
1259 <span class="oauth-eyebrow-dot" aria-hidden="true" />
1260 OAuth · {user.username}
7081261 </div>
709 ) : (
710 grouped.map(({ app, token }) => (
711 <div
712 style="padding: 12px 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; justify-content: space-between; align-items: center"
713 >
714 <div>
715 <strong>{app?.name || "Unknown app"}</strong>
1262 <h1 class="oauth-title">
1263 <span class="oauth-title-grad">Authorized applications.</span>
1264 </h1>
1265 <p class="oauth-sub">
1266 Apps you've granted access to your Gluecron account. Revoking
1267 immediately invalidates every access + refresh token issued to
1268 that app for you.
1269 </p>
1270 </div>
1271 </section>
1272
1273 {error && <div class="oauth-banner is-error">{decodeURIComponent(error)}</div>}
1274 {success && <div class="oauth-banner is-ok">{decodeURIComponent(success)}</div>}
1275
1276 {grouped.length === 0 ? (
1277 <div class="oauth-empty">
1278 No authorized applications. Apps you approve via the OAuth consent
1279 screen will appear here.
1280 </div>
1281 ) : (
1282 <ul class="oauth-grant-list">
1283 {grouped.map(({ app, token }) => {
1284 const initial = ((app?.name || "?").trim().charAt(0) || "?").toUpperCase();
1285 return (
1286 <li class="oauth-grant">
7161287 <div
717 style="color: var(--text-muted); font-size: 12px; margin-top: 2px"
1288 class="oauth-applogo"
1289 aria-hidden="true"
1290 style="width:42px;height:42px;font-size:17px;border-radius:10px"
7181291 >
719 scopes: <code>{token.scopes || "(none)"}</code>
720 {" · "}authorised{" "}
721 {new Date(token.createdAt).toLocaleDateString()}
722 {token.lastUsedAt &&
723 ` · last used ${new Date(token.lastUsedAt).toLocaleDateString()}`}
1292 {initial}
7241293 </div>
725 </div>
726 <form
727 method="post"
728 action={`/settings/authorizations/${token.appId}/revoke`}
729 onsubmit="return confirm('Revoke access for this application?')"
730 >
731 <button type="submit" class="btn btn-sm btn-danger">
732 Revoke
733 </button>
734 </form>
735 </div>
736 ))
737 )}
1294 <div class="oauth-grant-info">
1295 <h3 class="oauth-grant-name">{app?.name || "Unknown app"}</h3>
1296 <div class="oauth-grant-meta">
1297 scopes: <code>{token.scopes || "(none)"}</code>
1298 {" · "}authorised{" "}
1299 {new Date(token.createdAt).toLocaleDateString()}
1300 {token.lastUsedAt &&
1301 ` · last used ${new Date(token.lastUsedAt).toLocaleDateString()}`}
1302 </div>
1303 </div>
1304 <form
1305 method="post"
1306 action={`/settings/authorizations/${token.appId}/revoke`}
1307 onsubmit="return confirm('Revoke access for this application?')"
1308 style="margin:0"
1309 >
1310 <button type="submit" class="oauth-btn-danger-sm">
1311 Revoke
1312 </button>
1313 </form>
1314 </li>
1315 );
1316 })}
1317 </ul>
1318 )}
1319
1320 <div class="oauth-explainer" style="margin-top:var(--space-5)">
1321 <strong>Building an OAuth app?</strong>
1322 Register one at <a href="/settings/applications">Developer applications</a>.
1323 You'll get a <code>client_id</code> + <code>client_secret</code> and
1324 can point users at <code>/oauth/authorize?client_id=…</code>.
1325 The full spec — supported scopes ({SUPPORTED_SCOPES.length}), PKCE
1326 rules, token TTLs — is at <a href="/api-docs">/api-docs</a>.
7381327 </div>
739 <p style="margin-top: 16px; font-size: 12px; color: var(--text-muted)">
740 Building an OAuth app?{" "}
741 <a href="/settings/applications">Register one</a>.
742 </p>
7431328 </div>
1329 <style dangerouslySetInnerHTML={{ __html: oauthStyles }} />
7441330 </Layout>
7451331 );
7461332});
Modifiedsrc/routes/onboarding.tsx+609−127View fileUnifiedSplit
22 * Onboarding flow — guided setup for new users.
33 *
44 * Goal: get a fresh user from 0 to first repo in <60 seconds.
5 * Headline + 1-line value prop + 3 concrete next-step CTAs + skip-to-dashboard.
5 * Headline + 1-line value prop + 4 concrete numbered step cards +
6 * skip-to-dashboard. 2026 polish — gradient hairline hero, orb, eyebrow,
7 * gradient verb in the headline, numbered step cards. All scoped under
8 * `.onb-*`.
69 */
710
811import { Hono } from "hono";
1114import type { AuthEnv } from "../middleware/auth";
1215import { eq, sql } from "drizzle-orm";
1316import { db } from "../db";
14import { repositories, sshKeys, apiTokens, users } from "../db/schema";
17import { repositories, sshKeys, apiTokens } from "../db/schema";
1518import { config } from "../lib/config";
16import {
17 Container,
18 WelcomeHero,
19 StepIndicator,
20 Card,
21 Flex,
22 Text,
23 LinkButton,
24 CopyBlock,
25 Kbd,
26 Spacer,
27} from "../views/ui";
2819
2920const onboardingRoutes = new Hono<AuthEnv>();
3021
22/* ─────────────────────────────────────────────────────────────────────────
23 * Scoped CSS — every class prefixed `.onb-` so this surface can't bleed
24 * into other pages. Mirrors the gradient-hairline hero + section card
25 * patterns from admin-integrations.tsx, admin-ops.tsx, error-page.tsx.
26 * ───────────────────────────────────────────────────────────────────── */
27const styles = `
28 .onb-wrap { max-width: 1000px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
29
30 /* ─── Hero ─── */
31 .onb-hero {
32 position: relative;
33 margin-bottom: var(--space-5);
34 padding: clamp(28px, 4vw, 48px) clamp(24px, 4vw, 48px);
35 background: var(--bg-elevated);
36 border: 1px solid var(--border);
37 border-radius: 18px;
38 overflow: hidden;
39 }
40 .onb-hero::before {
41 content: '';
42 position: absolute;
43 top: 0; left: 0; right: 0;
44 height: 2px;
45 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
46 opacity: 0.75;
47 pointer-events: none;
48 }
49 .onb-hero-orb {
50 position: absolute;
51 inset: -30% -10% auto auto;
52 width: 460px; height: 460px;
53 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
54 filter: blur(80px);
55 opacity: 0.7;
56 pointer-events: none;
57 z-index: 0;
58 }
59 .onb-hero-inner { position: relative; z-index: 1; max-width: 720px; }
60 .onb-eyebrow {
61 display: inline-flex;
62 align-items: center;
63 gap: 8px;
64 font-family: var(--font-mono);
65 font-size: 11px;
66 letter-spacing: 0.18em;
67 text-transform: uppercase;
68 color: var(--text-muted);
69 font-weight: 600;
70 margin-bottom: 16px;
71 }
72 .onb-eyebrow-dot {
73 width: 8px; height: 8px;
74 border-radius: 9999px;
75 background: linear-gradient(135deg, #8c6dff, #36c5d6);
76 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
77 }
78 .onb-eyebrow strong { color: var(--accent); font-weight: 600; letter-spacing: 0.04em; }
79 .onb-title {
80 font-family: var(--font-display);
81 font-size: clamp(32px, 5vw, 48px);
82 font-weight: 800;
83 letter-spacing: -0.030em;
84 line-height: 1.05;
85 margin: 0 0 var(--space-3);
86 color: var(--text-strong);
87 }
88 .onb-title-grad {
89 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
90 -webkit-background-clip: text;
91 background-clip: text;
92 -webkit-text-fill-color: transparent;
93 color: transparent;
94 }
95 .onb-sub {
96 font-size: 16px;
97 color: var(--text-muted);
98 margin: 0;
99 line-height: 1.55;
100 max-width: 600px;
101 }
102
103 /* ─── Welcome banner ─── */
104 .onb-welcome {
105 margin-bottom: var(--space-5);
106 padding: 14px 18px;
107 border-radius: 12px;
108 background: linear-gradient(135deg, rgba(140,109,255,0.16), rgba(54,197,214,0.10));
109 border: 1px solid rgba(140,109,255,0.40);
110 font-size: 14px;
111 color: var(--text-strong);
112 display: flex;
113 align-items: center;
114 gap: 10px;
115 }
116 .onb-welcome-spark {
117 width: 10px; height: 10px;
118 border-radius: 9999px;
119 background: linear-gradient(135deg, #8c6dff, #36c5d6);
120 box-shadow: 0 0 0 4px rgba(140,109,255,0.18);
121 flex-shrink: 0;
122 }
123
124 /* ─── Step cards grid ─── */
125 .onb-steps {
126 display: grid;
127 grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
128 gap: var(--space-3);
129 margin-bottom: var(--space-5);
130 }
131 .onb-step {
132 position: relative;
133 padding: var(--space-4);
134 background: var(--bg-elevated);
135 border: 1px solid var(--border);
136 border-radius: 14px;
137 display: flex;
138 flex-direction: column;
139 gap: 10px;
140 transition: border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
141 }
142 .onb-step:hover {
143 border-color: rgba(140,109,255,0.45);
144 transform: translateY(-2px);
145 box-shadow: 0 10px 28px -10px rgba(140,109,255,0.30);
146 }
147 .onb-step.is-done {
148 border-color: rgba(52,211,153,0.40);
149 background: linear-gradient(180deg, rgba(52,211,153,0.04), var(--bg-elevated) 60%);
150 }
151 .onb-step-head {
152 display: flex;
153 align-items: center;
154 gap: 12px;
155 }
156 .onb-step-num {
157 display: inline-flex;
158 align-items: center;
159 justify-content: center;
160 width: 30px; height: 30px;
161 border-radius: 9999px;
162 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
163 color: #c5b3ff;
164 border: 1px solid rgba(140,109,255,0.40);
165 font-family: var(--font-display);
166 font-weight: 700;
167 font-size: 14px;
168 flex-shrink: 0;
169 }
170 .onb-step.is-done .onb-step-num {
171 background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
172 color: #062b1f;
173 border-color: rgba(52,211,153,0.55);
174 }
175 .onb-step-icon {
176 display: inline-flex;
177 align-items: center;
178 justify-content: center;
179 width: 30px; height: 30px;
180 border-radius: 9px;
181 background: rgba(140,109,255,0.12);
182 color: #b69dff;
183 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
184 margin-left: auto;
185 flex-shrink: 0;
186 }
187 .onb-step-title {
188 font-family: var(--font-display);
189 font-size: 15px;
190 font-weight: 700;
191 color: var(--text-strong);
192 margin: 0;
193 letter-spacing: -0.012em;
194 }
195 .onb-step-desc {
196 font-size: 13px;
197 color: var(--text-muted);
198 margin: 0;
199 line-height: 1.5;
200 flex: 1;
201 }
202 .onb-step-foot {
203 display: flex;
204 align-items: center;
205 gap: 10px;
206 margin-top: 4px;
207 }
208 .onb-btn {
209 display: inline-flex;
210 align-items: center;
211 justify-content: center;
212 gap: 6px;
213 padding: 8px 14px;
214 border-radius: 9px;
215 font-size: 13px;
216 font-weight: 600;
217 text-decoration: none;
218 border: 1px solid transparent;
219 cursor: pointer;
220 font: inherit;
221 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
222 line-height: 1;
223 }
224 .onb-btn-primary {
225 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
226 color: #ffffff;
227 box-shadow: 0 6px 16px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
228 }
229 .onb-btn-primary:hover {
230 transform: translateY(-1px);
231 box-shadow: 0 10px 22px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
232 color: #ffffff;
233 text-decoration: none;
234 }
235 .onb-btn-ghost {
236 background: transparent;
237 color: var(--text);
238 border-color: var(--border-strong, var(--border));
239 }
240 .onb-btn-ghost:hover {
241 background: rgba(140,109,255,0.06);
242 border-color: rgba(140,109,255,0.45);
243 color: var(--text-strong);
244 text-decoration: none;
245 }
246 .onb-skip {
247 background: transparent;
248 color: var(--text-muted);
249 border: none;
250 padding: 6px 4px;
251 font-size: 12px;
252 text-decoration: underline;
253 text-decoration-style: dotted;
254 text-underline-offset: 3px;
255 }
256 .onb-skip:hover { color: var(--text); text-decoration-style: solid; }
257 .onb-step-done-badge {
258 display: inline-flex;
259 align-items: center;
260 gap: 6px;
261 padding: 4px 10px;
262 border-radius: 9999px;
263 font-size: 11px;
264 font-weight: 600;
265 letter-spacing: 0.04em;
266 text-transform: uppercase;
267 background: rgba(52,211,153,0.14);
268 color: #6ee7b7;
269 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
270 }
271 .onb-step-done-badge .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
272
273 /* ─── Push snippet card ─── */
274 .onb-section {
275 margin-bottom: var(--space-5);
276 background: var(--bg-elevated);
277 border: 1px solid var(--border);
278 border-radius: 14px;
279 overflow: hidden;
280 }
281 .onb-section-head {
282 padding: var(--space-4) var(--space-5);
283 border-bottom: 1px solid var(--border);
284 }
285 .onb-section-title {
286 margin: 0;
287 font-family: var(--font-display);
288 font-size: 16px;
289 font-weight: 700;
290 color: var(--text-strong);
291 letter-spacing: -0.014em;
292 }
293 .onb-section-sub {
294 margin: 4px 0 0;
295 font-size: 12.5px;
296 color: var(--text-muted);
297 }
298 .onb-section-body { padding: var(--space-4) var(--space-5); }
299 .onb-code {
300 margin: 0;
301 padding: 14px 16px;
302 background: var(--bg-secondary, rgba(0,0,0,0.20));
303 border: 1px solid var(--border-subtle, var(--border));
304 border-radius: 10px;
305 font-family: var(--font-mono);
306 font-size: 12.5px;
307 line-height: 1.6;
308 color: var(--text);
309 overflow-x: auto;
310 white-space: pre;
311 }
312
313 /* ─── Empty / "all done" celebration ─── */
314 .onb-empty {
315 position: relative;
316 margin-bottom: var(--space-5);
317 padding: clamp(28px, 4vw, 44px) clamp(20px, 4vw, 40px);
318 text-align: center;
319 background: var(--bg-elevated);
320 border: 1px dashed rgba(140,109,255,0.40);
321 border-radius: 16px;
322 overflow: hidden;
323 }
324 .onb-empty-orb {
325 position: absolute;
326 inset: -40% -20% auto auto;
327 width: 320px; height: 320px;
328 background: radial-gradient(circle, rgba(52,211,153,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
329 filter: blur(60px);
330 opacity: 0.7;
331 pointer-events: none;
332 z-index: 0;
333 }
334 .onb-empty-inner { position: relative; z-index: 1; }
335 .onb-empty-glyph {
336 display: inline-flex;
337 align-items: center;
338 justify-content: center;
339 width: 56px; height: 56px;
340 border-radius: 9999px;
341 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.12));
342 color: #c5b3ff;
343 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
344 margin-bottom: 14px;
345 }
346 .onb-empty-title {
347 font-family: var(--font-display);
348 font-size: 22px;
349 font-weight: 700;
350 margin: 0 0 8px;
351 color: var(--text-strong);
352 letter-spacing: -0.018em;
353 }
354 .onb-empty-sub {
355 font-size: 14px;
356 color: var(--text-muted);
357 margin: 0 auto 18px;
358 max-width: 480px;
359 line-height: 1.55;
360 }
361 .onb-empty-actions {
362 display: flex;
363 gap: 10px;
364 justify-content: center;
365 flex-wrap: wrap;
366 }
367
368 /* ─── Skip + help foot ─── */
369 .onb-foot {
370 text-align: center;
371 padding: var(--space-3) 0 var(--space-6);
372 color: var(--text-muted);
373 font-size: 13px;
374 }
375 .onb-foot a { color: var(--accent); text-decoration: none; }
376 .onb-foot a:hover { text-decoration: underline; }
377 .onb-foot-kbd {
378 display: inline-flex;
379 align-items: center;
380 justify-content: center;
381 min-width: 18px;
382 padding: 1px 6px;
383 margin: 0 2px;
384 border-radius: 4px;
385 border: 1px solid var(--border);
386 background: rgba(255,255,255,0.04);
387 font-family: var(--font-mono);
388 font-size: 11px;
389 color: var(--text);
390 }
391`;
392
393/* Inline SVG icons used in the step cards. Small, monochrome, currentColor. */
394function IconConnect() {
395 return (
396 <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">
397 <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
398 <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
399 </svg>
400 );
401}
402function IconImport() {
403 return (
404 <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">
405 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
406 <polyline points="7 10 12 15 17 10" />
407 <line x1="12" y1="15" x2="12" y2="3" />
408 </svg>
409 );
410}
411function IconRun() {
412 return (
413 <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">
414 <polygon points="5 3 19 12 5 21 5 3" />
415 </svg>
416 );
417}
418function IconShip() {
419 return (
420 <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">
421 <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
422 <path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
423 <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
424 <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
425 </svg>
426 );
427}
428function IconKey() {
429 return (
430 <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">
431 <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" />
432 </svg>
433 );
434}
435function IconToken() {
436 return (
437 <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">
438 <rect x="3" y="11" width="18" height="11" rx="2" />
439 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
440 </svg>
441 );
442}
443function IconSparkle() {
444 return (
445 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
446 <path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z" />
447 </svg>
448 );
449}
450
31451// P3 — `/onboarding` is the canonical post-register landing. Alias the
32452// existing `/getting-started` handler so both URLs work; new users hit
33453// /onboarding?welcome=1 and see a celebration banner.
61481 } catch { /* DB may not be ready */ }
62482
63483 const firstRun = repoCount === 0;
484 const allDone = repoCount > 0 && hasKeys && hasTokens;
485
486 // The 4 steps. For new users: Connect / Import / Run / Ship.
487 // For existing users with some setup, mark completed cards as done.
488 const steps = firstRun
489 ? [
490 {
491 n: 1,
492 title: "Create a repository",
493 desc: "Green-ecosystem defaults, branch protection, labels, CODEOWNERS — wired on day one.",
494 cta: { href: "/new", label: "Create repo", primary: true },
495 skip: null,
496 icon: <IconConnect />,
497 done: false,
498 },
499 {
500 n: 2,
501 title: "Import from GitHub",
502 desc: "Mirror an existing repo by URL. History, branches, and tags come across on the first sync.",
503 cta: { href: "/import", label: "Import repo", primary: false },
504 skip: { href: "/dashboard", label: "Skip" },
505 icon: <IconImport />,
506 done: false,
507 },
508 {
509 n: 3,
510 title: "Run the gates",
511 desc: "Push a commit and watch GateTest, AI review, and CI run automatically.",
512 cta: { href: "/explore", label: "Browse repos", primary: false },
513 skip: { href: "/dashboard", label: "Skip" },
514 icon: <IconRun />,
515 done: false,
516 },
517 {
518 n: 4,
519 title: "Ship to production",
520 desc: "Configure auto-merge + deploy webhooks. Your push lands live in ~25 seconds.",
521 cta: { href: "/help", label: "Read the guide", primary: false },
522 skip: { href: "/dashboard", label: "Skip" },
523 icon: <IconShip />,
524 done: false,
525 },
526 ]
527 : [
528 {
529 n: 1,
530 title: "Your repositories",
531 desc: `You have ${repoCount} repositor${repoCount === 1 ? "y" : "ies"}. Push code, open issues, review PRs.`,
532 cta: { href: "/dashboard", label: "Open dashboard", primary: true },
533 skip: null,
534 icon: <IconConnect />,
535 done: repoCount > 0,
536 },
537 {
538 n: 2,
539 title: hasKeys ? "SSH key added" : "Add an SSH key",
540 desc: hasKeys
541 ? "Push without entering a password every time."
542 : "Generate a public key on your machine and paste it in to push without a password.",
543 cta: hasKeys
544 ? { href: "/settings/keys", label: "Manage keys", primary: false }
545 : { href: "/settings/keys", label: "Add key", primary: true },
546 skip: hasKeys ? null : { href: "/dashboard", label: "Skip" },
547 icon: <IconKey />,
548 done: hasKeys,
549 },
550 {
551 n: 3,
552 title: hasTokens ? "API token ready" : "Create an API token",
553 desc: hasTokens
554 ? "Use it for CI, CLI, and automation — same scopes as the web."
555 : "Authenticate scripts, CI, and the CLI. Scoped to your account, revocable any time.",
556 cta: hasTokens
557 ? { href: "/settings/tokens", label: "Manage tokens", primary: false }
558 : { href: "/settings/tokens", label: "Create token", primary: true },
559 skip: hasTokens ? null : { href: "/dashboard", label: "Skip" },
560 icon: <IconToken />,
561 done: hasTokens,
562 },
563 {
564 n: 4,
565 title: "Ship to production",
566 desc: "Configure auto-merge + deploy webhooks. Your push lands live in ~25 seconds.",
567 cta: { href: "/help", label: "Read the guide", primary: false },
568 skip: { href: "/dashboard", label: "Skip" },
569 icon: <IconShip />,
570 done: false,
571 },
572 ];
64573
65574 return c.html(
66575 <Layout title="Getting Started" user={user}>
67 <Container maxWidth={760}>
576 <style dangerouslySetInnerHTML={{ __html: styles }} />
577 <div class="onb-wrap">
68578 {welcome && (
69 <div
70 data-onboarding-welcome="1"
71 style="margin: 12px 0 20px; padding: 14px 18px; border-radius: 12px; background: linear-gradient(135deg, rgba(140,109,255,0.18) 0%, rgba(54,197,214,0.18) 100%); border: 1px solid rgba(140,109,255,0.45); font-size: 14px; color: var(--text-strong)"
72 >
73 🎉 Welcome to Gluecron! Let's get you set up.
579 <div class="onb-welcome" data-onboarding-welcome="1">
580 <span class="onb-welcome-spark" aria-hidden="true" />
581 Welcome to Gluecron, <strong>@{user.username}</strong> — let's get you set up.
74582 </div>
75583 )}
76 {/* ─── Welcome headline + 1-line value prop ─── */}
77 <WelcomeHero
78 title={firstRun ? `Welcome, ${user.username}` : "Finish setting up"}
79 subtitle="Ship safer code with AI-native hosting, automated CI, and push-time gates."
80 />
81
82 {/* ─── Three concrete next-step CTAs — the 60-second path ─── */}
83 {firstRun && (
84 <div class="panel" style="margin-bottom:20px">
85 <div class="panel-item" style="flex-direction:column;align-items:stretch;gap:4px;padding:16px">
86 <div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
87 <div style="flex:1">
88 <div style="font-size:15px;font-weight:600">Create a new repository</div>
89 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
90 Start from scratch. Green-ecosystem defaults, branch protection, labels, CODEOWNERS — all wired on day one.
91 </div>
92 </div>
93 <a href="/new" class="btn btn-primary">Create repo</a>
94 </div>
95 </div>
96 <div class="panel-item" style="flex-direction:column;align-items:stretch;gap:4px;padding:16px">
97 <div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
98 <div style="flex:1">
99 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
100 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
101 Mirror an existing repo by URL. History, branches, and tags come across on the first sync.
102 </div>
103 </div>
104 <a href="/import" class="btn">Import repo</a>
105 </div>
106 </div>
107 <div class="panel-item" style="flex-direction:column;align-items:stretch;gap:4px;padding:16px">
108 <div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
109 <div style="flex:1">
110 <div style="font-size:15px;font-weight:600">Browse public repos</div>
111 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
112 See what others are building. Fork or star without leaving the platform.
113 </div>
114 </div>
115 <a href="/explore" class="btn">Browse</a>
116 </div>
584
585 {/* ─── Hero ─── */}
586 <section class="onb-hero">
587 <div class="onb-hero-orb" aria-hidden="true" />
588 <div class="onb-hero-inner">
589 <div class="onb-eyebrow">
590 <span class="onb-eyebrow-dot" aria-hidden="true" />
591 Onboarding · <strong>@{user.username}</strong>
117592 </div>
593 <h1 class="onb-title">
594 <span class="onb-title-grad">{firstRun ? "Get started." : "Finish setup."}</span>
595 </h1>
596 <p class="onb-sub">
597 Ship safer code with AI-native hosting, automated CI, and push-time
598 gates. Four short steps — under a minute end-to-end.
599 </p>
118600 </div>
119 )}
601 </section>
120602
121 {/* ─── Existing users: show remaining setup as a compact checklist ─── */}
122 {!firstRun && (
123 <div class="panel" style="margin-bottom:20px">
124 <div class="panel-item" style="justify-content:space-between;padding:14px 16px">
125 <div>
126 <div style="font-size:14px;font-weight:600">
127 {"✓"} You have {repoCount} repositor{repoCount === 1 ? "y" : "ies"}
128 </div>
129 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
130 Push code, open issues, review PRs.
131 </div>
132 </div>
133 <a href="/dashboard" class="btn btn-sm">Open dashboard</a>
134 </div>
135 <div class="panel-item" style="justify-content:space-between;padding:14px 16px">
136 <div>
137 <div style="font-size:14px;font-weight:600">
138 {hasKeys ? "✓ SSH key added" : "Add an SSH key"}
139 </div>
140 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
141 {hasKeys ? "Push without passwords." : "Push without entering a password every time."}
142 </div>
603 {/* ─── Numbered step cards ─── */}
604 <div class="onb-steps">
605 {steps.map((s) => (
606 <div class={"onb-step" + (s.done ? " is-done" : "")}>
607 <div class="onb-step-head">
608 <span class="onb-step-num">{s.done ? "✓" : s.n}</span>
609 <span class="onb-step-icon" aria-hidden="true">{s.icon}</span>
143610 </div>
144 {!hasKeys && <a href="/settings/keys" class="btn btn-sm">Add key</a>}
145 </div>
146 <div class="panel-item" style="justify-content:space-between;padding:14px 16px">
147 <div>
148 <div style="font-size:14px;font-weight:600">
149 {hasTokens ? "✓ API token ready" : "Create an API token"}
150 </div>
151 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
152 {hasTokens ? "Use it for CI, CLI, and automation." : "Authenticate scripts, CI, and the CLI."}
153 </div>
611 <h3 class="onb-step-title">{s.title}</h3>
612 <p class="onb-step-desc">{s.desc}</p>
613 <div class="onb-step-foot">
614 {s.done ? (
615 <span class="onb-step-done-badge">
616 <span class="dot" aria-hidden="true" />
617 Done
618 </span>
619 ) : (
620 <a
621 href={s.cta.href}
622 class={"onb-btn " + (s.cta.primary ? "onb-btn-primary" : "onb-btn-ghost")}
623 >
624 {s.cta.label}
625 </a>
626 )}
627 {s.skip && !s.done && (
628 <a href={s.skip.href} class="onb-skip">
629 {s.skip.label}
630 </a>
631 )}
154632 </div>
155 {!hasTokens && <a href="/settings/tokens" class="btn btn-sm">Create token</a>}
156633 </div>
157 </div>
158 )}
634 ))}
635 </div>
159636
160637 {/* ─── Push snippet (only once the user has at least one repo) ─── */}
161638 {!firstRun && (
162 <Card style="padding:16px;margin-bottom:20px">
163 <h3 style="font-size:14px;margin:0 0 8px 0">Push an existing project</h3>
164 <CopyBlock
165 text={`git remote add gluecron ${config.appBaseUrl}/${user.username}/your-repo.git\ngit push -u gluecron main`}
166 label="Commands"
167 />
168 </Card>
639 <section class="onb-section">
640 <header class="onb-section-head">
641 <h3 class="onb-section-title">Push an existing project</h3>
642 <p class="onb-section-sub">
643 Add Gluecron as a git remote and push. We'll pick up the history on the first sync.
644 </p>
645 </header>
646 <div class="onb-section-body">
647 <pre class="onb-code">{`git remote add gluecron ${config.appBaseUrl}/${user.username}/your-repo.git
648git push -u gluecron main`}</pre>
649 </div>
650 </section>
169651 )}
170652
171 {/* ─── All done celebration ─── */}
172 {repoCount > 0 && hasKeys && hasTokens && (
173 <Card style="text-align:center;padding:32px 0;border-color:var(--green);margin-bottom:20px;background:rgba(63,185,80,0.05)">
174 <div style="font-size:40px;margin-bottom:8px">&#127881;</div>
175 <h2 style="margin:0">You're all set.</h2>
176 <Text size={13} muted style="display:block;margin-top:6px">
177 Setup complete. Start building.
178 </Text>
179 <Flex gap={12} justify="center" style="margin-top:16px">
180 <LinkButton href="/dashboard" variant="primary">Open dashboard</LinkButton>
181 <LinkButton href="/explore">Discover repos</LinkButton>
182 </Flex>
183 </Card>
653 {/* ─── All-done empty state — celebration card ─── */}
654 {allDone && (
655 <section class="onb-empty">
656 <div class="onb-empty-orb" aria-hidden="true" />
657 <div class="onb-empty-inner">
658 <span class="onb-empty-glyph" aria-hidden="true"><IconSparkle /></span>
659 <h2 class="onb-empty-title">You're all set.</h2>
660 <p class="onb-empty-sub">
661 Setup complete. Start building, browsing, or invite a teammate.
662 </p>
663 <div class="onb-empty-actions">
664 <a href="/dashboard" class="onb-btn onb-btn-primary">Open dashboard</a>
665 <a href="/explore" class="onb-btn onb-btn-ghost">Discover repos</a>
666 </div>
667 </div>
668 </section>
184669 )}
185670
186 {/* ─── Skip-to-dashboard + help ─── */}
187 <div style="text-align:center;padding:16px 0 32px 0">
188 <a href="/dashboard" style="font-size:13px;color:var(--text-muted);text-decoration:underline">
189 Skip to dashboard {"→"}
190 </a>
191 <div style="margin-top:12px">
192 <Text size={12} muted>
193 Need help? See the <a href="/api/docs">API docs</a> or press <Kbd>?</Kbd> for shortcuts.
194 </Text>
671 {/* ─── Skip-to-dashboard + help foot ─── */}
672 <div class="onb-foot">
673 <a href="/dashboard">Skip to dashboard &rarr;</a>
674 <div style="margin-top:10px">
675 Need help? See the <a href="/api/docs">API docs</a> or press{" "}
676 <span class="onb-foot-kbd">?</span> for shortcuts.
195677 </div>
196678 </div>
197 </Container>
679 </div>
198680 </Layout>
199681 );
200682};
Modifiedsrc/routes/org-insights.tsx+612−69View fileUnifiedSplit
88 *
99 * No new tables — computed live from existing `repositories`, `gate_runs`,
1010 * `pull_requests`, `issues`.
11 *
12 * 2026 polish: gradient-hairline hero + radial orb + aggregated stat-card
13 * grid + leaderboard cards for most-active repos and most-active
14 * contributors. Every class prefixed `.org-ins-` so this surface can't
15 * bleed into the wider polish. Route + `computeOrgInsights()` contract
16 * preserved exactly (extended with an optional `topContributors` field).
1117 */
1218
1319import { Hono } from "hono";
2026 orgMembers,
2127 pullRequests,
2228 repositories,
29 users,
2330} from "../db/schema";
2431import { Layout } from "../views/layout";
2532import { softAuth, requireAuth } from "../middleware/auth";
4653 openPrs: number;
4754 openIssues: number;
4855 }>;
56 /** Most-active contributors (by total PRs opened in this org). Optional so
57 * the contract stays backwards-compatible with existing callers/tests. */
58 topContributors?: Array<{ username: string; prs: number; merged: number }>;
4959}
5060
5161export async function computeOrgInsights(
6272 openPrs: 0,
6373 mergedPrs30d: 0,
6474 perRepo: [],
75 topContributors: [],
6576 };
6677
6778 try {
172183 )
173184 );
174185
186 // Top contributors — by PR count across org repos. Wrapped separately so
187 // a failure here doesn't take down the whole rollup.
188 let topContributors: NonNullable<
189 OrgInsightsSummary["topContributors"]
190 > = [];
191 try {
192 const contribRows = await db
193 .select({
194 username: users.username,
195 prs: sql<number>`count(*)::int`,
196 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
197 })
198 .from(pullRequests)
199 .innerJoin(users, eq(pullRequests.authorId, users.id))
200 .where(sql`${pullRequests.repositoryId} IN (${idList})`)
201 .groupBy(users.username)
202 .orderBy(sql`count(*) desc`)
203 .limit(8);
204 topContributors = contribRows.map((r) => ({
205 username: r.username,
206 prs: Number(r.prs || 0),
207 merged: Number(r.merged || 0),
208 }));
209 } catch {
210 topContributors = [];
211 }
212
175213 const perRepo = repos.map((r) => {
176214 const b = byRepo.get(r.id) || {
177215 runs: 0,
204242 openPrs,
205243 mergedPrs30d: Number(mergedRow?.n || 0),
206244 perRepo,
245 topContributors,
207246 };
208247 } catch {
209248 return empty;
236275 }
237276}
238277
278/* ─────────────────────────────────────────────────────────────────────────
279 * Scoped CSS — every class prefixed `.org-ins-` so this surface can't
280 * bleed into the wider polish. Mirrors the gradient-hairline hero +
281 * stat-card grid + leaderboard pattern from `insights.tsx`.
282 * ───────────────────────────────────────────────────────────────────── */
283const styles = `
284 .org-ins-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
285
286 .org-ins-hero {
287 position: relative;
288 margin-bottom: var(--space-5);
289 padding: var(--space-5) var(--space-6);
290 background: var(--bg-elevated);
291 border: 1px solid var(--border);
292 border-radius: 16px;
293 overflow: hidden;
294 }
295 .org-ins-hero::before {
296 content: '';
297 position: absolute;
298 top: 0; left: 0; right: 0;
299 height: 2px;
300 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
301 opacity: 0.75;
302 pointer-events: none;
303 }
304 .org-ins-hero-orb {
305 position: absolute;
306 inset: -30% -15% auto auto;
307 width: 460px; height: 460px;
308 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
309 filter: blur(80px);
310 opacity: 0.75;
311 pointer-events: none;
312 z-index: 0;
313 }
314 .org-ins-hero-inner {
315 position: relative;
316 z-index: 1;
317 display: flex;
318 align-items: flex-start;
319 justify-content: space-between;
320 gap: var(--space-4);
321 flex-wrap: wrap;
322 }
323 .org-ins-hero-text { max-width: 720px; }
324 .org-ins-eyebrow {
325 display: inline-flex;
326 align-items: center;
327 gap: 8px;
328 text-transform: uppercase;
329 font-family: var(--font-mono);
330 font-size: 11px;
331 letter-spacing: 0.18em;
332 color: var(--text-muted);
333 font-weight: 600;
334 margin-bottom: 14px;
335 }
336 .org-ins-eyebrow-dot {
337 width: 8px; height: 8px;
338 border-radius: 9999px;
339 background: linear-gradient(135deg, #8c6dff, #36c5d6);
340 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
341 }
342 .org-ins-title {
343 font-family: var(--font-display);
344 font-size: clamp(28px, 4vw, 40px);
345 font-weight: 800;
346 letter-spacing: -0.028em;
347 line-height: 1.05;
348 margin: 0 0 var(--space-2);
349 color: var(--text-strong);
350 }
351 .org-ins-title-grad {
352 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
353 -webkit-background-clip: text;
354 background-clip: text;
355 -webkit-text-fill-color: transparent;
356 color: transparent;
357 }
358 .org-ins-sub {
359 font-size: 15px;
360 color: var(--text-muted);
361 margin: 0;
362 line-height: 1.55;
363 }
364 .org-ins-back {
365 display: inline-flex;
366 align-items: center;
367 gap: 6px;
368 padding: 8px 14px;
369 border-radius: 10px;
370 border: 1px solid var(--border-strong, var(--border));
371 background: transparent;
372 color: var(--text);
373 font-size: 13px;
374 font-weight: 600;
375 text-decoration: none;
376 transition: background 120ms ease, border-color 120ms ease;
377 white-space: nowrap;
378 }
379 .org-ins-back:hover {
380 background: rgba(140,109,255,0.06);
381 border-color: rgba(140,109,255,0.45);
382 text-decoration: none;
383 }
384
385 /* Stat-card grid */
386 .org-ins-stats {
387 display: grid;
388 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
389 gap: var(--space-3);
390 margin-bottom: var(--space-5);
391 }
392 .org-ins-stat {
393 position: relative;
394 background: var(--bg-elevated);
395 border: 1px solid var(--border);
396 border-radius: 14px;
397 padding: var(--space-4);
398 transition: border-color 120ms ease, transform 120ms ease;
399 }
400 .org-ins-stat:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
401 .org-ins-stat-label {
402 font-size: 10.5px;
403 letter-spacing: 0.14em;
404 text-transform: uppercase;
405 color: var(--text-muted);
406 font-weight: 700;
407 margin-bottom: 6px;
408 }
409 .org-ins-stat-value {
410 font-family: var(--font-display);
411 font-size: 32px;
412 font-weight: 800;
413 letter-spacing: -0.022em;
414 color: var(--text-strong);
415 font-variant-numeric: tabular-nums;
416 line-height: 1;
417 }
418 .org-ins-stat-value.is-good { color: #6ee7b7; }
419 .org-ins-stat-value.is-warn { color: #fca5a5; }
420 .org-ins-stat-value.is-info { color: #93c5fd; }
421 .org-ins-stat-value.is-accent {
422 background-image: linear-gradient(135deg, #a48bff 0%, #36c5d6 100%);
423 -webkit-background-clip: text;
424 background-clip: text;
425 -webkit-text-fill-color: transparent;
426 color: transparent;
427 }
428 .org-ins-stat-hint {
429 margin-top: 6px;
430 font-size: 12px;
431 color: var(--text-muted);
432 }
433
434 /* Gate-status mini stat row */
435 .org-ins-gates {
436 display: grid;
437 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
438 gap: var(--space-3);
439 margin-bottom: var(--space-5);
440 }
441 .org-ins-gate {
442 background: var(--bg-elevated);
443 border: 1px solid var(--border);
444 border-radius: 12px;
445 padding: var(--space-3) var(--space-4);
446 text-align: center;
447 }
448 .org-ins-gate-value {
449 font-family: var(--font-display);
450 font-size: 22px;
451 font-weight: 700;
452 color: var(--text-strong);
453 font-variant-numeric: tabular-nums;
454 line-height: 1;
455 }
456 .org-ins-gate-value.is-good { color: #6ee7b7; }
457 .org-ins-gate-value.is-warn { color: #fca5a5; }
458 .org-ins-gate-value.is-soft { color: #c4b5fd; }
459 .org-ins-gate-label {
460 margin-top: 6px;
461 font-size: 10.5px;
462 letter-spacing: 0.14em;
463 text-transform: uppercase;
464 color: var(--text-muted);
465 font-weight: 700;
466 }
467
468 /* Section heading */
469 .org-ins-section-head {
470 margin: 0 0 var(--space-3);
471 display: flex;
472 align-items: baseline;
473 justify-content: space-between;
474 gap: var(--space-3);
475 flex-wrap: wrap;
476 }
477 .org-ins-section-title {
478 margin: 0;
479 font-family: var(--font-display);
480 font-size: 18px;
481 font-weight: 700;
482 letter-spacing: -0.018em;
483 color: var(--text-strong);
484 }
485 .org-ins-section-sub { font-size: 12.5px; color: var(--text-muted); }
486
487 /* Two-column leaderboard layout */
488 .org-ins-twocol {
489 display: grid;
490 grid-template-columns: 1.2fr 0.8fr;
491 gap: var(--space-4);
492 margin-bottom: var(--space-5);
493 }
494 @media (max-width: 820px) {
495 .org-ins-twocol { grid-template-columns: 1fr; }
496 }
497
498 /* Leaderboard cards */
499 .org-ins-list {
500 display: flex;
501 flex-direction: column;
502 gap: var(--space-2);
503 }
504 .org-ins-card {
505 position: relative;
506 background: var(--bg-elevated);
507 border: 1px solid var(--border);
508 border-radius: 12px;
509 padding: var(--space-3) var(--space-4);
510 transition: border-color 120ms ease, transform 120ms ease;
511 display: flex;
512 align-items: center;
513 gap: 14px;
514 }
515 .org-ins-card:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
516 .org-ins-rank {
517 flex: none;
518 width: 28px; height: 28px;
519 border-radius: 8px;
520 background: rgba(140,109,255,0.10);
521 color: #b69dff;
522 font-family: var(--font-mono);
523 font-size: 12px;
524 font-weight: 700;
525 display: flex;
526 align-items: center;
527 justify-content: center;
528 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
529 }
530 .org-ins-rank.is-1 {
531 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.30));
532 color: #fff;
533 }
534 .org-ins-card-main { flex: 1; min-width: 0; }
535 .org-ins-card-title {
536 font-family: var(--font-display);
537 font-size: 14.5px;
538 font-weight: 700;
539 color: var(--text-strong);
540 letter-spacing: -0.005em;
541 line-height: 1.3;
542 margin: 0 0 4px;
543 word-break: break-word;
544 }
545 .org-ins-card-title a { color: inherit; text-decoration: none; }
546 .org-ins-card-title a:hover { color: var(--accent); }
547 .org-ins-card-sub {
548 font-size: 12.5px;
549 color: var(--text-muted);
550 line-height: 1.5;
551 margin: 0;
552 font-variant-numeric: tabular-nums;
553 }
554 .org-ins-card-meta {
555 flex: none;
556 text-align: right;
557 font-family: var(--font-mono);
558 font-size: 13px;
559 font-variant-numeric: tabular-nums;
560 }
561 .org-ins-card-meta.is-good { color: #6ee7b7; }
562 .org-ins-card-meta.is-warn { color: #fca5a5; }
563 .org-ins-card-meta.is-mid { color: #fcd34d; }
564 .org-ins-card-meta.is-muted { color: var(--text-muted); }
565
566 .org-ins-avatar {
567 flex: none;
568 width: 32px; height: 32px;
569 border-radius: 9999px;
570 background: linear-gradient(135deg, #8c6dff, #36c5d6);
571 color: #fff;
572 display: flex;
573 align-items: center;
574 justify-content: center;
575 font-family: var(--font-display);
576 font-size: 12px;
577 font-weight: 700;
578 text-transform: uppercase;
579 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18);
580 }
581
582 /* Empty state — dashed orb card */
583 .org-ins-empty {
584 position: relative;
585 overflow: hidden;
586 text-align: center;
587 padding: var(--space-6) var(--space-4);
588 border: 1px dashed var(--border-strong, var(--border));
589 border-radius: 16px;
590 background: rgba(255,255,255,0.012);
591 color: var(--text-muted);
592 }
593 .org-ins-empty::before {
594 content: '';
595 position: absolute;
596 inset: -40% -20% auto auto;
597 width: 320px; height: 320px;
598 background: radial-gradient(circle, rgba(140,109,255,0.14), rgba(54,197,214,0.06) 45%, transparent 70%);
599 filter: blur(60px);
600 pointer-events: none;
601 }
602 .org-ins-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
603 .org-ins-empty strong {
604 font-family: var(--font-display);
605 font-size: 17px;
606 font-weight: 700;
607 color: var(--text-strong);
608 margin: 0;
609 }
610 .org-ins-empty p { font-size: 13px; margin: 0; max-width: 420px; }
611 .org-ins-empty .cta {
612 display: inline-flex;
613 align-items: center;
614 padding: 9px 16px;
615 border-radius: 10px;
616 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
617 color: #fff;
618 font-size: 13px;
619 font-weight: 600;
620 text-decoration: none;
621 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
622 }
623 .org-ins-empty .cta:hover { text-decoration: none; transform: translateY(-1px); }
624`;
625
626function initials(name: string): string {
627 if (!name) return "?";
628 return name.slice(0, 2);
629}
630
239631orgInsights.get("/orgs/:slug/insights", requireAuth, async (c) => {
240632 const user = c.get("user")!;
241633 const slug = c.req.param("slug");
246638
247639 const summary = await computeOrgInsights(org.id);
248640 const pct = (n: number) => Math.round(n * 100);
641 const contributors = summary.topContributors || [];
642
643 // Total commit proxy — `gateRunsTotal` is the closest aggregated signal
644 // we already collect (each push triggers a gate run). Surfaced as
645 // "Tracked runs" rather than literal "commits" so the number is honest.
646 const totalRuns = summary.gateRunsTotal;
249647
250648 return c.html(
251649 <Layout title={`${org.name} — Insights`} user={user}>
252 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
253 <h2>{org.name} · Insights</h2>
254 <a href={`/orgs/${slug}`} class="btn btn-sm">
255 Back to {slug}
256 </a>
257 </div>
650 <div class="org-ins-wrap">
651 <section class="org-ins-hero">
652 <div class="org-ins-hero-orb" aria-hidden="true" />
653 <div class="org-ins-hero-inner">
654 <div class="org-ins-hero-text">
655 <div class="org-ins-eyebrow">
656 <span class="org-ins-eyebrow-dot" aria-hidden="true" />
657 Org insights · {slug}
658 </div>
659 <h2 class="org-ins-title">
660 <span class="org-ins-title-grad">{org.name}</span>
661 </h2>
662 <p class="org-ins-sub">
663 Aggregated health across every repo in the org — gate runs,
664 pull-request flow, open issues, and the people moving things
665 forward.
666 </p>
667 </div>
668 <a href={`/orgs/${slug}`} class="org-ins-back">
669 ← Back to {slug}
670 </a>
671 </div>
672 </section>
258673
259 <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
260 <div class="panel" style="padding:12px;text-align:center">
261 <div style="font-size:22px;font-weight:700">{summary.repoCount}</div>
262 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
263 Repos
674 <div class="org-ins-stats">
675 <div class="org-ins-stat">
676 <div class="org-ins-stat-label">Repositories</div>
677 <div class="org-ins-stat-value">
678 {summary.repoCount.toLocaleString()}
679 </div>
680 <div class="org-ins-stat-hint">Owned by this org</div>
264681 </div>
265 </div>
266 <div class="panel" style="padding:12px;text-align:center">
267 <div style="font-size:22px;font-weight:700;color:var(--green)">
268 {pct(summary.greenRate)}%
682 <div class="org-ins-stat">
683 <div class="org-ins-stat-label">Contributors</div>
684 <div class="org-ins-stat-value is-info">
685 {contributors.length.toLocaleString()}
686 </div>
687 <div class="org-ins-stat-hint">Distinct PR authors</div>
269688 </div>
270 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
271 Green rate
689 <div class="org-ins-stat">
690 <div class="org-ins-stat-label">Tracked runs</div>
691 <div class="org-ins-stat-value is-accent">
692 {totalRuns.toLocaleString()}
693 </div>
694 <div class="org-ins-stat-hint">All-time gate executions</div>
695 </div>
696 <div class="org-ins-stat">
697 <div class="org-ins-stat-label">Open issues</div>
698 <div
699 class={
700 "org-ins-stat-value" +
701 (summary.openIssues > 0 ? " is-warn" : "")
702 }
703 >
704 {summary.openIssues.toLocaleString()}
705 </div>
706 <div class="org-ins-stat-hint">Across all repos</div>
272707 </div>
273708 </div>
274 <div class="panel" style="padding:12px;text-align:center">
275 <div style="font-size:22px;font-weight:700;color:#79c0ff">
276 {summary.openPrs}
709
710 <div class="org-ins-stats">
711 <div class="org-ins-stat">
712 <div class="org-ins-stat-label">Green rate</div>
713 <div
714 class={
715 "org-ins-stat-value" +
716 (summary.greenRate >= 0.9
717 ? " is-good"
718 : summary.greenRate >= 0.7
719 ? ""
720 : " is-warn")
721 }
722 >
723 {pct(summary.greenRate)}%
724 </div>
725 <div class="org-ins-stat-hint">Passed + repaired ÷ total</div>
277726 </div>
278 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
279 Open PRs
727 <div class="org-ins-stat">
728 <div class="org-ins-stat-label">Open PRs</div>
729 <div class="org-ins-stat-value is-info">
730 {summary.openPrs.toLocaleString()}
731 </div>
732 <div class="org-ins-stat-hint">Across all repos</div>
280733 </div>
281 </div>
282 <div class="panel" style="padding:12px;text-align:center">
283 <div style="font-size:22px;font-weight:700;color:#d2a8ff">
284 {summary.mergedPrs30d}
734 <div class="org-ins-stat">
735 <div class="org-ins-stat-label">Merged 30d</div>
736 <div class="org-ins-stat-value is-accent">
737 {summary.mergedPrs30d.toLocaleString()}
738 </div>
739 <div class="org-ins-stat-hint">Pull requests merged</div>
285740 </div>
286 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
287 Merged 30d
741 <div class="org-ins-stat">
742 <div class="org-ins-stat-label">Gate runs</div>
743 <div class="org-ins-stat-value">
744 {summary.gateRunsTotal.toLocaleString()}
745 </div>
746 <div class="org-ins-stat-hint">Total recorded</div>
288747 </div>
289748 </div>
290 </div>
291749
292 <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
293 <div class="panel" style="padding:12px;text-align:center">
294 <div style="font-size:18px;font-weight:600">{summary.gateRunsTotal}</div>
295 <div style="font-size:11px;color:var(--text-muted)">Total gate runs</div>
296 </div>
297 <div class="panel" style="padding:12px;text-align:center">
298 <div style="font-size:18px;font-weight:600;color:var(--green)">
299 {summary.gatePassed}
750 <div class="org-ins-gates">
751 <div class="org-ins-gate">
752 <div class="org-ins-gate-value is-good">
753 {summary.gatePassed.toLocaleString()}
754 </div>
755 <div class="org-ins-gate-label">Passed</div>
300756 </div>
301 <div style="font-size:11px;color:var(--text-muted)">Passed</div>
302 </div>
303 <div class="panel" style="padding:12px;text-align:center">
304 <div style="font-size:18px;font-weight:600;color:#bc8cff">
305 {summary.gateRepaired}
757 <div class="org-ins-gate">
758 <div class="org-ins-gate-value is-soft">
759 {summary.gateRepaired.toLocaleString()}
760 </div>
761 <div class="org-ins-gate-label">Repaired</div>
306762 </div>
307 <div style="font-size:11px;color:var(--text-muted)">Repaired</div>
308 </div>
309 <div class="panel" style="padding:12px;text-align:center">
310 <div style="font-size:18px;font-weight:600;color:var(--red)">
311 {summary.gateFailed}
763 <div class="org-ins-gate">
764 <div class="org-ins-gate-value is-warn">
765 {summary.gateFailed.toLocaleString()}
766 </div>
767 <div class="org-ins-gate-label">Failed</div>
312768 </div>
313 <div style="font-size:11px;color:var(--text-muted)">Failed</div>
314769 </div>
315 </div>
316770
317 <h3>Per-repo breakdown</h3>
318 <div class="panel" style="margin-bottom:20px">
319 {summary.perRepo.length === 0 ? (
320 <div class="panel-empty">This org has no repositories yet.</div>
321 ) : (
322 summary.perRepo.map((r) => (
323 <div class="panel-item" style="justify-content:space-between">
324 <div style="flex:1;min-width:0">
325 <a href={`/${slug}/${r.name}`} style="font-weight:600">
326 {slug}/{r.name}
327 </a>
328 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
329 {r.runs} runs · {r.openPrs} open PRs · {r.openIssues} open
330 issues
771 <div class="org-ins-twocol">
772 <div>
773 <div class="org-ins-section-head">
774 <h3 class="org-ins-section-title">Most-active repos</h3>
775 <span class="org-ins-section-sub">
776 {summary.perRepo.length} total
777 </span>
778 </div>
779 {summary.perRepo.length === 0 ? (
780 <div class="org-ins-empty">
781 <div class="org-ins-empty-inner">
782 <strong>No repositories yet</strong>
783 <p>
784 Create the first repo in {org.name} to start collecting
785 insights across the org.
786 </p>
787 <a href="/new" class="cta">
788 + New repository
789 </a>
331790 </div>
332791 </div>
333 <span
334 style={`font-family:var(--font-mono);color:${r.greenRate >= 0.9 ? "var(--green)" : r.greenRate >= 0.7 ? "#f0b72f" : "var(--red)"}`}
335 >
336 {r.runs > 0 ? `${pct(r.greenRate)}%` : "—"}
792 ) : (
793 <div class="org-ins-list">
794 {summary.perRepo.map((r, i) => (
795 <div class="org-ins-card">
796 <div class={"org-ins-rank" + (i === 0 ? " is-1" : "")}>
797 {i + 1}
798 </div>
799 <div class="org-ins-card-main">
800 <h4 class="org-ins-card-title">
801 <a href={`/${slug}/${r.name}`}>
802 {slug}/{r.name}
803 </a>
804 </h4>
805 <p class="org-ins-card-sub">
806 {r.runs.toLocaleString()} run
807 {r.runs === 1 ? "" : "s"} ·{" "}
808 {r.openPrs.toLocaleString()} open PR
809 {r.openPrs === 1 ? "" : "s"} ·{" "}
810 {r.openIssues.toLocaleString()} open issue
811 {r.openIssues === 1 ? "" : "s"}
812 </p>
813 </div>
814 <div
815 class={
816 "org-ins-card-meta " +
817 (r.runs === 0
818 ? "is-muted"
819 : r.greenRate >= 0.9
820 ? "is-good"
821 : r.greenRate >= 0.7
822 ? "is-mid"
823 : "is-warn")
824 }
825 >
826 {r.runs > 0 ? `${pct(r.greenRate)}%` : "—"}
827 </div>
828 </div>
829 ))}
830 </div>
831 )}
832 </div>
833
834 <div>
835 <div class="org-ins-section-head">
836 <h3 class="org-ins-section-title">Top contributors</h3>
837 <span class="org-ins-section-sub">
838 {contributors.length} of recent
337839 </span>
338840 </div>
339 ))
340 )}
841 {contributors.length === 0 ? (
842 <div class="org-ins-empty">
843 <div class="org-ins-empty-inner">
844 <strong>No contributors yet</strong>
845 <p>
846 Once people open pull requests across the org, the
847 leaderboard fills in here.
848 </p>
849 </div>
850 </div>
851 ) : (
852 <div class="org-ins-list">
853 {contributors.map((c, i) => (
854 <div class="org-ins-card">
855 <div class={"org-ins-rank" + (i === 0 ? " is-1" : "")}>
856 {i + 1}
857 </div>
858 <div
859 class="org-ins-avatar"
860 aria-label={`@${c.username}`}
861 >
862 {initials(c.username)}
863 </div>
864 <div class="org-ins-card-main">
865 <h4 class="org-ins-card-title">
866 <a href={`/${c.username}`}>@{c.username}</a>
867 </h4>
868 <p class="org-ins-card-sub">
869 {c.prs.toLocaleString()} PR
870 {c.prs === 1 ? "" : "s"} · {c.merged.toLocaleString()}{" "}
871 merged
872 </p>
873 </div>
874 <div class="org-ins-card-meta is-muted">
875 {c.prs.toLocaleString()}
876 </div>
877 </div>
878 ))}
879 </div>
880 )}
881 </div>
882 </div>
341883 </div>
884 <style dangerouslySetInnerHTML={{ __html: styles }} />
342885 </Layout>
343886 );
344887});
Modifiedsrc/routes/packages.tsx+633−168View fileUnifiedSplit
66 *
77 * Packages doesn't yet have a tab in RepoNav — we render with active="code"
88 * so the page still lays out correctly.
9 *
10 * 2026 polish: scoped `.pkg-*` class system mirrors `admin-ops.tsx` and
11 * `collaborators.tsx` — eyebrow + display headline, mono version pills,
12 * tabular-nums for sizes/dates, dashed orb-lit empty state with publish
13 * instructions, and a sidebar info card on detail.
914 */
1015
1116import { Hono } from "hono";
7075 return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
7176}
7277
78// ─── Scoped CSS (.pkg-*) ────────────────────────────────────────────────────
79const pkgStyles = `
80 .pkg-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
81
82 .pkg-head {
83 display: flex;
84 align-items: flex-end;
85 justify-content: space-between;
86 gap: var(--space-4);
87 flex-wrap: wrap;
88 margin-bottom: var(--space-5);
89 }
90 .pkg-head-text { flex: 1; min-width: 280px; }
91 .pkg-eyebrow {
92 display: inline-flex;
93 align-items: center;
94 gap: 8px;
95 text-transform: uppercase;
96 font-family: var(--font-mono);
97 font-size: 11px;
98 letter-spacing: 0.16em;
99 color: var(--text-muted);
100 font-weight: 600;
101 margin-bottom: 10px;
102 }
103 .pkg-eyebrow-dot {
104 width: 8px; height: 8px;
105 border-radius: 9999px;
106 background: linear-gradient(135deg, #8c6dff, #36c5d6);
107 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
108 }
109 .pkg-title {
110 font-family: var(--font-display);
111 font-size: clamp(24px, 3.4vw, 36px);
112 font-weight: 800;
113 letter-spacing: -0.028em;
114 line-height: 1.1;
115 margin: 0 0 6px;
116 color: var(--text-strong);
117 }
118 .pkg-title-grad {
119 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
120 -webkit-background-clip: text;
121 background-clip: text;
122 -webkit-text-fill-color: transparent;
123 color: transparent;
124 }
125 .pkg-sub {
126 margin: 0;
127 font-size: 14px;
128 color: var(--text-muted);
129 line-height: 1.5;
130 max-width: 640px;
131 }
132
133 /* Buttons */
134 .pkg-btn {
135 display: inline-flex;
136 align-items: center;
137 justify-content: center;
138 gap: 6px;
139 padding: 9px 16px;
140 border-radius: 10px;
141 font-size: 13px;
142 font-weight: 600;
143 text-decoration: none;
144 border: 1px solid transparent;
145 cursor: pointer;
146 font: inherit;
147 line-height: 1;
148 white-space: nowrap;
149 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
150 }
151 .pkg-btn-primary {
152 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
153 color: #ffffff;
154 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
155 }
156 .pkg-btn-primary:hover {
157 transform: translateY(-1px);
158 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
159 text-decoration: none;
160 color: #ffffff;
161 }
162 .pkg-btn-ghost {
163 background: transparent;
164 color: var(--text);
165 border-color: var(--border-strong);
166 padding: 6px 12px;
167 font-size: 12px;
168 }
169 .pkg-btn-ghost:hover {
170 background: rgba(140,109,255,0.06);
171 border-color: rgba(140,109,255,0.45);
172 color: var(--text-strong);
173 text-decoration: none;
174 }
175 .pkg-btn-danger {
176 background: transparent;
177 color: #fca5a5;
178 border-color: rgba(248,113,113,0.35);
179 padding: 6px 12px;
180 font-size: 12px;
181 }
182 .pkg-btn-danger:hover {
183 border-style: dashed;
184 border-color: rgba(248,113,113,0.70);
185 background: rgba(248,113,113,0.06);
186 color: #fecaca;
187 text-decoration: none;
188 }
189
190 /* Crumbs */
191 .pkg-crumbs {
192 display: flex;
193 align-items: center;
194 gap: 12px;
195 flex-wrap: wrap;
196 margin-bottom: var(--space-4);
197 font-size: 12.5px;
198 }
199 .pkg-crumbs a {
200 display: inline-flex;
201 align-items: center;
202 gap: 5px;
203 padding: 6px 11px;
204 background: rgba(255,255,255,0.025);
205 border: 1px solid var(--border);
206 border-radius: 8px;
207 color: var(--text-muted);
208 text-decoration: none;
209 font-weight: 500;
210 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
211 }
212 .pkg-crumbs a:hover {
213 border-color: var(--border-strong);
214 color: var(--text-strong);
215 background: rgba(255,255,255,0.04);
216 text-decoration: none;
217 }
218 .pkg-crumbs span.cur {
219 font-family: var(--font-mono);
220 font-size: 12px;
221 color: var(--text);
222 }
223
224 /* Package list */
225 .pkg-list { display: flex; flex-direction: column; gap: 10px; }
226 .pkg-card {
227 display: block;
228 text-decoration: none;
229 color: inherit;
230 background: var(--bg-elevated);
231 border: 1px solid var(--border);
232 border-radius: 12px;
233 padding: 14px 16px;
234 transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
235 }
236 .pkg-card:hover {
237 border-color: var(--border-strong);
238 background: rgba(255,255,255,0.03);
239 text-decoration: none;
240 color: inherit;
241 }
242 .pkg-card-row {
243 display: flex;
244 align-items: baseline;
245 justify-content: space-between;
246 gap: 14px;
247 flex-wrap: wrap;
248 }
249 .pkg-card-name {
250 font-family: var(--font-display);
251 font-weight: 700;
252 font-size: 15.5px;
253 color: var(--text-strong);
254 letter-spacing: -0.005em;
255 }
256 .pkg-card-desc {
257 margin-top: 4px;
258 font-size: 13px;
259 color: var(--text-muted);
260 line-height: 1.45;
261 }
262 .pkg-card-meta {
263 display: flex;
264 align-items: center;
265 gap: 10px;
266 flex-wrap: wrap;
267 font-size: 12px;
268 color: var(--text-muted);
269 font-variant-numeric: tabular-nums;
270 white-space: nowrap;
271 }
272 .pkg-version {
273 display: inline-flex;
274 align-items: center;
275 padding: 3px 9px;
276 border-radius: 9999px;
277 font-family: var(--font-mono);
278 font-size: 11.5px;
279 font-weight: 600;
280 background: rgba(140,109,255,0.12);
281 color: #c4b5fd;
282 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
283 font-variant-numeric: tabular-nums;
284 }
285 .pkg-version.is-empty {
286 background: rgba(148,163,184,0.12);
287 color: #cbd5e1;
288 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
289 }
290 .pkg-card-meta .sep { opacity: 0.4; }
291
292 /* Detail grid */
293 .pkg-detail-grid {
294 display: grid;
295 grid-template-columns: minmax(0, 1fr) 300px;
296 gap: 24px;
297 align-items: start;
298 }
299 @media (max-width: 880px) {
300 .pkg-detail-grid { grid-template-columns: 1fr; }
301 }
302 .pkg-section { margin-bottom: 20px; }
303 .pkg-section-label {
304 font-family: var(--font-mono);
305 font-size: 11px;
306 text-transform: uppercase;
307 letter-spacing: 0.16em;
308 font-weight: 600;
309 color: var(--text-muted);
310 margin: 0 0 8px;
311 }
312 .pkg-code {
313 display: block;
314 background: rgba(255,255,255,0.03);
315 border: 1px solid var(--border);
316 border-radius: 10px;
317 padding: 12px 14px;
318 font-family: var(--font-mono);
319 font-size: 13px;
320 color: var(--text);
321 overflow-x: auto;
322 white-space: pre;
323 }
324 .pkg-readme {
325 background: rgba(255,255,255,0.02);
326 border: 1px solid var(--border);
327 border-radius: 10px;
328 padding: 14px 16px;
329 font-family: var(--font-mono);
330 font-size: 12.5px;
331 line-height: 1.55;
332 white-space: pre-wrap;
333 color: var(--text);
334 max-height: 480px;
335 overflow: auto;
336 }
337
338 /* Version list */
339 .pkg-versions {
340 background: var(--bg-elevated);
341 border: 1px solid var(--border);
342 border-radius: 12px;
343 overflow: hidden;
344 }
345 .pkg-version-row {
346 display: flex;
347 align-items: center;
348 justify-content: space-between;
349 gap: 12px;
350 padding: 12px 14px;
351 border-bottom: 1px solid var(--border);
352 }
353 .pkg-version-row:last-child { border-bottom: 0; }
354 .pkg-version-row:hover { background: rgba(255,255,255,0.02); }
355 .pkg-version-main { flex: 1; min-width: 0; }
356 .pkg-version-line {
357 display: flex;
358 align-items: center;
359 gap: 8px;
360 flex-wrap: wrap;
361 }
362 .pkg-version-meta {
363 margin-top: 4px;
364 font-size: 12px;
365 color: var(--text-muted);
366 font-variant-numeric: tabular-nums;
367 }
368 .pkg-version-meta code {
369 font-family: var(--font-mono);
370 font-size: 11px;
371 padding: 1px 6px;
372 border-radius: 6px;
373 background: rgba(255,255,255,0.04);
374 border: 1px solid var(--border);
375 }
376 .pkg-version-actions {
377 display: flex;
378 gap: 6px;
379 align-items: center;
380 flex-wrap: wrap;
381 }
382 .pkg-version-actions form { margin: 0; }
383 .pkg-yanked {
384 display: inline-flex;
385 align-items: center;
386 gap: 4px;
387 padding: 2px 8px;
388 border-radius: 9999px;
389 font-size: 11px;
390 font-weight: 600;
391 text-transform: uppercase;
392 letter-spacing: 0.04em;
393 background: rgba(248,113,113,0.12);
394 color: #fca5a5;
395 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30);
396 }
397
398 /* Sidebar */
399 .pkg-side {
400 background: var(--bg-elevated);
401 border: 1px solid var(--border);
402 border-radius: 12px;
403 padding: 14px 16px;
404 position: relative;
405 overflow: hidden;
406 }
407 .pkg-side::before {
408 content: '';
409 position: absolute;
410 top: 0; left: 0; right: 0;
411 height: 2px;
412 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
413 opacity: 0.55;
414 }
415 .pkg-side-label {
416 font-family: var(--font-mono);
417 font-size: 10.5px;
418 text-transform: uppercase;
419 letter-spacing: 0.14em;
420 color: var(--text-muted);
421 margin: 0 0 4px;
422 font-weight: 600;
423 }
424 .pkg-side-value {
425 font-size: 13px;
426 color: var(--text);
427 margin: 0 0 14px;
428 word-break: break-all;
429 }
430 .pkg-side-value:last-child { margin-bottom: 0; }
431 .pkg-side-value code {
432 font-family: var(--font-mono);
433 font-size: 12px;
434 }
435
436 /* Empty */
437 .pkg-empty {
438 position: relative;
439 overflow: hidden;
440 padding: clamp(32px, 6vw, 56px) clamp(20px, 4vw, 36px);
441 text-align: center;
442 background: var(--bg-elevated);
443 border: 1px dashed var(--border-strong);
444 border-radius: 16px;
445 }
446 .pkg-empty-orb {
447 position: absolute;
448 inset: -40% 30% auto 30%;
449 height: 280px;
450 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
451 filter: blur(70px);
452 opacity: 0.7;
453 pointer-events: none;
454 z-index: 0;
455 }
456 .pkg-empty-inner { position: relative; z-index: 1; }
457 .pkg-empty-icon {
458 width: 56px; height: 56px;
459 border-radius: 9999px;
460 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
461 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
462 display: inline-flex;
463 align-items: center;
464 justify-content: center;
465 color: #c4b5fd;
466 margin-bottom: 14px;
467 }
468 .pkg-empty-title {
469 font-family: var(--font-display);
470 font-size: 18px;
471 font-weight: 700;
472 margin: 0 0 6px;
473 color: var(--text-strong);
474 }
475 .pkg-empty-sub {
476 margin: 0 auto 16px;
477 font-size: 13.5px;
478 color: var(--text-muted);
479 max-width: 480px;
480 line-height: 1.5;
481 }
482 .pkg-empty-howto {
483 text-align: left;
484 background: rgba(255,255,255,0.02);
485 border: 1px solid var(--border);
486 border-radius: 12px;
487 padding: 16px 18px;
488 font-size: 13px;
489 max-width: 640px;
490 margin: 16px auto 0;
491 line-height: 1.55;
492 }
493 .pkg-empty-howto ol { padding-left: 20px; margin: 8px 0 0; }
494 .pkg-empty-howto li { margin-bottom: 6px; }
495 .pkg-empty-howto code {
496 font-family: var(--font-mono);
497 font-size: 11.5px;
498 padding: 1px 6px;
499 border-radius: 6px;
500 background: rgba(255,255,255,0.04);
501 border: 1px solid var(--border);
502 }
503 .pkg-empty-howto pre {
504 background: rgba(0,0,0,0.25);
505 border: 1px solid var(--border);
506 border-radius: 8px;
507 padding: 8px 12px;
508 font-family: var(--font-mono);
509 font-size: 11.5px;
510 margin: 6px 0;
511 white-space: pre-wrap;
512 color: var(--text);
513 }
514`;
515
516function IconBox() {
517 return (
518 <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">
519 <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
520 <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
521 <line x1="12" y1="22.08" x2="12" y2="12" />
522 </svg>
523 );
524}
525
73526// ---------------------------------------------------------------------------
74527// List page
75528// ---------------------------------------------------------------------------
144597 />
145598 <RepoNav owner={owner} repo={repo} active="code" />
146599
147 <div style="max-width: 900px">
148 <h2 style="margin: 0 0 16px 0">Packages</h2>
600 <div class="pkg-wrap">
601 <header class="pkg-head">
602 <div class="pkg-head-text">
603 <div class="pkg-eyebrow">
604 <span class="pkg-eyebrow-dot" aria-hidden="true" />
605 Repository · Packages
606 </div>
607 <h1 class="pkg-title">
608 <span class="pkg-title-grad">Published artifacts.</span>
609 </h1>
610 <p class="pkg-sub">
611 Every npm package shipped from {owner}/{repo}, scoped to the
612 repo's own registry namespace.
613 </p>
614 </div>
615 </header>
149616
150617 {rows.length === 0 ? (
151 <div class="empty-state">
152 <p style="margin-bottom: 12px">
153 No npm packages published from this repository yet.
154 </p>
155 <div
156 style="text-align: left; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; padding: 12px 16px; font-size: 13px; max-width: 640px; margin: 0 auto"
157 >
158 <strong>To publish:</strong>
159 <ol style="padding-left: 20px; margin: 8px 0 0 0; line-height: 1.6">
160 <li>
161 Create a personal access token at{" "}
162 <a href="/settings/tokens">/settings/tokens</a>.
163 </li>
164 <li>
165 Add to your <code>.npmrc</code>:
166 <pre style="background: var(--bg-tertiary); color: var(--text); padding: 8px 12px; border-radius: 4px; font-size: 12px; margin: 6px 0">
167 registry={registryUrl}
168 {"\n"}
169 //{host}/npm/:_authToken=YOUR_PAT
170 </pre>
171 </li>
172 <li>
173 In <code>package.json</code>, point{" "}
174 <code>repository.url</code> at this repo
175 (<code>
176 {`http://${host}/${owner}/${repo}.git`}
177 </code>
178 ).
179 </li>
180 <li>
181 Run <code>npm publish</code>.
182 </li>
183 </ol>
618 <div class="pkg-empty">
619 <div class="pkg-empty-orb" aria-hidden="true" />
620 <div class="pkg-empty-inner">
621 <div class="pkg-empty-icon" aria-hidden="true">
622 <IconBox />
623 </div>
624 <h3 class="pkg-empty-title">Publish your first package</h3>
625 <p class="pkg-empty-sub">
626 Wire your repo to the Gluecron npm registry and run{" "}
627 <code>npm publish</code> — your tarball will appear here.
628 </p>
629 <div class="pkg-empty-howto">
630 <strong>To publish:</strong>
631 <ol>
632 <li>
633 Create a personal access token at{" "}
634 <a href="/settings/tokens">/settings/tokens</a>.
635 </li>
636 <li>
637 Add to your <code>.npmrc</code>:
638 <pre>
639 registry={registryUrl}
640 {"\n"}
641 //{host}/npm/:_authToken=YOUR_PAT
642 </pre>
643 </li>
644 <li>
645 In <code>package.json</code>, point{" "}
646 <code>repository.url</code> at this repo
647 (<code>{`http://${host}/${owner}/${repo}.git`}</code>).
648 </li>
649 <li>
650 Run <code>npm publish</code>.
651 </li>
652 </ol>
653 </div>
184654 </div>
185655 </div>
186656 ) : (
187 <div class="panel" style="overflow: hidden">
657 <div class="pkg-list">
188658 {rows.map((p) => {
189659 const fullName = fullPkgName(p);
190660 return (
191661 <a
192662 href={`/${owner}/${repo}/packages/${encodeURIComponent(fullName)}`}
193 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit"
663 class="pkg-card"
194664 >
195 <div style="display: flex; justify-content: space-between; align-items: baseline; gap: 12px">
665 <div class="pkg-card-row">
196666 <div style="flex: 1; min-width: 0">
197 <div style="font-weight: 600">{fullName}</div>
667 <div class="pkg-card-name">{fullName}</div>
198668 {p.description && (
199 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
200 {p.description}
201 </div>
669 <div class="pkg-card-desc">{p.description}</div>
202670 )}
203671 </div>
204 <div style="font-size: 12px; color: var(--text-muted); white-space: nowrap">
672 <div class="pkg-card-meta">
205673 {p.latestVersion ? (
206 <span>
207 <code>{p.latestVersion}</code>
208 </span>
674 <span class="pkg-version">{p.latestVersion}</span>
209675 ) : (
210 <span>no versions</span>
676 <span class="pkg-version is-empty">no versions</span>
211677 )}
212 {" · "}
678 <span class="sep">·</span>
213679 <span>{relTime(p.updatedAt)}</span>
214680 </div>
215681 </div>
219685 </div>
220686 )}
221687 </div>
688 <style dangerouslySetInnerHTML={{ __html: pkgStyles }} />
222689 </Layout>
223690 );
224691});
297764 />
298765 <RepoNav owner={owner} repo={repo} active="code" />
299766
300 <div style="max-width: 900px">
301 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px">
302 <a href={`/${owner}/${repo}/packages`}>Packages</a>
303 {" / "}
304 <span>{fullName}</span>
767 <div class="pkg-wrap">
768 <div class="pkg-crumbs">
769 <a href={`/${owner}/${repo}/packages`}>
770 Packages
771 </a>
772 <span class="cur">{fullName}</span>
305773 </div>
306 <h2 style="margin: 0 0 4px 0">{fullName}</h2>
307 {pkg.description && (
308 <p style="color: var(--text-muted); margin: 0 0 16px 0">
309 {pkg.description}
310 </p>
311 )}
312774
313 <div style="display: grid; grid-template-columns: 1fr 280px; gap: 24px">
775 <header class="pkg-head">
776 <div class="pkg-head-text">
777 <div class="pkg-eyebrow">
778 <span class="pkg-eyebrow-dot" aria-hidden="true" />
779 Package · npm
780 </div>
781 <h1 class="pkg-title">
782 <span class="pkg-title-grad">{fullName}</span>
783 </h1>
784 {pkg.description && (
785 <p class="pkg-sub">{pkg.description}</p>
786 )}
787 </div>
788 {latestVersion && (
789 <span class="pkg-version">v{latestVersion.version}</span>
790 )}
791 </header>
792
793 <div class="pkg-detail-grid">
314794 <div>
315 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 0 0 8px 0">
316 Install
317 </h3>
318 <pre style="background: var(--bg-tertiary); color: var(--text); padding: 10px 14px; border-radius: 6px; font-size: 13px; overflow-x: auto">
319 npm install {fullName}
320 {latestVersion ? `@${latestVersion.version}` : ""}
321 </pre>
795 <section class="pkg-section">
796 <h3 class="pkg-section-label">Install</h3>
797 <code class="pkg-code">
798 npm install {fullName}
799 {latestVersion ? `@${latestVersion.version}` : ""}
800 </code>
801 </section>
322802
323803 {pkg.readme && (
324 <>
325 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 20px 0 8px 0">
326 Readme
327 </h3>
328 <pre
329 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; white-space: pre-wrap; font-size: 13px; line-height: 1.5"
330 >
331 {pkg.readme}
332 </pre>
333 </>
804 <section class="pkg-section">
805 <h3 class="pkg-section-label">Readme</h3>
806 <pre class="pkg-readme">{pkg.readme}</pre>
807 </section>
334808 )}
335809
336 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 20px 0 8px 0">
337 Versions
338 </h3>
339 {versions.length === 0 ? (
340 <div class="empty-state">
341 <p>No versions yet.</p>
342 </div>
343 ) : (
344 <div class="panel" style="overflow: hidden">
345 {versions.map((v) => (
346 <div
347 style="padding: 10px 14px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; gap: 12px"
348 >
349 <div style="flex: 1; min-width: 0">
350 <div style="font-weight: 500">
351 <code>{v.version}</code>
352 {v.yanked && (
353 <span
354 style="margin-left: 8px; font-size: 11px; color: var(--red); text-transform: uppercase"
355 >
356 yanked
357 </span>
358 )}
810 <section class="pkg-section">
811 <h3 class="pkg-section-label">
812 Versions
813 <span style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);font-weight:500;font-variant-numeric:tabular-nums">
814 {" "}({versions.length})
815 </span>
816 </h3>
817 {versions.length === 0 ? (
818 <div class="pkg-empty" style="padding: 28px 20px">
819 <div class="pkg-empty-inner">
820 <p class="pkg-empty-sub" style="margin-bottom: 0">No versions yet.</p>
821 </div>
822 </div>
823 ) : (
824 <div class="pkg-versions">
825 {versions.map((v) => (
826 <div class="pkg-version-row">
827 <div class="pkg-version-main">
828 <div class="pkg-version-line">
829 <span class="pkg-version">{v.version}</span>
830 {v.yanked && (
831 <span class="pkg-yanked">yanked</span>
832 )}
833 </div>
834 <div class="pkg-version-meta">
835 {humanSize(v.sizeBytes)} · published{" "}
836 {relTime(v.publishedAt)}
837 {v.shasum && (
838 <>
839 {" · sha1 "}
840 <code>{v.shasum.slice(0, 12)}</code>
841 </>
842 )}
843 </div>
359844 </div>
360 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
361 {humanSize(v.sizeBytes)} · published{" "}
362 {relTime(v.publishedAt)}
363 {v.shasum && (
364 <>
365 {" · sha1 "}
366 <code style="font-size: 11px">
367 {v.shasum.slice(0, 12)}
368 </code>
369 </>
845 <div class="pkg-version-actions">
846 <a
847 class="pkg-btn pkg-btn-ghost"
848 href={`/npm/${encodeURIComponent(fullName)}/-/${pkg.name}-${v.version}.tgz`}
849 >
850 Download
851 </a>
852 {isOwner && !v.yanked && (
853 <form
854 method="post"
855 action={`/api/packages/${owner}/${repo}/${encodeURIComponent(fullName)}/${v.version}/yank`}
856 onsubmit="return confirm('Yank this version? It will still download, but will be flagged as yanked.')"
857 >
858 <button
859 type="submit"
860 class="pkg-btn pkg-btn-danger"
861 >
862 Yank
863 </button>
864 </form>
370865 )}
371866 </div>
372867 </div>
373 <a
374 class="btn btn-sm"
375 href={`/npm/${encodeURIComponent(fullName)}/-/${pkg.name}-${v.version}.tgz`}
376 >
377 Download
378 </a>
379 {isOwner && !v.yanked && (
380 <form
381 method="post"
382 action={`/api/packages/${owner}/${repo}/${encodeURIComponent(fullName)}/${v.version}/yank`}
383 onsubmit="return confirm('Yank this version? It will still download, but will be flagged as yanked.')"
384 style="margin: 0"
385 >
386 <button
387 type="submit"
388 class="btn btn-sm btn-danger"
389 >
390 Yank
391 </button>
392 </form>
393 )}
394 </div>
395 ))}
396 </div>
397 )}
868 ))}
869 </div>
870 )}
871 </section>
398872 </div>
399873
400 <aside>
401 <div class="panel" style="padding: 12px 14px">
402 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
403 Registry
404 </div>
405 <code style="font-size: 12px">http://{host}/npm/</code>
406
407 {pkg.homepage && (
408 <>
409 <div style="font-size: 12px; color: var(--text-muted); margin: 10px 0 4px 0">
410 Homepage
411 </div>
412 <a href={pkg.homepage} style="font-size: 13px; word-break: break-all">
413 {pkg.homepage}
414 </a>
415 </>
416 )}
417 {pkg.license && (
418 <>
419 <div style="font-size: 12px; color: var(--text-muted); margin: 10px 0 4px 0">
420 License
421 </div>
422 <div style="font-size: 13px">{pkg.license}</div>
423 </>
424 )}
425 <div style="font-size: 12px; color: var(--text-muted); margin: 10px 0 4px 0">
426 Repository
427 </div>
428 <a
429 href={`/${owner}/${repo}`}
430 style="font-size: 13px; word-break: break-all"
431 >
874 <aside class="pkg-side">
875 <p class="pkg-side-label">Registry</p>
876 <p class="pkg-side-value">
877 <code>http://{host}/npm/</code>
878 </p>
879 {pkg.homepage && (
880 <>
881 <p class="pkg-side-label">Homepage</p>
882 <p class="pkg-side-value">
883 <a href={pkg.homepage}>{pkg.homepage}</a>
884 </p>
885 </>
886 )}
887 {pkg.license && (
888 <>
889 <p class="pkg-side-label">License</p>
890 <p class="pkg-side-value">{pkg.license}</p>
891 </>
892 )}
893 <p class="pkg-side-label">Repository</p>
894 <p class="pkg-side-value">
895 <a href={`/${owner}/${repo}`}>
432896 {owner}/{repo}
433897 </a>
434 </div>
898 </p>
435899 </aside>
436900 </div>
437901 </div>
902 <style dangerouslySetInnerHTML={{ __html: pkgStyles }} />
438903 </Layout>
439904 );
440905});
Modifiedsrc/routes/releases.tsx+731−157View fileUnifiedSplit
88 * POST /:owner/:repo/releases/:tag/delete — owner-only delete (also removes git tag)
99 *
1010 * Publishing a release fans out `release_published` notifications to starrers.
11 *
12 * 2026 polish: scoped `.rel-*` class system mirrors `admin-ops.tsx` and
13 * `collaborators.tsx` — eyebrow + display headline, polished cards with
14 * tabular-nums for counts/dates, version pills (mono), gradient hero CTA, and
15 * an orb-lit dashed empty state. RepoHeader / RepoNav are untouched.
1116 */
1217
1318import { Hono } from "hono";
6065 return row;
6166}
6267
68// ─── Scoped CSS (.rel-*) ────────────────────────────────────────────────────
69//
70// Every selector prefixed `.rel-*` so it can't leak into surrounding chrome.
71// Tokens come from the layout (--bg-elevated, --border, --text-strong,
72// --space-*, --font-*).
73const relStyles = `
74 .rel-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
75
76 .rel-head {
77 display: flex;
78 align-items: flex-end;
79 justify-content: space-between;
80 gap: var(--space-4);
81 flex-wrap: wrap;
82 margin-bottom: var(--space-5);
83 }
84 .rel-head-text { flex: 1; min-width: 280px; }
85 .rel-eyebrow {
86 display: inline-flex;
87 align-items: center;
88 gap: 8px;
89 text-transform: uppercase;
90 font-family: var(--font-mono);
91 font-size: 11px;
92 letter-spacing: 0.16em;
93 color: var(--text-muted);
94 font-weight: 600;
95 margin-bottom: 10px;
96 }
97 .rel-eyebrow-dot {
98 width: 8px; height: 8px;
99 border-radius: 9999px;
100 background: linear-gradient(135deg, #8c6dff, #36c5d6);
101 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
102 }
103 .rel-title {
104 font-family: var(--font-display);
105 font-size: clamp(24px, 3.4vw, 36px);
106 font-weight: 800;
107 letter-spacing: -0.028em;
108 line-height: 1.1;
109 margin: 0 0 6px;
110 color: var(--text-strong);
111 }
112 .rel-title-grad {
113 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
114 -webkit-background-clip: text;
115 background-clip: text;
116 -webkit-text-fill-color: transparent;
117 color: transparent;
118 }
119 .rel-sub {
120 margin: 0;
121 font-size: 14px;
122 color: var(--text-muted);
123 line-height: 1.5;
124 max-width: 640px;
125 }
126
127 /* Buttons */
128 .rel-btn {
129 display: inline-flex;
130 align-items: center;
131 justify-content: center;
132 gap: 6px;
133 padding: 9px 16px;
134 border-radius: 10px;
135 font-size: 13px;
136 font-weight: 600;
137 text-decoration: none;
138 border: 1px solid transparent;
139 cursor: pointer;
140 font: inherit;
141 line-height: 1;
142 white-space: nowrap;
143 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
144 }
145 .rel-btn-primary {
146 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
147 color: #ffffff;
148 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
149 }
150 .rel-btn-primary:hover {
151 transform: translateY(-1px);
152 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
153 text-decoration: none;
154 color: #ffffff;
155 }
156 .rel-btn-ghost {
157 background: transparent;
158 color: var(--text);
159 border-color: var(--border-strong);
160 }
161 .rel-btn-ghost:hover {
162 background: rgba(140,109,255,0.06);
163 border-color: rgba(140,109,255,0.45);
164 color: var(--text-strong);
165 text-decoration: none;
166 }
167 .rel-btn-danger {
168 background: transparent;
169 color: #fca5a5;
170 border-color: rgba(248,113,113,0.35);
171 }
172 .rel-btn-danger:hover {
173 border-style: dashed;
174 border-color: rgba(248,113,113,0.70);
175 background: rgba(248,113,113,0.06);
176 color: #fecaca;
177 text-decoration: none;
178 }
179
180 /* Banner */
181 .rel-banner {
182 margin-bottom: var(--space-4);
183 padding: 10px 14px;
184 border-radius: 10px;
185 font-size: 13.5px;
186 border: 1px solid rgba(248,113,113,0.40);
187 background: rgba(248,113,113,0.08);
188 color: #fecaca;
189 display: flex;
190 align-items: center;
191 gap: 10px;
192 }
193 .rel-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; }
194
195 /* Crumb */
196 .rel-crumbs {
197 display: flex;
198 align-items: center;
199 gap: 12px;
200 flex-wrap: wrap;
201 margin-bottom: var(--space-4);
202 font-size: 12.5px;
203 }
204 .rel-crumbs a {
205 display: inline-flex;
206 align-items: center;
207 gap: 5px;
208 padding: 6px 11px;
209 background: rgba(255,255,255,0.025);
210 border: 1px solid var(--border);
211 border-radius: 8px;
212 color: var(--text-muted);
213 text-decoration: none;
214 font-weight: 500;
215 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
216 }
217 .rel-crumbs a:hover {
218 border-color: var(--border-strong);
219 color: var(--text-strong);
220 background: rgba(255,255,255,0.04);
221 text-decoration: none;
222 }
223
224 /* Release cards */
225 .rel-list { display: flex; flex-direction: column; gap: 12px; }
226 .rel-card {
227 position: relative;
228 overflow: hidden;
229 background: var(--bg-elevated);
230 border: 1px solid var(--border);
231 border-radius: 14px;
232 padding: var(--space-4) var(--space-5);
233 transition: border-color 120ms ease, background 120ms ease;
234 }
235 .rel-card:hover { border-color: var(--border-strong); }
236 .rel-card.is-latest::before {
237 content: '';
238 position: absolute;
239 top: 0; left: 0; right: 0;
240 height: 2px;
241 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
242 opacity: 0.6;
243 pointer-events: none;
244 }
245 .rel-card-head {
246 display: flex;
247 align-items: flex-start;
248 justify-content: space-between;
249 gap: 14px;
250 flex-wrap: wrap;
251 margin-bottom: 10px;
252 }
253 .rel-card-titlewrap { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-width: 0; }
254 .rel-card-name {
255 font-family: var(--font-display);
256 font-weight: 700;
257 font-size: 18px;
258 letter-spacing: -0.015em;
259 color: var(--text-strong);
260 text-decoration: none;
261 }
262 .rel-card-name:hover { color: var(--text-strong); text-decoration: underline; }
263 .rel-tag-pill {
264 display: inline-flex;
265 align-items: center;
266 gap: 4px;
267 padding: 3px 9px;
268 border-radius: 9999px;
269 font-family: var(--font-mono);
270 font-size: 11.5px;
271 font-weight: 600;
272 background: rgba(140,109,255,0.12);
273 color: #c4b5fd;
274 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
275 font-variant-numeric: tabular-nums;
276 }
277 .rel-pill {
278 display: inline-flex;
279 align-items: center;
280 gap: 5px;
281 padding: 3px 9px;
282 border-radius: 9999px;
283 font-size: 11px;
284 font-weight: 600;
285 letter-spacing: 0.02em;
286 text-transform: capitalize;
287 }
288 .rel-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
289 .rel-pill.is-latest {
290 background: rgba(52,211,153,0.14);
291 color: #6ee7b7;
292 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
293 }
294 .rel-pill.is-draft {
295 background: rgba(251,191,36,0.12);
296 color: #fde68a;
297 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
298 }
299 .rel-pill.is-pre {
300 background: rgba(54,197,214,0.14);
301 color: #67e8f9;
302 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.32);
303 }
304 .rel-meta-row {
305 display: flex;
306 align-items: center;
307 gap: 8px;
308 flex-wrap: wrap;
309 font-size: 12px;
310 color: var(--text-muted);
311 font-variant-numeric: tabular-nums;
312 margin-bottom: 10px;
313 }
314 .rel-meta-row a { color: var(--text-muted); }
315 .rel-meta-row a:hover { color: var(--text-strong); }
316 .rel-meta-row code, .rel-meta-row .mono {
317 font-family: var(--font-mono);
318 font-size: 11.5px;
319 padding: 1px 6px;
320 border-radius: 6px;
321 background: rgba(255,255,255,0.04);
322 border: 1px solid var(--border);
323 color: var(--text);
324 }
325 .rel-meta-row .sep { opacity: 0.4; }
326
327 .rel-notes {
328 font-size: 13.5px;
329 color: var(--text);
330 line-height: 1.55;
331 max-height: 220px;
332 overflow: hidden;
333 position: relative;
334 border-top: 1px dashed var(--border);
335 padding-top: 12px;
336 }
337 .rel-notes::after {
338 content: '';
339 position: absolute;
340 bottom: 0; left: 0; right: 0;
341 height: 48px;
342 background: linear-gradient(to top, var(--bg-elevated), transparent);
343 pointer-events: none;
344 }
345 .rel-card-actions { margin-top: 12px; }
346
347 /* Detail card */
348 .rel-detail {
349 position: relative;
350 overflow: hidden;
351 background: var(--bg-elevated);
352 border: 1px solid var(--border);
353 border-radius: 14px;
354 padding: var(--space-5);
355 }
356 .rel-detail::before {
357 content: '';
358 position: absolute;
359 top: 0; left: 0; right: 0;
360 height: 2px;
361 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
362 opacity: 0.55;
363 }
364
365 /* Form */
366 .rel-form-card {
367 background: var(--bg-elevated);
368 border: 1px solid var(--border);
369 border-radius: 14px;
370 padding: var(--space-5);
371 max-width: 720px;
372 position: relative;
373 overflow: hidden;
374 }
375 .rel-form-card::before {
376 content: '';
377 position: absolute;
378 top: 0; left: 0; right: 0;
379 height: 2px;
380 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
381 opacity: 0.55;
382 }
383 .rel-field { margin-bottom: 14px; }
384 .rel-field-label {
385 display: block;
386 font-size: 11.5px;
387 color: var(--text-muted);
388 font-weight: 600;
389 text-transform: uppercase;
390 letter-spacing: 0.06em;
391 margin-bottom: 6px;
392 }
393 .rel-input, .rel-select, .rel-textarea {
394 width: 100%;
395 box-sizing: border-box;
396 padding: 9px 12px;
397 font: inherit;
398 font-size: 13.5px;
399 color: var(--text);
400 background: rgba(255,255,255,0.03);
401 border: 1px solid var(--border-strong);
402 border-radius: 10px;
403 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
404 }
405 .rel-textarea { font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5; resize: vertical; }
406 .rel-input:focus, .rel-select:focus, .rel-textarea:focus {
407 outline: none;
408 border-color: rgba(140,109,255,0.55);
409 background: rgba(255,255,255,0.05);
410 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
411 }
412 .rel-checks { display: flex; gap: 18px; flex-wrap: wrap; margin-bottom: 16px; font-size: 13px; }
413 .rel-check { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
414
415 /* Empty */
416 .rel-empty {
417 position: relative;
418 overflow: hidden;
419 padding: clamp(32px, 6vw, 60px) clamp(20px, 4vw, 36px);
420 text-align: center;
421 background: var(--bg-elevated);
422 border: 1px dashed var(--border-strong);
423 border-radius: 16px;
424 }
425 .rel-empty-orb {
426 position: absolute;
427 inset: -40% 30% auto 30%;
428 height: 280px;
429 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
430 filter: blur(70px);
431 opacity: 0.7;
432 pointer-events: none;
433 z-index: 0;
434 }
435 .rel-empty-inner { position: relative; z-index: 1; }
436 .rel-empty-icon {
437 width: 56px; height: 56px;
438 border-radius: 9999px;
439 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
440 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
441 display: inline-flex;
442 align-items: center;
443 justify-content: center;
444 color: #c4b5fd;
445 margin-bottom: 14px;
446 }
447 .rel-empty-title {
448 font-family: var(--font-display);
449 font-size: 18px;
450 font-weight: 700;
451 margin: 0 0 6px;
452 color: var(--text-strong);
453 }
454 .rel-empty-sub {
455 margin: 0 auto 16px;
456 font-size: 13.5px;
457 color: var(--text-muted);
458 max-width: 420px;
459 line-height: 1.5;
460 }
461`;
462
463function IconTag() {
464 return (
465 <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">
466 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
467 <line x1="7" y1="7" x2="7.01" y2="7" />
468 </svg>
469 );
470}
471function IconPlus() {
472 return (
473 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
474 <line x1="12" y1="5" x2="12" y2="19" />
475 <line x1="5" y1="12" x2="19" y2="12" />
476 </svg>
477 );
478}
479function IconArrowLeft() {
480 return (
481 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
482 <line x1="19" y1="12" x2="5" y2="12" />
483 <polyline points="12 19 5 12 12 5" />
484 </svg>
485 );
486}
487function IconDownload() {
488 return (
489 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
490 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
491 <polyline points="7 10 12 15 17 10" />
492 <line x1="12" y1="15" x2="12" y2="3" />
493 </svg>
494 );
495}
496
497/** YYYY-MM-DD with tabular-nums. */
498function shortDate(d: Date | string | null | undefined): string {
499 if (!d) return "—";
500 const dt = d instanceof Date ? d : new Date(d);
501 if (Number.isNaN(dt.getTime())) return "—";
502 return dt.toISOString().slice(0, 10);
503}
504
63505releasesRoute.get("/:owner/:repo/releases", requireRepoAccess("read"), async (c) => {
64506 const user = c.get("user");
65507 const { owner, repo } = c.req.param();
85527 .orderBy(desc(releases.createdAt));
86528
87529 const unread = user ? await getUnreadCount(user.id) : 0;
530 const isOwner = !!user && user.id === repoRow.ownerId;
88531
89532 return c.html(
90533 <Layout
100543 currentUser={user?.username || null}
101544 />
102545 <RepoNav owner={owner} repo={repo} active="releases" />
103 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
104 <h3>Releases</h3>
105 {user && user.id === repoRow.ownerId && (
106 <a href={`/${owner}/${repo}/releases/new`} class="btn btn-primary">
107 + Draft release
108 </a>
109 )}
110 </div>
546 <div class="rel-wrap">
547 <header class="rel-head">
548 <div class="rel-head-text">
549 <div class="rel-eyebrow">
550 <span class="rel-eyebrow-dot" aria-hidden="true" />
551 Repository · Releases
552 </div>
553 <h1 class="rel-title">
554 <span class="rel-title-grad">Tagged snapshots.</span>
555 </h1>
556 <p class="rel-sub">
557 Cut a versioned release of {owner}/{repo}. AI drafts the
558 changelog from your commit history — you decide what ships.
559 </p>
560 </div>
561 {isOwner && (
562 <a href={`/${owner}/${repo}/releases/new`} class="rel-btn rel-btn-primary">
563 <IconPlus />
564 Draft release
565 </a>
566 )}
567 </header>
111568
112 {rows.length === 0 ? (
113 <div class="empty-state">
114 <h2>No releases yet</h2>
115 <p>Tag a commit and share a changelog with your users.</p>
116 </div>
117 ) : (
118 <div>
119 {rows.map((r, i) => (
120 <div class="release-card">
121 <div class="release-header">
122 <div>
123 <span class="release-name">
124 <a href={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}`}>
125 {r.name}
126 </a>
127 </span>
128 {i === 0 && !r.isDraft && !r.isPrerelease && (
129 <span class="release-tag release-latest" style="margin-left: 8px">
130 Latest
131 </span>
132 )}
133 {r.isDraft && (
134 <span class="badge" style="margin-left: 8px; color: var(--yellow); border-color: var(--yellow)">
135 Draft
136 </span>
137 )}
138 {r.isPrerelease && (
139 <span class="badge" style="margin-left: 8px; color: var(--accent); border-color: var(--accent)">
140 Pre-release
141 </span>
142 )}
143 </div>
144 <span class="release-tag">{r.tag}</span>
569 {rows.length === 0 ? (
570 <div class="rel-empty">
571 <div class="rel-empty-orb" aria-hidden="true" />
572 <div class="rel-empty-inner">
573 <div class="rel-empty-icon" aria-hidden="true">
574 <IconTag />
145575 </div>
146 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px">
147 {r.authorName} released{" "}
148 {new Date(r.publishedAt || r.createdAt).toLocaleDateString()}
149 {" · "}
150 <a href={`/${owner}/${repo}/commit/${r.targetCommit}`}>
151 {r.targetCommit.slice(0, 7)}
576 <h3 class="rel-empty-title">Tag your first release</h3>
577 <p class="rel-empty-sub">
578 Pick a commit, name a tag like <code>v1.0.0</code>, and let
579 Claude write the changelog. Starrers will be notified the
580 moment it ships.
581 </p>
582 {isOwner && (
583 <a href={`/${owner}/${repo}/releases/new`} class="rel-btn rel-btn-primary">
584 <IconPlus />
585 Draft release
152586 </a>
153 </div>
154 {r.body && (
155 <div
156 class="markdown-body"
157 style="font-size: 13px; max-height: 200px; overflow: hidden; position: relative"
158 dangerouslySetInnerHTML={{
159 __html: renderMarkdown(r.body.slice(0, 600) + (r.body.length > 600 ? " …" : "")),
160 }}
161 ></div>
162 )}
163 {user && user.id === repoRow.ownerId && (
164 <form
165 method="post"
166 action={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}/delete`}
167 style="margin-top: 12px"
168 onsubmit="return confirm('Delete this release?')"
169 >
170 <button type="submit" class="btn btn-sm btn-danger">
171 Delete
172 </button>
173 </form>
174587 )}
175588 </div>
176 ))}
177 </div>
178 )}
589 </div>
590 ) : (
591 <div class="rel-list">
592 {rows.map((r, i) => {
593 const isLatest = i === 0 && !r.isDraft && !r.isPrerelease;
594 return (
595 <article class={`rel-card${isLatest ? " is-latest" : ""}`}>
596 <div class="rel-card-head">
597 <div class="rel-card-titlewrap">
598 <a
599 href={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}`}
600 class="rel-card-name"
601 >
602 {r.name}
603 </a>
604 <span class="rel-tag-pill">
605 <IconTag />
606 {r.tag}
607 </span>
608 {isLatest && (
609 <span class="rel-pill is-latest">
610 <span class="dot" aria-hidden="true" />
611 Latest
612 </span>
613 )}
614 {r.isDraft && (
615 <span class="rel-pill is-draft">
616 <span class="dot" aria-hidden="true" />
617 Draft
618 </span>
619 )}
620 {r.isPrerelease && (
621 <span class="rel-pill is-pre">
622 <span class="dot" aria-hidden="true" />
623 Pre-release
624 </span>
625 )}
626 </div>
627 </div>
628 <div class="rel-meta-row">
629 <span>@{r.authorName}</span>
630 <span class="sep">·</span>
631 <span>released {shortDate(r.publishedAt || r.createdAt)}</span>
632 <span class="sep">·</span>
633 <a href={`/${owner}/${repo}/commit/${r.targetCommit}`}>
634 <span class="mono">{r.targetCommit.slice(0, 7)}</span>
635 </a>
636 <span class="sep">·</span>
637 <a href={`/${owner}/${repo}/archive/${encodeURIComponent(r.tag)}.zip`}>
638 <IconDownload /> Download
639 </a>
640 </div>
641 {r.body && (
642 <div
643 class="rel-notes markdown-body"
644 dangerouslySetInnerHTML={{
645 __html: renderMarkdown(r.body.slice(0, 600) + (r.body.length > 600 ? " …" : "")),
646 }}
647 ></div>
648 )}
649 {isOwner && (
650 <div class="rel-card-actions">
651 <form
652 method="post"
653 action={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}/delete`}
654 onsubmit="return confirm('Delete this release?')"
655 >
656 <button type="submit" class="rel-btn rel-btn-danger">
657 Delete
658 </button>
659 </form>
660 </div>
661 )}
662 </article>
663 );
664 })}
665 </div>
666 )}
667 </div>
668 <style dangerouslySetInnerHTML={{ __html: relStyles }} />
179669 </Layout>
180670 );
181671});
206696 currentUser={user.username}
207697 />
208698 <RepoNav owner={owner} repo={repo} active="releases" />
209 <h3>Draft a new release</h3>
210 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
211 <form
212 method="post"
213 action={`/${owner}/${repo}/releases`}
214 style="max-width: 700px"
215 >
216 <div class="form-group">
217 <label>Tag</label>
218 <input
219 type="text"
220 name="tag"
221 required
222 placeholder="v1.0.0"
223 pattern="[A-Za-z0-9._\\-]+"
224 aria-label="Tag"
225 />
226 </div>
227 <div class="form-group">
228 <label>Target branch / commit</label>
229 <select name="target" aria-label="Target branch">
230 {branches.map((b) => (
231 <option value={b} selected={b === repoRow.defaultBranch}>
232 {b}
233 </option>
234 ))}
235 </select>
236 </div>
237 <div class="form-group">
238 <label>Release name</label>
239 <input type="text" name="name" required placeholder="v1.0.0 — the big one" aria-label="Release name" />
240 </div>
241 <div class="form-group">
242 <label>Previous tag (for AI changelog)</label>
243 <select name="previousTag" aria-label="Previous tag">
244 <option value="">(auto — last tag)</option>
245 {tags.map((t) => (
246 <option value={t.name}>{t.name}</option>
247 ))}
248 </select>
249 </div>
250 <div class="form-group">
251 <label>Notes (leave blank for AI-generated)</label>
252 <textarea name="body" rows={10} placeholder="Markdown supported. Leave blank to have Claude generate a grouped changelog from commits."></textarea>
253 </div>
254 <div style="display: flex; gap: 12px">
255 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
256 <input type="checkbox" name="isPrerelease" value="1" />
257 Pre-release
258 </label>
259 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
260 <input type="checkbox" name="isDraft" value="1" />
261 Save as draft
262 </label>
699 <div class="rel-wrap">
700 <div class="rel-crumbs">
701 <a href={`/${owner}/${repo}/releases`}>
702 <IconArrowLeft />
703 All releases
704 </a>
263705 </div>
264 <button type="submit" class="btn btn-primary" style="margin-top: 16px">
265 Publish release
266 </button>
267 </form>
706 <header class="rel-head">
707 <div class="rel-head-text">
708 <div class="rel-eyebrow">
709 <span class="rel-eyebrow-dot" aria-hidden="true" />
710 Releases · New
711 </div>
712 <h1 class="rel-title">
713 <span class="rel-title-grad">Draft a release.</span>
714 </h1>
715 <p class="rel-sub">
716 Pick a tag and target, write notes (or leave blank for the AI
717 changelog), then publish.
718 </p>
719 </div>
720 </header>
721
722 {error && (
723 <div class="rel-banner" role="alert">
724 <span class="rel-banner-dot" aria-hidden="true" />
725 {decodeURIComponent(error)}
726 </div>
727 )}
728
729 <form
730 method="post"
731 action={`/${owner}/${repo}/releases`}
732 class="rel-form-card"
733 >
734 <div class="rel-field">
735 <label class="rel-field-label" for="rel-tag">Tag</label>
736 <input
737 class="rel-input"
738 type="text"
739 id="rel-tag"
740 name="tag"
741 required
742 placeholder="v1.0.0"
743 pattern="[A-Za-z0-9._\\-]+"
744 aria-label="Tag"
745 />
746 </div>
747 <div class="rel-field">
748 <label class="rel-field-label" for="rel-target">Target branch / commit</label>
749 <select class="rel-select" id="rel-target" name="target" aria-label="Target branch">
750 {branches.map((b) => (
751 <option value={b} selected={b === repoRow.defaultBranch}>
752 {b}
753 </option>
754 ))}
755 </select>
756 </div>
757 <div class="rel-field">
758 <label class="rel-field-label" for="rel-name">Release name</label>
759 <input
760 class="rel-input"
761 type="text"
762 id="rel-name"
763 name="name"
764 required
765 placeholder="v1.0.0 — the big one"
766 aria-label="Release name"
767 />
768 </div>
769 <div class="rel-field">
770 <label class="rel-field-label" for="rel-prev">Previous tag (for AI changelog)</label>
771 <select class="rel-select" id="rel-prev" name="previousTag" aria-label="Previous tag">
772 <option value="">(auto — last tag)</option>
773 {tags.map((t) => (
774 <option value={t.name}>{t.name}</option>
775 ))}
776 </select>
777 </div>
778 <div class="rel-field">
779 <label class="rel-field-label" for="rel-body">Notes (leave blank for AI-generated)</label>
780 <textarea
781 class="rel-textarea"
782 id="rel-body"
783 name="body"
784 rows={10}
785 placeholder="Markdown supported. Leave blank to have Claude generate a grouped changelog from commits."
786 ></textarea>
787 </div>
788 <div class="rel-checks">
789 <label class="rel-check">
790 <input type="checkbox" name="isPrerelease" value="1" />
791 Pre-release
792 </label>
793 <label class="rel-check">
794 <input type="checkbox" name="isDraft" value="1" />
795 Save as draft
796 </label>
797 </div>
798 <button type="submit" class="rel-btn rel-btn-primary">
799 <IconTag />
800 Publish release
801 </button>
802 </form>
803 </div>
804 <style dangerouslySetInnerHTML={{ __html: relStyles }} />
268805 </Layout>
269806 );
270807});
428965 currentUser={user?.username || null}
429966 />
430967 <RepoNav owner={owner} repo={repo} active="releases" />
431 <div style="margin-bottom: 8px">
432 <a href={`/${owner}/${repo}/releases`}>{"\u2190"} All releases</a>
433 </div>
434 <div class="release-card">
435 <div class="release-header">
436 <span class="release-name">{release.name}</span>
437 <span class="release-tag">{release.tag}</span>
438 </div>
439 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
440 {release.authorName} released{" "}
441 {new Date(release.publishedAt || release.createdAt).toLocaleString()}
442 {" · "}
443 <a href={`/${owner}/${repo}/commit/${release.targetCommit}`}>
444 {release.targetCommit.slice(0, 7)}
445 </a>
446 {" · "}
447 <a
448 href={`/${owner}/${repo}/archive/${encodeURIComponent(release.tag)}.zip`}
449 >
450 Download
968 <div class="rel-wrap">
969 <div class="rel-crumbs">
970 <a href={`/${owner}/${repo}/releases`}>
971 <IconArrowLeft />
972 All releases
451973 </a>
452974 </div>
453 {release.body && (
454 <div
455 class="markdown-body"
456 dangerouslySetInnerHTML={{
457 __html: renderMarkdown(release.body),
458 }}
459 ></div>
460 )}
975 <header class="rel-head">
976 <div class="rel-head-text">
977 <div class="rel-eyebrow">
978 <span class="rel-eyebrow-dot" aria-hidden="true" />
979 Releases · {release.tag}
980 </div>
981 <h1 class="rel-title">
982 <span class="rel-title-grad">{release.name}</span>
983 </h1>
984 <p class="rel-sub">
985 Released by @{release.authorName} on {shortDate(release.publishedAt || release.createdAt)}.
986 </p>
987 </div>
988 </header>
989
990 <article class="rel-detail">
991 <div class="rel-card-head">
992 <div class="rel-card-titlewrap">
993 <span class="rel-tag-pill">
994 <IconTag />
995 {release.tag}
996 </span>
997 {release.isDraft && (
998 <span class="rel-pill is-draft">
999 <span class="dot" aria-hidden="true" />
1000 Draft
1001 </span>
1002 )}
1003 {release.isPrerelease && (
1004 <span class="rel-pill is-pre">
1005 <span class="dot" aria-hidden="true" />
1006 Pre-release
1007 </span>
1008 )}
1009 </div>
1010 </div>
1011 <div class="rel-meta-row">
1012 <span>@{release.authorName}</span>
1013 <span class="sep">·</span>
1014 <span>released {shortDate(release.publishedAt || release.createdAt)}</span>
1015 <span class="sep">·</span>
1016 <a href={`/${owner}/${repo}/commit/${release.targetCommit}`}>
1017 <span class="mono">{release.targetCommit.slice(0, 7)}</span>
1018 </a>
1019 <span class="sep">·</span>
1020 <a href={`/${owner}/${repo}/archive/${encodeURIComponent(release.tag)}.zip`}>
1021 <IconDownload /> Download
1022 </a>
1023 </div>
1024 {release.body && (
1025 <div
1026 class="markdown-body"
1027 style="border-top: 1px dashed var(--border); padding-top: 14px; margin-top: 4px"
1028 dangerouslySetInnerHTML={{
1029 __html: renderMarkdown(release.body),
1030 }}
1031 ></div>
1032 )}
1033 </article>
4611034 </div>
1035 <style dangerouslySetInnerHTML={{ __html: relStyles }} />
4621036 </Layout>
4631037 );
4641038});
Modifiedsrc/routes/sso.tsx+803−187View fileUnifiedSplit
Large file (1,047 lines). Load full file
Modifiedsrc/routes/traffic.tsx+551−102View fileUnifiedSplit
33 *
44 * GET /:owner/:repo/traffic — owner-only 14-day views/clones chart,
55 * unique visitors, top paths + referers.
6 *
7 * 2026 polish: gradient-hairline hero + radial orb + stat-card grid with
8 * tabular-nums + ▲/▼ trend arrows, simple 14-day bar chart, top-referers
9 * list with mono URLs, dashed-orb empty states. Every class prefixed
10 * `.traffic-` so this surface can't bleed into the wider repo polish. All
11 * routes, queries, and the `summarise()` contract preserved exactly.
612 */
713
814import { Hono } from "hono";
1824const traffic = new Hono<AuthEnv>();
1925traffic.use("*", softAuth);
2026
27/* ─────────────────────────────────────────────────────────────────────────
28 * Scoped CSS — every class prefixed `.traffic-` so this surface can't
29 * bleed into the wider repo polish. Mirrors the gradient-hairline hero +
30 * stat-card grid pattern from `insights.tsx` + `admin-integrations`.
31 * ───────────────────────────────────────────────────────────────────── */
32const styles = `
33 .traffic-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
34
35 .traffic-hero {
36 position: relative;
37 margin-bottom: var(--space-5);
38 padding: var(--space-5) var(--space-6);
39 background: var(--bg-elevated);
40 border: 1px solid var(--border);
41 border-radius: 16px;
42 overflow: hidden;
43 }
44 .traffic-hero::before {
45 content: '';
46 position: absolute;
47 top: 0; left: 0; right: 0;
48 height: 2px;
49 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
50 opacity: 0.75;
51 pointer-events: none;
52 }
53 .traffic-hero-orb {
54 position: absolute;
55 inset: -30% -15% auto auto;
56 width: 460px; height: 460px;
57 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
58 filter: blur(80px);
59 opacity: 0.75;
60 pointer-events: none;
61 z-index: 0;
62 }
63 .traffic-hero-inner {
64 position: relative;
65 z-index: 1;
66 display: flex;
67 align-items: flex-start;
68 justify-content: space-between;
69 gap: var(--space-4);
70 flex-wrap: wrap;
71 }
72 .traffic-hero-text { max-width: 720px; }
73 .traffic-eyebrow {
74 display: inline-flex;
75 align-items: center;
76 gap: 8px;
77 text-transform: uppercase;
78 font-family: var(--font-mono);
79 font-size: 11px;
80 letter-spacing: 0.18em;
81 color: var(--text-muted);
82 font-weight: 600;
83 margin-bottom: 14px;
84 }
85 .traffic-eyebrow-dot {
86 width: 8px; height: 8px;
87 border-radius: 9999px;
88 background: linear-gradient(135deg, #8c6dff, #36c5d6);
89 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
90 }
91 .traffic-title {
92 font-family: var(--font-display);
93 font-size: clamp(28px, 4vw, 40px);
94 font-weight: 800;
95 letter-spacing: -0.028em;
96 line-height: 1.05;
97 margin: 0 0 var(--space-2);
98 color: var(--text-strong);
99 }
100 .traffic-title-grad {
101 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
102 -webkit-background-clip: text;
103 background-clip: text;
104 -webkit-text-fill-color: transparent;
105 color: transparent;
106 }
107 .traffic-sub {
108 font-size: 15px;
109 color: var(--text-muted);
110 margin: 0;
111 line-height: 1.55;
112 }
113
114 /* Window switcher (7/14/30/90d) */
115 .traffic-windows {
116 display: inline-flex;
117 gap: 6px;
118 flex-wrap: wrap;
119 align-items: center;
120 }
121 .traffic-window {
122 display: inline-flex;
123 align-items: center;
124 padding: 6px 12px;
125 border-radius: 9999px;
126 border: 1px solid var(--border);
127 background: var(--bg-elevated);
128 color: var(--text-muted);
129 font-size: 12.5px;
130 font-weight: 600;
131 text-decoration: none;
132 font-variant-numeric: tabular-nums;
133 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
134 }
135 .traffic-window:hover { border-color: rgba(140,109,255,0.45); color: var(--text-strong); text-decoration: none; }
136 .traffic-window.is-active {
137 color: #fff;
138 background: linear-gradient(135deg, rgba(140,109,255,0.85), rgba(54,197,214,0.85));
139 border-color: rgba(140,109,255,0.55);
140 }
141
142 /* Stat-card grid */
143 .traffic-stats {
144 display: grid;
145 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
146 gap: var(--space-3);
147 margin-bottom: var(--space-5);
148 }
149 .traffic-stat {
150 position: relative;
151 background: var(--bg-elevated);
152 border: 1px solid var(--border);
153 border-radius: 14px;
154 padding: var(--space-4);
155 transition: border-color 120ms ease, transform 120ms ease;
156 }
157 .traffic-stat:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
158 .traffic-stat-label {
159 font-size: 10.5px;
160 letter-spacing: 0.14em;
161 text-transform: uppercase;
162 color: var(--text-muted);
163 font-weight: 700;
164 margin-bottom: 6px;
165 }
166 .traffic-stat-value {
167 font-family: var(--font-display);
168 font-size: 32px;
169 font-weight: 800;
170 letter-spacing: -0.022em;
171 color: var(--text-strong);
172 font-variant-numeric: tabular-nums;
173 line-height: 1;
174 }
175 .traffic-stat-trend {
176 margin-top: 8px;
177 display: inline-flex;
178 align-items: center;
179 gap: 4px;
180 font-size: 12px;
181 font-variant-numeric: tabular-nums;
182 color: var(--text-muted);
183 }
184 .traffic-stat-trend.is-up { color: #6ee7b7; }
185 .traffic-stat-trend.is-down { color: #fca5a5; }
186 .traffic-stat-trend .arrow { font-size: 11px; line-height: 1; }
187 .traffic-stat-hint {
188 margin-top: 6px;
189 font-size: 12px;
190 color: var(--text-muted);
191 }
192
193 /* Section heading */
194 .traffic-section-head {
195 margin: 0 0 var(--space-3);
196 display: flex;
197 align-items: baseline;
198 justify-content: space-between;
199 gap: var(--space-3);
200 flex-wrap: wrap;
201 }
202 .traffic-section-title {
203 margin: 0;
204 font-family: var(--font-display);
205 font-size: 18px;
206 font-weight: 700;
207 letter-spacing: -0.018em;
208 color: var(--text-strong);
209 }
210 .traffic-section-sub { font-size: 12.5px; color: var(--text-muted); }
211
212 /* Bar-chart card */
213 .traffic-chart {
214 background: var(--bg-elevated);
215 border: 1px solid var(--border);
216 border-radius: 14px;
217 padding: var(--space-4) var(--space-5);
218 margin-bottom: var(--space-5);
219 }
220 .traffic-chart-bars {
221 display: flex;
222 align-items: flex-end;
223 gap: 6px;
224 height: 160px;
225 padding: 12px 0 10px;
226 border-bottom: 1px solid var(--border);
227 }
228 .traffic-bar {
229 flex: 1;
230 min-width: 8px;
231 position: relative;
232 display: flex;
233 flex-direction: column-reverse;
234 border-radius: 4px 4px 0 0;
235 overflow: hidden;
236 background: rgba(255,255,255,0.03);
237 transition: transform 120ms ease;
238 }
239 .traffic-bar:hover { transform: translateY(-2px); }
240 .traffic-bar .seg-views {
241 background: linear-gradient(180deg, #8c6dff, #6d4dff);
242 box-shadow: 0 0 12px -2px rgba(140,109,255,0.45);
243 }
244 .traffic-bar .seg-clones {
245 background: linear-gradient(180deg, #36c5d6, #0891b2);
246 }
247 .traffic-bar-day {
248 margin-top: 6px;
249 font-family: var(--font-mono);
250 font-size: 10px;
251 color: var(--text-muted);
252 text-align: center;
253 transform: rotate(-35deg);
254 transform-origin: center;
255 white-space: nowrap;
256 }
257 .traffic-bars-row {
258 display: flex;
259 gap: 6px;
260 margin-top: 12px;
261 }
262 .traffic-bars-row > div { flex: 1; min-width: 8px; }
263 .traffic-chart-legend {
264 display: flex;
265 gap: 18px;
266 margin-top: 16px;
267 font-size: 12px;
268 color: var(--text-muted);
269 }
270 .traffic-legend-dot {
271 display: inline-block;
272 width: 10px; height: 10px;
273 border-radius: 3px;
274 margin-right: 6px;
275 vertical-align: middle;
276 }
277 .traffic-legend-dot.views { background: linear-gradient(180deg, #8c6dff, #6d4dff); }
278 .traffic-legend-dot.clones { background: linear-gradient(180deg, #36c5d6, #0891b2); }
279
280 /* Two-column lists (top paths + referrers) */
281 .traffic-twocol {
282 display: grid;
283 grid-template-columns: 1fr 1fr;
284 gap: var(--space-4);
285 margin-bottom: var(--space-5);
286 }
287 @media (max-width: 720px) {
288 .traffic-twocol { grid-template-columns: 1fr; }
289 }
290 .traffic-list-card {
291 background: var(--bg-elevated);
292 border: 1px solid var(--border);
293 border-radius: 14px;
294 padding: var(--space-3) var(--space-4);
295 }
296 .traffic-list-row {
297 display: flex;
298 align-items: center;
299 justify-content: space-between;
300 gap: 12px;
301 padding: 8px 0;
302 border-bottom: 1px solid var(--border);
303 }
304 .traffic-list-row:last-child { border-bottom: 0; }
305 .traffic-list-key {
306 font-family: var(--font-mono);
307 font-size: 12.5px;
308 color: var(--text);
309 overflow: hidden;
310 text-overflow: ellipsis;
311 white-space: nowrap;
312 flex: 1;
313 min-width: 0;
314 }
315 .traffic-list-key a { color: inherit; text-decoration: none; }
316 .traffic-list-key a:hover { color: var(--accent); text-decoration: none; }
317 .traffic-list-val {
318 font-family: var(--font-mono);
319 font-size: 12.5px;
320 color: var(--text-muted);
321 font-variant-numeric: tabular-nums;
322 white-space: nowrap;
323 }
324
325 /* Empty state — dashed orb card */
326 .traffic-empty {
327 position: relative;
328 overflow: hidden;
329 text-align: center;
330 padding: var(--space-6) var(--space-4);
331 border: 1px dashed var(--border-strong, var(--border));
332 border-radius: 16px;
333 background: rgba(255,255,255,0.012);
334 color: var(--text-muted);
335 }
336 .traffic-empty::before {
337 content: '';
338 position: absolute;
339 inset: -40% -20% auto auto;
340 width: 320px; height: 320px;
341 background: radial-gradient(circle, rgba(140,109,255,0.14), rgba(54,197,214,0.06) 45%, transparent 70%);
342 filter: blur(60px);
343 pointer-events: none;
344 }
345 .traffic-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
346 .traffic-empty strong {
347 font-family: var(--font-display);
348 font-size: 17px;
349 font-weight: 700;
350 color: var(--text-strong);
351 margin: 0;
352 }
353 .traffic-empty p { font-size: 13px; margin: 0; max-width: 420px; }
354`;
355
21356async function loadRepo(owner: string, repo: string) {
22357 try {
23358 const [row] = await db
53388 );
54389 const summary = await summarise(repoRow.id, windowDays);
55390
56 // Simple ascii-bar chart scaled to the max day.
391 // Bar-chart scaling: tallest single-day total across the window.
57392 const maxN = Math.max(
58393 1,
59394 ...summary.daily.map((d) => d.views + d.clones)
60395 );
61396
397 // Crude trend signals: compare first vs second half of the window. Used
398 // only for the ▲/▼ glyph on the stat cards — the actual numbers come
399 // straight from `summarise()` so the contract is preserved.
400 const half = Math.max(1, Math.floor(summary.daily.length / 2));
401 const firstViews = summary.daily
402 .slice(0, half)
403 .reduce((n, d) => n + d.views, 0);
404 const lastViews = summary.daily
405 .slice(half)
406 .reduce((n, d) => n + d.views, 0);
407 const viewsTrend =
408 summary.daily.length < 2
409 ? "flat"
410 : lastViews >= firstViews
411 ? "up"
412 : "down";
413 const firstClones = summary.daily
414 .slice(0, half)
415 .reduce((n, d) => n + d.clones, 0);
416 const lastClones = summary.daily
417 .slice(half)
418 .reduce((n, d) => n + d.clones, 0);
419 const clonesTrend =
420 summary.daily.length < 2
421 ? "flat"
422 : lastClones >= firstClones
423 ? "up"
424 : "down";
425
426 const trendClass = (t: string) =>
427 t === "up" ? "is-up" : t === "down" ? "is-down" : "";
428 const trendArrow = (t: string) => (t === "down" ? "▼" : "▲");
429
62430 return c.html(
63431 <Layout title={`Traffic — ${owner}/${repo}`} user={user}>
64432 <RepoHeader
70438 />
71439 <RepoNav owner={owner} repo={repo} active="insights" />
72440
73 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
74 <h3>Traffic ({windowDays}d)</h3>
75 <div>
76 {[7, 14, 30, 90].map((d) => (
77 <a
78 href={`/${owner}/${repo}/traffic?days=${d}`}
79 class={`btn btn-sm ${d === windowDays ? "btn-primary" : ""}`}
80 style="margin-left:4px"
81 >
82 {d}d
83 </a>
84 ))}
85 </div>
86 </div>
87
88 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
89 <div class="panel" style="padding:12px;text-align:center">
90 <div style="font-size:22px;font-weight:700;color:#79c0ff">
91 {summary.totalViews}
441 <div class="traffic-wrap">
442 <section class="traffic-hero">
443 <div class="traffic-hero-orb" aria-hidden="true" />
444 <div class="traffic-hero-inner">
445 <div class="traffic-hero-text">
446 <div class="traffic-eyebrow">
447 <span class="traffic-eyebrow-dot" aria-hidden="true" />
448 Traffic · {owner}/{repo}
449 </div>
450 <h2 class="traffic-title">
451 <span class="traffic-title-grad">Who's looking.</span>
452 </h2>
453 <p class="traffic-sub">
454 Views, clones, and unique visitors over the last{" "}
455 {windowDays} day{windowDays === 1 ? "" : "s"} — refreshed live
456 from every web hit and git-http access.
457 </p>
458 </div>
459 <div class="traffic-windows" aria-label="Time window">
460 {[7, 14, 30, 90].map((d) => (
461 <a
462 href={`/${owner}/${repo}/traffic?days=${d}`}
463 class={
464 "traffic-window" + (d === windowDays ? " is-active" : "")
465 }
466 >
467 {d}d
468 </a>
469 ))}
470 </div>
92471 </div>
93 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
94 Views
472 </section>
473
474 <div class="traffic-stats">
475 <div class="traffic-stat">
476 <div class="traffic-stat-label">Views</div>
477 <div class="traffic-stat-value">
478 {summary.totalViews.toLocaleString()}
479 </div>
480 <div class={"traffic-stat-trend " + trendClass(viewsTrend)}>
481 <span class="arrow" aria-hidden="true">
482 {trendArrow(viewsTrend)}
483 </span>
484 vs prior period
485 </div>
95486 </div>
96 </div>
97 <div class="panel" style="padding:12px;text-align:center">
98 <div style="font-size:22px;font-weight:700;color:#d2a8ff">
99 {summary.totalClones}
487 <div class="traffic-stat">
488 <div class="traffic-stat-label">Unique visitors</div>
489 <div class="traffic-stat-value">
490 {summary.uniqueVisitorsApprox.toLocaleString()}
491 </div>
492 <div class="traffic-stat-hint">Approx · distinct ip-hash</div>
100493 </div>
101 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
102 Clones
494 <div class="traffic-stat">
495 <div class="traffic-stat-label">Clones</div>
496 <div class="traffic-stat-value">
497 {summary.totalClones.toLocaleString()}
498 </div>
499 <div class={"traffic-stat-trend " + trendClass(clonesTrend)}>
500 <span class="arrow" aria-hidden="true">
501 {trendArrow(clonesTrend)}
502 </span>
503 vs prior period
504 </div>
103505 </div>
104 </div>
105 <div class="panel" style="padding:12px;text-align:center">
106 <div style="font-size:22px;font-weight:700;color:var(--green)">
107 {summary.uniqueVisitorsApprox}
108 </div>
109 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
110 Unique (approx)
506 <div class="traffic-stat">
507 <div class="traffic-stat-label">Referrers</div>
508 <div class="traffic-stat-value">
509 {summary.topReferers.length.toLocaleString()}
510 </div>
511 <div class="traffic-stat-hint">Distinct sources</div>
111512 </div>
112513 </div>
113 </div>
114514
115 <h4>Daily</h4>
116 <div class="panel" style="margin-bottom:20px;padding:12px">
117 {summary.daily.length === 0 ? (
118 <p style="color:var(--text-muted);font-size:13px">
119 No traffic recorded yet. Views are tracked automatically as people
120 visit this repo; clones + API hits are tracked on git-http access.
121 </p>
122 ) : (
123 summary.daily.map((d) => {
124 const total = d.views + d.clones;
125 const pct = Math.round((total / maxN) * 100);
126 return (
127 <div style="display:flex;align-items:center;gap:8px;font-size:12px;padding:3px 0">
128 <span
129 style="font-family:var(--font-mono);color:var(--text-muted);width:88px"
130 >
131 {d.day}
515 <div class="traffic-section-head">
516 <h3 class="traffic-section-title">Daily activity</h3>
517 <span class="traffic-section-sub">
518 {summary.daily.length} day{summary.daily.length === 1 ? "" : "s"}{" "}
519 with data
520 </span>
521 </div>
522 <div class="traffic-chart">
523 {summary.daily.length === 0 ? (
524 <div class="traffic-empty">
525 <div class="traffic-empty-inner">
526 <strong>No traffic recorded yet</strong>
527 <p>
528 Views are tracked automatically as people visit this repo;
529 clones + API hits are tracked on git-http access. Share the
530 URL and check back in a bit.
531 </p>
532 </div>
533 </div>
534 ) : (
535 <>
536 <div class="traffic-chart-bars" role="img" aria-label="Daily traffic bars">
537 {summary.daily.map((d) => {
538 const total = d.views + d.clones;
539 const heightPct = Math.max(2, (total / maxN) * 100);
540 const viewsShare = total ? (d.views / total) * heightPct : 0;
541 const clonesShare = total ? (d.clones / total) * heightPct : 0;
542 return (
543 <div
544 class="traffic-bar"
545 title={`${d.day} · ${d.views} views, ${d.clones} clones`}
546 style={`height:${heightPct}%`}
547 >
548 <div class="seg-clones" style={`height:${clonesShare}%`} />
549 <div class="seg-views" style={`height:${viewsShare}%`} />
550 </div>
551 );
552 })}
553 </div>
554 <div class="traffic-bars-row" aria-hidden="true">
555 {summary.daily.map((d) => (
556 <div class="traffic-bar-day">{d.day.slice(5)}</div>
557 ))}
558 </div>
559 <div class="traffic-chart-legend">
560 <span>
561 <span class="traffic-legend-dot views" aria-hidden="true" />
562 Views
132563 </span>
133 <div
134 style={`flex:1;height:14px;background:var(--bg-tertiary);border-radius:3px;position:relative;overflow:hidden`}
135 >
136 <div
137 style={`position:absolute;left:0;top:0;bottom:0;width:${pct}%;background:linear-gradient(90deg,#79c0ff ${d.views / Math.max(1, total) * 100}%,#d2a8ff ${d.views / Math.max(1, total) * 100}%)`}
138 />
139 </div>
140 <span style="font-family:var(--font-mono);width:56px;text-align:right">
141 {d.views}v / {d.clones}c
564 <span>
565 <span class="traffic-legend-dot clones" aria-hidden="true" />
566 Clones
142567 </span>
143568 </div>
144 );
145 })
146 )}
147 </div>
569 </>
570 )}
571 </div>
148572
149 <div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
150 <div>
151 <h4>Top paths</h4>
152 <div class="panel">
153 {summary.topPaths.length === 0 ? (
154 <div class="panel-empty">No paths recorded.</div>
155 ) : (
156 summary.topPaths.map((p) => (
157 <div class="panel-item" style="justify-content:space-between">
158 <code style="font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block">
159 {p.path}
160 </code>
161 <span style="font-family:var(--font-mono);color:var(--text-muted)">
162 {p.n}
163 </span>
573 <div class="traffic-twocol">
574 <div>
575 <div class="traffic-section-head">
576 <h3 class="traffic-section-title">Top paths</h3>
577 <span class="traffic-section-sub">
578 {summary.topPaths.length} path
579 {summary.topPaths.length === 1 ? "" : "s"}
580 </span>
581 </div>
582 <div class="traffic-list-card">
583 {summary.topPaths.length === 0 ? (
584 <div class="traffic-empty">
585 <div class="traffic-empty-inner">
586 <strong>No paths recorded</strong>
587 <p>Path hits appear here as visitors browse the repo.</p>
588 </div>
164589 </div>
165 ))
166 )}
590 ) : (
591 summary.topPaths.map((p) => (
592 <div class="traffic-list-row">
593 <span class="traffic-list-key" title={p.path}>
594 {p.path}
595 </span>
596 <span class="traffic-list-val">
597 {p.n.toLocaleString()}
598 </span>
599 </div>
600 ))
601 )}
602 </div>
167603 </div>
168 </div>
169 <div>
170 <h4>Top referers</h4>
171 <div class="panel">
172 {summary.topReferers.length === 0 ? (
173 <div class="panel-empty">No external referers.</div>
174 ) : (
175 summary.topReferers.map((r) => (
176 <div class="panel-item" style="justify-content:space-between">
177 <span
178 style="font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block"
179 >
180 {r.referer}
181 </span>
182 <span style="font-family:var(--font-mono);color:var(--text-muted)">
183 {r.n}
184 </span>
604 <div>
605 <div class="traffic-section-head">
606 <h3 class="traffic-section-title">Top referrers</h3>
607 <span class="traffic-section-sub">
608 {summary.topReferers.length} source
609 {summary.topReferers.length === 1 ? "" : "s"}
610 </span>
611 </div>
612 <div class="traffic-list-card">
613 {summary.topReferers.length === 0 ? (
614 <div class="traffic-empty">
615 <div class="traffic-empty-inner">
616 <strong>No external referrers</strong>
617 <p>
618 Share this repo on social or in docs to start seeing
619 where visits come from.
620 </p>
621 </div>
185622 </div>
186 ))
187 )}
623 ) : (
624 summary.topReferers.map((r) => (
625 <div class="traffic-list-row">
626 <span class="traffic-list-key" title={r.referer}>
627 {r.referer}
628 </span>
629 <span class="traffic-list-val">
630 {r.n.toLocaleString()}
631 </span>
632 </div>
633 ))
634 )}
635 </div>
188636 </div>
189637 </div>
190638 </div>
639 <style dangerouslySetInnerHTML={{ __html: styles }} />
191640 </Layout>
192641 );
193642});
194643