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

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

components.tsxBlame353 lines · 1 contributor
fc1817aClaude1import type { FC } from "hono/jsx";
06d5ffeClaude2import { html } from "hono/html";
fc1817aClaude3import type { GitCommit, GitTreeEntry, GitDiffFile } from "../git/repository";
06d5ffeClaude4import type { Repository } from "../db/schema";
fc1817aClaude5
06d5ffeClaude6export const RepoHeader: FC<{
7 owner: string;
8 repo: string;
9 starCount?: number;
10 starred?: boolean;
11 currentUser?: string | null;
12}> = ({ owner, repo, starCount, starred, currentUser }) => (
fc1817aClaude13 <div class="repo-header">
14 <a href={`/${owner}`} class="owner">
15 {owner}
16 </a>
17 <span class="separator">/</span>
18 <a href={`/${owner}/${repo}`} class="name">
19 {repo}
20 </a>
06d5ffeClaude21 <div class="repo-header-actions">
22 {starCount !== undefined && (
23 currentUser ? (
24 <form method="POST" action={`/${owner}/${repo}/star`} style="display:inline">
25 <button
26 type="submit"
27 class={`star-btn${starred ? " starred" : ""}`}
28 >
29 {starred ? "\u2605" : "\u2606"} {starCount}
30 </button>
31 </form>
32 ) : (
33 <span class="star-btn">
34 {"\u2606"} {starCount}
35 </span>
36 )
37 )}
38 </div>
fc1817aClaude39 </div>
40);
41
42export const RepoNav: FC<{
43 owner: string;
44 repo: string;
45 active: "code" | "commits";
46}> = ({ owner, repo, active }) => (
47 <div class="repo-nav">
06d5ffeClaude48 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
fc1817aClaude49 Code
50 </a>
51 <a
52 href={`/${owner}/${repo}/commits`}
53 class={active === "commits" ? "active" : ""}
54 >
55 Commits
56 </a>
57 </div>
58);
59
06d5ffeClaude60export const BranchSwitcher: FC<{
61 owner: string;
62 repo: string;
63 currentRef: string;
64 branches: string[];
65 pathType: "tree" | "blob" | "commits";
66 subPath?: string;
67}> = ({ owner, repo, currentRef, branches, pathType, subPath }) => {
68 if (branches.length <= 1) {
69 return <div class="branch-selector">{currentRef}</div>;
70 }
71
72 return (
73 <div class="branch-dropdown">
74 <button class="branch-selector" type="button">
75 {currentRef} &#9662;
76 </button>
77 <div class="branch-dropdown-content">
78 {branches.map((branch) => {
79 let href: string;
80 if (pathType === "commits") {
81 href = `/${owner}/${repo}/commits/${branch}`;
82 } else if (subPath) {
83 href = `/${owner}/${repo}/${pathType}/${branch}/${subPath}`;
84 } else {
85 href = `/${owner}/${repo}/tree/${branch}`;
86 }
87 return (
88 <a
89 href={href}
90 class={branch === currentRef ? "active-branch" : ""}
91 >
92 {branch}
93 </a>
94 );
95 })}
96 </div>
97 </div>
98 );
99};
100
fc1817aClaude101export const Breadcrumb: FC<{
102 owner: string;
103 repo: string;
104 ref: string;
105 path: string;
106}> = ({ owner, repo, ref, path }) => {
107 const parts = path.split("/").filter(Boolean);
108 const crumbs: { name: string; href: string }[] = [
109 { name: repo, href: `/${owner}/${repo}/tree/${ref}` },
110 ];
111 let accumulated = "";
112 for (const part of parts) {
113 accumulated += (accumulated ? "/" : "") + part;
114 crumbs.push({
115 name: part,
116 href: `/${owner}/${repo}/tree/${ref}/${accumulated}`,
117 });
118 }
119 return (
120 <div class="breadcrumb">
121 {crumbs.map((crumb, i) => (
122 <>
123 {i > 0 && <span>/</span>}
124 {i === crumbs.length - 1 ? (
125 <strong>{crumb.name}</strong>
126 ) : (
127 <a href={crumb.href}>{crumb.name}</a>
128 )}
129 </>
130 ))}
131 </div>
132 );
133};
134
135export const FileTable: FC<{
136 entries: GitTreeEntry[];
137 owner: string;
138 repo: string;
139 ref: string;
140 path: string;
141}> = ({ entries, owner, repo, ref, path }) => (
142 <table class="file-table">
143 <tbody>
144 {entries.map((entry) => {
145 const fullPath = path ? `${path}/${entry.name}` : entry.name;
146 const href =
147 entry.type === "tree"
148 ? `/${owner}/${repo}/tree/${ref}/${fullPath}`
149 : `/${owner}/${repo}/blob/${ref}/${fullPath}`;
150 return (
151 <tr>
152 <td class="file-icon">
153 {entry.type === "tree" ? "\u{1F4C1}" : "\u{1F4C4}"}
154 </td>
155 <td class="file-name">
156 <a href={href}>{entry.name}</a>
157 </td>
158 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
159 {entry.size !== undefined ? formatSize(entry.size) : ""}
160 </td>
161 </tr>
162 );
163 })}
164 </tbody>
165 </table>
166);
167
06d5ffeClaude168export const HighlightedCode: FC<{
169 highlightedHtml: string;
170 lineCount: number;
171}> = ({ highlightedHtml, lineCount }) => {
172 const lineNums = Array.from({ length: lineCount }, (_, i) => i + 1);
173 return (
174 <div class="blob-code">
175 <table>
176 <tbody>
177 <tr>
178 <td class="line-num" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
179 <pre style="margin: 0; line-height: 1.6; font-size: 13px">
180 {lineNums.map((n) => (
181 <>
182 <span>{n}</span>
183 {"\n"}
184 </>
185 ))}
186 </pre>
187 </td>
188 <td class="line-content" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
189 <pre style="margin: 0; line-height: 1.6; font-size: 13px">{html([highlightedHtml] as unknown as TemplateStringsArray)}</pre>
190 </td>
191 </tr>
192 </tbody>
193 </table>
194 </div>
195 );
196};
197
198export const PlainCode: FC<{ lines: string[] }> = ({ lines }) => (
199 <div class="blob-code">
200 <table>
201 <tbody>
202 {lines.map((line, i) => (
203 <tr>
204 <td class="line-num">{i + 1}</td>
205 <td class="line-content">{line}</td>
206 </tr>
207 ))}
208 </tbody>
209 </table>
210 </div>
211);
212
fc1817aClaude213export const CommitList: FC<{
214 commits: GitCommit[];
215 owner: string;
216 repo: string;
217}> = ({ commits, owner, repo }) => (
218 <div class="commit-list">
219 {commits.map((commit) => (
220 <div class="commit-item">
221 <div>
222 <div class="commit-message">
223 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
224 {commit.message}
225 </a>
226 </div>
227 <div class="commit-meta">
06d5ffeClaude228 {commit.author} committed {formatRelativeDate(commit.date)}
fc1817aClaude229 </div>
230 </div>
231 <a
232 href={`/${owner}/${repo}/commit/${commit.sha}`}
233 class="commit-sha"
234 >
235 {commit.sha.slice(0, 7)}
236 </a>
237 </div>
238 ))}
239 </div>
240);
241
242export const DiffView: FC<{ raw: string; files: GitDiffFile[] }> = ({
243 raw,
244 files,
245}) => {
246 const sections = parseDiff(raw);
247
248 return (
249 <div class="diff-view">
250 <div style="margin-bottom: 16px; font-size: 14px; color: var(--text-muted);">
251 Showing{" "}
252 <strong style="color: var(--text)">{files.length}</strong> changed
253 file{files.length !== 1 ? "s" : ""} with{" "}
254 <span class="stat-add">
255 +{files.reduce((s, f) => s + f.additions, 0)}
256 </span>{" "}
257 and{" "}
258 <span class="stat-del">
259 -{files.reduce((s, f) => s + f.deletions, 0)}
260 </span>
261 </div>
262 {sections.map((section) => (
263 <div class="diff-file">
264 <div class="diff-file-header">{section.path}</div>
265 <div class="diff-content">
266 {section.lines.map((line) => {
267 let cls = "line";
268 if (line.startsWith("+")) cls += " line-add";
269 else if (line.startsWith("-")) cls += " line-del";
270 else if (line.startsWith("@@")) cls += " line-hunk";
271 return <span class={cls}>{line + "\n"}</span>;
272 })}
273 </div>
274 </div>
275 ))}
276 </div>
277 );
278};
279
06d5ffeClaude280export const RepoCard: FC<{ repo: Repository; ownerName: string }> = ({
281 repo,
282 ownerName,
283}) => (
284 <div class="card">
285 <h3>
286 <a href={`/${ownerName}/${repo.name}`}>{repo.name}</a>
287 </h3>
288 {repo.description && <p>{repo.description}</p>}
289 <div class="card-meta">
290 {repo.isPrivate && <span class="badge">Private</span>}
291 <span>{"\u2606"} {repo.starCount}</span>
292 {repo.pushedAt && (
293 <span>Updated {formatRelativeDate(repo.pushedAt.toString())}</span>
294 )}
295 </div>
296 </div>
297);
298
fc1817aClaude299function parseDiff(raw: string): Array<{ path: string; lines: string[] }> {
300 const sections: Array<{ path: string; lines: string[] }> = [];
301 const diffRegex = /^diff --git a\/(.+?) b\/.+$/;
302 let current: { path: string; lines: string[] } | null = null;
303
304 for (const line of raw.split("\n")) {
305 const match = line.match(diffRegex);
306 if (match) {
307 if (current) sections.push(current);
308 current = { path: match[1], lines: [] };
309 continue;
310 }
311 if (current && !line.startsWith("diff --git")) {
312 if (
313 line.startsWith("index ") ||
314 line.startsWith("--- ") ||
315 line.startsWith("+++ ") ||
316 line.startsWith("new file") ||
317 line.startsWith("deleted file") ||
318 line.startsWith("old mode") ||
319 line.startsWith("new mode")
320 ) {
321 continue;
322 }
323 current.lines.push(line);
324 }
325 }
326 if (current) sections.push(current);
327 return sections;
328}
329
330function formatSize(bytes: number): string {
331 if (bytes < 1024) return `${bytes} B`;
332 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
333 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
334}
335
336function formatRelativeDate(dateStr: string): string {
337 const date = new Date(dateStr);
338 const now = new Date();
339 const diffMs = now.getTime() - date.getTime();
340 const diffMins = Math.floor(diffMs / 60000);
341 if (diffMins < 1) return "just now";
342 if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
343 const diffHours = Math.floor(diffMins / 60);
344 if (diffHours < 24)
345 return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
346 const diffDays = Math.floor(diffHours / 24);
347 if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
348 return date.toLocaleDateString("en-US", {
349 month: "short",
350 day: "numeric",
351 year: "numeric",
352 });
353}