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

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

web.tsxBlame683 lines · 1 contributor
fc1817aClaude1/**
2 * Web UI routes — browse repositories, code, commits, diffs.
06d5ffeClaude3 * Now auth-aware with user profiles, repo creation, stars, and syntax highlighting.
fc1817aClaude4 */
5
6import { Hono } from "hono";
06d5ffeClaude7import { eq, and, desc } from "drizzle-orm";
8import { db } from "../db";
9import { users, repositories, stars } from "../db/schema";
fc1817aClaude10import { Layout } from "../views/layout";
11import {
12 RepoHeader,
13 RepoNav,
14 Breadcrumb,
15 FileTable,
16 CommitList,
17 DiffView,
06d5ffeClaude18 RepoCard,
19 BranchSwitcher,
20 HighlightedCode,
21 PlainCode,
fc1817aClaude22} from "../views/components";
23import {
24 getTree,
25 getBlob,
26 listCommits,
27 getCommit,
28 getCommitFullMessage,
29 getDiff,
30 getReadme,
31 getDefaultBranch,
32 listBranches,
33 repoExists,
06d5ffeClaude34 initBareRepo,
fc1817aClaude35} from "../git/repository";
06d5ffeClaude36import { highlightCode } from "../lib/highlight";
37import { softAuth, requireAuth } from "../middleware/auth";
38import type { AuthEnv } from "../middleware/auth";
fc1817aClaude39
06d5ffeClaude40const web = new Hono<AuthEnv>();
41
42// Soft auth on all web routes — c.get("user") available but may be null
43web.use("*", softAuth);
fc1817aClaude44
45// Home page
06d5ffeClaude46web.get("/", async (c) => {
47 const user = c.get("user");
48
49 if (user) {
50 // Show user's repos
51 const repos = await db
52 .select()
53 .from(repositories)
54 .where(eq(repositories.ownerId, user.id))
55 .orderBy(desc(repositories.updatedAt));
56
57 return c.html(
58 <Layout title="Dashboard" user={user}>
59 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px">
60 <h2>Your repositories</h2>
61 <a href="/new" class="btn btn-primary">
62 + New repository
63 </a>
64 </div>
65 {repos.length === 0 ? (
66 <div class="empty-state">
67 <h2>No repositories yet</h2>
68 <p>Create your first repository to get started.</p>
69 </div>
70 ) : (
71 <div class="card-grid">
72 {repos.map((repo) => (
73 <RepoCard repo={repo} ownerName={user.username} />
74 ))}
75 </div>
76 )}
77 </Layout>
78 );
79 }
80
fc1817aClaude81 return c.html(
06d5ffeClaude82 <Layout user={null}>
fc1817aClaude83 <div class="empty-state">
84 <h2>gluecron</h2>
85 <p>AI-native code intelligence platform</p>
06d5ffeClaude86 <div style="margin-top: 24px; display: flex; gap: 12px; justify-content: center">
87 <a href="/register" class="btn btn-primary">
88 Get started
89 </a>
90 <a href="/login" class="btn">
91 Sign in
92 </a>
93 </div>
94 <pre style="margin-top: 32px">{`# Quick start
fc1817aClaude95curl -X POST http://localhost:3000/api/setup \\
96 -H 'Content-Type: application/json' \\
97 -d '{"username":"you","email":"you@dev.com","repoName":"hello"}'
98
99git remote add gluecron http://localhost:3000/you/hello.git
100git push gluecron main`}</pre>
101 </div>
102 </Layout>
103 );
104});
105
06d5ffeClaude106// New repository form
107web.get("/new", requireAuth, (c) => {
108 const user = c.get("user")!;
109 const error = c.req.query("error");
110
111 return c.html(
112 <Layout title="New repository" user={user}>
113 <div class="new-repo-form">
114 <h2>Create a new repository</h2>
115 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
116 <form method="POST" action="/new">
117 <div class="form-group">
118 <label>Owner</label>
119 <input type="text" value={user.username} disabled class="input-disabled" />
120 </div>
121 <div class="form-group">
122 <label for="name">Repository name</label>
123 <input
124 type="text"
125 id="name"
126 name="name"
127 required
128 pattern="^[a-zA-Z0-9._-]+$"
129 placeholder="my-project"
130 autocomplete="off"
131 />
132 </div>
133 <div class="form-group">
134 <label for="description">Description (optional)</label>
135 <input
136 type="text"
137 id="description"
138 name="description"
139 placeholder="A short description of your repository"
140 />
141 </div>
142 <div class="visibility-options">
143 <label class="visibility-option">
144 <input type="radio" name="visibility" value="public" checked />
145 <div class="vis-label">Public</div>
146 <div class="vis-desc">Anyone can see this repository</div>
147 </label>
148 <label class="visibility-option">
149 <input type="radio" name="visibility" value="private" />
150 <div class="vis-label">Private</div>
151 <div class="vis-desc">Only you can see this repository</div>
152 </label>
153 </div>
154 <button type="submit" class="btn btn-primary">
155 Create repository
156 </button>
157 </form>
158 </div>
159 </Layout>
160 );
161});
162
163web.post("/new", requireAuth, async (c) => {
164 const user = c.get("user")!;
165 const body = await c.req.parseBody();
166 const name = String(body.name || "").trim();
167 const description = String(body.description || "").trim();
168 const isPrivate = body.visibility === "private";
169
170 if (!name) {
171 return c.redirect("/new?error=Repository+name+is+required");
172 }
173
174 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
175 return c.redirect("/new?error=Invalid+repository+name");
176 }
177
178 if (await repoExists(user.username, name)) {
179 return c.redirect("/new?error=Repository+already+exists");
180 }
181
182 const diskPath = await initBareRepo(user.username, name);
183
184 await db.insert(repositories).values({
185 name,
186 ownerId: user.id,
187 description: description || null,
188 isPrivate,
189 diskPath,
190 });
191
192 return c.redirect(`/${user.username}/${name}`);
193});
194
195// User profile
fc1817aClaude196web.get("/:owner", async (c) => {
06d5ffeClaude197 const { owner: ownerName } = c.req.param();
198 const user = c.get("user");
199
200 // Avoid clashing with fixed routes
201 if (
202 ["login", "register", "logout", "new", "settings", "api"].includes(
203 ownerName
204 )
205 ) {
206 return c.notFound();
207 }
208
209 let ownerUser;
210 try {
211 const [found] = await db
212 .select()
213 .from(users)
214 .where(eq(users.username, ownerName))
215 .limit(1);
216 ownerUser = found;
217 } catch {
218 // DB not available — check if repos exist on disk
219 ownerUser = null;
220 }
221
222 // Even without DB, show repos if they exist on disk
223 let repos: any[] = [];
224 if (ownerUser) {
225 const allRepos = await db
226 .select()
227 .from(repositories)
228 .where(eq(repositories.ownerId, ownerUser.id))
229 .orderBy(desc(repositories.updatedAt));
230
231 // Show public repos to everyone, private only to owner
232 repos =
233 user?.id === ownerUser.id
234 ? allRepos
235 : allRepos.filter((r) => !r.isPrivate);
236 }
237
fc1817aClaude238 return c.html(
06d5ffeClaude239 <Layout title={ownerName} user={user}>
240 <div class="user-profile">
241 <div class="user-avatar">
242 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
243 </div>
244 <div class="user-info">
245 <h2>{ownerUser?.displayName || ownerName}</h2>
246 <div class="username">@{ownerName}</div>
247 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
248 </div>
249 </div>
250 <h3 style="margin-bottom: 16px">Repositories</h3>
251 {repos.length === 0 ? (
252 <p style="color: var(--text-muted)">No repositories yet.</p>
253 ) : (
254 <div class="card-grid">
255 {repos.map((repo) => (
256 <RepoCard repo={repo} ownerName={ownerName} />
257 ))}
258 </div>
259 )}
fc1817aClaude260 </Layout>
261 );
262});
263
06d5ffeClaude264// Star/unstar a repo
265web.post("/:owner/:repo/star", requireAuth, async (c) => {
266 const { owner: ownerName, repo: repoName } = c.req.param();
267 const user = c.get("user")!;
268
269 try {
270 const [ownerUser] = await db
271 .select()
272 .from(users)
273 .where(eq(users.username, ownerName))
274 .limit(1);
275 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
276
277 const [repo] = await db
278 .select()
279 .from(repositories)
280 .where(
281 and(
282 eq(repositories.ownerId, ownerUser.id),
283 eq(repositories.name, repoName)
284 )
285 )
286 .limit(1);
287 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
288
289 // Toggle star
290 const [existing] = await db
291 .select()
292 .from(stars)
293 .where(
294 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
295 )
296 .limit(1);
297
298 if (existing) {
299 await db.delete(stars).where(eq(stars.id, existing.id));
300 await db
301 .update(repositories)
302 .set({ starCount: Math.max(0, repo.starCount - 1) })
303 .where(eq(repositories.id, repo.id));
304 } else {
305 await db.insert(stars).values({
306 userId: user.id,
307 repositoryId: repo.id,
308 });
309 await db
310 .update(repositories)
311 .set({ starCount: repo.starCount + 1 })
312 .where(eq(repositories.id, repo.id));
313 }
314 } catch {
315 // DB error — ignore
316 }
317
318 return c.redirect(`/${ownerName}/${repoName}`);
319});
320
fc1817aClaude321// Repository overview — file tree at HEAD
322web.get("/:owner/:repo", async (c) => {
323 const { owner, repo } = c.req.param();
06d5ffeClaude324 const user = c.get("user");
fc1817aClaude325
326 if (!(await repoExists(owner, repo))) {
327 return c.html(
06d5ffeClaude328 <Layout title="Not Found" user={user}>
fc1817aClaude329 <div class="empty-state">
330 <h2>Repository not found</h2>
331 <p>
332 {owner}/{repo} does not exist.
333 </p>
334 </div>
335 </Layout>,
336 404
337 );
338 }
339
340 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude341 const branches = await listBranches(owner, repo);
fc1817aClaude342 const tree = await getTree(owner, repo, defaultBranch);
343
06d5ffeClaude344 // Get star info if user logged in
345 let starCount = 0;
346 let starred = false;
347 try {
348 const [ownerUser] = await db
349 .select()
350 .from(users)
351 .where(eq(users.username, owner))
352 .limit(1);
353 if (ownerUser) {
354 const [repoRow] = await db
355 .select()
356 .from(repositories)
357 .where(
358 and(
359 eq(repositories.ownerId, ownerUser.id),
360 eq(repositories.name, repo)
361 )
362 )
363 .limit(1);
364 if (repoRow) {
365 starCount = repoRow.starCount;
366 if (user) {
367 const [star] = await db
368 .select()
369 .from(stars)
370 .where(
371 and(
372 eq(stars.userId, user.id),
373 eq(stars.repositoryId, repoRow.id)
374 )
375 )
376 .limit(1);
377 starred = !!star;
378 }
379 }
380 }
381 } catch {
382 // DB not available
383 }
384
fc1817aClaude385 if (tree.length === 0) {
386 return c.html(
06d5ffeClaude387 <Layout title={`${owner}/${repo}`} user={user}>
388 <RepoHeader
389 owner={owner}
390 repo={repo}
391 starCount={starCount}
392 starred={starred}
393 currentUser={user?.username}
394 />
fc1817aClaude395 <RepoNav owner={owner} repo={repo} active="code" />
396 <div class="empty-state">
397 <h2>Empty repository</h2>
398 <p>Get started by pushing code:</p>
399 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
400git push -u gluecron main`}</pre>
401 </div>
402 </Layout>
403 );
404 }
405
406 const readme = await getReadme(owner, repo, defaultBranch);
407
408 return c.html(
06d5ffeClaude409 <Layout title={`${owner}/${repo}`} user={user}>
410 <RepoHeader
411 owner={owner}
412 repo={repo}
413 starCount={starCount}
414 starred={starred}
415 currentUser={user?.username}
416 />
fc1817aClaude417 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude418 <BranchSwitcher
419 owner={owner}
420 repo={repo}
421 currentRef={defaultBranch}
422 branches={branches}
423 pathType="tree"
424 />
fc1817aClaude425 <FileTable
426 entries={tree}
427 owner={owner}
428 repo={repo}
429 ref={defaultBranch}
430 path=""
431 />
432 {readme && (
06d5ffeClaude433 <div class="blob-view" style="margin-top: 20px">
fc1817aClaude434 <div class="blob-header">README.md</div>
435 <div style="padding: 16px; white-space: pre-wrap; font-size: 14px;">
436 {readme}
437 </div>
438 </div>
439 )}
440 </Layout>
441 );
442});
443
444// Browse tree at ref/path
445web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
446 const { owner, repo } = c.req.param();
06d5ffeClaude447 const user = c.get("user");
fc1817aClaude448 const refAndPath = c.req.param("ref");
449
450 const branches = await listBranches(owner, repo);
451 let ref = "";
452 let treePath = "";
453
454 for (const branch of branches) {
455 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
456 ref = branch;
457 treePath = refAndPath.slice(branch.length + 1);
458 break;
459 }
460 }
461
462 if (!ref) {
463 const slashIdx = refAndPath.indexOf("/");
464 if (slashIdx === -1) {
465 ref = refAndPath;
466 } else {
467 ref = refAndPath.slice(0, slashIdx);
468 treePath = refAndPath.slice(slashIdx + 1);
469 }
470 }
471
472 const tree = await getTree(owner, repo, ref, treePath);
473
474 return c.html(
06d5ffeClaude475 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude476 <RepoHeader owner={owner} repo={repo} />
477 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude478 <BranchSwitcher
479 owner={owner}
480 repo={repo}
481 currentRef={ref}
482 branches={branches}
483 pathType="tree"
484 subPath={treePath}
485 />
fc1817aClaude486 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
487 <FileTable
488 entries={tree}
489 owner={owner}
490 repo={repo}
491 ref={ref}
492 path={treePath}
493 />
494 </Layout>
495 );
496});
497
06d5ffeClaude498// View file blob with syntax highlighting
fc1817aClaude499web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
500 const { owner, repo } = c.req.param();
06d5ffeClaude501 const user = c.get("user");
fc1817aClaude502 const refAndPath = c.req.param("ref");
503
504 const branches = await listBranches(owner, repo);
505 let ref = "";
506 let filePath = "";
507
508 for (const branch of branches) {
509 if (refAndPath.startsWith(branch + "/")) {
510 ref = branch;
511 filePath = refAndPath.slice(branch.length + 1);
512 break;
513 }
514 }
515
516 if (!ref) {
517 const slashIdx = refAndPath.indexOf("/");
518 if (slashIdx === -1) return c.text("Not found", 404);
519 ref = refAndPath.slice(0, slashIdx);
520 filePath = refAndPath.slice(slashIdx + 1);
521 }
522
523 const blob = await getBlob(owner, repo, ref, filePath);
524 if (!blob) {
525 return c.html(
06d5ffeClaude526 <Layout title="Not Found" user={user}>
fc1817aClaude527 <div class="empty-state">
528 <h2>File not found</h2>
529 </div>
530 </Layout>,
531 404
532 );
533 }
534
06d5ffeClaude535 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude536
537 return c.html(
06d5ffeClaude538 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude539 <RepoHeader owner={owner} repo={repo} />
540 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude541 <BranchSwitcher
542 owner={owner}
543 repo={repo}
544 currentRef={ref}
545 branches={branches}
546 pathType="blob"
547 subPath={filePath}
548 />
fc1817aClaude549 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
550 <div class="blob-view">
551 <div class="blob-header">
06d5ffeClaude552 <span>{fileName} — {blob.size} bytes</span>
fc1817aClaude553 </div>
554 {blob.isBinary ? (
555 <div style="padding: 16px; color: var(--text-muted)">
556 Binary file not shown.
557 </div>
06d5ffeClaude558 ) : (() => {
559 const { html: highlighted, language } = highlightCode(
560 blob.content,
561 fileName
562 );
563 const lineCount = blob.content.split("\n").length;
564 // Trim trailing newline from count
565 const adjustedCount =
566 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
567
568 if (language) {
569 return (
570 <HighlightedCode
571 highlightedHtml={highlighted}
572 lineCount={adjustedCount}
573 />
574 );
575 }
576 const lines = blob.content.split("\n");
577 if (lines[lines.length - 1] === "") lines.pop();
578 return <PlainCode lines={lines} />;
579 })()}
fc1817aClaude580 </div>
581 </Layout>
582 );
583});
584
585// Commit log
586web.get("/:owner/:repo/commits/:ref?", async (c) => {
587 const { owner, repo } = c.req.param();
06d5ffeClaude588 const user = c.get("user");
fc1817aClaude589 const ref =
590 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude591 const branches = await listBranches(owner, repo);
fc1817aClaude592
593 const commits = await listCommits(owner, repo, ref, 50);
594
595 return c.html(
06d5ffeClaude596 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude597 <RepoHeader owner={owner} repo={repo} />
598 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude599 <BranchSwitcher
600 owner={owner}
601 repo={repo}
602 currentRef={ref}
603 branches={branches}
604 pathType="commits"
605 />
fc1817aClaude606 {commits.length === 0 ? (
607 <div class="empty-state">
608 <p>No commits yet.</p>
609 </div>
610 ) : (
611 <CommitList commits={commits} owner={owner} repo={repo} />
612 )}
613 </Layout>
614 );
615});
616
617// Single commit with diff
618web.get("/:owner/:repo/commit/:sha", async (c) => {
619 const { owner, repo, sha } = c.req.param();
06d5ffeClaude620 const user = c.get("user");
fc1817aClaude621
622 const commit = await getCommit(owner, repo, sha);
623 if (!commit) {
624 return c.html(
06d5ffeClaude625 <Layout title="Not Found" user={user}>
fc1817aClaude626 <div class="empty-state">
627 <h2>Commit not found</h2>
628 </div>
629 </Layout>,
630 404
631 );
632 }
633
634 const fullMessage = await getCommitFullMessage(owner, repo, sha);
635 const { files, raw } = await getDiff(owner, repo, sha);
636
637 return c.html(
06d5ffeClaude638 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude639 <RepoHeader owner={owner} repo={repo} />
640 <div
641 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
642 >
643 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
644 {commit.message}
645 </div>
646 {fullMessage !== commit.message && (
647 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
648 {fullMessage}
649 </div>
650 )}
651 <div style="font-size: 13px; color: var(--text-muted)">
652 <strong style="color: var(--text)">{commit.author}</strong>{" "}
653 committed on{" "}
654 {new Date(commit.date).toLocaleDateString("en-US", {
655 month: "long",
656 day: "numeric",
657 year: "numeric",
658 })}
659 </div>
660 <div style="margin-top: 8px">
661 <span class="commit-sha">{commit.sha}</span>
662 {commit.parentShas.length > 0 && (
663 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
664 Parent:{" "}
665 {commit.parentShas.map((p) => (
666 <a
667 href={`/${owner}/${repo}/commit/${p}`}
668 class="commit-sha"
669 style="margin-left: 4px"
670 >
671 {p.slice(0, 7)}
672 </a>
673 ))}
674 </span>
675 )}
676 </div>
677 </div>
678 <DiffView raw={raw} files={files} />
679 </Layout>
680 );
681});
682
683export default web;