Commit398a10cunknown_key
polish: 2026 hero + form cards for playground + install + team-collaborators + new + fork
polish: 2026 hero + form cards for playground + install + team-collaborators + new + fork https://claude.ai/code/session_01KdpgsaGpmEExJAz3yhFLvx
5 files changed+3138−390398a10ce4ad3e5a3551a0470b6a863e939920e37
5 changed files+3138−390
Deletedsrc/routes/fork.ts+0−117View fileUnifiedSplit
@@ -1,117 +0,0 @@
1/**
2 * Fork route — copy a repository into your account.
3 */
4
5import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
7import { db } from "../db";
8import { repositories, users, activityFeed } from "../db/schema";
9import { softAuth, requireAuth } from "../middleware/auth";
10import type { AuthEnv } from "../middleware/auth";
11import { getRepoPath, repoExists, initBareRepo } from "../git/repository";
12import { config } from "../lib/config";
13import { join } from "path";
14
15const fork = new Hono<AuthEnv>();
16
17fork.use("*", softAuth);
18
19// Fork a repository
20fork.post("/:owner/:repo/fork", requireAuth, async (c) => {
21 const { owner: ownerName, repo: repoName } = c.req.param();
22 const user = c.get("user")!;
23
24 // Can't fork your own repo
25 if (ownerName === user.username) {
26 return c.redirect(`/${ownerName}/${repoName}`);
27 }
28
29 // Check source exists
30 if (!(await repoExists(ownerName, repoName))) {
31 return c.redirect(`/${ownerName}/${repoName}`);
32 }
33
34 // Check if already forked
35 if (await repoExists(user.username, repoName)) {
36 return c.redirect(`/${user.username}/${repoName}`);
37 }
38
39 // Get source repo from DB
40 const [sourceOwner] = await db
41 .select()
42 .from(users)
43 .where(eq(users.username, ownerName))
44 .limit(1);
45 if (!sourceOwner) return c.redirect(`/${ownerName}/${repoName}`);
46
47 const [sourceRepo] = await db
48 .select()
49 .from(repositories)
50 .where(
51 and(
52 eq(repositories.ownerId, sourceOwner.id),
53 eq(repositories.name, repoName)
54 )
55 )
56 .limit(1);
57 if (!sourceRepo) return c.redirect(`/${ownerName}/${repoName}`);
58
59 // Clone the bare repo
60 const sourcePath = getRepoPath(ownerName, repoName);
61 const destPath = join(config.gitReposPath, user.username, `${repoName}.git`);
62
63 const proc = Bun.spawn(["git", "clone", "--bare", sourcePath, destPath], {
64 stdout: "pipe",
65 stderr: "pipe",
66 });
67 await proc.exited;
68
69 // Insert into DB
70 const [newRepo] = await db
71 .insert(repositories)
72 .values({
73 name: repoName,
74 ownerId: user.id,
75 description: sourceRepo.description
76 ? `Fork of ${ownerName}/${repoName} — ${sourceRepo.description}`
77 : `Fork of ${ownerName}/${repoName}`,
78 isPrivate: false,
79 defaultBranch: sourceRepo.defaultBranch,
80 diskPath: destPath,
81 forkedFromId: sourceRepo.id,
82 })
83 .returning();
84
85 // Bootstrap the fork with green-by-default settings, protection, labels
86 if (newRepo) {
87 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
88 await bootstrapRepository({
89 repositoryId: newRepo.id,
90 ownerUserId: user.id,
91 defaultBranch: sourceRepo.defaultBranch,
92 skipWelcomeIssue: true, // forks don't need a welcome issue
93 });
94 }
95
96 // Update fork count
97 await db
98 .update(repositories)
99 .set({ forkCount: sourceRepo.forkCount + 1 })
100 .where(eq(repositories.id, sourceRepo.id));
101
102 // Log activity
103 try {
104 await db.insert(activityFeed).values({
105 repositoryId: sourceRepo.id,
106 userId: user.id,
107 action: "fork",
108 metadata: JSON.stringify({ forkOwner: user.username }),
109 });
110 } catch {
111 // best effort
112 }
113
114 return c.redirect(`/${user.username}/${repoName}`);
115});
116
117export default fork;
Addedsrc/routes/fork.tsx+665−0View fileUnifiedSplit
@@ -0,0 +1,665 @@
1/**
2 * Fork route — copy a repository into your account.
3 *
4 * GET /:owner/:repo/fork — polished confirmation page (source repo
5 * card + destination owner picker + gradient
6 * "Create my fork" submit). Mirrors the
7 * existing inline POST button on RepoHeader;
8 * the form here POSTs to the same endpoint
9 * below, so the POST handler logic is the
10 * single source of truth.
11 * POST /:owner/:repo/fork — perform the fork.
12 *
13 * All CSS is scoped under `.fork-*` so it can't bleed into other
14 * surfaces. Visual recipe follows `src/routes/import.tsx` and
15 * `src/routes/connect-claude.tsx`.
16 */
17
18import { Hono } from "hono";
19import { eq, and } from "drizzle-orm";
20import { db } from "../db";
21import { repositories, users, activityFeed } from "../db/schema";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import { getRepoPath, repoExists } from "../git/repository";
25import { config } from "../lib/config";
26import { join } from "path";
27import { Layout } from "../views/layout";
28
29const fork = new Hono<AuthEnv>();
30
31fork.use("*", softAuth);
32
33// ─── Scoped CSS — `.fork-*` ────────────────────────────────────────────────
34const forkStyles = `
35 .fork-wrap { max-width: 760px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
36
37 /* ─── Hero ─── */
38 .fork-hero {
39 position: relative;
40 margin-bottom: var(--space-5);
41 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 36px);
42 background: var(--bg-elevated);
43 border: 1px solid var(--border);
44 border-radius: 18px;
45 overflow: hidden;
46 }
47 .fork-hero::before {
48 content: '';
49 position: absolute;
50 top: 0; left: 0; right: 0;
51 height: 2px;
52 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
53 opacity: 0.75;
54 pointer-events: none;
55 }
56 .fork-hero-orb {
57 position: absolute;
58 inset: -30% -10% auto auto;
59 width: 420px; height: 420px;
60 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
61 filter: blur(80px);
62 opacity: 0.7;
63 pointer-events: none;
64 animation: forkHeroOrb 14s ease-in-out infinite;
65 z-index: 0;
66 }
67 @keyframes forkHeroOrb {
68 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
69 50% { transform: scale(1.08) translate(-12px, 8px); opacity: 0.85; }
70 }
71 @media (prefers-reduced-motion: reduce) {
72 .fork-hero-orb { animation: none; }
73 }
74 .fork-hero-inner { position: relative; z-index: 1; max-width: 640px; }
75 .fork-eyebrow {
76 display: inline-flex;
77 align-items: center;
78 gap: 8px;
79 font-family: var(--font-mono);
80 font-size: 11px;
81 letter-spacing: 0.16em;
82 text-transform: uppercase;
83 color: var(--text-muted);
84 font-weight: 600;
85 margin-bottom: 12px;
86 }
87 .fork-eyebrow-dot {
88 width: 8px; height: 8px;
89 border-radius: 9999px;
90 background: linear-gradient(135deg, #8c6dff, #36c5d6);
91 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
92 }
93 .fork-eyebrow strong { color: var(--accent); font-weight: 700; }
94 .fork-title {
95 font-family: var(--font-display);
96 font-size: clamp(28px, 4vw, 40px);
97 font-weight: 800;
98 letter-spacing: -0.028em;
99 line-height: 1.06;
100 margin: 0 0 var(--space-2);
101 color: var(--text-strong);
102 }
103 .fork-title-grad {
104 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
105 -webkit-background-clip: text;
106 background-clip: text;
107 -webkit-text-fill-color: transparent;
108 color: transparent;
109 }
110 .fork-sub {
111 font-size: 15px;
112 color: var(--text-muted);
113 line-height: 1.55;
114 margin: 0;
115 max-width: 560px;
116 }
117
118 /* ─── Source repo card ─── */
119 .fork-source-card {
120 position: relative;
121 display: flex;
122 align-items: center;
123 gap: 14px;
124 padding: 16px 18px;
125 margin-bottom: var(--space-4);
126 background: var(--bg-elevated);
127 border: 1px solid var(--border);
128 border-radius: 14px;
129 overflow: hidden;
130 }
131 .fork-source-card::before {
132 content: '';
133 position: absolute;
134 left: 0; top: 0; bottom: 0;
135 width: 3px;
136 background: linear-gradient(180deg, #8c6dff 0%, #36c5d6 100%);
137 }
138 .fork-source-icon {
139 flex-shrink: 0;
140 width: 44px; height: 44px;
141 border-radius: 12px;
142 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
143 border: 1px solid rgba(140,109,255,0.35);
144 display: inline-flex;
145 align-items: center;
146 justify-content: center;
147 color: #c5b3ff;
148 font-family: var(--font-display);
149 font-weight: 800;
150 font-size: 20px;
151 }
152 .fork-source-body { flex: 1; min-width: 0; }
153 .fork-source-eyebrow {
154 font-family: var(--font-mono);
155 font-size: 11px;
156 letter-spacing: 0.14em;
157 text-transform: uppercase;
158 color: var(--text-muted);
159 font-weight: 600;
160 margin-bottom: 3px;
161 }
162 .fork-source-name {
163 font-family: var(--font-display);
164 font-size: 16px;
165 font-weight: 700;
166 color: var(--text-strong);
167 text-decoration: none;
168 letter-spacing: -0.012em;
169 display: block;
170 }
171 .fork-source-name:hover { color: var(--accent); }
172 .fork-source-desc {
173 font-size: 13px;
174 color: var(--text-muted);
175 margin: 3px 0 0;
176 line-height: 1.5;
177 overflow: hidden;
178 text-overflow: ellipsis;
179 white-space: nowrap;
180 }
181
182 /* ─── Form card ─── */
183 .fork-form-card {
184 position: relative;
185 padding: clamp(20px, 3vw, 28px);
186 background: var(--bg-elevated);
187 border: 1px solid var(--border);
188 border-radius: 14px;
189 overflow: hidden;
190 }
191 .fork-form-head {
192 display: flex;
193 align-items: center;
194 gap: 10px;
195 margin-bottom: 8px;
196 }
197 .fork-form-badge {
198 display: inline-flex;
199 align-items: center;
200 justify-content: center;
201 width: 26px; height: 26px;
202 border-radius: 50%;
203 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
204 color: #c5b3ff;
205 border: 1px solid rgba(140,109,255,0.40);
206 font-family: var(--font-display);
207 font-weight: 700;
208 font-size: 13px;
209 }
210 .fork-form-title {
211 font-family: var(--font-display);
212 font-size: 16px;
213 font-weight: 700;
214 letter-spacing: -0.012em;
215 color: var(--text-strong);
216 margin: 0;
217 }
218 .fork-form-sub {
219 font-size: 13px;
220 color: var(--text-muted);
221 margin: 0 0 var(--space-3);
222 line-height: 1.55;
223 }
224 .fork-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-3); }
225 .fork-field-label {
226 font-size: 13px;
227 color: var(--text-strong);
228 font-weight: 600;
229 }
230 .fork-dest-row {
231 display: flex;
232 align-items: center;
233 gap: 10px;
234 padding: 12px 14px;
235 background: var(--bg);
236 border: 1px solid var(--border);
237 border-radius: 10px;
238 }
239 .fork-dest-avatar {
240 width: 28px; height: 28px;
241 border-radius: 50%;
242 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
243 color: #fff;
244 display: inline-flex;
245 align-items: center;
246 justify-content: center;
247 font-family: var(--font-display);
248 font-weight: 700;
249 font-size: 13px;
250 flex-shrink: 0;
251 }
252 .fork-dest-name {
253 font-family: var(--font-display);
254 font-weight: 700;
255 font-size: 14px;
256 color: var(--text-strong);
257 }
258 .fork-dest-slash {
259 color: var(--text-muted);
260 font-family: var(--font-mono);
261 margin: 0 2px;
262 }
263 .fork-dest-repo {
264 font-family: var(--font-mono);
265 font-size: 13.5px;
266 color: var(--text);
267 }
268 .fork-dest-hint {
269 font-size: 12px;
270 color: var(--text-muted);
271 margin-top: 6px;
272 }
273
274 /* ─── Banners ─── */
275 .fork-banner {
276 position: relative;
277 padding: 12px 16px 12px 40px;
278 margin-bottom: var(--space-4);
279 border-radius: 12px;
280 border: 1px solid var(--border);
281 background: var(--bg-elevated);
282 font-size: 14px;
283 line-height: 1.5;
284 }
285 .fork-banner::before {
286 content: '';
287 position: absolute;
288 left: 14px; top: 16px;
289 width: 12px; height: 12px;
290 border-radius: 50%;
291 }
292 .fork-banner-warn {
293 border-color: rgba(251, 191, 36, 0.32);
294 background: linear-gradient(180deg, rgba(251,191,36,0.06) 0%, var(--bg-elevated) 100%);
295 }
296 .fork-banner-warn::before {
297 background: radial-gradient(circle, #fbbf24 30%, transparent 70%);
298 box-shadow: 0 0 10px rgba(251,191,36,0.5);
299 }
300 .fork-banner-error {
301 border-color: rgba(248, 81, 73, 0.32);
302 background: linear-gradient(180deg, rgba(248,81,73,0.06) 0%, var(--bg-elevated) 100%);
303 }
304 .fork-banner-error::before {
305 background: radial-gradient(circle, #f85149 30%, transparent 70%);
306 box-shadow: 0 0 10px rgba(248,81,73,0.5);
307 }
308 .fork-banner a { color: var(--accent); text-decoration: none; }
309 .fork-banner a:hover { text-decoration: underline; }
310
311 /* ─── Actions ─── */
312 .fork-actions {
313 display: flex;
314 align-items: center;
315 gap: var(--space-2);
316 margin-top: var(--space-4);
317 flex-wrap: wrap;
318 }
319 .fork-submit {
320 appearance: none;
321 border: 1px solid rgba(140,109,255,0.45);
322 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
323 color: #fff;
324 padding: 12px 22px;
325 border-radius: 10px;
326 font-family: var(--font-display);
327 font-weight: 700;
328 font-size: 14px;
329 cursor: pointer;
330 box-shadow: 0 10px 24px -10px rgba(140,109,255,0.55);
331 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
332 }
333 .fork-submit:hover {
334 transform: translateY(-1px);
335 box-shadow: 0 14px 28px -10px rgba(140,109,255,0.7);
336 filter: brightness(1.06);
337 }
338 .fork-submit:focus-visible {
339 outline: 3px solid rgba(140,109,255,0.45);
340 outline-offset: 2px;
341 }
342 .fork-submit:disabled {
343 cursor: not-allowed;
344 filter: grayscale(0.6) brightness(0.8);
345 box-shadow: none;
346 }
347 .fork-cancel {
348 font-size: 13.5px;
349 color: var(--text-muted);
350 text-decoration: none;
351 }
352 .fork-cancel:hover { color: var(--text-strong); }
353
354 /* ─── Empty state (zero state when invalid source) ─── */
355 .fork-empty {
356 position: relative;
357 padding: 48px 24px;
358 text-align: center;
359 background: var(--bg-elevated);
360 border: 1px solid var(--border);
361 border-radius: 16px;
362 overflow: hidden;
363 }
364 .fork-empty-orb {
365 position: absolute;
366 inset: -40% 25% auto 25%;
367 width: 50%; height: 240px;
368 background: radial-gradient(circle, rgba(140,109,255,0.18), transparent 65%);
369 filter: blur(60px);
370 opacity: 0.7;
371 pointer-events: none;
372 z-index: 0;
373 }
374 .fork-empty-inner { position: relative; z-index: 1; }
375 .fork-empty-title {
376 font-family: var(--font-display);
377 font-size: 18px;
378 font-weight: 700;
379 letter-spacing: -0.014em;
380 color: var(--text-strong);
381 margin: 0 0 8px;
382 }
383 .fork-empty-desc {
384 font-size: 14px;
385 color: var(--text-muted);
386 line-height: 1.55;
387 margin: 0 0 var(--space-3);
388 }
389`;
390
391// ─── GET — confirmation form ───────────────────────────────────────────────
392// Preserves the existing POST contract; this just gives users a polished
393// confirmation surface before firing. The existing inline button on the
394// repo header is untouched and still posts straight through.
395fork.get("/:owner/:repo/fork", requireAuth, async (c) => {
396 const { owner: ownerName, repo: repoName } = c.req.param();
397 const user = c.get("user")!;
398
399 // Resolve source repo for the card
400 const [sourceOwner] = await db
401 .select()
402 .from(users)
403 .where(eq(users.username, ownerName))
404 .limit(1);
405
406 const sourceRepo = sourceOwner
407 ? (
408 await db
409 .select()
410 .from(repositories)
411 .where(
412 and(
413 eq(repositories.ownerId, sourceOwner.id),
414 eq(repositories.name, repoName)
415 )
416 )
417 .limit(1)
418 )[0]
419 : null;
420
421 // Zero state — invalid source
422 if (!sourceOwner || !sourceRepo || !(await repoExists(ownerName, repoName))) {
423 return c.html(
424 <Layout title="Fork repository" user={user}>
425 <style dangerouslySetInnerHTML={{ __html: forkStyles }} />
426 <div class="fork-wrap">
427 <div class="fork-empty">
428 <div class="fork-empty-orb" aria-hidden="true" />
429 <div class="fork-empty-inner">
430 <h1 class="fork-empty-title">
431 {ownerName}/{repoName} can't be forked
432 </h1>
433 <p class="fork-empty-desc">
434 Either the repository doesn't exist or it's no longer
435 available. Try browsing repositories you can fork.
436 </p>
437 <a href="/explore" class="fork-submit">Explore repositories</a>
438 </div>
439 </div>
440 </div>
441 </Layout>,
442 404
443 );
444 }
445
446 // Owner forking own repo
447 const isSelf = ownerName === user.username;
448 // Already forked
449 const alreadyForked = !isSelf && (await repoExists(user.username, repoName));
450
451 const avatarLetter = (user.username || "?").charAt(0).toUpperCase();
452
453 return c.html(
454 <Layout
455 title={`Fork ${ownerName}/${repoName}`}
456 user={user}
457 >
458 <style dangerouslySetInnerHTML={{ __html: forkStyles }} />
459 <div class="fork-wrap">
460 {/* ─── Hero ─── */}
461 <div class="fork-hero">
462 <div class="fork-hero-orb" aria-hidden="true" />
463 <div class="fork-hero-inner">
464 <div class="fork-eyebrow">
465 <span class="fork-eyebrow-dot" aria-hidden="true" />
466 <strong>Fork</strong> · copy to your account
467 </div>
468 <h1 class="fork-title">
469 Fork{" "}
470 <span class="fork-title-grad">{ownerName}/{repoName}</span>.
471 </h1>
472 <p class="fork-sub">
473 We'll make a private copy under <strong>{user.username}</strong>.
474 All branches and history carry over; gates, AI review, and
475 auto-merge are wired up the moment the fork is ready.
476 </p>
477 </div>
478 </div>
479
480 {/* ─── Source repo card ─── */}
481 <div class="fork-source-card">
482 <div class="fork-source-icon" aria-hidden="true">
483 {(sourceRepo.name || "?").charAt(0).toUpperCase()}
484 </div>
485 <div class="fork-source-body">
486 <div class="fork-source-eyebrow">Source</div>
487 <a
488 class="fork-source-name"
489 href={`/${ownerName}/${repoName}`}
490 >
491 {ownerName}/{repoName}
492 </a>
493 {sourceRepo.description && (
494 <p class="fork-source-desc" title={sourceRepo.description}>
495 {sourceRepo.description}
496 </p>
497 )}
498 </div>
499 </div>
500
501 {/* ─── Conflict banners ─── */}
502 {isSelf && (
503 <div class="fork-banner fork-banner-warn" role="alert">
504 You can't fork your own repository. Browse{" "}
505 <a href="/explore">other repositories</a> instead.
506 </div>
507 )}
508 {alreadyForked && (
509 <div class="fork-banner fork-banner-warn" role="alert">
510 You already have a fork at{" "}
511 <a href={`/${user.username}/${repoName}`}>
512 {user.username}/{repoName}
513 </a>
514 .
515 </div>
516 )}
517
518 {/* ─── Destination + submit ─── */}
519 <div class="fork-form-card">
520 <div class="fork-form-head">
521 <span class="fork-form-badge" aria-hidden="true">1</span>
522 <h2 class="fork-form-title">Destination</h2>
523 </div>
524 <p class="fork-form-sub">
525 v1 forks always land under your personal account. Org
526 destinations coming soon.
527 </p>
528 <div class="fork-field">
529 <span class="fork-field-label">Owner</span>
530 <div class="fork-dest-row">
531 <span class="fork-dest-avatar" aria-hidden="true">
532 {avatarLetter}
533 </span>
534 <span class="fork-dest-name">{user.username}</span>
535 <span class="fork-dest-slash" aria-hidden="true">/</span>
536 <span class="fork-dest-repo">{repoName}</span>
537 </div>
538 <p class="fork-dest-hint">
539 The fork will live at{" "}
540 <strong>{user.username}/{repoName}</strong>.
541 </p>
542 </div>
543
544 <form
545 method="post"
546 action={`/${ownerName}/${repoName}/fork`}
547 >
548 <div class="fork-actions">
549 <button
550 type="submit"
551 class="fork-submit"
552 disabled={isSelf || alreadyForked}
553 >
554 {alreadyForked ? "Already forked" : "Create my fork"}
555 </button>
556 <a href={`/${ownerName}/${repoName}`} class="fork-cancel">
557 Cancel
558 </a>
559 </div>
560 </form>
561 </div>
562 </div>
563 </Layout>
564 );
565});
566
567// Fork a repository
568fork.post("/:owner/:repo/fork", requireAuth, async (c) => {
569 const { owner: ownerName, repo: repoName } = c.req.param();
570 const user = c.get("user")!;
571
572 // Can't fork your own repo
573 if (ownerName === user.username) {
574 return c.redirect(`/${ownerName}/${repoName}`);
575 }
576
577 // Check source exists
578 if (!(await repoExists(ownerName, repoName))) {
579 return c.redirect(`/${ownerName}/${repoName}`);
580 }
581
582 // Check if already forked
583 if (await repoExists(user.username, repoName)) {
584 return c.redirect(`/${user.username}/${repoName}`);
585 }
586
587 // Get source repo from DB
588 const [sourceOwner] = await db
589 .select()
590 .from(users)
591 .where(eq(users.username, ownerName))
592 .limit(1);
593 if (!sourceOwner) return c.redirect(`/${ownerName}/${repoName}`);
594
595 const [sourceRepo] = await db
596 .select()
597 .from(repositories)
598 .where(
599 and(
600 eq(repositories.ownerId, sourceOwner.id),
601 eq(repositories.name, repoName)
602 )
603 )
604 .limit(1);
605 if (!sourceRepo) return c.redirect(`/${ownerName}/${repoName}`);
606
607 // Clone the bare repo
608 const sourcePath = getRepoPath(ownerName, repoName);
609 const destPath = join(config.gitReposPath, user.username, `${repoName}.git`);
610
611 const proc = Bun.spawn(["git", "clone", "--bare", sourcePath, destPath], {
612 stdout: "pipe",
613 stderr: "pipe",
614 });
615 await proc.exited;
616
617 // Insert into DB
618 const [newRepo] = await db
619 .insert(repositories)
620 .values({
621 name: repoName,
622 ownerId: user.id,
623 description: sourceRepo.description
624 ? `Fork of ${ownerName}/${repoName} — ${sourceRepo.description}`
625 : `Fork of ${ownerName}/${repoName}`,
626 isPrivate: false,
627 defaultBranch: sourceRepo.defaultBranch,
628 diskPath: destPath,
629 forkedFromId: sourceRepo.id,
630 })
631 .returning();
632
633 // Bootstrap the fork with green-by-default settings, protection, labels
634 if (newRepo) {
635 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
636 await bootstrapRepository({
637 repositoryId: newRepo.id,
638 ownerUserId: user.id,
639 defaultBranch: sourceRepo.defaultBranch,
640 skipWelcomeIssue: true, // forks don't need a welcome issue
641 });
642 }
643
644 // Update fork count
645 await db
646 .update(repositories)
647 .set({ forkCount: sourceRepo.forkCount + 1 })
648 .where(eq(repositories.id, sourceRepo.id));
649
650 // Log activity
651 try {
652 await db.insert(activityFeed).values({
653 repositoryId: sourceRepo.id,
654 userId: user.id,
655 action: "fork",
656 metadata: JSON.stringify({ forkOwner: user.username }),
657 });
658 } catch {
659 // best effort
660 }
661
662 return c.redirect(`/${user.username}/${repoName}`);
663});
664
665export default fork;
Modifiedsrc/routes/playground.tsx+526−177View fileUnifiedSplit
@@ -10,6 +10,11 @@
1010 * POST /play is rate-limited at 3/min/IP via the shared rate-limit
1111 * middleware so a bot can't hammer the endpoint and mint accounts +
1212 * repos by the thousand.
13 *
14 * 2026 polish: gradient-hairline hero + orb, eyebrow + display headline,
15 * three explanation cards (what / try / examples) before the start
16 * button, polished claim form card with focus rings + gradient submit.
17 * All CSS scoped under `.play-*`.
1318 */
1419
1520import { Hono } from "hono";
@@ -19,10 +24,6 @@ import { softAuth, requireAuth } from "../middleware/auth";
1924import type { AuthEnv } from "../middleware/auth";
2025import {
2126 Form,
22 FormGroup,
23 Input,
24 Button,
25 Text,
2627} from "../views/ui";
2728import { rateLimit } from "../middleware/rate-limit";
2829import {
@@ -39,6 +40,366 @@ const playgroundRoutes = new Hono<AuthEnv>();
3940// but enforces in prod / dev.
4041const playgroundCreateRateLimit = rateLimit(3, 60_000, "playground-create");
4142
43// ─── Scoped CSS — all classes prefixed `.play-*` ───────────────────────────
44const playStyles = `
45 .play-wrap { max-width: 980px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
46
47 /* ─── Hero ─── */
48 .play-hero {
49 position: relative;
50 margin-bottom: var(--space-5);
51 padding: clamp(28px, 4vw, 48px) clamp(24px, 4vw, 48px);
52 background: var(--bg-elevated);
53 border: 1px solid var(--border);
54 border-radius: 18px;
55 overflow: hidden;
56 }
57 .play-hero::before {
58 content: '';
59 position: absolute;
60 top: 0; left: 0; right: 0;
61 height: 2px;
62 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
63 opacity: 0.75;
64 pointer-events: none;
65 }
66 .play-hero-orb {
67 position: absolute;
68 inset: -30% -10% auto auto;
69 width: 460px; height: 460px;
70 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
71 filter: blur(80px);
72 opacity: 0.7;
73 pointer-events: none;
74 animation: playHeroOrb 14s ease-in-out infinite;
75 z-index: 0;
76 }
77 @keyframes playHeroOrb {
78 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
79 50% { transform: scale(1.08) translate(-12px, 8px); opacity: 0.85; }
80 }
81 @media (prefers-reduced-motion: reduce) {
82 .play-hero-orb { animation: none; }
83 }
84 .play-hero-inner { position: relative; z-index: 1; max-width: 720px; }
85 .play-eyebrow {
86 display: inline-flex;
87 align-items: center;
88 gap: 8px;
89 font-family: var(--font-mono);
90 font-size: 11px;
91 letter-spacing: 0.18em;
92 text-transform: uppercase;
93 color: var(--text-muted);
94 font-weight: 600;
95 margin-bottom: 16px;
96 }
97 .play-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 .play-eyebrow strong { color: var(--accent); font-weight: 600; letter-spacing: 0.04em; }
104 .play-title {
105 font-family: var(--font-display);
106 font-size: clamp(32px, 5vw, 48px);
107 font-weight: 800;
108 letter-spacing: -0.030em;
109 line-height: 1.05;
110 margin: 0 0 var(--space-3);
111 color: var(--text-strong);
112 }
113 .play-title-grad {
114 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
115 -webkit-background-clip: text;
116 background-clip: text;
117 -webkit-text-fill-color: transparent;
118 color: transparent;
119 }
120 .play-sub {
121 font-size: 16px;
122 color: var(--text-muted);
123 margin: 0;
124 line-height: 1.55;
125 max-width: 600px;
126 }
127
128 /* ─── Error banner ─── */
129 .play-error {
130 position: relative;
131 padding: 14px 16px 14px 44px;
132 margin-bottom: var(--space-5);
133 border-radius: 12px;
134 border: 1px solid rgba(248, 81, 73, 0.32);
135 background: linear-gradient(180deg, rgba(248,81,73,0.06) 0%, var(--bg-elevated) 100%);
136 color: var(--text);
137 font-size: 14px;
138 }
139 .play-error::before {
140 content: '';
141 position: absolute;
142 left: 14px; top: 18px;
143 width: 14px; height: 14px;
144 border-radius: 50%;
145 background: radial-gradient(circle, #f85149 30%, transparent 70%);
146 box-shadow: 0 0 10px rgba(248,81,73,0.5);
147 }
148
149 /* ─── Explanation cards (what / try / examples) ─── */
150 .play-cards {
151 display: grid;
152 grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
153 gap: var(--space-3);
154 margin-bottom: var(--space-5);
155 }
156 .play-card {
157 position: relative;
158 padding: var(--space-4);
159 background: var(--bg-elevated);
160 border: 1px solid var(--border);
161 border-radius: 14px;
162 display: flex;
163 flex-direction: column;
164 gap: 8px;
165 transition: border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
166 }
167 .play-card:hover {
168 border-color: rgba(140,109,255,0.45);
169 transform: translateY(-2px);
170 box-shadow: 0 10px 28px -10px rgba(140,109,255,0.30);
171 }
172 .play-card-badge {
173 display: inline-flex;
174 align-items: center;
175 justify-content: center;
176 width: 28px; height: 28px;
177 border-radius: 8px;
178 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
179 color: #c5b3ff;
180 border: 1px solid rgba(140,109,255,0.40);
181 font-family: var(--font-display);
182 font-weight: 700;
183 font-size: 13px;
184 }
185 .play-card-title {
186 font-family: var(--font-display);
187 font-size: 16px;
188 font-weight: 700;
189 letter-spacing: -0.014em;
190 color: var(--text-strong);
191 margin: 0;
192 }
193 .play-card-body {
194 font-size: 13.5px;
195 color: var(--text-muted);
196 line-height: 1.55;
197 margin: 0;
198 }
199 .play-card-body code {
200 font-family: var(--font-mono);
201 font-size: 12px;
202 background: rgba(140,109,255,0.10);
203 color: #c8b6ff;
204 padding: 1px 6px;
205 border-radius: 5px;
206 }
207
208 /* ─── Start panel (big CTA + bullets) ─── */
209 .play-start {
210 position: relative;
211 padding: clamp(24px, 3.5vw, 36px);
212 background: var(--bg-elevated);
213 border: 1px solid var(--border);
214 border-radius: 16px;
215 overflow: hidden;
216 margin-bottom: var(--space-5);
217 }
218 .play-start::before {
219 content: '';
220 position: absolute;
221 top: 0; left: 0; right: 0;
222 height: 2px;
223 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 50%, #8c6dff 100%);
224 opacity: 0.65;
225 pointer-events: none;
226 }
227 .play-start-inner {
228 display: flex;
229 gap: var(--space-5);
230 align-items: center;
231 flex-wrap: wrap;
232 }
233 .play-start-text { flex: 1; min-width: 260px; }
234 .play-start-eyebrow {
235 font-size: 11px;
236 font-family: var(--font-mono);
237 text-transform: uppercase;
238 letter-spacing: 0.18em;
239 color: var(--accent);
240 font-weight: 700;
241 margin-bottom: 6px;
242 }
243 .play-start-headline {
244 font-family: var(--font-display);
245 font-size: clamp(20px, 2.4vw, 26px);
246 font-weight: 700;
247 letter-spacing: -0.018em;
248 line-height: 1.18;
249 color: var(--text-strong);
250 margin: 0 0 8px;
251 }
252 .play-start-desc {
253 font-size: 14px;
254 color: var(--text-muted);
255 line-height: 1.55;
256 margin: 0;
257 }
258 .play-start-cta { flex-shrink: 0; }
259 .play-submit {
260 appearance: none;
261 border: 1px solid rgba(140,109,255,0.45);
262 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
263 color: #fff;
264 padding: 14px 24px;
265 border-radius: 12px;
266 font-family: var(--font-display);
267 font-weight: 700;
268 font-size: 15px;
269 letter-spacing: -0.005em;
270 cursor: pointer;
271 text-decoration: none;
272 display: inline-flex; align-items: center; gap: 8px;
273 box-shadow: 0 10px 24px -10px rgba(140,109,255,0.55);
274 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
275 }
276 .play-submit:hover {
277 transform: translateY(-1px);
278 box-shadow: 0 14px 28px -10px rgba(140,109,255,0.7);
279 filter: brightness(1.06);
280 }
281 .play-submit:focus-visible {
282 outline: 3px solid rgba(140,109,255,0.45);
283 outline-offset: 2px;
284 }
285 .play-bullets {
286 margin: var(--space-4) 0 0;
287 padding-left: 20px;
288 font-size: 13.5px;
289 line-height: 1.7;
290 color: var(--text);
291 }
292 .play-bullets li { margin-bottom: 4px; }
293 .play-bullets li strong { color: var(--text-strong); }
294 .play-bullets code {
295 font-family: var(--font-mono);
296 font-size: 12px;
297 background: rgba(140,109,255,0.10);
298 color: #c8b6ff;
299 padding: 1px 6px;
300 border-radius: 5px;
301 }
302
303 /* ─── Footnote ─── */
304 .play-footnote {
305 text-align: center;
306 font-size: 13px;
307 color: var(--text-muted);
308 margin: var(--space-2) 0 0;
309 }
310 .play-footnote a { color: var(--accent); text-decoration: none; }
311 .play-footnote a:hover { text-decoration: underline; }
312
313 /* ─── Claim form card ─── */
314 .play-claim-wrap { max-width: 560px; margin: 0 auto; padding: var(--space-6, 32px) var(--space-4, 24px); }
315 .play-claim-card {
316 position: relative;
317 padding: clamp(24px, 3vw, 32px);
318 background: var(--bg-elevated);
319 border: 1px solid var(--border);
320 border-radius: 16px;
321 overflow: hidden;
322 }
323 .play-claim-card::before {
324 content: '';
325 position: absolute;
326 top: 0; left: 0; right: 0;
327 height: 2px;
328 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
329 opacity: 0.75;
330 pointer-events: none;
331 }
332 .play-claim-eyebrow {
333 font-size: 11px;
334 font-family: var(--font-mono);
335 text-transform: uppercase;
336 letter-spacing: 0.18em;
337 color: var(--accent);
338 font-weight: 700;
339 margin-bottom: 6px;
340 }
341 .play-claim-title {
342 font-family: var(--font-display);
343 font-size: clamp(22px, 3vw, 28px);
344 font-weight: 800;
345 letter-spacing: -0.020em;
346 line-height: 1.15;
347 color: var(--text-strong);
348 margin: 0 0 8px;
349 }
350 .play-claim-desc {
351 font-size: 14px;
352 color: var(--text-muted);
353 line-height: 1.55;
354 margin: 0 0 var(--space-4);
355 }
356 .play-claim-desc code {
357 font-family: var(--font-mono);
358 font-size: 12.5px;
359 background: var(--bg-secondary);
360 border: 1px solid var(--border);
361 border-radius: 5px;
362 padding: 1px 6px;
363 color: var(--text-strong);
364 }
365 .play-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-3); }
366 .play-field-label {
367 font-size: 13px;
368 color: var(--text-strong);
369 font-weight: 600;
370 }
371 .play-field-input {
372 appearance: none;
373 width: 100%;
374 background: var(--bg);
375 border: 1px solid var(--border);
376 color: var(--text-strong);
377 border-radius: 10px;
378 padding: 10px 12px;
379 font-size: 14px;
380 font-family: inherit;
381 transition: border-color 140ms ease, box-shadow 140ms ease;
382 }
383 .play-field-input:focus {
384 outline: none;
385 border-color: var(--accent);
386 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
387 }
388 .play-claim-actions {
389 display: flex;
390 gap: var(--space-2);
391 margin-top: var(--space-4);
392 align-items: center;
393 flex-wrap: wrap;
394 }
395 .play-claim-cancel {
396 font-size: 13px;
397 color: var(--text-muted);
398 text-decoration: none;
399 }
400 .play-claim-cancel:hover { color: var(--text-strong); }
401`;
402
42403// ---------------------------------------------------------------------------
43404// GET /play — landing page
44405// ---------------------------------------------------------------------------
@@ -56,36 +417,90 @@ playgroundRoutes.get("/play", softAuth, (c) => {
56417 ogTitle="Try Gluecron — no signup"
57418 ogDescription="A 24-hour public sandbox. Push, open issues, watch Claude work."
58419 >
59 <div class="play-landing">
60 <div class="play-card">
61 <div class="play-eyebrow">PLAYGROUND</div>
62 <h1 class="play-title">
63 Try Gluecron for {hours} hours.
64 <br />
65 <span class="play-title-accent">No signup.</span>
66 </h1>
67 <p class="play-sub">
68 One click and you're inside the product — a public sandbox
69 repo, real git, real issues, Claude already working on the
70 first one. Decide later whether to keep it.
71 </p>
72
73 {err && (
74 <div class="auth-error" role="alert">
75 {decodeURIComponent(err)}
420 <style dangerouslySetInnerHTML={{ __html: playStyles }} />
421 <div class="play-wrap">
422 {/* ─── Hero ─── */}
423 <div class="play-hero">
424 <div class="play-hero-orb" aria-hidden="true" />
425 <div class="play-hero-inner">
426 <div class="play-eyebrow">
427 <span class="play-eyebrow-dot" aria-hidden="true" />
428 <strong>Playground</strong> · no signup
76429 </div>
77 )}
430 <h1 class="play-title">
431 Try Gluecron for {hours} hours.{" "}
432 <span class="play-title-grad">No signup.</span>
433 </h1>
434 <p class="play-sub">
435 One click and you're inside the product — a public sandbox
436 repo, real git, real issues, Claude already working on the
437 first one. Decide later whether to keep it.
438 </p>
439 </div>
440 </div>
78441
79 <Form
80 method="post"
81 action="/play"
82 csrfToken={csrf}
83 class="play-form"
84 >
85 <Button type="submit" variant="primary">
86 Start playing →
87 </Button>
88 </Form>
442 {err && (
443 <div class="play-error" role="alert">
444 {decodeURIComponent(err)}
445 </div>
446 )}
447
448 {/* ─── Explanation cards ─── */}
449 <div class="play-cards">
450 <div class="play-card">
451 <span class="play-card-badge" aria-hidden="true">1</span>
452 <h3 class="play-card-title">What you get</h3>
453 <p class="play-card-body">
454 A fresh public sandbox repo under a temporary{" "}
455 <code>guest-*</code> account. Real git, real issues, real
456 push — gone after {hours} hours unless you claim it.
457 </p>
458 </div>
459 <div class="play-card">
460 <span class="play-card-badge" aria-hidden="true">2</span>
461 <h3 class="play-card-title">Try this first</h3>
462 <p class="play-card-body">
463 Open an issue, label it <code>ai:build</code>, watch the
464 autopilot pick it up and open a PR within minutes. Then
465 review it like you would any other.
466 </p>
467 </div>
468 <div class="play-card">
469 <span class="play-card-badge" aria-hidden="true">3</span>
470 <h3 class="play-card-title">Push from your laptop</h3>
471 <p class="play-card-body">
472 Clone via HTTPS, push commits, watch the gate pipeline run.
473 Branch protection, AI review, auto-merge — all live in your
474 sandbox.
475 </p>
476 </div>
477 </div>
478
479 {/* ─── Start CTA ─── */}
480 <div class="play-start">
481 <div class="play-start-inner">
482 <div class="play-start-text">
483 <div class="play-start-eyebrow">Start the clock</div>
484 <h2 class="play-start-headline">
485 One click and you're in. No email, no card.
486 </h2>
487 <p class="play-start-desc">
488 We mint a temporary account, spin up your sandbox repo,
489 and seed the first issue. Took us about 800ms last test.
490 </p>
491 </div>
492 <div class="play-start-cta">
493 <Form
494 method="post"
495 action="/play"
496 csrfToken={csrf}
497 >
498 <button type="submit" class="play-submit">
499 Start playing →
500 </button>
501 </Form>
502 </div>
503 </div>
89504
90505 <ul class="play-bullets" aria-label="What you get">
91506 <li>
@@ -106,91 +521,14 @@ playgroundRoutes.get("/play", softAuth, (c) => {
106521 the playground account into a real one. Otherwise: poof.
107522 </li>
108523 </ul>
109
110 <p class="play-footnote">
111 Already have an account?{" "}
112 <a href="/login">Sign in</a> or{" "}
113 <a href="/register">create one</a> the normal way.
114 </p>
115524 </div>
116 </div>
117525
118 <style
119 dangerouslySetInnerHTML={{
120 __html: /* css */ `
121 .play-landing {
122 max-width: 720px;
123 margin: 48px auto;
124 padding: 0 24px;
125 }
126 .play-card {
127 background: var(--panel, #161b22);
128 border: 1px solid var(--border, #30363d);
129 border-radius: 16px;
130 padding: 40px;
131 text-align: center;
132 }
133 .play-eyebrow {
134 font-family: var(--font-mono, ui-monospace, monospace);
135 font-size: 11px;
136 letter-spacing: 0.18em;
137 color: var(--yellow, #fbbf24);
138 margin-bottom: 12px;
139 }
140 .play-title {
141 margin: 0 0 16px;
142 font-size: 36px;
143 line-height: 1.15;
144 font-weight: 700;
145 color: var(--text-strong, #e6edf3);
146 }
147 .play-title-accent {
148 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
149 -webkit-background-clip: text;
150 background-clip: text;
151 color: transparent;
152 }
153 .play-sub {
154 margin: 0 auto 24px;
155 max-width: 480px;
156 font-size: 15px;
157 line-height: 1.55;
158 color: var(--text-muted, #8b949e);
159 }
160 .play-form {
161 margin: 24px 0;
162 display: inline-block;
163 }
164 .play-form button[type="submit"] {
165 font-size: 16px;
166 padding: 14px 28px;
167 }
168 .play-bullets {
169 margin: 24px auto 0;
170 max-width: 520px;
171 padding-left: 20px;
172 text-align: left;
173 font-size: 14px;
174 line-height: 1.7;
175 color: var(--text, #c9d1d9);
176 }
177 .play-bullets li { margin-bottom: 6px; }
178 .play-bullets code {
179 font-family: var(--font-mono, ui-monospace, monospace);
180 font-size: 12px;
181 padding: 1px 6px;
182 border-radius: 4px;
183 background: rgba(140,109,255,0.16);
184 color: #c8b6ff;
185 }
186 .play-footnote {
187 margin: 24px 0 0;
188 font-size: 13px;
189 color: var(--text-muted, #8b949e);
190 }
191 `,
192 }}
193 />
526 <p class="play-footnote">
527 Already have an account?{" "}
528 <a href="/login">Sign in</a> or{" "}
529 <a href="/register">create one</a> the normal way.
530 </p>
531 </div>
194532 </Layout>
195533 );
196534});
@@ -253,69 +591,80 @@ playgroundRoutes.get("/play/claim", requireAuth, (c) => {
253591
254592 return c.html(
255593 <Layout title="Save your playground" user={user}>
256 <div class="auth-container">
257 <h2>Save your work</h2>
258 <p class="auth-switch" style="margin-bottom: 16px; margin-top: 0">
259 Convert{" "}
260 <code class="mono">{user.username}</code>{" "}
261 into a permanent account. We'll send a verification link to your
262 email; nothing is changed about the repo you've been working in.
263 </p>
264 {err && (
265 <div class="auth-error" role="alert">
266 {decodeURIComponent(err)}
267 </div>
268 )}
269 <Form
270 method="post"
271 action="/play/claim"
272 csrfToken={csrf}
273 >
274 <FormGroup label="Email" htmlFor="email">
275 <Input
276 type="email"
277 name="email"
278 required
279 placeholder="you@example.com"
280 autocomplete="email"
281 aria-label="Email"
282 />
283 </FormGroup>
284 <FormGroup label="Password" htmlFor="password">
285 <Input
286 type="password"
287 name="password"
288 required
289 minLength={8}
290 placeholder="Min 8 characters"
291 autocomplete="new-password"
292 aria-label="Password"
293 />
294 </FormGroup>
295 <FormGroup
296 label="Pick a new username (optional)"
297 htmlFor="username"
594 <style dangerouslySetInnerHTML={{ __html: playStyles }} />
595 <div class="play-claim-wrap">
596 <div class="play-claim-card">
597 <div class="play-claim-eyebrow">Claim your sandbox</div>
598 <h1 class="play-claim-title">Save your work</h1>
599 <p class="play-claim-desc">
600 Convert{" "}
601 <code>{user.username}</code>{" "}
602 into a permanent account. We'll send a verification link to your
603 email; nothing changes about the repo you've been working in.
604 </p>
605 {err && (
606 <div class="play-error" role="alert">
607 {decodeURIComponent(err)}
608 </div>
609 )}
610 <Form
611 method="post"
612 action="/play/claim"
613 csrfToken={csrf}
298614 >
299 <Input
300 type="text"
301 name="username"
302 pattern="^[a-zA-Z0-9_-]+$"
303 minLength={2}
304 maxLength={39}
305 placeholder={user.username}
306 autocomplete="username"
307 />
308 </FormGroup>
309 <Button type="submit" variant="primary">
310 Save my account
311 </Button>
312 </Form>
313 <p class="auth-switch">
314 <Text>
315 Changed your mind?{" "}
316 <a href={`/${user.username}/sandbox`}>Back to the sandbox</a>
317 </Text>
318 </p>
615 <div class="play-field">
616 <label class="play-field-label" for="email">Email</label>
617 <input
618 type="email"
619 id="email"
620 name="email"
621 required
622 placeholder="you@example.com"
623 autocomplete="email"
624 aria-label="Email"
625 class="play-field-input"
626 />
627 </div>
628 <div class="play-field">
629 <label class="play-field-label" for="password">Password</label>
630 <input
631 type="password"
632 id="password"
633 name="password"
634 required
635 minLength={8}
636 placeholder="Min 8 characters"
637 autocomplete="new-password"
638 aria-label="Password"
639 class="play-field-input"
640 />
641 </div>
642 <div class="play-field">
643 <label class="play-field-label" for="username">
644 Pick a new username (optional)
645 </label>
646 <input
647 type="text"
648 id="username"
649 name="username"
650 pattern="^[a-zA-Z0-9_-]+$"
651 minLength={2}
652 maxLength={39}
653 placeholder={user.username}
654 autocomplete="username"
655 class="play-field-input"
656 />
657 </div>
658 <div class="play-claim-actions">
659 <button type="submit" class="play-submit">
660 Save my account
661 </button>
662 <a href={`/${user.username}/sandbox`} class="play-claim-cancel">
663 Back to the sandbox
664 </a>
665 </div>
666 </Form>
667 </div>
319668 </div>
320669 </Layout>
321670 );
Modifiedsrc/routes/team-collaborators.tsx+509−65View fileUnifiedSplit
@@ -10,6 +10,10 @@
1010 *
1111 * GET /:owner/:repo/settings/collaborators/teams — form + list
1212 * POST /:owner/:repo/settings/collaborators/teams/add — bulk insert
13 *
14 * 2026 polish: gradient-hairline hero + orb, scoped `.tc-*` classes,
15 * polished form card with focus rings + gradient submit, polished
16 * collaborator list cards, orbital empty state.
1317 */
1418
1519import { Hono } from "hono";
@@ -29,13 +33,6 @@ import { RepoHeader } from "../views/components";
2933import { softAuth, requireAuth } from "../middleware/auth";
3034import type { AuthEnv } from "../middleware/auth";
3135import {
32 Container,
33 Form,
34 FormGroup,
35 Input,
36 Select,
37 Button,
38 Alert,
3936 EmptyState,
4037} from "../views/ui";
4138
@@ -43,6 +40,391 @@ const teamCollaboratorRoutes = new Hono<AuthEnv>();
4340
4441teamCollaboratorRoutes.use("*", softAuth);
4542
43// ─── Scoped CSS — all classes prefixed `.tc-*` ─────────────────────────────
44const tcStyles = `
45 .tc-wrap { max-width: 880px; margin: 0 auto; padding: var(--space-5, 24px) var(--space-4, 24px); }
46
47 /* ─── Back link ─── */
48 .tc-back {
49 display: inline-flex;
50 align-items: center;
51 gap: 6px;
52 font-size: 13px;
53 color: var(--text-muted);
54 text-decoration: none;
55 margin-bottom: var(--space-3);
56 transition: color 140ms ease;
57 }
58 .tc-back:hover { color: var(--text-strong); }
59
60 /* ─── Hero ─── */
61 .tc-hero {
62 position: relative;
63 margin-bottom: var(--space-5);
64 padding: clamp(24px, 3.5vw, 36px) clamp(20px, 3vw, 32px);
65 background: var(--bg-elevated);
66 border: 1px solid var(--border);
67 border-radius: 16px;
68 overflow: hidden;
69 }
70 .tc-hero::before {
71 content: '';
72 position: absolute;
73 top: 0; left: 0; right: 0;
74 height: 2px;
75 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
76 opacity: 0.75;
77 pointer-events: none;
78 }
79 .tc-hero-orb {
80 position: absolute;
81 inset: -25% -10% auto auto;
82 width: 360px; height: 360px;
83 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
84 filter: blur(80px);
85 opacity: 0.7;
86 pointer-events: none;
87 animation: tcHeroOrb 14s ease-in-out infinite;
88 z-index: 0;
89 }
90 @keyframes tcHeroOrb {
91 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
92 50% { transform: scale(1.08) translate(-10px, 8px); opacity: 0.8; }
93 }
94 @media (prefers-reduced-motion: reduce) {
95 .tc-hero-orb { animation: none; }
96 }
97 .tc-hero-inner { position: relative; z-index: 1; max-width: 640px; }
98 .tc-eyebrow {
99 font-family: var(--font-mono);
100 font-size: 11px;
101 letter-spacing: 0.16em;
102 text-transform: uppercase;
103 color: var(--text-muted);
104 font-weight: 600;
105 margin-bottom: 10px;
106 }
107 .tc-eyebrow strong { color: var(--accent); font-weight: 700; }
108 .tc-title {
109 font-family: var(--font-display);
110 font-size: clamp(26px, 3.6vw, 36px);
111 font-weight: 800;
112 letter-spacing: -0.026em;
113 line-height: 1.08;
114 margin: 0 0 var(--space-2);
115 color: var(--text-strong);
116 }
117 .tc-title-grad {
118 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
119 -webkit-background-clip: text;
120 background-clip: text;
121 -webkit-text-fill-color: transparent;
122 color: transparent;
123 }
124 .tc-sub {
125 font-size: 14.5px;
126 color: var(--text-muted);
127 line-height: 1.55;
128 margin: 0;
129 max-width: 560px;
130 }
131
132 /* ─── Banners ─── */
133 .tc-banner {
134 position: relative;
135 padding: 12px 16px 12px 40px;
136 margin-bottom: var(--space-4);
137 border-radius: 12px;
138 border: 1px solid var(--border);
139 background: var(--bg-elevated);
140 font-size: 14px;
141 line-height: 1.5;
142 }
143 .tc-banner::before {
144 content: '';
145 position: absolute;
146 left: 14px; top: 16px;
147 width: 12px; height: 12px;
148 border-radius: 50%;
149 }
150 .tc-banner-success {
151 border-color: rgba(63, 185, 80, 0.32);
152 background: linear-gradient(180deg, rgba(63,185,80,0.06) 0%, var(--bg-elevated) 100%);
153 }
154 .tc-banner-success::before {
155 background: radial-gradient(circle, #3fb950 30%, transparent 70%);
156 box-shadow: 0 0 10px rgba(63,185,80,0.5);
157 }
158 .tc-banner-error {
159 border-color: rgba(248, 81, 73, 0.32);
160 background: linear-gradient(180deg, rgba(248,81,73,0.06) 0%, var(--bg-elevated) 100%);
161 }
162 .tc-banner-error::before {
163 background: radial-gradient(circle, #f85149 30%, transparent 70%);
164 box-shadow: 0 0 10px rgba(248,81,73,0.5);
165 }
166
167 /* ─── Form card ─── */
168 .tc-card {
169 position: relative;
170 margin-bottom: var(--space-5);
171 padding: clamp(20px, 3vw, 28px);
172 background: var(--bg-elevated);
173 border: 1px solid var(--border);
174 border-radius: 14px;
175 overflow: hidden;
176 }
177 .tc-card-head {
178 display: flex;
179 align-items: center;
180 gap: 10px;
181 margin-bottom: 6px;
182 }
183 .tc-card-badge {
184 display: inline-flex;
185 align-items: center;
186 justify-content: center;
187 width: 26px; height: 26px;
188 border-radius: 50%;
189 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
190 color: #c5b3ff;
191 border: 1px solid rgba(140,109,255,0.40);
192 font-family: var(--font-display);
193 font-weight: 700;
194 font-size: 12.5px;
195 }
196 .tc-card-title {
197 font-family: var(--font-display);
198 font-size: 16px;
199 font-weight: 700;
200 letter-spacing: -0.012em;
201 color: var(--text-strong);
202 margin: 0;
203 }
204 .tc-card-sub {
205 font-size: 13px;
206 color: var(--text-muted);
207 margin: 0 0 var(--space-3);
208 line-height: 1.5;
209 }
210 .tc-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-3); }
211 .tc-field-label {
212 font-size: 13px;
213 color: var(--text-strong);
214 font-weight: 600;
215 }
216 .tc-field-input,
217 .tc-field-select {
218 appearance: none;
219 width: 100%;
220 background: var(--bg);
221 border: 1px solid var(--border);
222 color: var(--text-strong);
223 border-radius: 10px;
224 padding: 10px 12px;
225 font-size: 14px;
226 font-family: inherit;
227 transition: border-color 140ms ease, box-shadow 140ms ease;
228 }
229 .tc-field-select {
230 background-image: linear-gradient(45deg, transparent 50%, var(--text-muted) 50%),
231 linear-gradient(135deg, var(--text-muted) 50%, transparent 50%);
232 background-position: calc(100% - 18px) 50%, calc(100% - 13px) 50%;
233 background-size: 5px 5px, 5px 5px;
234 background-repeat: no-repeat;
235 padding-right: 32px;
236 }
237 .tc-field-input:focus,
238 .tc-field-select:focus {
239 outline: none;
240 border-color: var(--accent);
241 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
242 }
243 .tc-submit {
244 appearance: none;
245 border: 1px solid rgba(140,109,255,0.45);
246 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
247 color: #fff;
248 padding: 11px 22px;
249 border-radius: 10px;
250 font-family: var(--font-display);
251 font-weight: 700;
252 font-size: 14px;
253 cursor: pointer;
254 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.55);
255 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
256 }
257 .tc-submit:hover {
258 transform: translateY(-1px);
259 box-shadow: 0 12px 24px -8px rgba(140,109,255,0.7);
260 filter: brightness(1.06);
261 }
262 .tc-submit:focus-visible {
263 outline: 3px solid rgba(140,109,255,0.45);
264 outline-offset: 2px;
265 }
266
267 /* ─── Empty card (no orgs) ─── */
268 .tc-empty-orgs {
269 position: relative;
270 padding: var(--space-4);
271 border: 1px dashed var(--border);
272 border-radius: 12px;
273 background: var(--bg-secondary);
274 text-align: center;
275 font-size: 13.5px;
276 color: var(--text-muted);
277 line-height: 1.55;
278 }
279 .tc-empty-orgs a { color: var(--accent); text-decoration: none; }
280 .tc-empty-orgs a:hover { text-decoration: underline; }
281
282 /* ─── Collaborator list ─── */
283 .tc-list-head {
284 display: flex;
285 align-items: baseline;
286 justify-content: space-between;
287 margin: var(--space-5) 0 var(--space-3);
288 }
289 .tc-list-title {
290 font-family: var(--font-display);
291 font-size: 18px;
292 font-weight: 700;
293 letter-spacing: -0.014em;
294 color: var(--text-strong);
295 margin: 0;
296 }
297 .tc-list-count {
298 font-family: var(--font-mono);
299 font-size: 12px;
300 color: var(--text-muted);
301 }
302 .tc-list {
303 display: flex;
304 flex-direction: column;
305 gap: 8px;
306 }
307 .tc-row {
308 display: flex;
309 align-items: center;
310 gap: 12px;
311 padding: 12px 16px;
312 background: var(--bg-elevated);
313 border: 1px solid var(--border);
314 border-radius: 12px;
315 transition: border-color 140ms ease, transform 140ms ease;
316 }
317 .tc-row:hover {
318 border-color: rgba(140,109,255,0.35);
319 transform: translateY(-1px);
320 }
321 .tc-avatar {
322 width: 32px; height: 32px;
323 border-radius: 50%;
324 object-fit: cover;
325 background: var(--bg-secondary);
326 flex-shrink: 0;
327 }
328 .tc-avatar-fallback {
329 width: 32px; height: 32px;
330 border-radius: 50%;
331 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
332 color: #fff;
333 display: inline-flex;
334 align-items: center;
335 justify-content: center;
336 font-family: var(--font-display);
337 font-weight: 700;
338 font-size: 13px;
339 flex-shrink: 0;
340 }
341 .tc-row-body { flex: 1; min-width: 0; }
342 .tc-row-name {
343 font-size: 14px;
344 color: var(--text-strong);
345 font-weight: 600;
346 text-decoration: none;
347 }
348 .tc-row-name:hover { color: var(--accent); }
349 .tc-row-meta {
350 font-size: 12px;
351 color: var(--text-muted);
352 margin-top: 2px;
353 display: flex;
354 gap: 10px;
355 flex-wrap: wrap;
356 }
357 .tc-row-role {
358 font-family: var(--font-mono);
359 font-size: 11px;
360 padding: 2px 8px;
361 border-radius: 999px;
362 background: rgba(140,109,255,0.10);
363 border: 1px solid rgba(140,109,255,0.30);
364 color: #c5b3ff;
365 text-transform: uppercase;
366 letter-spacing: 0.04em;
367 }
368 .tc-row-pill-ok {
369 font-family: var(--font-mono);
370 font-size: 11px;
371 padding: 2px 8px;
372 border-radius: 999px;
373 background: rgba(63,185,80,0.10);
374 border: 1px solid rgba(63,185,80,0.35);
375 color: #4ec55d;
376 text-transform: uppercase;
377 letter-spacing: 0.04em;
378 }
379 .tc-row-pill-warn {
380 font-family: var(--font-mono);
381 font-size: 11px;
382 padding: 2px 8px;
383 border-radius: 999px;
384 background: rgba(251,191,36,0.10);
385 border: 1px solid rgba(251,191,36,0.35);
386 color: #fbbf24;
387 text-transform: uppercase;
388 letter-spacing: 0.04em;
389 }
390
391 /* ─── Empty state for no collaborators ─── */
392 .tc-empty {
393 position: relative;
394 padding: 36px 24px;
395 text-align: center;
396 background: var(--bg-elevated);
397 border: 1px solid var(--border);
398 border-radius: 14px;
399 overflow: hidden;
400 }
401 .tc-empty-orb {
402 position: absolute;
403 inset: -50% 25% auto 25%;
404 width: 50%; height: 200px;
405 background: radial-gradient(circle, rgba(140,109,255,0.16), transparent 65%);
406 filter: blur(50px);
407 opacity: 0.7;
408 pointer-events: none;
409 z-index: 0;
410 }
411 .tc-empty-inner { position: relative; z-index: 1; }
412 .tc-empty-title {
413 font-family: var(--font-display);
414 font-size: 16px;
415 font-weight: 700;
416 letter-spacing: -0.012em;
417 color: var(--text-strong);
418 margin: 0 0 6px;
419 }
420 .tc-empty-desc {
421 font-size: 13.5px;
422 color: var(--text-muted);
423 line-height: 1.55;
424 margin: 0;
425 }
426`;
427
46428/**
47429 * Resolve (owner user, repo) from URL params and enforce owner-only access.
48430 * Mirrors the helper in `src/routes/collaborators.tsx` for consistency.
@@ -129,101 +511,163 @@ teamCollaboratorRoutes.get(
129511 user={user}
130512 >
131513 <RepoHeader owner={ownerName} repo={repoName} />
132 <Container maxWidth={700}>
133 <h2 style="margin-bottom: 16px">Invite a team</h2>
134 <p style="font-size:14px;color:var(--text-muted);margin-bottom:16px">
135 <a href={`/${ownerName}/${repoName}/settings/collaborators`}>
136 ← Back to collaborators
137 </a>
138 </p>
514 <style dangerouslySetInnerHTML={{ __html: tcStyles }} />
515 <div class="tc-wrap">
516 <a
517 href={`/${ownerName}/${repoName}/settings/collaborators`}
518 class="tc-back"
519 >
520 ← Back to collaborators
521 </a>
522
523 {/* ─── Hero ─── */}
524 <div class="tc-hero">
525 <div class="tc-hero-orb" aria-hidden="true" />
526 <div class="tc-hero-inner">
527 <div class="tc-eyebrow">
528 <strong>Teams</strong> · bulk invite
529 </div>
530 <h1 class="tc-title">
531 Invite a whole{" "}
532 <span class="tc-title-grad">team</span> at once.
533 </h1>
534 <p class="tc-sub">
535 Pick an org and a team, choose a role, and every member of
536 that team is added to{" "}
537 <strong>{ownerName}/{repoName}</strong> in one shot. No
538 individual invite emails to chase.
539 </p>
540 </div>
541 </div>
542
139543 {success && (
140 <Alert variant="success">{decodeURIComponent(success)}</Alert>
544 <div class="tc-banner tc-banner-success" role="status">
545 {decodeURIComponent(success)}
546 </div>
547 )}
548 {error && (
549 <div class="tc-banner tc-banner-error" role="alert">
550 {decodeURIComponent(error)}
551 </div>
141552 )}
142 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
143553
144 <div
145 style="margin-bottom: 24px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
146 >
147 <h3 style="margin-bottom: 12px">Invite every member of a team</h3>
554 {/* ─── Form card ─── */}
555 <div class="tc-card">
556 <div class="tc-card-head">
557 <span class="tc-card-badge" aria-hidden="true">1</span>
558 <h2 class="tc-card-title">Invite every member of a team</h2>
559 </div>
560 <p class="tc-card-sub">
561 Each member is added with the role you pick. Existing
562 collaborators are updated; the repo owner is always skipped.
563 </p>
148564 {userOrgs.length === 0 ? (
149 <p style="font-size:14px;color:var(--text-muted)">
150 You don't belong to any organizations yet.
151 </p>
565 <div class="tc-empty-orgs">
566 You don't belong to any organizations yet.{" "}
567 <a href="/orgs/new">Create one</a> to start inviting teams.
568 </div>
152569 ) : (
153 <Form
570 <form
154571 method="post"
155572 action={`/${ownerName}/${repoName}/settings/collaborators/teams/add`}
156573 >
157 <FormGroup label="Organization" htmlFor="orgSlug">
158 <Select name="orgSlug" id="orgSlug">
574 <div class="tc-field">
575 <label class="tc-field-label" for="orgSlug">Organization</label>
576 <select
577 name="orgSlug"
578 id="orgSlug"
579 class="tc-field-select"
580 >
159581 {userOrgs.map((o) => (
160582 <option value={o.slug}>
161583 {o.name} ({o.slug})
162584 </option>
163585 ))}
164 </Select>
165 </FormGroup>
166 <FormGroup label="Team slug" htmlFor="teamSlug">
167 <Input
586 </select>
587 </div>
588 <div class="tc-field">
589 <label class="tc-field-label" for="teamSlug">Team slug</label>
590 <input
168591 name="teamSlug"
169592 id="teamSlug"
170593 placeholder="engineering"
171594 required
595 class="tc-field-input"
172596 />
173 </FormGroup>
174 <FormGroup label="Role" htmlFor="role">
175 <Select name="role" id="role" value="read">
597 </div>
598 <div class="tc-field">
599 <label class="tc-field-label" for="role">Role</label>
600 <select
601 name="role"
602 id="role"
603 class="tc-field-select"
604 >
176605 <option value="read">Read — clone + pull</option>
177606 <option value="write">Write — push + merge</option>
178607 <option value="admin">Admin — full control</option>
179 </Select>
180 </FormGroup>
181 <Button type="submit" variant="primary">
608 </select>
609 </div>
610 <button type="submit" class="tc-submit">
182611 Invite team
183 </Button>
184 </Form>
612 </button>
613 </form>
185614 )}
186615 </div>
187616
188 <h3 style="margin-bottom: 12px">
189 Current collaborators ({rows.length})
190 </h3>
617 {/* ─── Current collaborators ─── */}
618 <div class="tc-list-head">
619 <h2 class="tc-list-title">Current collaborators</h2>
620 <span class="tc-list-count">{rows.length} total</span>
621 </div>
191622 {rows.length === 0 ? (
192 <EmptyState title="No collaborators yet">
193 <p>Invite a team above to add multiple people at once.</p>
194 </EmptyState>
623 <div class="tc-empty">
624 <div class="tc-empty-orb" aria-hidden="true" />
625 <div class="tc-empty-inner">
626 <h3 class="tc-empty-title">No collaborators yet</h3>
627 <p class="tc-empty-desc">
628 Invite a team above to add multiple people at once,
629 or invite a single user from the collaborators page.
630 </p>
631 </div>
632 </div>
195633 ) : (
196 <div>
634 <div class="tc-list">
197635 {rows.map((row) => (
198 <div class="ssh-key-item">
199 <div>
200 <strong>
201 {row.avatarUrl && (
202 <img
203 src={row.avatarUrl}
204 alt=""
205 width={20}
206 height={20}
207 style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:6px"
208 />
209 )}
210 <a href={`/${row.username}`}>{row.username}</a>
211 </strong>
212 <div class="ssh-key-meta">
213 Role: <strong>{row.role}</strong> | Invited:{" "}
214 {new Date(row.invitedAt).toLocaleDateString()} |{" "}
636 <div class="tc-row">
637 {row.avatarUrl ? (
638 <img
639 class="tc-avatar"
640 src={row.avatarUrl}
641 alt=""
642 width={32}
643 height={32}
644 />
645 ) : (
646 <span class="tc-avatar-fallback" aria-hidden="true">
647 {row.username.charAt(0).toUpperCase()}
648 </span>
649 )}
650 <div class="tc-row-body">
651 <a href={`/${row.username}`} class="tc-row-name">
652 {row.username}
653 </a>
654 <div class="tc-row-meta">
655 <span class="tc-row-role">{row.role}</span>
215656 {row.acceptedAt ? (
216 <span style="color: var(--green)">Accepted</span>
657 <span class="tc-row-pill-ok">Accepted</span>
217658 ) : (
218 <span style="color: var(--yellow)">Pending</span>
659 <span class="tc-row-pill-warn">Pending</span>
219660 )}
661 <span>
662 Invited {new Date(row.invitedAt).toLocaleDateString()}
663 </span>
220664 </div>
221665 </div>
222666 </div>
223667 ))}
224668 </div>
225669 )}
226 </Container>
670 </div>
227671 </Layout>
228672 );
229673 }
Modifiedsrc/routes/web.tsx+1438−31View fileUnifiedSplit
Large file (1,588 lines). Load full file