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

previews.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

previews.tsxBlame560 lines · 1 contributor
4bbacbeClaude1/**
2 * Per-branch preview URLs — list view (migration 0062).
3 *
4 * GET /:owner/:repo/previews
5 *
6 * Lists every live preview row for the repo (one per branch). Status
7 * pills, mono branch names, short SHAs, clickable URLs, expires-in
8 * countdowns. Empty state when no pushes have been made to a
9 * non-default branch yet.
10 *
11 * All page-local CSS is scoped under `.preview-*` so it can't bleed
12 * into the shared layout (per CLAUDE.md: do NOT modify shared
13 * layout/components/ui). Mirrors the gradient hairline + orb pattern
14 * used by environments.tsx / admin-integrations.tsx.
15 *
16 * The corresponding JSON API + force-rebuild endpoints live in
17 * src/routes/api-v2.ts.
18 */
19
20import { Hono } from "hono";
21import { and, eq } from "drizzle-orm";
22import { db } from "../db";
23import { repositories, users } from "../db/schema";
24import { softAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { Layout } from "../views/layout";
27import { RepoHeader, RepoNav } from "../views/components";
28import { getUnreadCount } from "../lib/unread";
29import {
30 formatExpiresIn,
31 listPreviewsForRepo,
32 previewStatusLabel,
33} from "../lib/branch-previews";
34
35const r = new Hono<AuthEnv>();
36r.use("*", softAuth);
37
38async function loadRepo(owner: string, repo: string) {
39 try {
40 const [row] = await db
41 .select({
42 id: repositories.id,
43 name: repositories.name,
44 defaultBranch: repositories.defaultBranch,
45 ownerId: repositories.ownerId,
46 starCount: repositories.starCount,
47 forkCount: repositories.forkCount,
48 previewBuildsEnabled: repositories.previewBuildsEnabled,
49 })
50 .from(repositories)
51 .innerJoin(users, eq(repositories.ownerId, users.id))
52 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
53 .limit(1);
54 return row || null;
55 } catch (err) {
56 console.error("[previews] loadRepo failed:", err);
57 return null;
58 }
59}
60
61/* ─────────────────────────────────────────────────────────────────────────
62 * Scoped CSS — every class prefixed `.preview-*` so this page can't bleed
63 * into the layout. Same gradient hairline + orb language as environments.
64 * ───────────────────────────────────────────────────────────────────── */
65const previewStyles = `
eed4684Claude66 .preview-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
4bbacbeClaude67
68 .preview-head {
69 position: relative;
70 margin-bottom: var(--space-5);
71 padding: var(--space-4) var(--space-5);
72 background: var(--bg-elevated);
73 border: 1px solid var(--border);
74 border-radius: 14px;
75 overflow: hidden;
76 }
77 .preview-head::before {
78 content: '';
79 position: absolute;
80 top: 0; left: 0; right: 0;
81 height: 2px;
82 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
83 opacity: 0.7;
84 pointer-events: none;
85 }
86 .preview-head-orb {
87 position: absolute;
88 inset: -30% -10% auto auto;
89 width: 320px; height: 320px;
90 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 45%, transparent 70%);
91 filter: blur(70px);
92 opacity: 0.7;
93 pointer-events: none;
94 z-index: 0;
95 }
96 .preview-head-inner {
97 position: relative;
98 z-index: 1;
99 display: flex;
100 align-items: flex-end;
101 justify-content: space-between;
102 gap: var(--space-4);
103 flex-wrap: wrap;
104 }
105 .preview-head-text { flex: 1; min-width: 240px; max-width: 720px; }
106 .preview-eyebrow {
107 display: inline-flex;
108 align-items: center;
109 gap: 8px;
110 font-family: var(--font-mono);
111 font-size: 11px;
112 letter-spacing: 0.14em;
113 text-transform: uppercase;
114 font-weight: 600;
115 color: var(--text-muted);
116 margin-bottom: 10px;
117 }
118 .preview-eyebrow-dot {
119 width: 8px; height: 8px;
120 border-radius: 9999px;
121 background: linear-gradient(135deg, #8c6dff, #36c5d6);
122 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
123 }
124 .preview-title {
125 margin: 0 0 6px;
126 font-family: var(--font-display);
127 font-size: clamp(22px, 2.6vw, 30px);
128 font-weight: 800;
129 letter-spacing: -0.022em;
130 line-height: 1.1;
131 color: var(--text-strong);
132 }
133 .preview-title-grad {
134 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
135 -webkit-background-clip: text;
136 background-clip: text;
137 -webkit-text-fill-color: transparent;
138 color: transparent;
139 }
140 .preview-sub {
141 margin: 0;
142 font-size: 13.5px;
143 line-height: 1.5;
144 color: var(--text-muted);
145 }
146
147 .preview-col-title {
148 margin: 0 0 var(--space-2);
149 font-family: var(--font-mono);
150 font-size: 11px;
151 text-transform: uppercase;
152 letter-spacing: 0.14em;
153 font-weight: 600;
154 color: var(--text-muted);
155 }
156
157 .preview-list {
158 display: flex;
159 flex-direction: column;
160 gap: var(--space-3);
161 margin-bottom: var(--space-5);
162 }
163
164 .preview-card {
165 position: relative;
166 padding: var(--space-4) var(--space-4);
167 background: var(--bg-elevated);
168 border: 1px solid var(--border);
169 border-radius: 14px;
170 overflow: hidden;
171 transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
172 }
173 .preview-card::before {
174 content: '';
175 position: absolute;
176 top: 0; left: 14px; right: 14px;
177 height: 1px;
178 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
179 opacity: 0;
180 transition: opacity 160ms ease;
181 }
182 .preview-card:hover {
183 transform: translateY(-1px);
184 border-color: rgba(140,109,255,0.32);
185 box-shadow: 0 8px 22px -10px rgba(0,0,0,0.40);
186 }
187 .preview-card:hover::before { opacity: 1; }
188
189 .preview-card-head {
190 display: flex;
191 align-items: center;
192 justify-content: space-between;
193 gap: var(--space-3);
194 flex-wrap: wrap;
195 margin-bottom: var(--space-2);
196 }
197 .preview-card-titles { flex: 1; min-width: 200px; }
198 .preview-card-branch {
199 margin: 0;
200 font-family: var(--font-mono);
201 font-size: 15px;
202 font-weight: 700;
203 color: var(--text-strong);
204 word-break: break-all;
205 }
206 .preview-card-url {
207 margin-top: 4px;
208 font-family: var(--font-mono);
209 font-size: 12.5px;
210 overflow: hidden;
211 text-overflow: ellipsis;
212 white-space: nowrap;
213 }
214 .preview-card-url a { color: var(--accent, #8c6dff); text-decoration: none; }
215 .preview-card-url a:hover { color: var(--accent, #36c5d6); text-decoration: underline; }
216 .preview-card-meta {
217 margin-top: 6px;
218 display: flex;
219 flex-wrap: wrap;
220 gap: 10px;
221 font-size: 11.5px;
222 color: var(--text-muted);
223 font-variant-numeric: tabular-nums;
224 }
225 .preview-card-meta code {
226 font-family: var(--font-mono);
227 font-size: 11.5px;
228 color: var(--text);
229 background: rgba(255,255,255,0.04);
230 padding: 1px 6px;
231 border-radius: 6px;
232 }
233
234 /* ─── status pills ─── */
235 .preview-pill {
236 display: inline-flex;
237 align-items: center;
238 gap: 5px;
239 padding: 2px 9px;
240 border-radius: 9999px;
241 font-size: 10.5px;
242 font-weight: 600;
243 letter-spacing: 0.05em;
244 text-transform: uppercase;
245 font-family: var(--font-mono);
246 background: rgba(255,255,255,0.04);
247 color: var(--text-muted);
248 box-shadow: inset 0 0 0 1px var(--border);
249 }
250 .preview-pill.is-building {
251 background: rgba(251,191,36,0.10);
252 color: #fde68a;
253 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
254 }
255 .preview-pill.is-ready {
256 background: rgba(52,211,153,0.10);
257 color: #6ee7b7;
258 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
259 }
260 .preview-pill.is-failed {
261 background: rgba(248,113,113,0.10);
262 color: #fecaca;
263 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.35);
264 }
265 .preview-pill.is-expired {
44f1a02Claude266 background: rgba(100,116,139,0.10);
267 color: #94a3b8;
268 box-shadow: inset 0 0 0 1px rgba(100,116,139,0.28);
269 }
270
271 /* ─── expired card treatment ─── */
272 .preview-card.is-expired .preview-card-branch { color: var(--text-muted); }
273 .preview-card.is-expired .preview-card-url-expired {
274 color: var(--text-muted);
275 text-decoration: line-through;
276 text-decoration-color: rgba(148,163,184,0.45);
277 }
278 .preview-rebuild-btn {
279 display: inline-flex;
280 align-items: center;
281 gap: 5px;
282 margin-top: var(--space-2);
283 padding: 4px 12px;
284 border-radius: 8px;
285 font-size: 12px;
286 font-weight: 600;
287 font-family: var(--font-mono);
288 color: #a48bff;
289 background: rgba(140,109,255,0.08);
290 border: 1px solid rgba(140,109,255,0.22);
291 cursor: pointer;
292 text-decoration: none;
293 transition: background 120ms ease, border-color 120ms ease;
294 }
295 .preview-rebuild-btn:hover {
296 background: rgba(140,109,255,0.15);
297 border-color: rgba(140,109,255,0.40);
4bbacbeClaude298 }
299 .preview-pill-dot {
300 width: 6px; height: 6px;
301 border-radius: 9999px;
302 background: currentColor;
303 }
304 .preview-pill.is-building .preview-pill-dot {
305 animation: previewPulse 1.4s ease-in-out infinite;
306 }
307 @keyframes previewPulse {
308 0%, 100% { opacity: 1; }
309 50% { opacity: 0.35; }
310 }
311
312 .preview-error {
313 margin-top: var(--space-2);
314 padding: 8px 12px;
315 border-radius: 8px;
316 font-family: var(--font-mono);
317 font-size: 12px;
318 color: #fecaca;
319 background: rgba(248,113,113,0.06);
320 border: 1px solid rgba(248,113,113,0.30);
321 white-space: pre-wrap;
322 word-break: break-word;
323 }
324
325 /* ─── empty state ─── */
326 .preview-empty {
327 position: relative;
328 padding: var(--space-6) var(--space-5);
329 background: var(--bg-elevated);
330 border: 1px dashed var(--border-strong, var(--border));
331 border-radius: 14px;
332 text-align: center;
333 overflow: hidden;
334 margin-bottom: var(--space-5);
335 }
336 .preview-empty-orb {
337 position: absolute;
338 inset: auto auto -40% 50%;
339 transform: translateX(-50%);
340 width: 320px; height: 320px;
341 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 45%, transparent 70%);
342 filter: blur(70px);
343 opacity: 0.7;
344 pointer-events: none;
345 }
346 .preview-empty-inner { position: relative; z-index: 1; max-width: 460px; margin: 0 auto; }
347 .preview-empty-icon {
348 width: 44px; height: 44px;
349 margin: 0 auto var(--space-3);
350 border-radius: 14px;
351 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.14));
352 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
353 display: flex; align-items: center; justify-content: center;
354 color: #b69dff;
355 }
356 .preview-empty-title {
357 font-family: var(--font-display);
358 font-size: 16px;
359 font-weight: 700;
360 color: var(--text-strong);
361 margin: 0 0 6px;
362 letter-spacing: -0.01em;
363 }
364 .preview-empty-body {
365 font-size: 13.5px;
366 color: var(--text-muted);
367 line-height: 1.5;
368 margin: 0 0 var(--space-3);
369 }
370 .preview-empty code {
371 font-family: var(--font-mono);
372 font-size: 12px;
373 color: var(--text);
374 background: rgba(255,255,255,0.06);
375 padding: 1px 6px;
376 border-radius: 6px;
377 }
378
379 /* ─── secondary tab strip — only used when RepoNav has no slot ─── */
380 .preview-tabbar {
381 display: flex;
382 gap: 4px;
383 margin-bottom: var(--space-3);
384 border-bottom: 1px solid var(--border);
385 padding-bottom: 0;
386 }
387 .preview-tabbar a {
388 padding: 8px 12px;
389 font-size: 13px;
390 font-weight: 600;
391 color: var(--text-muted);
392 text-decoration: none;
393 border-bottom: 2px solid transparent;
394 margin-bottom: -1px;
395 }
396 .preview-tabbar a.is-active {
397 color: var(--text-strong);
398 border-bottom-color: #8c6dff;
399 }
400 .preview-tabbar a:hover { color: var(--text); }
401`;
402
403r.get("/:owner/:repo/previews", async (c) => {
404 const user = c.get("user");
405 const { owner, repo } = c.req.param();
406 const repoRow = await loadRepo(owner, repo);
407 if (!repoRow) return c.notFound();
408
409 const previews = await listPreviewsForRepo(repoRow.id);
410 const unread = user ? await getUnreadCount(user.id) : 0;
411 const now = new Date();
412
413 return c.html(
414 <Layout
415 title={`Previews — ${owner}/${repo}`}
416 user={user}
417 notificationCount={unread}
418 >
419 <RepoHeader
420 owner={owner}
421 repo={repo}
422 starCount={repoRow.starCount}
423 forkCount={repoRow.forkCount}
424 currentUser={user?.username}
425 />
426 <RepoNav owner={owner} repo={repo} active="code" />
427
428 <div class="preview-wrap">
429 <section class="preview-head">
430 <div class="preview-head-orb" aria-hidden="true" />
431 <div class="preview-head-inner">
432 <div class="preview-head-text">
433 <div class="preview-eyebrow">
434 <span class="preview-eyebrow-dot" aria-hidden="true" />
435 Branch previews · {owner}/{repo}
436 </div>
437 <h2 class="preview-title">
438 <span class="preview-title-grad">Previews.</span>
439 </h2>
440 <p class="preview-sub">
441 Every push to a non-default branch gets a unique preview
442 URL. Open the URL to see the branch as if it were live —
443 no merge required. Previews auto-expire 24 hours after
444 the last push.
445 </p>
446 </div>
447 </div>
448 </section>
449
450 <nav class="preview-tabbar" aria-label="Repository previews navigation">
451 <a href={`/${owner}/${repo}`}>Code</a>
452 <a class="is-active" href={`/${owner}/${repo}/previews`}>Previews</a>
453 <a href={`/${owner}/${repo}/deployments`}>Deployments</a>
454 </nav>
455
456 <h4 class="preview-col-title">
457 {previews.length === 0
458 ? "No previews yet"
459 : `${previews.length} preview${previews.length === 1 ? "" : "s"}`}
460 </h4>
461
462 {previews.length === 0 ? (
463 <div class="preview-empty">
464 <div class="preview-empty-orb" aria-hidden="true" />
465 <div class="preview-empty-inner">
466 <div class="preview-empty-icon" aria-hidden="true">
467 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
468 <circle cx="12" cy="12" r="9" />
469 <path d="M12 7v5l3 2" />
470 </svg>
471 </div>
472 <h3 class="preview-empty-title">Push to a branch to get a preview URL</h3>
473 <p class="preview-empty-body">
474 Create a branch other than{" "}
475 <code>{repoRow.defaultBranch}</code>, push some commits,
476 and a unique preview URL will land here within seconds.
477 Each preview lasts 24 hours after the last push.
478 </p>
479 </div>
480 </div>
481 ) : (
482 <div class="preview-list">
483 {previews.map((p) => {
484 const shortSha = (p.commitSha || "").slice(0, 7);
485 const expiresLabel = formatExpiresIn(p.expiresAt, now);
44f1a02Claude486 const isExpired = p.status === "expired";
4bbacbeClaude487 const statusKey = p.status as
488 | "building"
489 | "ready"
490 | "failed"
491 | "expired";
492 const pillClass = `preview-pill is-${statusKey}`;
44f1a02Claude493 const cardClass = isExpired
494 ? "preview-card is-expired"
495 : "preview-card";
496 // Rebuild pushes a branch-preview re-enqueue via the API.
497 const rebuildHref = `/${owner}/${repo}/previews/rebuild?branch=${encodeURIComponent(p.branchName)}`;
4bbacbeClaude498 return (
44f1a02Claude499 <div class={cardClass}>
4bbacbeClaude500 <div class="preview-card-head">
501 <div class="preview-card-titles">
502 <h3 class="preview-card-branch">{p.branchName}</h3>
503 <div class="preview-card-url">
504 {p.status === "ready" ? (
505 <a href={p.previewUrl} target="_blank" rel="noopener noreferrer">
506 {p.previewUrl}
507 </a>
44f1a02Claude508 ) : isExpired ? (
509 <span class="preview-card-url-expired">
510 {p.previewUrl}
511 </span>
4bbacbeClaude512 ) : (
513 <span style="color: var(--text-muted)">
514 {p.previewUrl}
515 </span>
516 )}
517 </div>
518 <div class="preview-card-meta">
519 <span>
520 commit <code>{shortSha}</code>
521 </span>
522 <span>
44f1a02Claude523 {isExpired
524 ? "preview expired · push to branch to rebuild"
4bbacbeClaude525 : `expires in ${expiresLabel}`}
526 </span>
527 </div>
44f1a02Claude528 {isExpired && (
529 <a
530 href={rebuildHref}
531 class="preview-rebuild-btn"
532 title="Re-enqueue a preview build for this branch"
533 >
534 ↺ Rebuild
535 </a>
536 )}
4bbacbeClaude537 </div>
538 <div>
539 <span class={pillClass}>
540 <span class="preview-pill-dot" aria-hidden="true" />
541 {previewStatusLabel(p.status)}
542 </span>
543 </div>
544 </div>
545 {p.status === "failed" && p.errorMessage && (
546 <div class="preview-error">{p.errorMessage}</div>
547 )}
548 </div>
549 );
550 })}
551 </div>
552 )}
553 </div>
554
555 <style dangerouslySetInnerHTML={{ __html: previewStyles }} />
556 </Layout>
557 );
558});
559
560export default r;