website

#astro#js#html#css

git clone https://git.pyrossh.dev/website

木 Personal website of pyrossh. Built with astrojs, shiki, vite.


d31af54pyrossh 2026-07-08T19:58:30+05:30
feat: create Hono app with all 23 routes
Files changed (1) hide show
  1. src/index.ts +829 -0
src/index.ts ADDED
@@ -0,0 +1,829 @@
1
+ import { Hono } from 'hono'
2
+ import { html, raw } from 'hono/html'
3
+ import { getS3 } from './s3'
4
+ import { getPosts, getPost, getRepos, getRepo } from './lib/content'
5
+ import { getRepoReadme } from './lib/repoContent'
6
+ import { renderMarkdown } from './lib/markdown'
7
+ import { getCommits, getFiles, getFileHistory, getFileContentData, generateHTMLDiff } from './lib/gitReader'
8
+ import { buildFileTree, sortChildren } from './lib/files'
9
+ import { getGitBugIssues, getGitBugIssue, createGitBugIssue, addGitBugIssueComment, setGitBugIssueState } from './lib/gitBug'
10
+ import { Header } from './components/header'
11
+ import { Footer } from './components/footer'
12
+ import { RepoLayout } from './components/repo-layout'
13
+ import { FileLayout } from './components/file-layout'
14
+ import { CommitEntry } from './components/commit-entry'
15
+ import { IssueCard } from './components/issue-card'
16
+ import { FileTree } from './components/file-tree'
17
+ import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL, REPOS, TOOLS } from './config'
18
+
19
+ const app = new Hono()
20
+
21
+ const Layout = (props: {
22
+ title: string
23
+ description?: string
24
+ css?: string
25
+ currentPath?: string
26
+ children: any
27
+ }) => html`
28
+ <!DOCTYPE html>
29
+ <html lang="en">
30
+ <head>
31
+ <meta charset="utf-8" />
32
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
33
+ <meta name="theme-color" content="#131618" />
34
+ <link rel="icon" type="image/svg+xml" href="/assets/icons/icon.svg" />
35
+ <link rel="alternate" type="application/rss+xml" title="${props.title}" href="/rss.xml" />
36
+ <link rel="canonical" href="${SITE_URL}/" />
37
+ <link rel="stylesheet" href="/assets/css/shared.css" />
38
+ ${props.css ? html`<link rel="stylesheet" href="${props.css}" />` : ''}
39
+ <title>${props.title}</title>
40
+ <meta name="title" content="${props.title}" />
41
+ <meta name="description" content="${props.description ?? SITE_DESCRIPTION}" />
42
+ <meta property="og:title" content="${props.title}" />
43
+ <meta property="og:description" content="${props.description ?? SITE_DESCRIPTION}" />
44
+ <meta property="og:image" content="/assets/icons/icon.svg" />
45
+ <meta name="twitter:card" content="summary_large_image" />
46
+ <script src="https://unpkg.com/[email protected]"></script>
47
+ </head>
48
+ <body>
49
+ ${Header({ currentPath: props.currentPath })}
50
+ <div class="wrapper">
51
+ <main>${props.children}</main>
52
+ </div>
53
+ ${Footer()}
54
+ </body>
55
+ </html>`
56
+
57
+ // ─── 1. Simple routes ───────────────────────────────────────────
58
+
59
+ app.get('/', (c) => {
60
+ return c.html(Layout({
61
+ css: '/assets/css/workers/home.css',
62
+ title: SITE_TITLE,
63
+ description: 'Tech Lead from Bangalore who likes to create frameworks and programming languages.',
64
+ currentPath: c.req.path,
65
+ children: html`<div class="pageContainer">
66
+ <h1 class="title">Hello!</h1>
67
+ <p class="description">
68
+ <a href="https://www.linkedin.com/in/pyrossh" target="_blank" rel="noopener noreferrer" class="linkUnderline"><strong>I'm</strong></a>
69
+ a tech lead based out of Bangalore, India and have worked for startups and enterprises
70
+ that focus on products in the healthcare and finance domains. I like to build
71
+ frameworks and automate common tasks to make it easier to develop applications.
72
+ </p>
73
+ <p class="description">
74
+ I enjoy working on open-source projects, and during the last decade, I've created a
75
+ number of projects that are widely used.
76
+ </p>
77
+ <div class="gridContainer">
78
+ <section class="interests">
79
+ <h2>Interests</h2>
80
+ <ul style="list-style:none;padding:0">
81
+ ${['HTML', 'CSS', 'Javascript', 'S3', 'SQL', 'Bun', 'Astro', 'Tauri', 'Remix', 'Zig', 'K8s', 'Iceberg'].map((i) => html`<li>${i}</li>`)}
82
+ </ul>
83
+ </section>
84
+ <section class="contact">
85
+ <h2>Contact</h2>
86
+ <ul style="list-style:none;padding:0">
87
+ <li><strong>email:</strong><span>[email protected]</span></li>
88
+ <li><strong>crates:</strong><a href="https://crates.io/users/pyrossh">https://crates.io/users/pyrossh</a></li>
89
+ <li><strong>npm:</strong><a href="https://www.npmjs.com/~pyrossh">https://www.npmjs.com/~pyrossh</a></li>
90
+ <li><strong>linkedin:</strong><a href="https://www.linkedin.com/in/pyrossh">https://www.linkedin.com/in/pyrossh</a></li>
91
+ <li><strong>google group:</strong><a href="https://groups.google.com/g/rust-embed-devs">rust-embed-devs</a></li>
92
+ </ul>
93
+ </section>
94
+ </div>
95
+ <section class="tools">
96
+ <h2>Tools</h2>
97
+ <ul style="list-style:none;padding:0">
98
+ ${TOOLS.map((tool) => html`<li>
99
+ <a href="${tool.link}" target="_blank" rel="noopener noreferrer">
100
+ <div>${tool.name}</div>
101
+ ${tool.image ? html`<img src="${tool.image}" alt="${tool.name}" />` : ''}
102
+ </a>
103
+ </li>`)}
104
+ </ul>
105
+ </section>
106
+ <section>
107
+ <h2>Git</h2>
108
+ <div class="repos">
109
+ ${REPOS.map((repo) => html`<div class="repo">
110
+ <h4><a href="/repos/${repo.title}">${repo.title}</a></h4>
111
+ <p>${repo.description}</p>
112
+ <div class="tags">
113
+ ${repo.tags.map((tag) => html`<span class="tag">#${tag}</span>`)}
114
+ </div>
115
+ </div>`)}
116
+ </div>
117
+ </section>
118
+ </div>`
119
+ }))
120
+ })
121
+
122
+ app.get('/cv', (c) => {
123
+ return c.html(Layout({
124
+ css: '/assets/css/workers/cv.css',
125
+ title: 'Curriculum Vitae',
126
+ description: 'Peter John CV',
127
+ currentPath: c.req.path,
128
+ children: html`<div>
129
+ <h1 style="display:flex;align-items:flex-start;font-weight:800;font-size:2rem;margin-bottom:1rem">
130
+ Curriculum Vitae
131
+ <a href="/assets/pdfs/resume.pdf" target="_blank" rel="noopener noreferrer" style="margin-left:0.4rem;margin-top:0.8rem;color:var(--color-link)">↗</a>
132
+ </h1>
133
+ <div style="border:1px solid black">
134
+ <img src="/assets/pdfs/resume.svg" alt="Resume" style="width:100%" />
135
+ </div>
136
+ </div>`
137
+ }))
138
+ })
139
+
140
+ app.get('/robots.txt', (c) => {
141
+ return c.text('User-agent: *\nAllow: /\n')
142
+ })
143
+
144
+ app.notFound((c) => {
145
+ return c.html(Layout({
146
+ title: '404 - Not Found',
147
+ description: 'Page not found',
148
+ currentPath: c.req.path,
149
+ children: html`<h1>404 - Page Not Found</h1>
150
+ <p>Sorry, the page you are looking for does not exist.</p>
151
+ <a href="/">Go back home</a>`
152
+ }), 404)
153
+ })
154
+
155
+ app.onError((err, c) => {
156
+ console.error('Unhandled error:', err)
157
+ return c.html(Layout({
158
+ title: '500 - Server Error',
159
+ description: 'Internal server error',
160
+ currentPath: c.req.path,
161
+ children: html`<h1>500 - Internal Server Error</h1>
162
+ <p>Something went wrong. Please try again later.</p>
163
+ <a href="/">Go back home</a>`
164
+ }), 500)
165
+ })
166
+
167
+ app.get('/only-bible-app', (c) => {
168
+ return c.html(Layout({
169
+ css: '/assets/css/workers/only-bible-app.css',
170
+ title: 'Only Bible App',
171
+ description: 'The only bible app you will ever need',
172
+ currentPath: c.req.path,
173
+ children: html`<div style="display:flex;flex:1;flex-direction:column;padding-bottom:16rem">
174
+ <div style="display:flex;flex-direction:column;align-items:center">
175
+ <div style="display:block;margin-bottom:1rem">
176
+ <h1 style="display:flex;flex-direction:column;align-items:center;font-size:1.875rem;line-height:2.25rem;font-weight:700;text-align:center">
177
+ The only bible app you will ever need
178
+ </h1>
179
+ <div style="text-align:center;display:flex;flex-direction:column;font-size:1.125rem;line-height:1.75rem">
180
+ <span>No ads,</span>
181
+ <span>No in-app purchases,</span>
182
+ <span>No distractions.</span>
183
+ <span>Works completely offline.</span>
184
+ </div>
185
+ </div>
186
+ <div style="display:flex;flex:1;flex-direction:column;align-items:center">
187
+ <h3 style="margin:0;margin-top:1rem;margin-bottom:1rem;font-weight:700;font-size:1.25rem;line-height:1.75rem">
188
+ What are you waiting for? Get it now! Quickly.
189
+ </h3>
190
+ <div style="display:flex;flex-direction:column">
191
+ <a style="margin-top:1rem" href="https://play.google.com/store/apps/details?id=dev.pyrossh.onlyBible" rel="noopener noreferrer" target="_blank">
192
+ <svg viewBox="0 0 135 40" width="196" xmlns="http://www.w3.org/2000/svg" fill="#000000">
193
+ <path fill="#a6a6a6" d="M130 40H5c-2.8 0-5-2.2-5-5V5c0-2.8 2.2-5 5-5h125c2.8 0 5 2.2 5 5v30c0 2.8-2.2 5-5 5z"></path>
194
+ <path fill="#fff" stroke="#fff" stroke-width="0.2" stroke-miterlimit="10" d="M47.4 10.2c0 .8-.2 1.5-.7 2-.6.6-1.3.9-2.2.9-.9 0-1.6-.3-2.2-.9-.6-.6-.9-1.3-.9-2.2 0-.9.3-1.6.9-2.2.6-.6 1.3-.9 2.2-.9.4 0 .8.1 1.2.3.4.2.7.4.9.7l-.5.5c-.4-.5-.9-.7-1.6-.7-.6 0-1.2.2-1.6.7-.5.4-.7 1-.7 1.7s.2 1.3.7 1.7c.5.4 1 .7 1.6.7.7 0 1.2-.2 1.7-.7.3-.3.5-.7.5-1.2h-2.2v-.8h2.9v.4zM52 7.7h-2.7v1.9h2.5v.7h-2.5v1.9H52v.8h-3.5V7H52v.7zM55.3 13h-.8V7.7h-1.7V7H57v.7h-1.7V13zM59.9 13V7h.8v6h-.8zM64.1 13h-.8V7.7h-1.7V7h4.1v.7H64V13zM73.6 12.2c-.6.6-1.3.9-2.2.9-.9 0-1.6-.3-2.2-.9-.6-.6-.9-1.3-.9-2.2s.3-1.6.9-2.2c.6-.6 1.3-.9 2.2-.9.9 0 1.6.3 2.2.9.6.6.9 1.3.9 2.2 0 .9-.3 1.6-.9 2.2zm-3.8-.5c.4.4 1 .7 1.6.7.6 0 1.2-.2 1.6-.7.4-.4.7-1 .7-1.7s-.2-1.3-.7-1.7c-.4-.4-1-.7-1.6-.7-.6 0-1.2.2-1.6.7-.4.4-.7 1-.7 1.7s.2 1.3.7 1.7zM75.6 13V7h.9l2.9 4.7V7h.8v6h-.8l-3.1-4.9V13h-.7z"></path>
195
+ <path fill="#fff" d="M68.1 21.8c-2.4 0-4.3 1.8-4.3 4.3 0 2.4 1.9 4.3 4.3 4.3s4.3-1.8 4.3-4.3c0-2.6-1.9-4.3-4.3-4.3zm0 6.8c-1.3 0-2.4-1.1-2.4-2.6s1.1-2.6 2.4-2.6c1.3 0 2.4 1 2.4 2.6 0 1.5-1.1 2.6-2.4 2.6zm-9.3-6.8c-2.4 0-4.3 1.8-4.3 4.3 0 2.4 1.9 4.3 4.3 4.3s4.3-1.8 4.3-4.3c0-2.6-1.9-4.3-4.3-4.3zm0 6.8c-1.3 0-2.4-1.1-2.4-2.6s1.1-2.6 2.4-2.6c1.3 0 2.4 1 2.4 2.6 0 1.5-1.1 2.6-2.4 2.6zm-11.1-5.5v1.8H52c-.1 1-.5 1.8-1 2.3-.6.6-1.6 1.3-3.3 1.3-2.7 0-4.7-2.1-4.7-4.8s2.1-4.8 4.7-4.8c1.4 0 2.5.6 3.3 1.3l1.3-1.3c-1.1-1-2.5-1.8-4.5-1.8-3.6 0-6.7 3-6.7 6.6 0 3.6 3.1 6.6 6.7 6.6 2 0 3.4-.6 4.6-1.9 1.2-1.2 1.6-2.9 1.6-4.2 0-.4 0-.8-.1-1.1h-6.2zm45.4 1.4c-.4-1-1.4-2.7-3.6-2.7s-4 1.7-4 4.3c0 2.4 1.8 4.3 4.2 4.3 1.9 0 3.1-1.2 3.5-1.9l-1.4-1c-.5.7-1.1 1.2-2.1 1.2s-1.6-.4-2.1-1.3l5.7-2.4-.2-.5zm-5.8 1.4c0-1.6 1.3-2.5 2.2-2.5.7 0 1.4.4 1.6.9l-3.8 1.6zM82.6 30h1.9V17.5h-1.9V30zm-3-7.3c-.5-.5-1.3-1-2.3-1-2.1 0-4.1 1.9-4.1 4.3s1.9 4.2 4.1 4.2c1 0 1.8-.5 2.2-1h.1v.6c0 1.6-.9 2.5-2.3 2.5-1.1 0-1.9-.8-2.1-1.5l-1.6.7c.5 1.1 1.7 2.5 3.8 2.5 2.2 0 4-1.3 4-4.4V22h-1.8v.7zm-2.2 5.9c-1.3 0-2.4-1.1-2.4-2.6s1.1-2.6 2.4-2.6c1.3 0 2.3 1.1 2.3 2.6s-1 2.6-2.3 2.6zm24.4-11.1h-4.5V30h1.9v-4.7h2.6c2.1 0 4.1-1.5 4.1-3.9s-2-3.9-4.1-3.9zm.1 6h-2.7v-4.3h2.7c1.4 0 2.2 1.2 2.2 2.1-.1 1.1-.9 2.2-2.2 2.2zm11.5-1.8c-1.4 0-2.8.6-3.3 1.9l1.7.7c.4-.7 1-.9 1.7-.9 1 0 1.9.6 2 1.6v.1c-.3-.2-1.1-.5-1.9-.5-1.8 0-3.6 1-3.6 2.8 0 1.7 1.5 2.8 3.1 2.8 1.3 0 1.9-.6 2.4-1.2h.1v1h1.8v-4.8c-.2-2.2-1.9-3.5-4-3.5zm-.2 6.9c-.6 0-1.5-.3-1.5-1.1 0-1 1.1-1.3 2-1.3.8 0 1.2.2 1.7.4-.2 1.2-1.2 2-2.2 2zm10.5-6.6l-2.1 5.4h-.1l-2.2-5.4h-2l3.3 7.6-1.9 4.2h1.9l5.1-11.8h-2zm-16.8 8h1.9V17.5h-1.9V30z"></path>
196
+ <g>
197
+ <linearGradient id="g1" gradientUnits="userSpaceOnUse" x1="21.8" y1="33.29" x2="5.017" y2="16.508" gradientTransform="matrix(1 0 0 -1 0 42)">
198
+ <stop offset="0" stop-color="#00a0ff"></stop>
199
+ <stop offset=".007" stop-color="#00a1ff"></stop>
200
+ <stop offset=".26" stop-color="#00beff"></stop>
201
+ <stop offset=".512" stop-color="#00d2ff"></stop>
202
+ <stop offset=".76" stop-color="#00dfff"></stop>
203
+ <stop offset="1" stop-color="#00e3ff"></stop>
204
+ </linearGradient>
205
+ <linearGradient id="g2" gradientUnits="userSpaceOnUse" x1="33.834" y1="21.999" x2="9.637" y2="21.999" gradientTransform="matrix(1 0 0 -1 0 42)">
206
+ <stop offset="0" stop-color="#ffe000"></stop>
207
+ <stop offset=".409" stop-color="#ffbd00"></stop>
208
+ <stop offset=".775" stop-color="orange"></stop>
209
+ <stop offset="1" stop-color="#ff9c00"></stop>
210
+ </linearGradient>
211
+ <linearGradient id="g3" gradientUnits="userSpaceOnUse" x1="24.827" y1="19.704" x2="2.069" y2="-3.054" gradientTransform="matrix(1 0 0 -1 0 42)">
212
+ <stop offset="0" stop-color="#ff3a44"></stop>
213
+ <stop offset="1" stop-color="#c31162"></stop>
214
+ </linearGradient>
215
+ <linearGradient id="g4" gradientUnits="userSpaceOnUse" x1="7.297" y1="41.824" x2="17.46" y2="31.661" gradientTransform="matrix(1 0 0 -1 0 42)">
216
+ <stop offset="0" stop-color="#32a071"></stop>
217
+ <stop offset=".069" stop-color="#2da771"></stop>
218
+ <stop offset=".476" stop-color="#15cf74"></stop>
219
+ <stop offset=".801" stop-color="#06e775"></stop>
220
+ <stop offset="1" stop-color="#00f076"></stop>
221
+ </linearGradient>
222
+ <path fill="url(#g1)" d="M10.4 7.5c-.3.3-.4.8-.4 1.4V31c0 .6.2 1.1.5 1.4l.1.1L23 20.1v-.2L10.4 7.5z"></path>
223
+ <path fill="url(#g2)" d="M27 24.3l-4.1-4.1V19.9l4.1-4.1.1.1 4.9 2.8c1.4.8 1.4 2.1 0 2.9l-5 2.7z"></path>
224
+ <path fill="url(#g3)" d="M27.1 24.2L22.9 20 10.4 32.5c.5.5 1.2.5 2.1.1l14.6-8.4"></path>
225
+ <path fill="url(#g4)" d="M27.1 15.8L12.5 7.5c-.9-.5-1.6-.4-2.1.1L22.9 20l4.2-4.2z"></path>
226
+ <path opacity="0.2" d="M27 24.1l-14.5 8.2c-.8.5-1.5.4-2 0l-.1.1.1.1c.5.4 1.2.5 2 0L27 24.1z"></path>
227
+ <path opacity="0.12" d="M10.4 32.3c-.3-.3-.4-.8-.4-1.4v.1c0 .6.2 1.1.5 1.4v-.1h-.1zM32 21.3l-5 2.8.1.1 4.9-2.8c.7-.4 1-.9 1-1.4 0 .5-.4.9-1 1.3z"></path>
228
+ <path opacity="0.25" fill="#fff" d="M12.5 7.6L32 18.7c.6.4 1 .8 1 1.3 0-.5-.3-1-1-1.4L12.5 7.5c-1.4-.8-2.5-.2-2.5 1.4V9c0-1.5 1.1-2.2 2.5-1.4z"></path>
229
+ </g>
230
+ </svg>
231
+ </a>
232
+ <a style="margin-top:2rem" href="https://apps.apple.com/us/app/only-bible-app/id6467606465?itsct=apps_box_badge&itscg=30200" rel="noopener noreferrer" target="_blank">
233
+ <svg viewBox="0 0 539.856 170" width="196" xmlns="http://www.w3.org/2000/svg" fill="#000000">
234
+ <g transform="scale(4.00216 4.0011)">
235
+ <path fill="#FFF" d="M134.032 35.268a3.83 3.83 0 0 1-3.834 3.83H4.729a3.835 3.835 0 0 1-3.839-3.83V4.725A3.84 3.84 0 0 1 4.729.89h125.468a3.834 3.834 0 0 1 3.834 3.835l.001 30.543z"></path>
236
+ <path fill="#A6A6A6" d="M130.198 39.989H4.729A4.73 4.73 0 0 1 0 35.268V4.726A4.733 4.733 0 0 1 4.729 0h125.468a4.735 4.735 0 0 1 4.729 4.726v30.542c.002 2.604-2.123 4.721-4.728 4.721z"></path>
237
+ <path d="M134.032 35.268a3.83 3.83 0 0 1-3.834 3.83H4.729a3.835 3.835 0 0 1-3.839-3.83V4.725A3.84 3.84 0 0 1 4.729.89h125.468a3.834 3.834 0 0 1 3.834 3.835l.001 30.543z"></path>
238
+ <path fill="#FFF" d="M30.128 19.784c-.029-3.223 2.639-4.791 2.761-4.864-1.511-2.203-3.853-2.504-4.676-2.528-1.967-.207-3.875 1.177-4.877 1.177-1.022 0-2.565-1.157-4.228-1.123-2.14.033-4.142 1.272-5.24 3.196-2.266 3.923-.576 9.688 1.595 12.859 1.086 1.554 2.355 3.287 4.016 3.226 1.625-.066 2.232-1.035 4.193-1.035 1.943 0 2.513 1.035 4.207.996 1.744-.027 2.842-1.56 3.89-3.127 1.255-1.779 1.759-3.533 1.779-3.623-.04-.014-3.386-1.292-3.42-5.154zM26.928 10.306c.874-1.093 1.472-2.58 1.306-4.089-1.265.056-2.847.875-3.758 1.944-.806.942-1.526 2.486-1.34 3.938 1.421.106 2.88-.717 3.792-1.793z"></path>
239
+ <g fill="#FFF">
240
+ <path d="M53.665 31.504h-2.271l-1.244-3.909h-4.324l-1.185 3.909H42.43l4.285-13.308h2.646l4.304 13.308zm-3.89-5.549L48.65 22.48c-.119-.355-.343-1.191-.671-2.507h-.04c-.132.566-.343 1.402-.632 2.507l-1.106 3.475h3.574zM64.663 26.588c0 1.632-.443 2.922-1.33 3.869-.794.843-1.781 1.264-2.958 1.264-1.271 0-2.185-.453-2.74-1.361v5.035h-2.132V25.062c0-1.025-.027-2.076-.079-3.154h1.875l.119 1.521h.04c.711-1.146 1.79-1.719 3.238-1.719 1.132 0 2.077.447 2.833 1.342.755.897 1.134 2.075 1.134 3.536zm-2.172.078c0-.934-.21-1.704-.632-2.311-.461-.631-1.08-.947-1.856-.947-.526 0-1.004.176-1.431.523-.428.35-.708.807-.839 1.373a2.784 2.784 0 0 0-.099.649v1.601c0 .697.214 1.286.642 1.768.428.48.984.721 1.668.721.803 0 1.428-.311 1.875-.928.448-.619.672-1.435.672-2.449zM75.7 26.588c0 1.632-.443 2.922-1.33 3.869-.795.843-1.781 1.264-2.959 1.264-1.271 0-2.185-.453-2.74-1.361v5.035h-2.132V25.062c0-1.025-.027-2.076-.079-3.154h1.875l.119 1.521h.04c.71-1.146 1.789-1.719 3.238-1.719 1.131 0 2.076.447 2.834 1.342.754.897 1.134 2.075 1.134 3.536zm-2.173.078c0-.934-.211-1.704-.633-2.311-.461-.631-1.078-.947-1.854-.947-.526 0-1.004.176-1.433.523-.428.35-.707.807-.838 1.373-.065.264-.1.479-.1.649v1.601c0 .697.215 1.286.641 1.768.428.479.984.721 1.67.721.804 0 1.429-.311 1.875-.928.448-.619.672-1.435.672-2.449zM88.04 27.771c0 1.133-.396 2.054-1.183 2.765-.866.776-2.075 1.165-3.625 1.165-1.432 0-2.58-.276-3.446-.829l.493-1.777c.935.554 1.962.83 3.08.83.804 0 1.429-.182 1.875-.543.447-.362.673-.846.673-1.45 0-.541-.187-.994-.554-1.363-.369-.368-.979-.711-1.836-1.026-2.33-.869-3.496-2.14-3.496-3.812 0-1.092.412-1.986 1.234-2.685.822-.698 1.912-1.047 3.268-1.047 1.211 0 2.22.211 3.021.632l-.535 1.738c-.754-.408-1.605-.612-2.557-.612-.752 0-1.342.185-1.764.553-.355.329-.535.73-.535 1.206 0 .525.205.961.613 1.303.354.315 1 .658 1.934 1.026 1.146.462 1.988 1 2.527 1.618.543.618.813 1.389.813 2.308zM95.107 23.508h-2.35v4.659c0 1.185.414 1.776 1.244 1.776.381 0 .697-.032.947-.099l.059-1.648c-.237.039-.414.059-.533.059-.415 0-.621-.265-.621-.795v-3.952h2.254v-1.621-.001zM97.567 28.265c0 .631.239.936.717.936.336 0 .609-.095.819-.283v-1.484c-.336-.158-.714-.237-1.137-.237-.289 0-.399.087-.399.261v.807zm3.505 2.343c.798 0 1.299-.362 1.7-1.144l.313.955h.876v-3.72c0-1.352-.941-2.202-2.437-2.202-1.1 0-2.044.436-2.609 1.29l.777.594c.38-.48.886-.754 1.587-.754.855 0 1.359.449 1.359 1.241v.053l-1.714.099c-1.367.079-2.201.781-2.201 1.814 0 1.104.876 1.774 1.949 1.774zm.226-1.4c-.521 0-.877-.314-.877-.754 0-.429.326-.683.991-.723l1.27-.069v.42c0 .643-.577 1.126-1.384 1.126z"></path>
241
+ </g>
242
+ </g>
243
+ </svg>
244
+ </a>
245
+ </div>
246
+ </div>
247
+ </div>
248
+ </div>`
249
+ }))
250
+ })
251
+
252
+ app.get('/only-bible-app/privacy-policy', (c) => {
253
+ return c.html(Layout({
254
+ css: '/assets/css/workers/only-bible-app-privacy.css',
255
+ title: 'Privacy Policy | Only Bible App',
256
+ description: 'Privacy Policy | Only Bible App',
257
+ currentPath: c.req.path,
258
+ children: html`<h1 style="font-size:1.875rem;line-height:2.25rem;font-weight:700">Privacy Policy</h1>
259
+ <p>
260
+ Only Bible App does not collect any information about you or your usage of the app. It
261
+ does not send any information about you to anyone. It does not contain ads. It will never
262
+ install additional products on your device or change your device's configuration. No ads,
263
+ No in-app purchases, No distractions.
264
+ </p>
265
+ <h4 style="font-weight:600;margin-top:0.5rem">Changes to this Privacy Policy</h4>
266
+ I may update our Privacy Policy from time to time. Thus, you are advised to review this page
267
+ periodically for any changes. I will notify you of any changes by posting the new Privacy
268
+ Policy on this page.
269
+ <p>
270
+ This policy is effective as of <strong style="font-weight:600">2023-09-08</strong>
271
+ </p>
272
+ <h4 style="font-weight:600;margin-top:0.5rem">Contact Us</h4>
273
+ If you have any questions or suggestions for us you can contact us at
274
+ <strong style="font-weight:600">[email protected]</strong>`
275
+ }))
276
+ })
277
+
278
+ app.get('/only-bible-app/terms-and-conditions', (c) => {
279
+ return c.html(Layout({
280
+ css: '/assets/css/workers/only-bible-app-terms.css',
281
+ title: 'Terms and Conditions | Only Bible App',
282
+ description: 'Terms and Conditions | Only Bible App',
283
+ currentPath: c.req.path,
284
+ children: html`<div style="padding-bottom:2.5rem;margin-top:2.5rem">
285
+ <h1 style="font-weight:700;font-size:1.875rem;line-height:2.25rem">Terms and Conditions</h1>
286
+ <p>
287
+ By downloading or using the app, these terms will automatically apply to you &mdash; you
288
+ should make sure therefore that you read them carefully before using the app.
289
+ You&rsquo;re not allowed to copy or modify the app, any part of the app, or our
290
+ trademarks in any way. You&rsquo;re not allowed to attempt to extract the source code of
291
+ the app, and you also shouldn&rsquo;t try to translate the app into other languages or
292
+ make derivative versions. The app itself, and all the trademarks, copyright, database
293
+ rights, and other intellectual property rights related to it, still belong to us.
294
+ </p>
295
+ <p>
296
+ We are committed to ensuring that the app is as useful and efficient as possible. For
297
+ that reason, we reserve the right to make changes to the app or to charge for its
298
+ services, at any time and for any reason. We will never charge you for the app or its
299
+ services without making it very clear to you exactly what you&rsquo;re paying for.
300
+ </p>
301
+ <p>
302
+ The Only Bible App app stores and processes personal data that you have provided to us,
303
+ to provide my Service. It&rsquo;s your responsibility to keep your phone and access to
304
+ the app secure. We therefore recommend that you do not jailbreak or root your phone,
305
+ which is the process of removing software restrictions and limitations imposed by the
306
+ official operating system of your device. It could make your phone vulnerable to
307
+ malware/viruses/malicious programs, compromise your phone&rsquo;s security features and
308
+ it could mean that the Only Bible App app won&rsquo;t work properly or at all.
309
+ </p>
310
+ <h4 style="font-weight:600;margin-top:0.5rem;font-size:1.17em">Changes to this Terms and conditions</h4>
311
+ I may update our Terms and conditions from time to time. Thus, you are advised to review
312
+ this page periodically for any changes. I will notify you of any changes by posting the
313
+ new Terms and conditions on this page.
314
+ <p>
315
+ This policy is effective as of <strong style="font-weight:bold">2023-09-08</strong>
316
+ </p>
317
+ <h4 style="font-weight:600;margin-top:0.5rem;font-size:1.17em">Contact Us</h4>
318
+ If you have any questions or suggestions for us you can contact us at
319
+ <strong style="font-weight:bold">[email protected]</strong>
320
+ </div>`
321
+ }))
322
+ })
323
+
324
+ // ─── 2. Blog ────────────────────────────────────────────────────
325
+
326
+ app.get('/posts', async (c) => {
327
+ const s3 = getS3()
328
+ const posts = await getPosts(s3)
329
+ return c.html(Layout({
330
+ css: '/assets/css/workers/posts-index.css',
331
+ title: 'Posts',
332
+ description: 'Posts',
333
+ currentPath: c.req.path,
334
+ children: html`<h1 style="font-weight:800;font-size:2rem">Posts</h1>
335
+ <ul style="display:flex;flex-direction:column;list-style:none;padding:0">
336
+ ${posts.map((post) => html`<li style="display:grid;grid-template-columns:1fr;gap:0.4rem;margin-top:1.25rem;line-height:1.5rem">
337
+ <div>
338
+ <a href="/posts/${post.id}" style="font-size:1.1rem;color:var(--color-post-link);text-underline-offset:4px">
339
+ ${post.data.title}
340
+ </a>
341
+ <span style="font-size:13pt;display:block">${post.data.description}</span>
342
+ </div>
343
+ <time style="font-size:1.1rem">${post.data.pubDate.toLocaleDateString()}</time>
344
+ </li>`)}
345
+ </ul>`
346
+ }))
347
+ })
348
+
349
+ app.get('/posts/:id', async (c) => {
350
+ const s3 = getS3()
351
+ const postId = c.req.param('id')
352
+ const post = await getPost(s3, postId)
353
+ if (!post) {
354
+ return c.redirect('/404', 302)
355
+ }
356
+ return c.html(Layout({
357
+ css: '/assets/css/workers/posts-detail.css',
358
+ title: post.data.title,
359
+ description: post.data.description,
360
+ currentPath: c.req.path,
361
+ children: html`<article>
362
+ <h1 style="text-align:left;font-size:1.8rem;font-weight:bold;line-height:1;margin-top:0.5rem;margin-bottom:0.1rem">
363
+ ${post.data.title}
364
+ </h1>
365
+ <h2 style="font-size:1.1rem;font-weight:500">${post.data.description}</h2>
366
+ <time style="font-size:1.1rem;color:light-dark(hsl(from var(--color-text) h s 40%), hsl(from var(--color-text) h s 80%))">
367
+ ${post.data.pubDate.toLocaleDateString()}
368
+ </time>
369
+ <hr style="margin:0.5rem 0;border-color:var(--color-text)" />
370
+ ${raw(post.html)}
371
+ </article>`
372
+ }))
373
+ })
374
+
375
+ // ─── 3. RSS ─────────────────────────────────────────────────────
376
+
377
+ app.get('/rss.xml', async (c) => {
378
+ const s3 = getS3()
379
+ const posts = await getPosts(s3)
380
+ const escapeXml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;')
381
+ const items = posts.map((post) => `
382
+ <item>
383
+ <title>${escapeXml(post.data.title)}</title>
384
+ <link>${SITE_URL}/posts/${post.id}</link>
385
+ <guid isPermaLink="true">${SITE_URL}/posts/${post.id}</guid>
386
+ <pubDate>${post.data.pubDate.toUTCString()}</pubDate>
387
+ <description>${escapeXml(post.data.description)}</description>
388
+ </item>`).join('')
389
+ const rss = `<?xml version="1.0" encoding="UTF-8"?>
390
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
391
+ <channel>
392
+ <title>${escapeXml(SITE_TITLE)}</title>
393
+ <link>${SITE_URL}</link>
394
+ <description>${escapeXml(SITE_DESCRIPTION)}</description>
395
+ <atom:link href="${SITE_URL}/rss.xml" rel="self" type="application/rss+xml"/>
396
+ ${items}
397
+ </channel>
398
+ </rss>`
399
+ return c.body(rss, 200, { 'Content-Type': 'application/rss+xml' })
400
+ })
401
+
402
+ // ─── 4. Repo routes ─────────────────────────────────────────────
403
+
404
+ app.get('/repos/:id', async (c) => {
405
+ const repoId = c.req.param('id')
406
+ const repo = getRepo(repoId)
407
+ if (!repo) return c.redirect('/404', 302)
408
+
409
+ const s3 = getS3()
410
+ const readmeRaw = await getRepoReadme(s3, repo.id)
411
+ const readmeHtml = readmeRaw ? await renderMarkdown(readmeRaw) : ''
412
+
413
+ return c.html(Layout({
414
+ title: `${repo.data.title}`,
415
+ description: repo.data.description,
416
+ css: '/assets/css/workers/readme.css',
417
+ currentPath: c.req.path,
418
+ children: RepoLayout({
419
+ repo,
420
+ currentTab: 'Readme',
421
+ children: html`<article>
422
+ ${readmeHtml ? raw(readmeHtml) : html`<p>No README found.</p>`}
423
+ </article>`
424
+ })
425
+ }))
426
+ })
427
+
428
+ app.get('/repos/:id/commits', async (c) => {
429
+ const repoId = c.req.param('id')
430
+ const repo = getRepo(repoId)
431
+ if (!repo) return c.redirect('/404', 302)
432
+
433
+ const s3 = getS3()
434
+ const commits = await getCommits(s3, repo.id)
435
+
436
+ return c.html(Layout({
437
+ title: `Commits | ${repo.data.title}`,
438
+ css: '/assets/css/workers/commits-index.css',
439
+ currentPath: c.req.path,
440
+ children: RepoLayout({
441
+ repo,
442
+ currentTab: 'Commits',
443
+ children: html`${commits.map((commit) => html`<div class="event">
444
+ <div>
445
+ <a href="/repos/${repo.id}/commits/${commit.hash}">${commit.hash.substring(0, 7)}</a>
446
+ —<strong>${commit.author_name}</strong>
447
+ <small class="pull-right">
448
+ <span>${commit.date}</span>
449
+ </small>
450
+ </div>
451
+ <pre class="commit">${commit.message}</pre>
452
+ </div>`)}`
453
+ })
454
+ }))
455
+ })
456
+
457
+ app.get('/repos/:id/commits/:hash', async (c) => {
458
+ const repoId = c.req.param('id')
459
+ const hash = c.req.param('hash')
460
+ const repo = getRepo(repoId)
461
+ if (!repo) return c.redirect('/404', 302)
462
+ if (!hash) return c.redirect(`/repos/${repo.id}/commits`, 302)
463
+
464
+ const s3 = getS3()
465
+ const commits = await getCommits(s3, repo.id)
466
+ const commit = commits.find((c2) => c2.hash === hash)
467
+ if (!commit) return c.redirect(`/repos/${repo.id}/commits`, 302)
468
+
469
+ const diffHtml = await generateHTMLDiff(s3, repo.id, commit.hash)
470
+
471
+ return c.html(Layout({
472
+ title: `${commit.hash.substring(0, 7)} | ${repo.data.title}`,
473
+ css: '/assets/css/workers/commits-detail.css',
474
+ currentPath: c.req.path,
475
+ children: RepoLayout({
476
+ repo,
477
+ currentTab: 'Commits',
478
+ children: html`<div class="event">
479
+ <div>
480
+ <a href="/repos/${repo.id}/commits/${commit.hash}">${commit.hash.substring(0, 7)}</a>—
481
+ <strong>${commit.author_name}</strong>
482
+ <small class="pull-right">
483
+ <span>${commit.date}</span>
484
+ </small>
485
+ </div>
486
+ <pre class="commit">${commit.message}</pre>
487
+ </div>
488
+ ${raw(diffHtml)}`
489
+ })
490
+ }))
491
+ })
492
+
493
+ app.get('/repos/:id/files', async (c) => {
494
+ const repoId = c.req.param('id')
495
+ const repo = getRepo(repoId)
496
+ if (!repo) return c.redirect('/404', 302)
497
+
498
+ const s3 = getS3()
499
+ const files = await getFiles(s3, repo.id)
500
+ const sortedFiles = sortChildren(buildFileTree(files))
501
+
502
+ return c.html(Layout({
503
+ title: `Files | ${repo.data.title}`,
504
+ css: '/assets/css/workers/files-index.css',
505
+ currentPath: c.req.path,
506
+ children: RepoLayout({
507
+ repo,
508
+ currentTab: 'Files',
509
+ children: FileTree({ nodes: sortedFiles, repoId: repo.id })
510
+ })
511
+ }))
512
+ })
513
+
514
+ const BINARY_EXTS = ['apk', 'dex', 'ap_', 'jar', 'fnt']
515
+ const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'icns', 'curve', 'atlas']
516
+ const LARGE_SIZE = 1024 * 512
517
+
518
+ const toBase64 = (data: Uint8Array) => btoa(String.fromCharCode(...data))
519
+
520
+ app.get('/repos/:id/files/:path{.+}/history', async (c) => {
521
+ const repoId = c.req.param('id')
522
+ const filePath = c.req.param('path')
523
+ const repo = getRepo(repoId)
524
+ if (!repo) return c.redirect('/404', 302)
525
+
526
+ const s3 = getS3()
527
+ const files = await getFiles(s3, repo.id)
528
+ const file = files.find((f) => f.absolutePath === filePath)
529
+ if (!file) return c.redirect(`/repos/${repo.id}/files`, 302)
530
+
531
+ const history = await getFileHistory(s3, repo.id, file.absolutePath)
532
+
533
+ return c.html(Layout({
534
+ title: `${file.name} history | ${repo.data.title}`,
535
+ currentPath: c.req.path,
536
+ children: RepoLayout({
537
+ repo,
538
+ currentTab: 'Files',
539
+ children: FileLayout({
540
+ repo,
541
+ file,
542
+ pathname: c.req.path,
543
+ children: html`${history.map((commit) => CommitEntry({ repoId: repo.id, commit }))}`
544
+ })
545
+ })
546
+ }))
547
+ })
548
+
549
+ app.get('/repos/:id/files/:path{.+}/blame', async (c) => {
550
+ const repoId = c.req.param('id')
551
+ const filePath = c.req.param('path')
552
+ const repo = getRepo(repoId)
553
+ if (!repo) return c.redirect('/404', 302)
554
+
555
+ const s3 = getS3()
556
+ const files = await getFiles(s3, repo.id)
557
+ const file = files.find((f) => f.absolutePath === filePath)
558
+ if (!file) return c.redirect(`/repos/${repo.id}/files`, 302)
559
+
560
+ return c.html(Layout({
561
+ title: `${file.name} blame | ${repo.data.title}`,
562
+ currentPath: c.req.path,
563
+ children: RepoLayout({
564
+ repo,
565
+ currentTab: 'Files',
566
+ children: FileLayout({
567
+ repo,
568
+ file,
569
+ pathname: c.req.path,
570
+ children: html`<p>WIP: Work in Progress...</p>`
571
+ })
572
+ })
573
+ }))
574
+ })
575
+
576
+ app.get('/repos/:id/files/:path{.+}', async (c) => {
577
+ const repoId = c.req.param('id')
578
+ const filePath = c.req.param('path')
579
+ const repo = getRepo(repoId)
580
+ if (!repo) return c.redirect('/404', 302)
581
+
582
+ const s3 = getS3()
583
+ const files = await getFiles(s3, repo.id)
584
+ const file = files.find((f) => f.absolutePath === filePath)
585
+ if (!file) return c.redirect(`/repos/${repo.id}/files`, 302)
586
+
587
+ const contentBuffer = await getFileContentData(s3, repo.id, file.absolutePath)
588
+ const isBinary = BINARY_EXTS.includes(file.ext)
589
+ const isLarge = file.size > LARGE_SIZE
590
+ const isImage = IMAGE_EXTS.includes(file.ext)
591
+
592
+ let content: any
593
+ if (isBinary || isLarge) {
594
+ content = html`<p>The file is too large to be displayed (${file.size} bytes).</p>`
595
+ } else if (isImage && contentBuffer) {
596
+ content = html`<img src="data:image/${file.ext};base64,${toBase64(contentBuffer)}" alt="${file.path}" />`
597
+ } else if (contentBuffer) {
598
+ const text = new TextDecoder('utf-8').decode(contentBuffer).trim() || 'No content to display.'
599
+ content = html`<pre><code class="language-${file.ext}">${text}</code></pre>`
600
+ } else {
601
+ content = html`<p>Unable to load file content.</p>`
602
+ }
603
+
604
+ return c.html(Layout({
605
+ title: `${file.name} | ${repo.data.title}`,
606
+ currentPath: c.req.path,
607
+ children: RepoLayout({
608
+ repo,
609
+ currentTab: 'Files',
610
+ children: FileLayout({
611
+ repo,
612
+ file,
613
+ pathname: c.req.path,
614
+ children: content
615
+ })
616
+ })
617
+ }))
618
+ })
619
+
620
+ // ─── 5. Issue routes ────────────────────────────────────────────
621
+
622
+ app.get('/repos/:id/issues', async (c) => {
623
+ const repoId = c.req.param('id')
624
+ const repo = getRepo(repoId)
625
+ if (!repo) return c.redirect('/404', 302)
626
+
627
+ const s3 = getS3()
628
+ const { issues, isAvailable } = await getGitBugIssues(s3, repo.id)
629
+ const openIssues = issues.filter((issue) => issue.state === 'open')
630
+ const closedIssues = issues.filter((issue) => issue.state === 'closed')
631
+
632
+ return c.html(Layout({
633
+ title: `Issues | ${repo.data.title}`,
634
+ css: '/assets/css/workers/issues-index.css',
635
+ currentPath: c.req.path,
636
+ children: RepoLayout({
637
+ repo,
638
+ currentTab: 'Issues',
639
+ children: html`<section class="issues">
640
+ <div class="summary">
641
+ <strong>${issues.length}</strong> issues
642
+ <span>${openIssues.length} open</span>
643
+ <span>${closedIssues.length} closed</span>
644
+ </div>
645
+ <form class="new-issue" hx-post="/repos/${repo.id}/issues" hx-target="#issues-list" hx-swap="innerHTML">
646
+ <input name="repoId" type="hidden" value="${repo.id}" />
647
+ <input name="title" placeholder="Title" required="true" />
648
+ <textarea name="body" placeholder="Description"></textarea>
649
+ <div class="form-row">
650
+ <input name="author" placeholder="Author" />
651
+ <input name="labels" placeholder="labels, comma-separated" />
652
+ <button type="submit">Create</button>
653
+ </div>
654
+ </form>
655
+ <div id="issues-list">
656
+ ${issues.length > 0
657
+ ? issues.map((issue) => IssueCard({ repoId: repo.id, issue }))
658
+ : html`<p class="empty">
659
+ ${isAvailable
660
+ ? 'No git-bug issues found in this repository.'
661
+ : 'No git-bug issues could be loaded from R2.'}
662
+ </p>`}
663
+ </div>
664
+ </section>`
665
+ })
666
+ }))
667
+ })
668
+
669
+ app.post('/repos/:id/issues', async (c) => {
670
+ const repoId = c.req.param('id')
671
+ const repo = getRepo(repoId)
672
+ if (!repo) return c.redirect('/404', 302)
673
+
674
+ if (!c.req.header('HX-Request')) {
675
+ return c.redirect(c.req.url, 303)
676
+ }
677
+
678
+ const s3 = getS3()
679
+ const formData = await c.req.formData()
680
+ const title = formData.get('title') as string
681
+ const body = formData.get('body') as string
682
+ const author = formData.get('author') as string
683
+ const labelsRaw = formData.get('labels') as string
684
+ const labels = labelsRaw
685
+ ? labelsRaw.split(',').map((l) => l.trim()).filter(Boolean)
686
+ : undefined
687
+
688
+ try {
689
+ await createGitBugIssue(s3, repo.id, { title, body, author, labels })
690
+ const { issues } = await getGitBugIssues(s3, repo.id)
691
+ const htmlContent = issues.map((issue) => IssueCard({ repoId: repo.id, issue })).join('')
692
+ return c.html(htmlContent)
693
+ } catch (err) {
694
+ const message = err instanceof Error ? err.message : 'Unknown error'
695
+ return c.html(`<div class="error" style="color:red;padding:0.5rem">${message}</div>`)
696
+ }
697
+ })
698
+
699
+ app.get('/repos/:id/issues/:issueId', async (c) => {
700
+ const repoId = c.req.param('id')
701
+ const issueId = c.req.param('issueId')
702
+ const repo = getRepo(repoId)
703
+ if (!repo) return c.redirect('/404', 302)
704
+
705
+ if (!/^[a-f0-9]{16}$/.test(issueId)) {
706
+ return c.redirect(`/repos/${repo.id}/issues`, 302)
707
+ }
708
+
709
+ const s3 = getS3()
710
+ const issue = await getGitBugIssue(s3, repo.id, issueId)
711
+ if (!issue) return c.redirect(`/repos/${repo.id}/issues`, 302)
712
+
713
+ return c.html(Layout({
714
+ title: `${issue.title} | ${repo.data.title}`,
715
+ css: '/assets/css/workers/issues-detail.css',
716
+ currentPath: c.req.path,
717
+ children: RepoLayout({
718
+ repo,
719
+ currentTab: 'Issues',
720
+ children: html`${StateBadge(issue)}
721
+ ${IssueHeaderSection(repo.id, issue)}
722
+ ${StateFormSection(repo.id, issue)}
723
+ ${issue.body ? html`<section class="body"><pre>${issue.body}</pre></section>` : ''}
724
+ ${CommentsSection(repo.id, issue)}`
725
+ })
726
+ }))
727
+ })
728
+
729
+ app.post('/repos/:id/issues/:issueId', async (c) => {
730
+ const repoId = c.req.param('id')
731
+ const issueId = c.req.param('issueId')
732
+ const repo = getRepo(repoId)
733
+ if (!repo) return c.redirect('/404', 302)
734
+
735
+ if (!c.req.header('HX-Request')) {
736
+ return c.redirect(c.req.url, 303)
737
+ }
738
+
739
+ const s3 = getS3()
740
+ const formData = await c.req.formData()
741
+ const body = formData.get('body') as string
742
+ const author = formData.get('author') as string
743
+
744
+ try {
745
+ const updated = await addGitBugIssueComment(s3, repo.id, issueId, { body, author })
746
+ return c.html(CommentsSection(repo.id, updated))
747
+ } catch (err) {
748
+ const message = err instanceof Error ? err.message : 'Unknown error'
749
+ return c.html(`<div class="error" style="color:red;padding:0.5rem">${message}</div>`)
750
+ }
751
+ })
752
+
753
+ app.put('/repos/:id/issues/:issueId', async (c) => {
754
+ const repoId = c.req.param('id')
755
+ const issueId = c.req.param('issueId')
756
+ const repo = getRepo(repoId)
757
+ if (!repo) return c.redirect('/404', 302)
758
+
759
+ if (!c.req.header('HX-Request')) {
760
+ return c.redirect(c.req.url, 303)
761
+ }
762
+
763
+ const s3 = getS3()
764
+ const formData = await c.req.formData()
765
+ const state = formData.get('state') as 'open' | 'closed'
766
+ const author = formData.get('author') as string
767
+
768
+ try {
769
+ const updated = await setGitBugIssueState(s3, repo.id, issueId, state, author)
770
+ return c.html(StateBadge(updated))
771
+ } catch (err) {
772
+ const message = err instanceof Error ? err.message : 'Unknown error'
773
+ return c.html(`<div class="error" style="color:red;padding:0.5rem">${message}</div>`)
774
+ }
775
+ })
776
+
777
+ // ─── Issue helper components ────────────────────────────────────
778
+
779
+ const fmtDate = (date: string) => new Date(date).toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' })
780
+
781
+ const StateBadge = (issue: { state: string }) => html`<span id="state-badge" class="state ${issue.state}">${issue.state}</span>`
782
+
783
+ const IssueHeaderSection = (repoId: string, issue: any) => html`<article class="issue">
784
+ <div class="issue-title">
785
+ <a href="/repos/${repoId}/issues/${issue.id}" title="${issue.id}">${issue.title}</a>
786
+ ${StateBadge(issue)}
787
+ </div>
788
+ <div class="meta">
789
+ <a href="/repos/${repoId}/issues/${issue.id}" title="${issue.id}">${issue.id.substring(0, 8)}</a>
790
+ ${issue.author ? html`<span>by ${issue.author}</span>` : ''}
791
+ ${issue.createdAt ? html`<span>${fmtDate(issue.createdAt)}</span>` : ''}
792
+ ${issue.comments.length > 0 ? html`<span>${issue.comments.length} comments</span>` : ''}
793
+ </div>
794
+ ${issue.labels.length > 0 ? html`<div class="labels">
795
+ ${issue.labels.map((label: string) => html`<span class="label">#${label}</span>`)}
796
+ </div>` : ''}
797
+ </article>`
798
+
799
+ const StateFormSection = (repoId: string, issue: any) => html`<form class="state-form" hx-put="/repos/${repoId}/issues/${issue.id}" hx-target="#state-badge" hx-swap="outerHTML">
800
+ <input name="repoId" type="hidden" value="${repoId}" />
801
+ <input name="issueId" type="hidden" value="${issue.id}" />
802
+ <input name="state" type="hidden" value="${issue.state === 'open' ? 'closed' : 'open'}" />
803
+ <input name="author" type="hidden" value="${issue.author || ''}" />
804
+ <button type="submit">${issue.state === 'open' ? 'Close' : 'Reopen'}</button>
805
+ </form>`
806
+
807
+ const CommentsSection = (repoId: string, issue: any) => html`<section id="comments-section" class="comments">
808
+ ${issue.comments.length > 0 ? html`
809
+ <h3>Comments</h3>
810
+ ${issue.comments.map((comment: any) => html`<article class="comment">
811
+ <div class="comment-meta">
812
+ ${comment.author ? html`<strong>${comment.author}</strong>` : ''}
813
+ ${comment.createdAt ? html`<span>${fmtDate(comment.createdAt)}</span>` : ''}
814
+ </div>
815
+ <pre>${comment.body}</pre>
816
+ </article>`)}
817
+ ` : ''}
818
+ <form class="comment-form" hx-post="/repos/${repoId}/issues/${issue.id}" hx-target="#comments-section" hx-swap="outerHTML">
819
+ <input name="repoId" type="hidden" value="${repoId}" />
820
+ <input name="issueId" type="hidden" value="${issue.id}" />
821
+ <textarea name="body" placeholder="Comment" required="true"></textarea>
822
+ <div class="comment-actions">
823
+ <input name="author" placeholder="Author" />
824
+ <button type="submit">Comment</button>
825
+ </div>
826
+ </form>
827
+ </section>`
828
+
829
+ export default app