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