Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

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

pages.tsxBlame1032 lines · 1 contributor
25a91a6Claude1/**
2 * Block C3 — Pages / static hosting routes.
3 *
4 * GET /:owner/:repo/pages/* — serve a static file from the
5 * latest successful gh-pages
6 * deployment
7 * GET /:owner/:repo/settings/pages — settings UI (owner-only)
8 * POST /:owner/:repo/settings/pages — upsert settings
9 * POST /:owner/:repo/settings/pages/redeploy — manual redeploy trigger
10 *
11 * The serving endpoint reads blobs directly out of the bare git repo at the
12 * commit sha of the most recent pages_deployments row for that repo. There is
13 * no on-disk export — the git store IS the CDN.
711675bClaude14 *
15 * 2026 polish: settings UI uses a scoped `.pages-*` class system mirroring
16 * `admin-ops.tsx` — eyebrow + display headline, polished settings card with
17 * gradient hairline, deployment table with tabular-nums + status pills, and
18 * an orb-lit dashed empty state for "no deployments yet".
25a91a6Claude19 */
20
21import { Hono } from "hono";
22import { and, desc, eq } from "drizzle-orm";
23import { db } from "../db";
24import {
25 pagesDeployments,
26 pagesSettings,
27 repositories,
28 users,
29} from "../db/schema";
30import { Layout } from "../views/layout";
31import { RepoHeader, RepoNav } from "../views/components";
32import { softAuth, requireAuth } from "../middleware/auth";
33import type { AuthEnv } from "../middleware/auth";
34import { getBlob, getRawBlob, resolveRef } from "../git/repository";
35import { audit } from "../lib/notify";
36import { getUnreadCount } from "../lib/unread";
37import { config } from "../lib/config";
38import {
39 contentTypeFor,
40 onPagesPush,
41 resolvePagesPath,
42} from "../lib/pages";
43
44const pagesRoute = new Hono<AuthEnv>();
45pagesRoute.use("*", softAuth);
46
47interface LoadedRepo {
48 id: string;
49 name: string;
50 ownerId: string;
51 ownerUsername: string;
52}
53
54async function loadRepo(
55 owner: string,
56 repo: string
57): Promise<LoadedRepo | null> {
58 try {
59 const [row] = await db
60 .select({
61 id: repositories.id,
62 name: repositories.name,
63 ownerId: repositories.ownerId,
64 ownerUsername: users.username,
65 })
66 .from(repositories)
67 .innerJoin(users, eq(repositories.ownerId, users.id))
68 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
69 .limit(1);
70 return row || null;
71 } catch {
72 return null;
73 }
74}
75
76async function getEffectiveSettings(repositoryId: string) {
77 try {
78 const [row] = await db
79 .select()
80 .from(pagesSettings)
81 .where(eq(pagesSettings.repositoryId, repositoryId))
82 .limit(1);
83 if (row) return row;
84 } catch {
85 /* fall through to defaults */
86 }
87 // Synthesise defaults when the row doesn't exist.
88 return {
89 repositoryId,
90 enabled: true,
91 sourceBranch: "gh-pages",
92 sourceDir: "/",
93 customDomain: null as string | null,
94 updatedAt: new Date(),
95 };
96}
97
711675bClaude98// ─── Scoped CSS (.pages-*) ──────────────────────────────────────────────────
99const pagesStyles = `
eed4684Claude100 .pages-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
711675bClaude101
102 .pages-head {
103 display: flex;
104 align-items: flex-end;
105 justify-content: space-between;
106 gap: var(--space-4);
107 flex-wrap: wrap;
108 margin-bottom: var(--space-5);
109 }
110 .pages-head-text { flex: 1; min-width: 280px; }
111 .pages-eyebrow {
112 display: inline-flex;
113 align-items: center;
114 gap: 8px;
115 text-transform: uppercase;
116 font-family: var(--font-mono);
117 font-size: 11px;
118 letter-spacing: 0.16em;
119 color: var(--text-muted);
120 font-weight: 600;
121 margin-bottom: 10px;
122 }
123 .pages-eyebrow-dot {
124 width: 8px; height: 8px;
125 border-radius: 9999px;
126 background: linear-gradient(135deg, #8c6dff, #36c5d6);
127 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
128 }
129 .pages-title {
130 font-family: var(--font-display);
131 font-size: clamp(24px, 3.4vw, 36px);
132 font-weight: 800;
133 letter-spacing: -0.028em;
134 line-height: 1.1;
135 margin: 0 0 6px;
136 color: var(--text-strong);
137 }
138 .pages-title-grad {
139 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
140 -webkit-background-clip: text;
141 background-clip: text;
142 -webkit-text-fill-color: transparent;
143 color: transparent;
144 }
145 .pages-sub {
146 margin: 0;
147 font-size: 14px;
148 color: var(--text-muted);
149 line-height: 1.5;
150 max-width: 640px;
151 }
152
153 /* Banners */
154 .pages-banner {
155 margin-bottom: var(--space-4);
156 padding: 10px 14px;
157 border-radius: 10px;
158 font-size: 13.5px;
159 border: 1px solid var(--border);
160 background: rgba(255,255,255,0.025);
161 display: flex;
162 align-items: center;
163 gap: 10px;
164 }
165 .pages-banner.is-ok {
166 border-color: rgba(52,211,153,0.40);
167 background: rgba(52,211,153,0.08);
168 color: #bbf7d0;
169 }
170 .pages-banner.is-info {
171 border-color: rgba(54,197,214,0.40);
172 background: rgba(54,197,214,0.08);
173 color: #a5f3fc;
174 }
175 .pages-banner.is-error {
176 border-color: rgba(248,113,113,0.40);
177 background: rgba(248,113,113,0.08);
178 color: #fecaca;
179 }
180 .pages-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
181
182 /* Section cards */
183 .pages-section {
184 background: var(--bg-elevated);
185 border: 1px solid var(--border);
186 border-radius: 14px;
187 overflow: hidden;
188 margin-bottom: var(--space-5);
189 position: relative;
190 }
191 .pages-section::before {
192 content: '';
193 position: absolute;
194 top: 0; left: 0; right: 0;
195 height: 2px;
196 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
197 opacity: 0.55;
198 pointer-events: none;
199 }
200 .pages-section-head {
201 padding: var(--space-4) var(--space-5) var(--space-3);
202 border-bottom: 1px solid var(--border);
203 display: flex;
204 align-items: flex-start;
205 justify-content: space-between;
206 gap: var(--space-3);
207 flex-wrap: wrap;
208 }
209 .pages-section-head-text { flex: 1; min-width: 240px; }
210 .pages-section-title {
211 margin: 0;
212 font-family: var(--font-display);
213 font-size: 16px;
214 font-weight: 700;
215 letter-spacing: -0.018em;
216 color: var(--text-strong);
217 display: flex;
218 align-items: center;
219 gap: 10px;
220 }
221 .pages-section-title-icon {
222 display: inline-flex;
223 align-items: center;
224 justify-content: center;
225 width: 26px; height: 26px;
226 border-radius: 8px;
227 background: rgba(140,109,255,0.12);
228 color: #b69dff;
229 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
230 flex-shrink: 0;
231 }
232 .pages-section-sub {
233 margin: 6px 0 0 36px;
234 font-size: 12.5px;
235 color: var(--text-muted);
236 line-height: 1.45;
237 }
238 .pages-section-body { padding: var(--space-4) var(--space-5); }
239
240 /* Site URL card */
241 .pages-url-card {
242 display: flex;
243 align-items: center;
244 justify-content: space-between;
245 gap: 14px;
246 flex-wrap: wrap;
247 padding: 14px 16px;
248 background: rgba(255,255,255,0.02);
249 border: 1px solid var(--border);
250 border-radius: 12px;
251 }
252 .pages-url-label {
253 font-family: var(--font-mono);
254 font-size: 10.5px;
255 text-transform: uppercase;
256 letter-spacing: 0.14em;
257 color: var(--text-muted);
258 margin: 0 0 4px;
259 font-weight: 600;
260 }
261 .pages-url-value {
262 font-family: var(--font-mono);
263 font-size: 13px;
264 word-break: break-all;
265 }
266 .pages-url-value a {
267 color: #c4b5fd;
268 text-decoration: none;
269 }
270 .pages-url-value a:hover { color: #a48bff; text-decoration: underline; }
271
272 /* Fields */
273 .pages-field { margin-bottom: 16px; }
274 .pages-field-label {
275 display: block;
276 font-size: 11.5px;
277 color: var(--text-muted);
278 font-weight: 600;
279 text-transform: uppercase;
280 letter-spacing: 0.06em;
281 margin-bottom: 6px;
282 }
283 .pages-input {
284 width: 100%;
285 box-sizing: border-box;
286 padding: 9px 12px;
287 font: inherit;
288 font-size: 13.5px;
289 color: var(--text);
290 background: rgba(255,255,255,0.03);
291 border: 1px solid var(--border-strong);
292 border-radius: 10px;
293 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
294 }
295 .pages-input:focus {
296 outline: none;
297 border-color: rgba(140,109,255,0.55);
298 background: rgba(255,255,255,0.05);
299 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
300 }
301 .pages-help {
302 font-size: 12px;
303 color: var(--text-muted);
304 margin-top: 4px;
305 line-height: 1.45;
306 }
307 .pages-check { display: inline-flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
308
309 /* Buttons */
310 .pages-btn {
311 display: inline-flex;
312 align-items: center;
313 justify-content: center;
314 gap: 6px;
315 padding: 9px 16px;
316 border-radius: 10px;
317 font-size: 13px;
318 font-weight: 600;
319 text-decoration: none;
320 border: 1px solid transparent;
321 cursor: pointer;
322 font: inherit;
323 line-height: 1;
324 white-space: nowrap;
325 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
326 }
327 .pages-btn-primary {
328 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
329 color: #ffffff;
330 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
331 }
332 .pages-btn-primary:hover {
333 transform: translateY(-1px);
334 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
335 text-decoration: none;
336 color: #ffffff;
337 }
338 .pages-btn-ghost {
339 background: transparent;
340 color: var(--text);
341 border-color: var(--border-strong);
342 }
343 .pages-btn-ghost:hover {
344 background: rgba(140,109,255,0.06);
345 border-color: rgba(140,109,255,0.45);
346 color: var(--text-strong);
347 text-decoration: none;
348 }
349
350 /* Deploy list */
351 .pages-deploys {
352 background: rgba(255,255,255,0.02);
353 border: 1px solid var(--border);
354 border-radius: 12px;
355 overflow: hidden;
356 }
357 .pages-deploy-row {
358 display: grid;
359 grid-template-columns: 1.4fr 1fr 1fr auto;
360 gap: 12px;
361 align-items: center;
362 padding: 10px 14px;
363 border-bottom: 1px solid var(--border);
364 font-size: 12.5px;
365 font-variant-numeric: tabular-nums;
366 }
367 .pages-deploy-row:last-child { border-bottom: 0; }
368 .pages-deploy-row.is-head {
369 font-family: var(--font-mono);
370 font-size: 10.5px;
371 text-transform: uppercase;
372 letter-spacing: 0.12em;
373 color: var(--text-muted);
374 font-weight: 600;
375 background: rgba(255,255,255,0.02);
376 }
377 .pages-deploy-row code {
378 font-family: var(--font-mono);
379 font-size: 11.5px;
380 padding: 1px 6px;
381 border-radius: 6px;
382 background: rgba(255,255,255,0.04);
383 border: 1px solid var(--border);
384 color: var(--text);
385 }
386 .pages-status {
387 display: inline-flex;
388 align-items: center;
389 gap: 5px;
390 padding: 2px 9px;
391 border-radius: 9999px;
392 font-size: 11px;
393 font-weight: 600;
394 letter-spacing: 0.02em;
395 text-transform: capitalize;
396 justify-self: end;
397 }
398 .pages-status .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
399 .pages-status.is-success {
400 background: rgba(52,211,153,0.14);
401 color: #6ee7b7;
402 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
403 }
404 .pages-status.is-failed {
405 background: rgba(248,113,113,0.14);
406 color: #fca5a5;
407 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
408 }
409 .pages-status.is-pending {
410 background: rgba(251,191,36,0.12);
411 color: #fde68a;
412 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
413 }
414
415 /* Empty */
416 .pages-empty {
417 position: relative;
418 overflow: hidden;
419 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
420 text-align: center;
421 background: var(--bg-elevated);
422 border: 1px dashed var(--border-strong);
423 border-radius: 16px;
424 }
425 .pages-empty-orb {
426 position: absolute;
427 inset: -40% 30% auto 30%;
428 height: 280px;
429 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
430 filter: blur(70px);
431 opacity: 0.7;
432 pointer-events: none;
433 z-index: 0;
434 }
435 .pages-empty-inner { position: relative; z-index: 1; }
436 .pages-empty-icon {
437 width: 56px; height: 56px;
438 border-radius: 9999px;
439 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
440 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
441 display: inline-flex;
442 align-items: center;
443 justify-content: center;
444 color: #c4b5fd;
445 margin-bottom: 14px;
446 }
447 .pages-empty-title {
448 font-family: var(--font-display);
449 font-size: 18px;
450 font-weight: 700;
451 margin: 0 0 6px;
452 color: var(--text-strong);
453 }
454 .pages-empty-sub {
455 margin: 0 auto;
456 font-size: 13.5px;
457 color: var(--text-muted);
458 max-width: 420px;
459 line-height: 1.5;
460 }
461`;
462
463function IconGlobe() {
464 return (
465 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
466 <circle cx="12" cy="12" r="10" />
467 <line x1="2" y1="12" x2="22" y2="12" />
468 <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
469 </svg>
470 );
471}
472function IconRocket() {
473 return (
474 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
475 <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
476 <path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
477 <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
478 <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
479 </svg>
480 );
481}
482function IconSettings() {
483 return (
484 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
485 <circle cx="12" cy="12" r="3" />
486 <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
487 </svg>
488 );
489}
490
491function statusPillClass(s: string): string {
492 if (s === "success") return "pages-status is-success";
493 if (s === "failed" || s === "error") return "pages-status is-failed";
494 return "pages-status is-pending";
495}
496
497function shortDate(d: Date | string | null | undefined): string {
498 if (!d) return "—";
499 const dt = d instanceof Date ? d : new Date(d);
500 if (Number.isNaN(dt.getTime())) return "—";
501 return dt.toISOString().slice(0, 16).replace("T", " ");
502}
503
25a91a6Claude504// ---------------------------------------------------------------------------
505// Serve: GET /:owner/:repo/pages/*
506// ---------------------------------------------------------------------------
507
508pagesRoute.get("/:owner/:repo/pages/*", async (c) => {
509 const { owner, repo } = c.req.param();
510
511 // Hono gives us the full path via c.req.path; extract whatever sits after
512 // the "/pages/" segment. This is the only path component we treat as the
513 // user-facing URL.
514 const full = c.req.path;
515 const marker = `/${owner}/${repo}/pages/`;
516 const idx = full.indexOf(marker);
517 const urlRest = idx >= 0 ? full.slice(idx + marker.length) : "";
518
519 const repoRow = await loadRepo(owner, repo);
520 if (!repoRow) {
521 return c.text("No Pages site published for this repository.", 404);
522 }
523
524 const settings = await getEffectiveSettings(repoRow.id);
525 if (!settings.enabled) {
526 return c.text("No Pages site published for this repository.", 404);
527 }
528
529 let deployment:
530 | { commitSha: string; createdAt: Date; status: string }
531 | null = null;
532 try {
533 const [row] = await db
534 .select({
535 commitSha: pagesDeployments.commitSha,
536 createdAt: pagesDeployments.createdAt,
537 status: pagesDeployments.status,
538 })
539 .from(pagesDeployments)
540 .where(
541 and(
542 eq(pagesDeployments.repositoryId, repoRow.id),
543 eq(pagesDeployments.status, "success")
544 )
545 )
546 .orderBy(desc(pagesDeployments.createdAt))
547 .limit(1);
548 deployment = row || null;
549 } catch {
550 return c.text("Service unavailable", 503);
551 }
552
553 if (!deployment) {
554 return c.text(
555 "No Pages site published for this repository. Push to the configured source branch to publish.",
556 404
557 );
558 }
559
560 const candidates = resolvePagesPath(urlRest, settings.sourceDir);
561
562 for (const candidate of candidates) {
563 // Try as text first — getBlob fills in isBinary for us.
564 const blob = await getBlob(owner, repo, deployment.commitSha, candidate);
565 if (!blob) continue;
566
567 const headers: Record<string, string> = {
568 "Content-Type": contentTypeFor(candidate),
569 "Cache-Control": "public, max-age=60",
570 "X-Gluecron-Pages-Sha": deployment.commitSha.slice(0, 7),
571 };
572
573 if (blob.isBinary) {
574 // getBlob blanks the content for binary — re-read the raw bytes.
575 const raw = await getRawBlob(
576 owner,
577 repo,
578 deployment.commitSha,
579 candidate
580 );
581 if (!raw) continue;
772a24fClaude582 return new Response(raw as BodyInit, { status: 200, headers });
25a91a6Claude583 }
584
585 return new Response(blob.content, { status: 200, headers });
586 }
587
588 return c.text("Not found in Pages site.", 404);
589});
590
591// ---------------------------------------------------------------------------
592// Settings UI: GET /:owner/:repo/settings/pages
593// ---------------------------------------------------------------------------
594
595pagesRoute.get(
596 "/:owner/:repo/settings/pages",
597 requireAuth,
598 async (c) => {
599 const { owner: ownerName, repo: repoName } = c.req.param();
600 const user = c.get("user")!;
601 const success = c.req.query("success");
602 const error = c.req.query("error");
603 const info = c.req.query("info");
604
605 const repoRow = await loadRepo(ownerName, repoName);
606 if (!repoRow) return c.notFound();
607 if (repoRow.ownerId !== user.id) {
608 return c.html(
609 <Layout title="Unauthorized" user={user}>
711675bClaude610 <div class="pages-wrap">
611 <div class="pages-empty">
612 <div class="pages-empty-orb" aria-hidden="true" />
613 <div class="pages-empty-inner">
614 <h2 class="pages-empty-title">Unauthorized</h2>
615 <p class="pages-empty-sub">Only the repository owner can configure Pages.</p>
616 </div>
617 </div>
25a91a6Claude618 </div>
711675bClaude619 <style dangerouslySetInnerHTML={{ __html: pagesStyles }} />
25a91a6Claude620 </Layout>,
621 403
622 );
623 }
624
625 const settings = await getEffectiveSettings(repoRow.id);
626
627 let recent: Array<{
628 id: string;
629 ref: string;
630 commitSha: string;
631 status: string;
632 createdAt: Date;
633 }> = [];
634 try {
635 recent = await db
636 .select({
637 id: pagesDeployments.id,
638 ref: pagesDeployments.ref,
639 commitSha: pagesDeployments.commitSha,
640 status: pagesDeployments.status,
641 createdAt: pagesDeployments.createdAt,
642 })
643 .from(pagesDeployments)
644 .where(eq(pagesDeployments.repositoryId, repoRow.id))
645 .orderBy(desc(pagesDeployments.createdAt))
646 .limit(10);
647 } catch {
648 /* fall through; render with empty list */
649 }
650
651 const unread = await getUnreadCount(user.id);
652 const siteUrl = `${config.appBaseUrl}/${ownerName}/${repoName}/pages/`;
653
654 return c.html(
655 <Layout
656 title={`Pages — ${ownerName}/${repoName}`}
657 user={user}
658 notificationCount={unread}
659 >
660 <RepoHeader owner={ownerName} repo={repoName} />
661 <RepoNav owner={ownerName} repo={repoName} active="code" />
711675bClaude662 <div class="pages-wrap">
663 <header class="pages-head">
664 <div class="pages-head-text">
665 <div class="pages-eyebrow">
666 <span class="pages-eyebrow-dot" aria-hidden="true" />
667 Repository · Pages
668 </div>
669 <h1 class="pages-title">
670 <span class="pages-title-grad">Static hosting.</span>
671 </h1>
672 <p class="pages-sub">
673 Publish a static site from {ownerName}/{repoName}. Every push
674 to the source branch becomes a fresh deployment served straight
675 from the git object store.
676 </p>
677 </div>
678 </header>
679
25a91a6Claude680 {success && (
711675bClaude681 <div class="pages-banner is-ok" role="status">
682 <span class="pages-banner-dot" aria-hidden="true" />
683 {decodeURIComponent(success)}
684 </div>
685 )}
686 {info && (
687 <div class="pages-banner is-info" role="status">
688 <span class="pages-banner-dot" aria-hidden="true" />
689 {decodeURIComponent(info)}
690 </div>
25a91a6Claude691 )}
692 {error && (
711675bClaude693 <div class="pages-banner is-error" role="alert">
694 <span class="pages-banner-dot" aria-hidden="true" />
695 {decodeURIComponent(error)}
696 </div>
25a91a6Claude697 )}
698
711675bClaude699 {/* Site URL */}
700 <section class="pages-section">
701 <header class="pages-section-head">
702 <div class="pages-section-head-text">
703 <h2 class="pages-section-title">
704 <span class="pages-section-title-icon" aria-hidden="true">
705 <IconGlobe />
706 </span>
707 Deployed URL
708 </h2>
709 <p class="pages-section-sub">
710 The public address of your site. Configure a custom domain
711 below to override this default.
712 </p>
713 </div>
714 </header>
715 <div class="pages-section-body">
716 <div class="pages-url-card">
717 <div>
718 <p class="pages-url-label">Site URL</p>
719 <div class="pages-url-value">
720 <a href={siteUrl}>{siteUrl}</a>
721 </div>
722 </div>
723 {settings.customDomain && (
724 <div>
725 <p class="pages-url-label">Custom domain</p>
726 <div class="pages-url-value">
727 <a href={`https://${settings.customDomain}/`}>
728 https://{settings.customDomain}/
729 </a>
730 </div>
731 </div>
732 )}
733 </div>
25a91a6Claude734 </div>
711675bClaude735 </section>
25a91a6Claude736
711675bClaude737 {/* Settings form */}
738 <section class="pages-section">
739 <header class="pages-section-head">
740 <div class="pages-section-head-text">
741 <h2 class="pages-section-title">
742 <span class="pages-section-title-icon" aria-hidden="true">
743 <IconSettings />
744 </span>
745 Settings
746 </h2>
747 <p class="pages-section-sub">
748 Choose which branch and folder to publish from, and optionally
749 point a custom domain at this site.
750 </p>
25a91a6Claude751 </div>
711675bClaude752 </header>
753 <div class="pages-section-body">
25a91a6Claude754 <form
e7e240eClaude755 method="post"
711675bClaude756 action={`/${ownerName}/${repoName}/settings/pages`}
25a91a6Claude757 >
711675bClaude758 <div class="pages-field">
759 <label class="pages-check">
760 <input
761 type="checkbox"
762 name="enabled"
763 value="1"
764 checked={settings.enabled}
765 aria-label="Enable Pages"
766 />
767 Enable Pages
768 </label>
769 </div>
770 <div class="pages-field">
771 <label class="pages-field-label" for="source_branch">
772 Source branch
773 </label>
774 <input
775 class="pages-input"
776 type="text"
777 id="source_branch"
778 name="source_branch"
779 value={settings.sourceBranch}
780 placeholder="gh-pages"
781 />
782 </div>
783 <div class="pages-field">
784 <label class="pages-field-label" for="source_dir">
785 Source directory
786 </label>
787 <input
788 class="pages-input"
789 type="text"
790 id="source_dir"
791 name="source_dir"
792 value={settings.sourceDir}
793 placeholder="/"
794 />
795 <div class="pages-help">
796 Use <code>/</code> to serve from the repo root, or e.g.{" "}
797 <code>/docs</code>.
798 </div>
799 </div>
800 <div class="pages-field">
801 <label class="pages-field-label" for="custom_domain">
802 Custom domain (optional)
803 </label>
804 <input
805 class="pages-input"
806 type="text"
807 id="custom_domain"
808 name="custom_domain"
809 value={settings.customDomain || ""}
810 placeholder="example.com"
811 />
812 <div class="pages-help">
813 Point a CNAME at this repo's pages host to terminate at your
814 own domain.
815 </div>
816 </div>
817 <button type="submit" class="pages-btn pages-btn-primary">
818 Save settings
25a91a6Claude819 </button>
820 </form>
821 </div>
711675bClaude822 </section>
823
824 {/* Deployments */}
825 <section class="pages-section">
826 <header class="pages-section-head">
827 <div class="pages-section-head-text">
828 <h2 class="pages-section-title">
829 <span class="pages-section-title-icon" aria-hidden="true">
830 <IconRocket />
831 </span>
832 Recent deployments
833 <span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted);font-weight:500;font-variant-numeric:tabular-nums">
834 {" "}({recent.length})
835 </span>
836 </h2>
837 <p class="pages-section-sub">
838 Latest 10 build attempts on this site. Trigger a manual
839 redeploy from <code>HEAD</code> of the source branch any time.
25a91a6Claude840 </p>
841 </div>
711675bClaude842 <form
843 method="post"
844 action={`/${ownerName}/${repoName}/settings/pages/redeploy`}
25a91a6Claude845 >
711675bClaude846 <button type="submit" class="pages-btn pages-btn-ghost">
847 Redeploy from HEAD
848 </button>
849 </form>
850 </header>
851 <div class="pages-section-body">
852 {recent.length === 0 ? (
853 <div class="pages-empty">
854 <div class="pages-empty-orb" aria-hidden="true" />
855 <div class="pages-empty-inner">
856 <div class="pages-empty-icon" aria-hidden="true">
857 <IconRocket />
858 </div>
859 <h3 class="pages-empty-title">Ship your first deploy</h3>
860 <p class="pages-empty-sub">
861 No deployments yet — push to{" "}
862 <code>{settings.sourceBranch}</code> or hit "Redeploy from
863 HEAD" to publish.
864 </p>
865 </div>
866 </div>
867 ) : (
868 <div class="pages-deploys">
869 <div class="pages-deploy-row is-head">
870 <div>When</div>
871 <div>Ref</div>
872 <div>Commit</div>
873 <div style="text-align:right">Status</div>
874 </div>
25a91a6Claude875 {recent.map((d) => (
711675bClaude876 <div class="pages-deploy-row">
877 <div>{shortDate(d.createdAt)}</div>
878 <div>
25a91a6Claude879 <code>{d.ref}</code>
711675bClaude880 </div>
881 <div>
25a91a6Claude882 <code>{d.commitSha.slice(0, 7)}</code>
711675bClaude883 </div>
884 <span class={statusPillClass(d.status)}>
885 <span class="dot" aria-hidden="true" />
25a91a6Claude886 {d.status}
711675bClaude887 </span>
888 </div>
25a91a6Claude889 ))}
711675bClaude890 </div>
891 )}
892 </div>
893 </section>
25a91a6Claude894 </div>
711675bClaude895 <style dangerouslySetInnerHTML={{ __html: pagesStyles }} />
25a91a6Claude896 </Layout>
897 );
898 }
899);
900
901// ---------------------------------------------------------------------------
902// Save settings: POST /:owner/:repo/settings/pages
903// ---------------------------------------------------------------------------
904
905pagesRoute.post(
906 "/:owner/:repo/settings/pages",
907 requireAuth,
908 async (c) => {
909 const { owner: ownerName, repo: repoName } = c.req.param();
910 const user = c.get("user")!;
911 const body = await c.req.parseBody();
912
913 const repoRow = await loadRepo(ownerName, repoName);
914 if (!repoRow) return c.notFound();
915 if (repoRow.ownerId !== user.id) {
916 return c.redirect(`/${ownerName}/${repoName}`);
917 }
918
919 const enabled = body.enabled === "1" || body.enabled === "on";
920 const sourceBranch =
921 String(body.source_branch || "gh-pages").trim() || "gh-pages";
922 let sourceDir = String(body.source_dir || "/").trim() || "/";
923 if (!sourceDir.startsWith("/")) sourceDir = `/${sourceDir}`;
924 const customDomainRaw = String(body.custom_domain || "").trim();
925 const customDomain = customDomainRaw === "" ? null : customDomainRaw;
926
927 try {
928 const [existing] = await db
929 .select({ repositoryId: pagesSettings.repositoryId })
930 .from(pagesSettings)
931 .where(eq(pagesSettings.repositoryId, repoRow.id))
932 .limit(1);
933 if (existing) {
934 await db
935 .update(pagesSettings)
936 .set({
937 enabled,
938 sourceBranch,
939 sourceDir,
940 customDomain,
941 updatedAt: new Date(),
942 })
943 .where(eq(pagesSettings.repositoryId, repoRow.id));
944 } else {
945 await db.insert(pagesSettings).values({
946 repositoryId: repoRow.id,
947 enabled,
948 sourceBranch,
949 sourceDir,
950 customDomain,
951 });
952 }
953 } catch (err) {
954 console.error("[pages] save settings:", err);
955 return c.redirect(
956 `/${ownerName}/${repoName}/settings/pages?error=${encodeURIComponent("Could not save settings")}`
957 );
958 }
959
960 await audit({
961 userId: user.id,
962 repositoryId: repoRow.id,
963 action: "pages.settings.update",
964 metadata: { enabled, sourceBranch, sourceDir, customDomain },
965 });
966
967 return c.redirect(
968 `/${ownerName}/${repoName}/settings/pages?success=${encodeURIComponent("Pages settings saved")}`
969 );
970 }
971);
972
973// ---------------------------------------------------------------------------
974// Manual redeploy: POST /:owner/:repo/settings/pages/redeploy
975// ---------------------------------------------------------------------------
976
977pagesRoute.post(
978 "/:owner/:repo/settings/pages/redeploy",
979 requireAuth,
980 async (c) => {
981 const { owner: ownerName, repo: repoName } = c.req.param();
982 const user = c.get("user")!;
983
984 const repoRow = await loadRepo(ownerName, repoName);
985 if (!repoRow) return c.notFound();
986 if (repoRow.ownerId !== user.id) {
987 return c.redirect(`/${ownerName}/${repoName}`);
988 }
989
990 const settings = await getEffectiveSettings(repoRow.id);
991 const branch = settings.sourceBranch || "gh-pages";
992 const ref = `refs/heads/${branch}`;
993
994 // Try to resolve the current head of the source branch. If the branch
995 // doesn't exist yet, tell the owner to push something to it instead of
996 // recording a bogus deployment row.
997 const sha = await resolveRef(ownerName, repoName, ref);
998 if (!sha) {
999 await audit({
1000 userId: user.id,
1001 repositoryId: repoRow.id,
1002 action: "pages.redeploy",
1003 metadata: { ref, result: "no-branch" },
1004 });
1005 return c.redirect(
1006 `/${ownerName}/${repoName}/settings/pages?info=${encodeURIComponent(`Branch ${branch} has no commits yet — push to it to deploy.`)}`
1007 );
1008 }
1009
1010 await onPagesPush({
1011 ownerLogin: ownerName,
1012 repoName,
1013 repositoryId: repoRow.id,
1014 ref,
1015 newSha: sha,
1016 triggeredByUserId: user.id,
1017 });
1018
1019 await audit({
1020 userId: user.id,
1021 repositoryId: repoRow.id,
1022 action: "pages.redeploy",
1023 metadata: { ref, sha },
1024 });
1025
1026 return c.redirect(
1027 `/${ownerName}/${repoName}/settings/pages?success=${encodeURIComponent("Redeploy recorded")}`
1028 );
1029 }
1030);
1031
1032export default pagesRoute;