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.tsxBlame512 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 = `
66 .preview-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
67
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 {
266 background: rgba(148,163,184,0.10);
267 color: #cbd5e1;
268 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
269 }
270 .preview-pill-dot {
271 width: 6px; height: 6px;
272 border-radius: 9999px;
273 background: currentColor;
274 }
275 .preview-pill.is-building .preview-pill-dot {
276 animation: previewPulse 1.4s ease-in-out infinite;
277 }
278 @keyframes previewPulse {
279 0%, 100% { opacity: 1; }
280 50% { opacity: 0.35; }
281 }
282
283 .preview-error {
284 margin-top: var(--space-2);
285 padding: 8px 12px;
286 border-radius: 8px;
287 font-family: var(--font-mono);
288 font-size: 12px;
289 color: #fecaca;
290 background: rgba(248,113,113,0.06);
291 border: 1px solid rgba(248,113,113,0.30);
292 white-space: pre-wrap;
293 word-break: break-word;
294 }
295
296 /* ─── empty state ─── */
297 .preview-empty {
298 position: relative;
299 padding: var(--space-6) var(--space-5);
300 background: var(--bg-elevated);
301 border: 1px dashed var(--border-strong, var(--border));
302 border-radius: 14px;
303 text-align: center;
304 overflow: hidden;
305 margin-bottom: var(--space-5);
306 }
307 .preview-empty-orb {
308 position: absolute;
309 inset: auto auto -40% 50%;
310 transform: translateX(-50%);
311 width: 320px; height: 320px;
312 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 45%, transparent 70%);
313 filter: blur(70px);
314 opacity: 0.7;
315 pointer-events: none;
316 }
317 .preview-empty-inner { position: relative; z-index: 1; max-width: 460px; margin: 0 auto; }
318 .preview-empty-icon {
319 width: 44px; height: 44px;
320 margin: 0 auto var(--space-3);
321 border-radius: 14px;
322 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.14));
323 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
324 display: flex; align-items: center; justify-content: center;
325 color: #b69dff;
326 }
327 .preview-empty-title {
328 font-family: var(--font-display);
329 font-size: 16px;
330 font-weight: 700;
331 color: var(--text-strong);
332 margin: 0 0 6px;
333 letter-spacing: -0.01em;
334 }
335 .preview-empty-body {
336 font-size: 13.5px;
337 color: var(--text-muted);
338 line-height: 1.5;
339 margin: 0 0 var(--space-3);
340 }
341 .preview-empty code {
342 font-family: var(--font-mono);
343 font-size: 12px;
344 color: var(--text);
345 background: rgba(255,255,255,0.06);
346 padding: 1px 6px;
347 border-radius: 6px;
348 }
349
350 /* ─── secondary tab strip — only used when RepoNav has no slot ─── */
351 .preview-tabbar {
352 display: flex;
353 gap: 4px;
354 margin-bottom: var(--space-3);
355 border-bottom: 1px solid var(--border);
356 padding-bottom: 0;
357 }
358 .preview-tabbar a {
359 padding: 8px 12px;
360 font-size: 13px;
361 font-weight: 600;
362 color: var(--text-muted);
363 text-decoration: none;
364 border-bottom: 2px solid transparent;
365 margin-bottom: -1px;
366 }
367 .preview-tabbar a.is-active {
368 color: var(--text-strong);
369 border-bottom-color: #8c6dff;
370 }
371 .preview-tabbar a:hover { color: var(--text); }
372`;
373
374r.get("/:owner/:repo/previews", async (c) => {
375 const user = c.get("user");
376 const { owner, repo } = c.req.param();
377 const repoRow = await loadRepo(owner, repo);
378 if (!repoRow) return c.notFound();
379
380 const previews = await listPreviewsForRepo(repoRow.id);
381 const unread = user ? await getUnreadCount(user.id) : 0;
382 const now = new Date();
383
384 return c.html(
385 <Layout
386 title={`Previews — ${owner}/${repo}`}
387 user={user}
388 notificationCount={unread}
389 >
390 <RepoHeader
391 owner={owner}
392 repo={repo}
393 starCount={repoRow.starCount}
394 forkCount={repoRow.forkCount}
395 currentUser={user?.username}
396 />
397 <RepoNav owner={owner} repo={repo} active="code" />
398
399 <div class="preview-wrap">
400 <section class="preview-head">
401 <div class="preview-head-orb" aria-hidden="true" />
402 <div class="preview-head-inner">
403 <div class="preview-head-text">
404 <div class="preview-eyebrow">
405 <span class="preview-eyebrow-dot" aria-hidden="true" />
406 Branch previews · {owner}/{repo}
407 </div>
408 <h2 class="preview-title">
409 <span class="preview-title-grad">Previews.</span>
410 </h2>
411 <p class="preview-sub">
412 Every push to a non-default branch gets a unique preview
413 URL. Open the URL to see the branch as if it were live —
414 no merge required. Previews auto-expire 24 hours after
415 the last push.
416 </p>
417 </div>
418 </div>
419 </section>
420
421 <nav class="preview-tabbar" aria-label="Repository previews navigation">
422 <a href={`/${owner}/${repo}`}>Code</a>
423 <a class="is-active" href={`/${owner}/${repo}/previews`}>Previews</a>
424 <a href={`/${owner}/${repo}/deployments`}>Deployments</a>
425 </nav>
426
427 <h4 class="preview-col-title">
428 {previews.length === 0
429 ? "No previews yet"
430 : `${previews.length} preview${previews.length === 1 ? "" : "s"}`}
431 </h4>
432
433 {previews.length === 0 ? (
434 <div class="preview-empty">
435 <div class="preview-empty-orb" aria-hidden="true" />
436 <div class="preview-empty-inner">
437 <div class="preview-empty-icon" aria-hidden="true">
438 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
439 <circle cx="12" cy="12" r="9" />
440 <path d="M12 7v5l3 2" />
441 </svg>
442 </div>
443 <h3 class="preview-empty-title">Push to a branch to get a preview URL</h3>
444 <p class="preview-empty-body">
445 Create a branch other than{" "}
446 <code>{repoRow.defaultBranch}</code>, push some commits,
447 and a unique preview URL will land here within seconds.
448 Each preview lasts 24 hours after the last push.
449 </p>
450 </div>
451 </div>
452 ) : (
453 <div class="preview-list">
454 {previews.map((p) => {
455 const shortSha = (p.commitSha || "").slice(0, 7);
456 const expiresLabel = formatExpiresIn(p.expiresAt, now);
457 const statusKey = p.status as
458 | "building"
459 | "ready"
460 | "failed"
461 | "expired";
462 const pillClass = `preview-pill is-${statusKey}`;
463 return (
464 <div class="preview-card">
465 <div class="preview-card-head">
466 <div class="preview-card-titles">
467 <h3 class="preview-card-branch">{p.branchName}</h3>
468 <div class="preview-card-url">
469 {p.status === "ready" ? (
470 <a href={p.previewUrl} target="_blank" rel="noopener noreferrer">
471 {p.previewUrl}
472 </a>
473 ) : (
474 <span style="color: var(--text-muted)">
475 {p.previewUrl}
476 </span>
477 )}
478 </div>
479 <div class="preview-card-meta">
480 <span>
481 commit <code>{shortSha}</code>
482 </span>
483 <span>
484 {p.status === "expired"
485 ? "expired"
486 : `expires in ${expiresLabel}`}
487 </span>
488 </div>
489 </div>
490 <div>
491 <span class={pillClass}>
492 <span class="preview-pill-dot" aria-hidden="true" />
493 {previewStatusLabel(p.status)}
494 </span>
495 </div>
496 </div>
497 {p.status === "failed" && p.errorMessage && (
498 <div class="preview-error">{p.errorMessage}</div>
499 )}
500 </div>
501 );
502 })}
503 </div>
504 )}
505 </div>
506
507 <style dangerouslySetInnerHTML={{ __html: previewStyles }} />
508 </Layout>
509 );
510});
511
512export default r;