Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitbb0f894unknown_key

feat: refactor all remaining routes to use UI component library

feat: refactor all remaining routes to use UI component library

Refactored auth, issues, pulls, editor, webhooks, repo-settings,
compare, and contributors routes from raw inline HTML to composable
UI components (Form, FormGroup, Input, Button, Alert, Container,
Flex, PageHeader, Badge, EmptyState, etc.)

Also extended Input component with minLength/maxLength props.
All 108 tests pass, TypeScript compiles clean.

https://claude.ai/code/session_01EQhj4UBSQDKmb7UwUgtWK7
Claude committed on April 18, 2026Parent: 3e8f8e8
9 files changed+478521bb0f894c21ae8ef210c60f89e7850e1a9f17a3df
9 changed files+478−521
Modifiedsrc/routes/auth.tsx+35−38View fileUnifiedSplit
1515 sessionExpiry,
1616} from "../lib/auth";
1717import { Layout } from "../views/layout";
18import {
19 Form,
20 FormGroup,
21 Input,
22 Button,
23 Alert,
24 Text,
25} from "../views/ui";
1826import type { AuthEnv } from "../middleware/auth";
1927
2028const auth = new Hono<AuthEnv>();
2735 <Layout title="Register">
2836 <div class="auth-container">
2937 <h2>Create account</h2>
30 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
31 <form method="post" action="/register">
32 <div class="form-group">
33 <label for="username">Username</label>
34 <input
38 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
39 <Form action="/register">
40 <FormGroup label="Username" htmlFor="username">
41 <Input
3542 type="text"
36 id="username"
3743 name="username"
3844 required
3945 pattern="^[a-zA-Z0-9_-]+$"
4248 placeholder="your-username"
4349 autocomplete="username"
4450 />
45 </div>
46 <div class="form-group">
47 <label for="email">Email</label>
48 <input
51 </FormGroup>
52 <FormGroup label="Email" htmlFor="email">
53 <Input
4954 type="email"
50 id="email"
5155 name="email"
5256 required
5357 placeholder="you@example.com"
5458 autocomplete="email"
5559 />
56 </div>
57 <div class="form-group">
58 <label for="password">Password</label>
59 <input
60 </FormGroup>
61 <FormGroup label="Password" htmlFor="password">
62 <Input
6063 type="password"
61 id="password"
6264 name="password"
6365 required
6466 minLength={8}
6567 placeholder="Min 8 characters"
6668 autocomplete="new-password"
6769 />
68 </div>
69 <button type="submit" class="btn btn-primary">
70 </FormGroup>
71 <Button type="submit" variant="primary">
7072 Create account
71 </button>
72 </form>
73 </Button>
74 </Form>
7375 <p class="auth-switch">
74 Already have an account? <a href="/login">Sign in</a>
76 <Text>Already have an account? <a href="/login">Sign in</a></Text>
7577 </p>
7678 </div>
7779 </Layout>
145147 <Layout title="Sign in">
146148 <div class="auth-container">
147149 <h2>Sign in</h2>
148 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
149 <form
150 method="post"
150 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
151 <Form
151152 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
152153 >
153 <div class="form-group">
154 <label for="username">Username or email</label>
155 <input
154 <FormGroup label="Username or email" htmlFor="username">
155 <Input
156156 type="text"
157 id="username"
158157 name="username"
159158 required
160159 placeholder="username or email"
161160 autocomplete="username"
162161 />
163 </div>
164 <div class="form-group">
165 <label for="password">Password</label>
166 <input
162 </FormGroup>
163 <FormGroup label="Password" htmlFor="password">
164 <Input
167165 type="password"
168 id="password"
169166 name="password"
170167 required
171168 placeholder="Password"
172169 autocomplete="current-password"
173170 />
174 </div>
175 <button type="submit" class="btn btn-primary">
171 </FormGroup>
172 <Button type="submit" variant="primary">
176173 Sign in
177 </button>
178 </form>
174 </Button>
175 </Form>
179176 <p class="auth-switch">
180 New to gluecron? <a href="/register">Create an account</a>
177 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
181178 </p>
182179 </div>
183180 </Layout>
Modifiedsrc/routes/compare.tsx+33−28View fileUnifiedSplit
1717import { softAuth } from "../middleware/auth";
1818import type { AuthEnv } from "../middleware/auth";
1919import type { GitDiffFile } from "../git/repository";
20import {
21 EmptyState,
22 Flex,
23 Select,
24 Button,
25 Text,
26} from "../views/ui";
2027
2128const compare = new Hono<AuthEnv>();
2229
3037 if (!(await repoExists(owner, repo))) {
3138 return c.html(
3239 <Layout title="Not Found" user={user}>
33 <div class="empty-state">
34 <h2>Repository not found</h2>
35 </div>
40 <EmptyState title="Repository not found" />
3641 </Layout>,
3742 404
3843 );
5156 <form
5257 method="get"
5358 action={`/${owner}/${repo}/compare`}
54 style="display: flex; gap: 12px; align-items: center; margin-bottom: 20px"
5559 >
56 <select name="base" class="branch-selector" style="cursor: pointer">
57 {branches.map((b) => (
58 <option value={b} selected={b === defaultBase}>
59 {b}
60 </option>
61 ))}
62 </select>
63 <span style="color: var(--text-muted)">...</span>
64 <select name="head" class="branch-selector" style="cursor: pointer">
65 {branches.map((b) => (
66 <option value={b} selected={b !== defaultBase}>
67 {b}
68 </option>
69 ))}
70 </select>
71 <button
72 type="submit"
73 class="btn btn-primary"
74 onclick={`this.form.action='/${owner}/${repo}/compare/'+this.form.base.value+'...'+this.form.head.value; return true;`}
75 >
76 Compare
77 </button>
60 <Flex gap={12} align="center" style="margin-bottom: 20px">
61 <Select name="base">
62 {branches.map((b) => (
63 <option value={b} selected={b === defaultBase}>
64 {b}
65 </option>
66 ))}
67 </Select>
68 <Text muted>...</Text>
69 <Select name="head">
70 {branches.map((b) => (
71 <option value={b} selected={b !== defaultBase}>
72 {b}
73 </option>
74 ))}
75 </Select>
76 <Button
77 type="submit"
78 variant="primary"
79 >
80 Compare
81 </Button>
82 </Flex>
7883 </form>
7984 </Layout>
8085 );
143148 <h2 style="margin-bottom: 8px">
144149 Comparing {base}...{head}
145150 </h2>
146 <div style="margin-bottom: 20px; font-size: 14px; color: var(--text-muted)">
151 <Text size={14} muted style="display:block;margin-bottom:20px">
147152 {commitsBetween.length} commit{commitsBetween.length !== 1 ? "s" : ""}
148 </div>
153 </Text>
149154
150155 {commitsBetween.length > 0 && (
151156 <div class="commit-list" style="margin-bottom: 24px">
Modifiedsrc/routes/contributors.tsx+42−37View fileUnifiedSplit
88import { getRepoPath, repoExists, getDefaultBranch } from "../git/repository";
99import { softAuth } from "../middleware/auth";
1010import type { AuthEnv } from "../middleware/auth";
11import {
12 Avatar,
13 Card,
14 Flex,
15 List,
16 ListItem,
17 PageHeader,
18 Text,
19 Tooltip,
20} from "../views/ui";
1121
1222const contributors = new Hono<AuthEnv>();
1323
8999 <Layout title={`Contributors — ${owner}/${repo}`} user={user}>
90100 <RepoHeader owner={owner} repo={repo} />
91101 <RepoNav owner={owner} repo={repo} active="code" />
92 <h2 style="margin-bottom: 16px">Contributors</h2>
93
94 <div
95 style="margin-bottom: 24px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px"
96 >
97 <div style="font-size: 13px; color: var(--text-muted); margin-bottom: 8px">
98 Commit activity — last year
99 </div>
100 <div style="display: flex; gap: 2px; align-items: flex-end; height: 60px">
102 <PageHeader title="Contributors" />
103
104 <Card style="margin-bottom:24px;padding:16px">
105 <Text size={13} muted>Commit activity — last year</Text>
106 <Flex gap={2} align="flex-end" style="height:60px;margin-top:8px">
101107 {weekCounts.map((count) => (
102 <div
103 style={`flex: 1; background: var(--green); opacity: ${count === 0 ? "0.1" : Math.max(0.3, count / maxWeek).toFixed(2)}; height: ${count === 0 ? "2px" : Math.max(4, (count / maxWeek) * 60).toFixed(0) + "px"}; border-radius: 1px;`}
104 title={`${count} commits`}
105 />
108 <Tooltip text={`${count} commits`}>
109 <div
110 style={`flex:1;background:var(--green);opacity:${count === 0 ? "0.1" : Math.max(0.3, count / maxWeek).toFixed(2)};height:${count === 0 ? "2px" : Math.max(4, (count / maxWeek) * 60).toFixed(0) + "px"};border-radius:1px;`}
111 />
112 </Tooltip>
106113 ))}
107 </div>
108 </div>
109
110 <div class="issue-list">
111 {contribs.map((contrib, i) => (
112 <div class="issue-item">
113 <div style="display: flex; align-items: center; gap: 12px; flex: 1">
114 <div class="user-avatar" style="width: 36px; height: 36px; font-size: 16px; flex-shrink: 0">
115 {contrib.name[0].toUpperCase()}
116 </div>
114 </Flex>
115 </Card>
116
117 <List>
118 {contribs.map((contrib) => (
119 <ListItem>
120 <Flex align="center" gap={12} style="flex:1">
121 <Avatar name={contrib.name} size={36} />
117122 <div>
118 <div style="font-weight: 600; font-size: 14px">
123 <Text size={14} weight={600}>
119124 {contrib.name}
120 </div>
121 <div style="font-size: 12px; color: var(--text-muted)">
125 </Text>
126 <br />
127 <Text size={12} muted>
122128 {contrib.email}
123 </div>
129 </Text>
124130 </div>
125 </div>
126 <div style="text-align: right">
127 <span style="font-weight: 600; font-size: 14px">
131 </Flex>
132 <div style="text-align:right">
133 <Text size={14} weight={600}>
128134 {contrib.commits}
129 </span>
130 <span style="color: var(--text-muted); font-size: 13px">
131 {" "}
132 commit{contrib.commits !== 1 ? "s" : ""}
133 </span>
135 </Text>
136 <Text size={13} muted>
137 {" "}commit{contrib.commits !== 1 ? "s" : ""}
138 </Text>
134139 </div>
135 </div>
140 </ListItem>
136141 ))}
137 </div>
142 </List>
138143 </Layout>
139144 );
140145});
Modifiedsrc/routes/editor.tsx+54−50View fileUnifiedSplit
55import { Hono } from "hono";
66import { Layout } from "../views/layout";
77import { RepoHeader, RepoNav, Breadcrumb } from "../views/components";
8import {
9 Container,
10 Flex,
11 Form,
12 FormGroup,
13 Input,
14 TextArea,
15 Button,
16 LinkButton,
17 EmptyState,
18 Text,
19} from "../views/ui";
820import {
921 getBlob,
1022 getDefaultBranch,
3446 <Layout title={`New file — ${owner}/${repo}`} user={user}>
3547 <RepoHeader owner={owner} repo={repo} />
3648 <RepoNav owner={owner} repo={repo} active="code" />
37 <div style="max-width: 900px">
49 <Container maxWidth={900}>
3850 <h2 style="margin-bottom: 16px">Create new file</h2>
39 <form method="post" action={`/${owner}/${repo}/new/${ref}`}>
51 <Form action={`/${owner}/${repo}/new/${ref}`}>
4052 <input type="hidden" name="dir_path" value={dirPath} />
41 <div class="form-group">
42 <label>File path</label>
43 <div style="display: flex; align-items: center; gap: 4px">
53 <FormGroup label="File path">
54 <Flex align="center" gap={4}>
4455 {dirPath && (
45 <span style="color: var(--text-muted); font-size: 14px">
56 <Text muted size={14}>
4657 {dirPath}/
47 </span>
58 </Text>
4859 )}
49 <input
50 type="text"
60 <Input
5161 name="filename"
5262 required
5363 placeholder="filename.ts"
5464 style="flex: 1"
5565 autocomplete="off"
5666 />
57 </div>
58 </div>
59 <div class="form-group">
60 <label>Content</label>
61 <textarea
67 </Flex>
68 </FormGroup>
69 <FormGroup label="Content">
70 <TextArea
6271 name="content"
6372 rows={20}
64 style="font-family: var(--font-mono); font-size: 13px; line-height: 1.5; tab-size: 2"
6573 placeholder="Enter file content..."
74 mono
75 style="line-height: 1.5; tab-size: 2"
6676 />
67 </div>
68 <div class="form-group">
69 <label>Commit message</label>
70 <input
71 type="text"
77 </FormGroup>
78 <FormGroup label="Commit message">
79 <Input
7280 name="message"
7381 placeholder="Create new file"
7482 required
7583 />
76 </div>
77 <button type="submit" class="btn btn-primary">
84 </FormGroup>
85 <Button type="submit" variant="primary">
7886 Commit new file
79 </button>
80 </form>
81 </div>
87 </Button>
88 </Form>
89 </Container>
8290 </Layout>
8391 );
8492});
196204 if (!blob || blob.isBinary) {
197205 return c.html(
198206 <Layout title="Cannot edit" user={user}>
199 <div class="empty-state">
200 <h2>{blob?.isBinary ? "Cannot edit binary file" : "File not found"}</h2>
201 </div>
207 <EmptyState title={blob?.isBinary ? "Cannot edit binary file" : "File not found"} />
202208 </Layout>,
203209 404
204210 );
209215 <RepoHeader owner={owner} repo={repo} />
210216 <RepoNav owner={owner} repo={repo} active="code" />
211217 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
212 <div style="max-width: 900px">
213 <form method="post" action={`/${owner}/${repo}/edit/${ref}/${filePath}`}>
214 <div class="form-group">
215 <textarea
218 <Container maxWidth={900}>
219 <Form action={`/${owner}/${repo}/edit/${ref}/${filePath}`}>
220 <FormGroup>
221 <TextArea
216222 name="content"
217223 rows={25}
218 style="font-family: var(--font-mono); font-size: 13px; line-height: 1.5; tab-size: 2; width: 100%"
219 >
220 {blob.content}
221 </textarea>
222 </div>
223 <div class="form-group">
224 <label>Commit message</label>
225 <input
226 type="text"
224 value={blob.content}
225 mono
226 style="line-height: 1.5; tab-size: 2; width: 100%"
227 />
228 </FormGroup>
229 <FormGroup label="Commit message">
230 <Input
227231 name="message"
228232 placeholder={`Update ${filePath.split("/").pop()}`}
229233 required
230234 />
231 </div>
232 <div style="display: flex; gap: 8px">
233 <button type="submit" class="btn btn-primary">
235 </FormGroup>
236 <Flex gap={8}>
237 <Button type="submit" variant="primary">
234238 Commit changes
235 </button>
236 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} class="btn">
239 </Button>
240 <LinkButton href={`/${owner}/${repo}/blob/${ref}/${filePath}`}>
237241 Cancel
238 </a>
239 </div>
240 </form>
241 </div>
242 </LinkButton>
243 </Flex>
244 </Form>
245 </Container>
242246 </Layout>
243247 );
244248});
Modifiedsrc/routes/issues.tsx+106−146View fileUnifiedSplit
1818import { renderMarkdown } from "../lib/markdown";
1919import { softAuth, requireAuth } from "../middleware/auth";
2020import type { AuthEnv } from "../middleware/auth";
21import { html } from "hono/html";
21import {
22 Flex,
23 Container,
24 PageHeader,
25 Form,
26 FormGroup,
27 Input,
28 TextArea,
29 Button,
30 LinkButton,
31 Badge,
32 EmptyState,
33 TabNav,
34 FilterTabs,
35 List,
36 ListItem,
37 Alert,
38 CommentBox,
39 CommentForm,
40 formatRelative,
41} from "../views/ui";
2242
2343const issueRoutes = new Hono<AuthEnv>();
2444
5373 if (!resolved) {
5474 return c.html(
5575 <Layout title="Not Found" user={user}>
56 <div class="empty-state">
57 <h2>Repository not found</h2>
58 </div>
76 <EmptyState title="Repository not found" />
5977 </Layout>,
6078 404
6179 );
88106 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
89107 <RepoHeader owner={ownerName} repo={repoName} />
90108 <IssueNav owner={ownerName} repo={repoName} active="issues" />
91 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
92 <div class="issue-tabs">
93 <a
94 href={`/${ownerName}/${repoName}/issues?state=open`}
95 class={state === "open" ? "active" : ""}
96 >
97 {counts?.open ?? 0} Open
98 </a>
99 <a
100 href={`/${ownerName}/${repoName}/issues?state=closed`}
101 class={state === "closed" ? "active" : ""}
102 >
103 {counts?.closed ?? 0} Closed
104 </a>
105 </div>
109 <Flex justify="space-between" align="center" style="margin-bottom:16px">
110 <FilterTabs
111 tabs={[
112 {
113 label: `${counts?.open ?? 0} Open`,
114 href: `/${ownerName}/${repoName}/issues?state=open`,
115 active: state === "open",
116 },
117 {
118 label: `${counts?.closed ?? 0} Closed`,
119 href: `/${ownerName}/${repoName}/issues?state=closed`,
120 active: state === "closed",
121 },
122 ]}
123 />
106124 {user && (
107 <a
125 <LinkButton
108126 href={`/${ownerName}/${repoName}/issues/new`}
109 class="btn btn-primary"
127 variant="primary"
110128 >
111129 New issue
112 </a>
130 </LinkButton>
113131 )}
114 </div>
132 </Flex>
115133 {issueList.length === 0 ? (
116 <div class="empty-state">
134 <EmptyState>
117135 <p>
118136 No {state} issues.
119137 {state === "closed" && (
125143 </span>
126144 )}
127145 </p>
128 </div>
146 </EmptyState>
129147 ) : (
130 <div class="issue-list">
148 <List>
131149 {issueList.map(({ issue, author }) => (
132 <div class="issue-item">
150 <ListItem>
133151 <div
134152 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
135153 >
146164 {formatRelative(issue.createdAt)}
147165 </div>
148166 </div>
149 </div>
167 </ListItem>
150168 ))}
151 </div>
169 </List>
152170 )}
153171 </Layout>
154172 );
168186 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
169187 <RepoHeader owner={ownerName} repo={repoName} />
170188 <IssueNav owner={ownerName} repo={repoName} active="issues" />
171 <div style="max-width: 800px">
172 <h2 style="margin-bottom: 16px">New issue</h2>
189 <Container maxWidth={800}>
190 <h2 style="margin-bottom:16px">New issue</h2>
173191 {error && (
174 <div class="auth-error">{decodeURIComponent(error)}</div>
192 <Alert variant="error">{decodeURIComponent(error)}</Alert>
175193 )}
176 <form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
177 <div class="form-group">
178 <input
179 type="text"
194 <Form action={`/${ownerName}/${repoName}/issues/new`} method="POST">
195 <FormGroup>
196 <Input
180197 name="title"
181198 required
182199 placeholder="Title"
183 style="font-size: 16px; padding: 10px 14px"
200 style="font-size:16px;padding:10px 14px"
184201 />
185 </div>
186 <div class="form-group">
187 <textarea
202 </FormGroup>
203 <FormGroup>
204 <TextArea
188205 name="body"
189206 rows={12}
190207 placeholder="Leave a comment... (Markdown supported)"
191 style="font-family: var(--font-mono); font-size: 13px"
208 mono
192209 />
193 </div>
194 <button type="submit" class="btn btn-primary">
210 </FormGroup>
211 <Button type="submit" variant="primary">
195212 Submit new issue
196 </button>
197 </form>
198 </div>
213 </Button>
214 </Form>
215 </Container>
199216 </Layout>
200217 );
201218 }
254271 if (!resolved) {
255272 return c.html(
256273 <Layout title="Not Found" user={user}>
257 <div class="empty-state">
258 <h2>Not found</h2>
259 </div>
274 <EmptyState title="Not found" />
260275 </Layout>,
261276 404
262277 );
276291 if (!issue) {
277292 return c.html(
278293 <Layout title="Not Found" user={user}>
279 <div class="empty-state">
280 <h2>Issue not found</h2>
281 </div>
294 <EmptyState title="Issue not found" />
282295 </Layout>,
283296 404
284297 );
315328 <div class="issue-detail">
316329 <h2>
317330 {issue.title}{" "}
318 <span style="color: var(--text-muted); font-weight: 400">
331 <span style="color:var(--text-muted);font-weight:400">
319332 #{issue.number}
320333 </span>
321334 </h2>
322 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
323 <span
324 class={`issue-badge ${issue.state === "open" ? "badge-open" : "badge-closed"}`}
325 >
335 <Flex align="center" gap={8} style="margin:8px 0 20px">
336 <Badge variant={issue.state === "open" ? "open" : "closed"}>
326337 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
327 </span>
328 <span style="color: var(--text-muted); font-size: 14px">
329 <strong style="color: var(--text)">
338 </Badge>
339 <span style="color:var(--text-muted);font-size:14px">
340 <strong style="color:var(--text)">
330341 {author?.username || "unknown"}
331342 </strong>{" "}
332343 opened this issue {formatRelative(issue.createdAt)}
333344 </span>
334 </div>
345 </Flex>
335346
336347 {issue.body && (
337 <div class="issue-comment-box">
338 <div class="comment-header">
339 <strong>{author?.username}</strong> commented{" "}
340 {formatRelative(issue.createdAt)}
341 </div>
342 <div class="markdown-body">
343 {html([renderMarkdown(issue.body)] as unknown as TemplateStringsArray)}
344 </div>
345 </div>
348 <CommentBox
349 author={author?.username || "unknown"}
350 date={issue.createdAt}
351 body={renderMarkdown(issue.body)}
352 />
346353 )}
347354
348355 {comments.map(({ comment, author: commentAuthor }) => (
349 <div class="issue-comment-box">
350 <div class="comment-header">
351 <strong>{commentAuthor.username}</strong> commented{" "}
352 {formatRelative(comment.createdAt)}
353 </div>
354 <div class="markdown-body">
355 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
356 </div>
357 </div>
356 <CommentBox
357 author={commentAuthor.username}
358 date={comment.createdAt}
359 body={renderMarkdown(comment.body)}
360 />
358361 ))}
359362
360363 {user && (
361 <div style="margin-top: 20px">
362 <form
363 method="post"
364 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
365 >
366 <div class="form-group">
367 <textarea
368 name="body"
369 rows={6}
370 required
371 placeholder="Leave a comment... (Markdown supported)"
372 style="font-family: var(--font-mono); font-size: 13px"
373 />
374 </div>
375 <div style="display: flex; gap: 8px">
376 <button type="submit" class="btn btn-primary">
377 Comment
378 </button>
379 {canManage && (
380 <button
381 type="submit"
382 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
383 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
384 >
385 {issue.state === "open"
386 ? "Close issue"
387 : "Reopen issue"}
388 </button>
389 )}
390 </div>
391 </form>
392 </div>
364 <CommentForm
365 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
366 submitLabel="Comment"
367 extraActions={
368 canManage && (
369 <Button
370 type="submit"
371 variant={issue.state === "open" ? "danger" : "default"}
372 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
373 >
374 {issue.state === "open"
375 ? "Close issue"
376 : "Reopen issue"}
377 </Button>
378 )
379 }
380 />
393381 )}
394382 </div>
395383 </Layout>
508496 repo: string;
509497 active: "code" | "commits" | "issues";
510498}) => (
511 <div class="repo-nav">
512 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
513 Code
514 </a>
515 <a
516 href={`/${owner}/${repo}/issues`}
517 class={active === "issues" ? "active" : ""}
518 >
519 Issues
520 </a>
521 <a
522 href={`/${owner}/${repo}/commits`}
523 class={active === "commits" ? "active" : ""}
524 >
525 Commits
526 </a>
527 </div>
499 <TabNav
500 tabs={[
501 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
502 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
503 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
504 ]}
505 />
528506);
529507
530function formatRelative(date: Date | string): string {
531 const d = typeof date === "string" ? new Date(date) : date;
532 const now = new Date();
533 const diffMs = now.getTime() - d.getTime();
534 const diffMins = Math.floor(diffMs / 60000);
535 if (diffMins < 1) return "just now";
536 if (diffMins < 60) return `${diffMins}m ago`;
537 const diffHours = Math.floor(diffMins / 60);
538 if (diffHours < 24) return `${diffHours}h ago`;
539 const diffDays = Math.floor(diffHours / 24);
540 if (diffDays < 30) return `${diffDays}d ago`;
541 return d.toLocaleDateString("en-US", {
542 month: "short",
543 day: "numeric",
544 year: "numeric",
545 });
546}
547
548508export default issueRoutes;
549509export { IssueNav };
Modifiedsrc/routes/pulls.tsx+134−165View fileUnifiedSplit
2222} from "../git/repository";
2323import type { GitDiffFile } from "../git/repository";
2424import { html } from "hono/html";
25import {
26 Flex,
27 Container,
28 Badge,
29 Button,
30 LinkButton,
31 Form,
32 FormGroup,
33 Input,
34 TextArea,
35 Select,
36 EmptyState,
37 FilterTabs,
38 TabNav,
39 List,
40 ListItem,
41 Text,
42 Alert,
43 MarkdownContent,
44 CommentBox,
45 formatRelative,
46} from "../views/ui";
2547
2648const pulls = new Hono<AuthEnv>();
2749
5375 repo: string;
5476 active: "code" | "issues" | "pulls" | "commits";
5577}) => (
56 <div class="repo-nav">
57 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
58 Code
59 </a>
60 <a
61 href={`/${owner}/${repo}/issues`}
62 class={active === "issues" ? "active" : ""}
63 >
64 Issues
65 </a>
66 <a
67 href={`/${owner}/${repo}/pulls`}
68 class={active === "pulls" ? "active" : ""}
69 >
70 Pull Requests
71 </a>
72 <a
73 href={`/${owner}/${repo}/commits`}
74 class={active === "commits" ? "active" : ""}
75 >
76 Commits
77 </a>
78 </div>
78 <TabNav
79 tabs={[
80 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
81 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
82 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
83 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
84 ]}
85 />
7986);
8087
8188// List PRs
115122 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
116123 <RepoHeader owner={ownerName} repo={repoName} />
117124 <PrNav owner={ownerName} repo={repoName} active="pulls" />
118 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
119 <div class="issue-tabs">
120 <a
121 href={`/${ownerName}/${repoName}/pulls?state=open`}
122 class={state === "open" ? "active" : ""}
123 >
124 {counts?.open ?? 0} Open
125 </a>
126 <a
127 href={`/${ownerName}/${repoName}/pulls?state=merged`}
128 class={state === "merged" ? "active" : ""}
129 >
130 {counts?.merged ?? 0} Merged
131 </a>
132 <a
133 href={`/${ownerName}/${repoName}/pulls?state=closed`}
134 class={state === "closed" ? "active" : ""}
135 >
136 {counts?.closed ?? 0} Closed
137 </a>
138 </div>
125 <Flex justify="space-between" align="center" style="margin-bottom:16px">
126 <FilterTabs
127 tabs={[
128 { label: `${counts?.open ?? 0} Open`, href: `/${ownerName}/${repoName}/pulls?state=open`, active: state === "open" },
129 { label: `${counts?.merged ?? 0} Merged`, href: `/${ownerName}/${repoName}/pulls?state=merged`, active: state === "merged" },
130 { label: `${counts?.closed ?? 0} Closed`, href: `/${ownerName}/${repoName}/pulls?state=closed`, active: state === "closed" },
131 ]}
132 />
139133 {user && (
140 <a
141 href={`/${ownerName}/${repoName}/pulls/new`}
142 class="btn btn-primary"
143 >
134 <LinkButton href={`/${ownerName}/${repoName}/pulls/new`} variant="primary">
144135 New pull request
145 </a>
136 </LinkButton>
146137 )}
147 </div>
138 </Flex>
148139 {prList.length === 0 ? (
149 <div class="empty-state">
140 <EmptyState>
150141 <p>No {state} pull requests.</p>
151 </div>
142 </EmptyState>
152143 ) : (
153 <div class="issue-list">
144 <List>
154145 {prList.map(({ pr, author }) => (
155 <div class="issue-item">
146 <ListItem>
156147 <div
157148 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
158149 >
175166 {formatRelative(pr.createdAt)}
176167 </div>
177168 </div>
178 </div>
169 </ListItem>
179170 ))}
180 </div>
171 </List>
181172 )}
182173 </Layout>
183174 );
199190 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
200191 <RepoHeader owner={ownerName} repo={repoName} />
201192 <PrNav owner={ownerName} repo={repoName} active="pulls" />
202 <div style="max-width: 800px">
203 <h2 style="margin-bottom: 16px">Open a pull request</h2>
193 <Container maxWidth={800}>
194 <h2 style="margin-bottom:16px">Open a pull request</h2>
204195 {error && (
205 <div class="auth-error">{decodeURIComponent(error)}</div>
196 <Alert variant="error">{decodeURIComponent(error)}</Alert>
206197 )}
207 <form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
208 <div style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px">
209 <select name="base" style="padding: 6px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px">
198 <Form action={`/${ownerName}/${repoName}/pulls/new`} method="POST">
199 <Flex gap={12} align="center" style="margin-bottom:16px">
200 <Select name="base" value={defaultBase}>
210201 {branches.map((b) => (
211202 <option value={b} selected={b === defaultBase}>
212203 {b}
213204 </option>
214205 ))}
215 </select>
216 <span style="color: var(--text-muted)">&larr;</span>
217 <select name="head" style="padding: 6px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px">
206 </Select>
207 <Text muted>&larr;</Text>
208 <Select name="head">
218209 {branches
219210 .filter((b) => b !== defaultBase)
220211 .concat(defaultBase === branches[0] ? [] : [branches[0]])
221212 .map((b) => (
222213 <option value={b}>{b}</option>
223214 ))}
224 </select>
225 </div>
226 <div class="form-group">
227 <input
228 type="text"
215 </Select>
216 </Flex>
217 <FormGroup>
218 <Input
229219 name="title"
230220 required
231221 placeholder="Title"
232 style="font-size: 16px; padding: 10px 14px"
222 style="font-size:16px;padding:10px 14px"
233223 />
234 </div>
235 <div class="form-group">
236 <textarea
224 </FormGroup>
225 <FormGroup>
226 <TextArea
237227 name="body"
238228 rows={8}
239229 placeholder="Description (Markdown supported)"
240 style="font-family: var(--font-mono); font-size: 13px"
230 mono
241231 />
242 </div>
243 <button type="submit" class="btn btn-primary">
232 </FormGroup>
233 <Button type="submit" variant="primary">
244234 Create pull request
245 </button>
246 </form>
247 </div>
235 </Button>
236 </Form>
237 </Container>
248238 </Layout>
249239 );
250240 }
383373 <div class="issue-detail">
384374 <h2>
385375 {pr.title}{" "}
386 <span style="color: var(--text-muted); font-weight: 400">
376 <Text color="var(--text-muted)" weight={400}>
387377 #{pr.number}
388 </span>
378 </Text>
389379 </h2>
390 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
391 <span
392 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
380 <Flex align="center" gap={8} style="margin:8px 0 20px">
381 <Badge
382 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
393383 >
394384 {pr.state === "open"
395385 ? "\u25CB Open"
396386 : pr.state === "merged"
397387 ? "\u2B8C Merged"
398388 : "\u2713 Closed"}
399 </span>
400 <span style="color: var(--text-muted); font-size: 14px">
401 <strong style="color: var(--text)">
389 </Badge>
390 <Text size={14} muted>
391 <strong style="color:var(--text)">
402392 {author?.username}
403393 </strong>{" "}
404394 wants to merge <code>{pr.headBranch}</code> into{" "}
405395 <code>{pr.baseBranch}</code>
406 </span>
407 </div>
408
409 <div class="issue-tabs" style="margin-bottom: 20px">
410 <a
411 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
412 class={tab === "conversation" ? "active" : ""}
413 >
414 Conversation
415 </a>
416 <a
417 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
418 class={tab === "files" ? "active" : ""}
419 >
420 Files changed
421 </a>
422 </div>
396 </Text>
397 </Flex>
398
399 <FilterTabs
400 tabs={[
401 {
402 label: "Conversation",
403 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
404 active: tab === "conversation",
405 },
406 {
407 label: "Files changed",
408 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
409 active: tab === "files",
410 },
411 ]}
412 />
423413
424414 {tab === "files" ? (
425415 <DiffView raw={diffRaw} files={diffFiles} />
426416 ) : (
427417 <>
428418 {pr.body && (
429 <div class="issue-comment-box">
430 <div class="comment-header">
431 <strong>{author?.username}</strong> commented{" "}
432 {formatRelative(pr.createdAt)}
433 </div>
434 <div class="markdown-body">
435 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
436 </div>
437 </div>
419 <CommentBox
420 author={author?.username ?? "unknown"}
421 date={pr.createdAt}
422 body={renderMarkdown(pr.body)}
423 />
438424 )}
439425
440426 {comments.map(({ comment, author: commentAuthor }) => (
442428 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
443429 >
444430 <div class="comment-header">
445 <strong>{commentAuthor.username}</strong>
446 {comment.isAiReview && (
447 <span class="badge" style="margin-left: 8px; background: rgba(31, 111, 235, 0.15); color: var(--text-link); border-color: var(--accent)">
448 AI Review
449 </span>
450 )}
451 {" "}
452 commented {formatRelative(comment.createdAt)}
453 {comment.filePath && (
454 <span style="margin-left: 8px; font-family: var(--font-mono); font-size: 11px">
455 {comment.filePath}
456 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
457 </span>
458 )}
459 </div>
460 <div class="markdown-body">
461 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
431 <Flex gap={8} align="center">
432 <strong>{commentAuthor.username}</strong>
433 {comment.isAiReview && (
434 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
435 AI Review
436 </Badge>
437 )}
438 <Text size={13} muted>
439 commented {formatRelative(comment.createdAt)}
440 </Text>
441 {comment.filePath && (
442 <Text size={11} mono style="margin-left:8px">
443 {comment.filePath}
444 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
445 </Text>
446 )}
447 </Flex>
462448 </div>
449 <MarkdownContent html={renderMarkdown(comment.body)} />
463450 </div>
464451 ))}
465452
466453 {user && pr.state === "open" && (
467 <div style="margin-top: 20px">
468 <form
469 method="post"
454 <div style="margin-top:20px">
455 <Form
470456 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
457 method="POST"
471458 >
472 <div class="form-group">
473 <textarea
459 <FormGroup>
460 <TextArea
474461 name="body"
475462 rows={6}
476463 required
477464 placeholder="Leave a comment... (Markdown supported)"
478 style="font-family: var(--font-mono); font-size: 13px"
465 mono
479466 />
480 </div>
481 <div style="display: flex; gap: 8px">
482 <button type="submit" class="btn btn-primary">
467 </FormGroup>
468 <Flex gap={8}>
469 <Button type="submit" variant="primary">
483470 Comment
484 </button>
471 </Button>
485472 {canManage && (
486473 <>
487474 <button
488475 type="submit"
489476 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
490477 class="btn"
491 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
478 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
492479 >
493480 Merge pull request
494481 </button>
495 <button
482 <Button
496483 type="submit"
484 variant="danger"
497485 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
498 class="btn btn-danger"
499486 >
500487 Close
501 </button>
488 </Button>
502489 </>
503490 )}
504 </div>
505 </form>
491 </Flex>
492 </Form>
506493 </div>
507494 )}
508495 </>
660647 }
661648);
662649
663function formatRelative(date: Date | string): string {
664 const d = typeof date === "string" ? new Date(date) : date;
665 const now = new Date();
666 const diffMs = now.getTime() - d.getTime();
667 const diffMins = Math.floor(diffMs / 60000);
668 if (diffMins < 1) return "just now";
669 if (diffMins < 60) return `${diffMins}m ago`;
670 const diffHours = Math.floor(diffMins / 60);
671 if (diffHours < 24) return `${diffHours}h ago`;
672 const diffDays = Math.floor(diffHours / 24);
673 if (diffDays < 30) return `${diffDays}d ago`;
674 return d.toLocaleDateString("en-US", {
675 month: "short",
676 day: "numeric",
677 year: "numeric",
678 });
679}
680
681650export default pulls;
Modifiedsrc/routes/repo-settings.tsx+37−30View fileUnifiedSplit
1212import type { AuthEnv } from "../middleware/auth";
1313import { listBranches } from "../git/repository";
1414import { rm } from "fs/promises";
15import {
16 Container,
17 Form,
18 FormGroup,
19 Input,
20 Select,
21 Button,
22 Alert,
23 EmptyState,
24 Section,
25 Text,
26} from "../views/ui";
1527
1628const repoSettings = new Hono<AuthEnv>();
1729
3345 if (!owner || owner.id !== user.id) {
3446 return c.html(
3547 <Layout title="Unauthorized" user={user}>
36 <div class="empty-state">
37 <h2>Unauthorized</h2>
48 <EmptyState title="Unauthorized">
3849 <p>Only the repository owner can access settings.</p>
39 </div>
50 </EmptyState>
4051 </Layout>,
4152 403
4253 );
5768 return c.html(
5869 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
5970 <RepoHeader owner={ownerName} repo={repoName} />
60 <div style="max-width: 600px">
71 <Container maxWidth={600}>
6172 <h2 style="margin-bottom: 20px">Repository settings</h2>
6273 {success && (
63 <div class="auth-success">{decodeURIComponent(success)}</div>
74 <Alert variant="success">{decodeURIComponent(success)}</Alert>
6475 )}
6576 {error && (
66 <div class="auth-error">{decodeURIComponent(error)}</div>
77 <Alert variant="error">{decodeURIComponent(error)}</Alert>
6778 )}
6879
69 <form
70 method="post"
80 <Form
7181 action={`/${ownerName}/${repoName}/settings`}
82 method="POST"
7283 >
73 <div class="form-group">
74 <label for="description">Description</label>
75 <input
76 type="text"
77 id="description"
84 <FormGroup label="Description" htmlFor="description">
85 <Input
7886 name="description"
87 id="description"
7988 value={repo.description || ""}
8089 placeholder="A short description"
8190 />
82 </div>
83 <div class="form-group">
84 <label for="default_branch">Default branch</label>
85 <select id="default_branch" name="default_branch">
91 </FormGroup>
92 <FormGroup label="Default branch" htmlFor="default_branch">
93 <Select name="default_branch" id="default_branch" value={repo.defaultBranch}>
8694 {branches.length === 0 ? (
8795 <option value={repo.defaultBranch}>
8896 {repo.defaultBranch}
94102 </option>
95103 ))
96104 )}
97 </select>
98 </div>
99 <div class="form-group">
100 <label>Visibility</label>
105 </Select>
106 </FormGroup>
107 <FormGroup label="Visibility">
101108 <div class="visibility-options">
102109 <label class="visibility-option">
103110 <input
118125 <div class="vis-label">Private</div>
119126 </label>
120127 </div>
121 </div>
122 <button type="submit" class="btn btn-primary">
128 </FormGroup>
129 <Button type="submit" variant="primary">
123130 Save changes
124 </button>
125 </form>
131 </Button>
132 </Form>
126133
127134 <div
128135 style="margin-top: 40px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
129136 >
130137 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
131 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
138 <Text size={14} muted style="display:block;margin-bottom:12px">
132139 Permanently delete this repository and all its data.
133 </p>
140 </Text>
134141 <form
135142 method="post"
136143 action={`/${ownerName}/${repoName}/settings/delete`}
137144 onsubmit="return confirm('Are you sure? This cannot be undone.')"
138145 >
139 <button type="submit" class="btn btn-danger">
146 <Button type="submit" variant="danger">
140147 Delete this repository
141 </button>
148 </Button>
142149 </form>
143150 </div>
144 </div>
151 </Container>
145152 </Layout>
146153 );
147154});
Modifiedsrc/routes/webhooks.tsx+33−27View fileUnifiedSplit
1010import { RepoHeader } from "../views/components";
1111import { softAuth, requireAuth } from "../middleware/auth";
1212import type { AuthEnv } from "../middleware/auth";
13import {
14 Container,
15 Flex,
16 Form,
17 FormGroup,
18 Input,
19 Button,
20 Alert,
21} from "../views/ui";
1322
1423const webhookRoutes = new Hono<AuthEnv>();
1524
5463 return c.html(
5564 <Layout title={`Webhooks — ${ownerName}/${repoName}`} user={user}>
5665 <RepoHeader owner={ownerName} repo={repoName} />
57 <div style="max-width: 700px">
66 <Container maxWidth={700}>
5867 <h2 style="margin-bottom: 16px">Webhooks</h2>
5968 {success && (
60 <div class="auth-success">{decodeURIComponent(success)}</div>
69 <Alert variant="success">{decodeURIComponent(success)}</Alert>
6170 )}
6271 {error && (
63 <div class="auth-error">{decodeURIComponent(error)}</div>
72 <Alert variant="error">{decodeURIComponent(error)}</Alert>
6473 )}
6574 {hooks.length > 0 && (
6675 <div style="margin-bottom: 24px">
8392 )}
8493 </div>
8594 </div>
86 <form
87 method="post"
95 <Form
96 method="POST"
8897 action={`/${ownerName}/${repoName}/settings/webhooks/${hook.id}/delete`}
8998 >
90 <button type="submit" class="btn btn-danger btn-sm">
99 <Button type="submit" variant="danger" size="sm">
91100 Delete
92 </button>
93 </form>
101 </Button>
102 </Form>
94103 </div>
95104 ))}
96105 </div>
97106 )}
98107
99108 <h3 style="margin-bottom: 12px">Add webhook</h3>
100 <form
101 method="post"
109 <Form
110 method="POST"
102111 action={`/${ownerName}/${repoName}/settings/webhooks`}
103112 >
104 <div class="form-group">
105 <label>Payload URL</label>
106 <input
113 <FormGroup label="Payload URL">
114 <Input
107115 type="url"
108116 name="url"
109117 required
110118 placeholder="https://example.com/hooks/gluecron"
111119 />
112 </div>
113 <div class="form-group">
114 <label>Secret (optional)</label>
115 <input
120 </FormGroup>
121 <FormGroup label="Secret (optional)">
122 <Input
116123 type="text"
117124 name="secret"
118125 placeholder="Shared secret for HMAC verification"
119126 />
120 </div>
121 <div class="form-group">
122 <label>Events</label>
123 <div style="display: flex; gap: 16px; flex-wrap: wrap">
127 </FormGroup>
128 <FormGroup label="Events">
129 <Flex gap={16} wrap>
124130 {["push", "issue", "pr", "star"].map((evt) => (
125131 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
126132 <input
132138 {evt}
133139 </label>
134140 ))}
135 </div>
136 </div>
137 <button type="submit" class="btn btn-primary">
141 </Flex>
142 </FormGroup>
143 <Button type="submit" variant="primary">
138144 Add webhook
139 </button>
140 </form>
141 </div>
145 </Button>
146 </Form>
147 </Container>
142148 </Layout>
143149 );
144150 }
Modifiedsrc/views/ui.tsx+4−0View fileUnifiedSplit
145145 pattern?: string;
146146 autocomplete?: string;
147147 autofocus?: boolean;
148 minLength?: number;
149 maxLength?: number;
148150 style?: string;
149151}> = (props) => (
150152 <input
158160 pattern={props.pattern}
159161 autocomplete={props.autocomplete}
160162 autofocus={props.autofocus}
163 minLength={props.minLength}
164 maxLength={props.maxLength}
161165 class={props.disabled ? "input-disabled" : ""}
162166 style={props.style}
163167 />
164168