Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

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

projects.tsxBlame601 lines · 1 contributor
1e162a8Claude1/**
2 * Block E1 — Projects / Kanban boards scoped to a repo.
3 *
4 * Each project has ordered columns ("To Do" / "In Progress" / "Done" by
5 * default) and items (notes or linked issues/PRs). Items belong to exactly
6 * one column at a time. Simple v1: positions are recomputed via "max+1".
7 *
8 * Never throws — all DB paths wrapped in try/catch.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc, asc, sql } from "drizzle-orm";
13import { db } from "../db";
14import {
15 projects,
16 projectColumns,
17 projectItems,
18 repositories,
19 users,
20} from "../db/schema";
21import { Layout } from "../views/layout";
22import { RepoHeader } from "../views/components";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25
26const DEFAULT_COLUMNS = ["To Do", "In Progress", "Done"] as const;
27
28const projectRoutes = new Hono<AuthEnv>();
29
30async function resolveRepo(ownerName: string, repoName: string) {
31 try {
32 const [owner] = await db
33 .select()
34 .from(users)
35 .where(eq(users.username, ownerName))
36 .limit(1);
37 if (!owner) return null;
38 const [repo] = await db
39 .select()
40 .from(repositories)
41 .where(
42 and(
43 eq(repositories.ownerId, owner.id),
44 eq(repositories.name, repoName)
45 )
46 )
47 .limit(1);
48 if (!repo) return null;
49 return { owner, repo };
50 } catch {
51 return null;
52 }
53}
54
55function notFound(user: any, label = "Not found") {
56 return (
57 <Layout title={label} user={user}>
58 <div class="empty-state">
59 <h2>{label}</h2>
60 </div>
61 </Layout>
62 );
63}
64
65// List
66projectRoutes.get("/:owner/:repo/projects", softAuth, async (c) => {
67 const { owner: ownerName, repo: repoName } = c.req.param();
68 const user = c.get("user");
69 const resolved = await resolveRepo(ownerName, repoName);
70 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
71 const { repo } = resolved;
72
73 let rows: any[] = [];
74 try {
75 rows = await db
76 .select({
77 p: projects,
78 columnCount: sql<number>`(SELECT count(*) FROM project_columns WHERE project_id = ${projects.id})`,
79 itemCount: sql<number>`(SELECT count(*) FROM project_items WHERE project_id = ${projects.id})`,
80 })
81 .from(projects)
82 .where(eq(projects.repositoryId, repo.id))
83 .orderBy(desc(projects.updatedAt));
84 } catch {
85 rows = [];
86 }
87
88 return c.html(
89 <Layout title={`Projects — ${ownerName}/${repoName}`} user={user}>
90 <RepoHeader owner={ownerName} repo={repoName} />
91 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
92 <h2 style="margin: 0;">Projects</h2>
93 {user && (
94 <a
95 href={`/${ownerName}/${repoName}/projects/new`}
96 class="btn btn-primary"
97 >
98 New project
99 </a>
100 )}
101 </div>
102 {rows.length === 0 ? (
103 <div class="empty-state">
104 <p>No projects yet.</p>
105 </div>
106 ) : (
107 <table class="file-table">
108 <tbody>
109 {rows.map((r) => (
110 <tr>
111 <td style="width: 40px; color: var(--text-muted);">
112 #{r.p.number}
113 </td>
114 <td>
115 <a
116 href={`/${ownerName}/${repoName}/projects/${r.p.number}`}
117 >
118 <strong>{r.p.title}</strong>
119 </a>
120 {r.p.state === "closed" && <span class="badge">closed</span>}
121 {r.p.description && (
122 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
123 {r.p.description}
124 </div>
125 )}
126 </td>
127 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
128 {r.columnCount} cols · {r.itemCount} items
129 </td>
130 </tr>
131 ))}
132 </tbody>
133 </table>
134 )}
135 </Layout>
136 );
137});
138
139// New form
140projectRoutes.get(
141 "/:owner/:repo/projects/new",
142 requireAuth,
143 async (c) => {
144 const { owner: ownerName, repo: repoName } = c.req.param();
145 const user = c.get("user");
146 const resolved = await resolveRepo(ownerName, repoName);
147 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
148 return c.html(
149 <Layout title="New project" user={user}>
150 <RepoHeader owner={ownerName} repo={repoName} />
151 <h2 style="margin-top: 20px;">Create a project</h2>
152 <form
9837657Claude153 method="post"
1e162a8Claude154 action={`/${ownerName}/${repoName}/projects`}
155 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
156 >
157 <input
158 type="text"
159 name="title"
160 placeholder="Title"
161 required
162 style="padding: 8px;"
163 />
164 <textarea
165 name="description"
166 rows={4}
167 placeholder="Description (optional)"
168 style="padding: 8px; font-family: inherit;"
169 ></textarea>
170 <button type="submit" class="btn btn-primary">
171 Create
172 </button>
173 </form>
174 </Layout>
175 );
176 }
177);
178
179// Create
180projectRoutes.post(
181 "/:owner/:repo/projects",
182 requireAuth,
183 async (c) => {
184 const { owner: ownerName, repo: repoName } = c.req.param();
185 const user = c.get("user")!;
186 const resolved = await resolveRepo(ownerName, repoName);
187 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
188
189 const form = await c.req.formData();
190 const title = (form.get("title") as string || "").trim();
191 const description = (form.get("description") as string || "").trim();
192
193 if (!title) {
194 return c.redirect(`/${ownerName}/${repoName}/projects/new`);
195 }
196
197 try {
198 const [row] = await db
199 .insert(projects)
200 .values({
201 repositoryId: resolved.repo.id,
202 ownerId: user.id,
203 title,
204 description,
205 })
206 .returning({ id: projects.id, number: projects.number });
207 // Seed default columns
208 await db.insert(projectColumns).values(
209 DEFAULT_COLUMNS.map((name, i) => ({
210 projectId: row.id,
211 name,
212 position: i,
213 }))
214 );
215 return c.redirect(`/${ownerName}/${repoName}/projects/${row.number}`);
216 } catch {
217 return c.redirect(`/${ownerName}/${repoName}/projects`);
218 }
219 }
220);
221
222// Board view
223projectRoutes.get(
224 "/:owner/:repo/projects/:number",
225 softAuth,
226 async (c) => {
227 const { owner: ownerName, repo: repoName } = c.req.param();
228 const user = c.get("user");
229 const numParam = Number(c.req.param("number"));
230 const resolved = await resolveRepo(ownerName, repoName);
231 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
232
233 let project: any = null;
234 let columns: any[] = [];
235 let items: any[] = [];
236 try {
237 const [row] = await db
238 .select()
239 .from(projects)
240 .where(
241 and(
242 eq(projects.repositoryId, resolved.repo.id),
243 eq(projects.number, numParam)
244 )
245 )
246 .limit(1);
247 if (row) {
248 project = row;
249 columns = await db
250 .select()
251 .from(projectColumns)
252 .where(eq(projectColumns.projectId, row.id))
253 .orderBy(asc(projectColumns.position), asc(projectColumns.createdAt));
254 items = await db
255 .select()
256 .from(projectItems)
257 .where(eq(projectItems.projectId, row.id))
258 .orderBy(asc(projectItems.position));
259 }
260 } catch {
261 // leave nulls
262 }
263
264 if (!project) return c.html(notFound(user, "Project not found"), 404);
265
266 const isOwner = user && user.id === resolved.repo.ownerId;
267 const itemsByCol: Record<string, any[]> = {};
268 for (const col of columns) itemsByCol[col.id] = [];
269 for (const it of items) {
270 if (itemsByCol[it.columnId]) itemsByCol[it.columnId].push(it);
271 }
272
273 return c.html(
274 <Layout
275 title={`${project.title} — project #${project.number}`}
276 user={user}
277 >
278 <RepoHeader owner={ownerName} repo={repoName} />
279 <style>{`
280 .kanban { display: flex; gap: 16px; overflow-x: auto; padding: 16px 0; }
281 .kcol { background: var(--bg-soft); border: 1px solid var(--border); border-radius: 6px; min-width: 260px; flex-shrink: 0; padding: 12px; }
282 .kcol h4 { margin: 0 0 12px; display: flex; justify-content: space-between; }
283 .kcard { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 8px; margin-bottom: 8px; font-size: 13px; }
284 .kcard form { display: inline; }
285 `}</style>
286 <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 16px;">
287 <h1 style="margin: 0;">
288 {project.title}{" "}
289 <span style="color: var(--text-muted);">#{project.number}</span>
290 {project.state === "closed" && <span class="badge">closed</span>}
291 </h1>
292 {user && (
293 <form
9837657Claude294 method="post"
1e162a8Claude295 action={`/${ownerName}/${repoName}/projects/${project.number}/close`}
296 style="display: inline;"
297 >
298 <button type="submit" class="btn">
299 {project.state === "open" ? "Close" : "Reopen"}
300 </button>
301 </form>
302 )}
303 </div>
304 {project.description && (
305 <div style="color: var(--text-muted); margin-top: 4px;">
306 {project.description}
307 </div>
308 )}
309 <div class="kanban">
310 {columns.map((col) => (
311 <div class="kcol">
312 <h4>
313 <span>{col.name}</span>
314 <span style="color: var(--text-muted); font-size: 13px;">
315 {(itemsByCol[col.id] || []).length}
316 </span>
317 </h4>
318 {(itemsByCol[col.id] || []).map((it) => (
319 <div class="kcard">
320 <div>
321 <strong>{it.title || "(untitled)"}</strong>
322 </div>
323 {it.note && (
324 <div style="color: var(--text-muted); margin-top: 4px;">
325 {it.note}
326 </div>
327 )}
328 {user && (
329 <div style="margin-top: 8px; display: flex; gap: 4px; flex-wrap: wrap;">
330 {columns
331 .filter((oc) => oc.id !== col.id)
332 .map((oc) => (
333 <form
9837657Claude334 method="post"
1e162a8Claude335 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/move`}
336 >
337 <input
338 type="hidden"
339 name="column_id"
340 value={oc.id}
341 />
342 <button
343 type="submit"
344 class="btn"
345 style="font-size: 11px; padding: 2px 6px;"
346 >
347 → {oc.name}
348 </button>
349 </form>
350 ))}
351 <form
9837657Claude352 method="post"
1e162a8Claude353 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/delete`}
354 >
355 <button
356 type="submit"
357 class="btn"
358 style="font-size: 11px; padding: 2px 6px;"
359 >
360 ×
361 </button>
362 </form>
363 </div>
364 )}
365 </div>
366 ))}
367 {user && (
368 <form
9837657Claude369 method="post"
1e162a8Claude370 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
371 style="margin-top: 8px; display: flex; flex-direction: column; gap: 4px;"
372 >
373 <input type="hidden" name="column_id" value={col.id} />
374 <input
375 type="text"
376 name="title"
377 placeholder="New card title"
378 required
379 style="padding: 4px; font-size: 12px;"
380 />
381 <button
382 type="submit"
383 class="btn"
384 style="font-size: 12px; padding: 4px;"
385 >
386 + Add card
387 </button>
388 </form>
389 )}
390 </div>
391 ))}
392 {user && (
393 <div class="kcol" style="background: transparent; border-style: dashed;">
394 <form
9837657Claude395 method="post"
1e162a8Claude396 action={`/${ownerName}/${repoName}/projects/${project.number}/columns`}
397 style="display: flex; flex-direction: column; gap: 8px;"
398 >
399 <input
400 type="text"
401 name="name"
402 placeholder="New column"
403 required
404 style="padding: 6px;"
405 />
406 <button type="submit" class="btn">
407 + Add column
408 </button>
409 </form>
410 </div>
411 )}
412 </div>
413 </Layout>
414 );
415 }
416);
417
418// Add column
419projectRoutes.post(
420 "/:owner/:repo/projects/:number/columns",
421 requireAuth,
422 async (c) => {
423 const { owner: ownerName, repo: repoName } = c.req.param();
424 const numParam = Number(c.req.param("number"));
425 const resolved = await resolveRepo(ownerName, repoName);
426 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
427
428 const form = await c.req.formData();
429 const name = (form.get("name") as string || "").trim();
430 if (!name) {
431 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
432 }
433
434 try {
435 const [row] = await db
436 .select()
437 .from(projects)
438 .where(
439 and(
440 eq(projects.repositoryId, resolved.repo.id),
441 eq(projects.number, numParam)
442 )
443 )
444 .limit(1);
445 if (row) {
446 const [maxPos] = await db
447 .select({ p: sql<number>`coalesce(max(${projectColumns.position}), -1)` })
448 .from(projectColumns)
449 .where(eq(projectColumns.projectId, row.id));
450 await db.insert(projectColumns).values({
451 projectId: row.id,
452 name,
453 position: Number(maxPos?.p || -1) + 1,
454 });
455 }
456 } catch {
457 // swallow
458 }
459 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
460 }
461);
462
463// Add item
464projectRoutes.post(
465 "/:owner/:repo/projects/:number/items",
466 requireAuth,
467 async (c) => {
468 const { owner: ownerName, repo: repoName } = c.req.param();
469 const numParam = Number(c.req.param("number"));
470 const resolved = await resolveRepo(ownerName, repoName);
471 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
472
473 const form = await c.req.formData();
474 const columnId = (form.get("column_id") as string || "").trim();
475 const title = (form.get("title") as string || "").trim();
476 const note = (form.get("note") as string || "").trim();
477 if (!columnId || !title) {
478 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
479 }
480
481 try {
482 const [row] = await db
483 .select()
484 .from(projects)
485 .where(
486 and(
487 eq(projects.repositoryId, resolved.repo.id),
488 eq(projects.number, numParam)
489 )
490 )
491 .limit(1);
492 if (row) {
493 const [maxPos] = await db
494 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
495 .from(projectItems)
496 .where(eq(projectItems.columnId, columnId));
497 await db.insert(projectItems).values({
498 projectId: row.id,
499 columnId,
500 title,
501 note,
502 position: Number(maxPos?.p || -1) + 1,
503 });
504 }
505 } catch {
506 // swallow
507 }
508 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
509 }
510);
511
512// Move item
513projectRoutes.post(
514 "/:owner/:repo/projects/:number/items/:itemId/move",
515 requireAuth,
516 async (c) => {
517 const { owner: ownerName, repo: repoName, itemId } = c.req.param();
518 const numParam = Number(c.req.param("number"));
519 const resolved = await resolveRepo(ownerName, repoName);
520 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
521
522 const form = await c.req.formData();
523 const columnId = (form.get("column_id") as string || "").trim();
524 if (!columnId) {
525 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
526 }
527
528 try {
529 const [maxPos] = await db
530 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
531 .from(projectItems)
532 .where(eq(projectItems.columnId, columnId));
533 await db
534 .update(projectItems)
535 .set({
536 columnId,
537 position: Number(maxPos?.p || -1) + 1,
538 updatedAt: new Date(),
539 })
540 .where(eq(projectItems.id, itemId));
541 } catch {
542 // swallow
543 }
544 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
545 }
546);
547
548// Delete item
549projectRoutes.post(
550 "/:owner/:repo/projects/:number/items/:itemId/delete",
551 requireAuth,
552 async (c) => {
553 const { owner: ownerName, repo: repoName, itemId } = c.req.param();
554 const numParam = Number(c.req.param("number"));
555 try {
556 await db.delete(projectItems).where(eq(projectItems.id, itemId));
557 } catch {
558 // swallow
559 }
560 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
561 }
562);
563
564// Toggle close
565projectRoutes.post(
566 "/:owner/:repo/projects/:number/close",
567 requireAuth,
568 async (c) => {
569 const { owner: ownerName, repo: repoName } = c.req.param();
570 const numParam = Number(c.req.param("number"));
571 const resolved = await resolveRepo(ownerName, repoName);
572 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
573
574 try {
575 const [row] = await db
576 .select()
577 .from(projects)
578 .where(
579 and(
580 eq(projects.repositoryId, resolved.repo.id),
581 eq(projects.number, numParam)
582 )
583 )
584 .limit(1);
585 if (row) {
586 await db
587 .update(projects)
588 .set({
589 state: row.state === "open" ? "closed" : "open",
590 updatedAt: new Date(),
591 })
592 .where(eq(projects.id, row.id));
593 }
594 } catch {
595 // swallow
596 }
597 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
598 }
599);
600
601export default projectRoutes;