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.tsxBlame1395 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.
655a200Claude9 *
10 * 2026 polish: scoped `.proj-*` class system mirrors `collaborators.tsx` —
11 * eyebrow + display headline, polished project cards with tabular-nums for
12 * counts, state pills, and a kanban board with hairline-gradient columns.
1e162a8Claude13 */
14
15import { Hono } from "hono";
16import { and, eq, desc, asc, sql } from "drizzle-orm";
17import { db } from "../db";
18import {
19 projects,
20 projectColumns,
21 projectItems,
22 repositories,
23 users,
d9b3649Claude24 issues,
25 pullRequests,
1e162a8Claude26} from "../db/schema";
27import { Layout } from "../views/layout";
28import { RepoHeader } from "../views/components";
29import { softAuth, requireAuth } from "../middleware/auth";
30import type { AuthEnv } from "../middleware/auth";
31
32const DEFAULT_COLUMNS = ["To Do", "In Progress", "Done"] as const;
33
34const projectRoutes = new Hono<AuthEnv>();
35
655a200Claude36// ─── Scoped CSS (.proj-*) ───────────────────────────────────────────────────
37const projStyles = `
eed4684Claude38 .proj-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
655a200Claude39
40 .proj-head {
41 display: flex;
42 align-items: flex-end;
43 justify-content: space-between;
44 gap: var(--space-4);
45 flex-wrap: wrap;
46 margin-bottom: var(--space-5);
47 }
48 .proj-head-text { flex: 1; min-width: 280px; }
49 .proj-eyebrow {
50 display: inline-flex;
51 align-items: center;
52 gap: 8px;
53 text-transform: uppercase;
54 font-family: var(--font-mono);
55 font-size: 11px;
56 letter-spacing: 0.16em;
57 color: var(--text-muted);
58 font-weight: 600;
59 margin-bottom: 10px;
60 }
61 .proj-eyebrow-dot {
62 width: 8px; height: 8px;
63 border-radius: 9999px;
64 background: linear-gradient(135deg, #8c6dff, #36c5d6);
65 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
66 }
67 .proj-title {
68 font-family: var(--font-display);
69 font-size: clamp(24px, 3.4vw, 36px);
70 font-weight: 800;
71 letter-spacing: -0.028em;
72 line-height: 1.1;
73 margin: 0 0 6px;
74 color: var(--text-strong);
75 }
76 .proj-title-grad {
77 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
78 -webkit-background-clip: text;
79 background-clip: text;
80 -webkit-text-fill-color: transparent;
81 color: transparent;
82 }
83 .proj-sub {
84 margin: 0;
85 font-size: 14px;
86 color: var(--text-muted);
87 line-height: 1.5;
88 max-width: 640px;
89 }
90
91 /* Buttons */
92 .proj-btn {
93 display: inline-flex;
94 align-items: center;
95 justify-content: center;
96 gap: 6px;
97 padding: 9px 16px;
98 border-radius: 10px;
99 font-size: 13px;
100 font-weight: 600;
101 text-decoration: none;
102 border: 1px solid transparent;
103 cursor: pointer;
104 font: inherit;
105 line-height: 1;
106 white-space: nowrap;
107 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
108 }
109 .proj-btn-primary {
110 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
111 color: #ffffff;
112 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
113 }
114 .proj-btn-primary:hover {
115 transform: translateY(-1px);
116 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
117 text-decoration: none;
118 color: #ffffff;
119 }
120 .proj-btn-ghost {
121 background: transparent;
122 color: var(--text);
123 border-color: var(--border-strong);
124 }
125 .proj-btn-ghost:hover {
126 background: rgba(140,109,255,0.06);
127 border-color: rgba(140,109,255,0.45);
128 color: var(--text-strong);
129 text-decoration: none;
130 }
131 .proj-btn-mini {
132 padding: 4px 10px;
133 font-size: 11.5px;
134 border-radius: 8px;
135 }
136
137 /* Crumbs */
138 .proj-crumbs {
139 display: flex;
140 align-items: center;
141 gap: 12px;
142 flex-wrap: wrap;
143 margin-bottom: var(--space-4);
144 font-size: 12.5px;
145 }
146 .proj-crumbs a {
147 display: inline-flex;
148 align-items: center;
149 gap: 5px;
150 padding: 6px 11px;
151 background: rgba(255,255,255,0.025);
152 border: 1px solid var(--border);
153 border-radius: 8px;
154 color: var(--text-muted);
155 text-decoration: none;
156 font-weight: 500;
157 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
158 }
159 .proj-crumbs a:hover {
160 border-color: var(--border-strong);
161 color: var(--text-strong);
162 background: rgba(255,255,255,0.04);
163 text-decoration: none;
164 }
165
166 /* Project list cards */
167 .proj-list { display: flex; flex-direction: column; gap: 10px; }
168 .proj-card {
169 display: flex;
170 align-items: center;
171 gap: 16px;
172 padding: 14px 18px;
173 background: var(--bg-elevated);
174 border: 1px solid var(--border);
175 border-radius: 12px;
176 transition: border-color 120ms ease, background 120ms ease;
177 }
178 .proj-card:hover {
179 border-color: var(--border-strong);
180 background: rgba(255,255,255,0.03);
181 }
182 .proj-num {
183 font-family: var(--font-mono);
184 font-size: 12.5px;
185 color: var(--text-muted);
186 font-variant-numeric: tabular-nums;
187 flex-shrink: 0;
188 }
189 .proj-card-body { flex: 1; min-width: 0; }
190 .proj-card-title {
191 display: inline-flex;
192 align-items: center;
193 gap: 8px;
194 flex-wrap: wrap;
195 }
196 .proj-card-title a {
197 font-family: var(--font-display);
198 font-weight: 700;
199 font-size: 15.5px;
200 color: var(--text-strong);
201 text-decoration: none;
202 letter-spacing: -0.005em;
203 }
204 .proj-card-title a:hover { text-decoration: underline; }
205 .proj-card-desc {
206 margin-top: 3px;
207 font-size: 13px;
208 color: var(--text-muted);
209 line-height: 1.45;
210 }
211 .proj-card-meta {
212 display: flex;
213 align-items: center;
214 gap: 10px;
215 flex-wrap: wrap;
216 font-size: 12px;
217 color: var(--text-muted);
218 font-variant-numeric: tabular-nums;
219 white-space: nowrap;
220 }
221 .proj-card-meta .sep { opacity: 0.4; }
222
223 /* Pills */
224 .proj-pill {
225 display: inline-flex;
226 align-items: center;
227 gap: 5px;
228 padding: 2px 9px;
229 border-radius: 9999px;
230 font-size: 11px;
231 font-weight: 600;
232 letter-spacing: 0.02em;
233 text-transform: capitalize;
234 }
235 .proj-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
236 .proj-pill.is-open {
237 background: rgba(52,211,153,0.14);
238 color: #6ee7b7;
239 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
240 }
241 .proj-pill.is-closed {
242 background: rgba(148,163,184,0.16);
243 color: #cbd5e1;
244 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
245 }
246 .proj-pill.is-count {
247 background: rgba(140,109,255,0.12);
248 color: #c4b5fd;
249 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
250 font-family: var(--font-mono);
251 font-variant-numeric: tabular-nums;
252 }
253
254 /* Form card */
255 .proj-form-card {
256 background: var(--bg-elevated);
257 border: 1px solid var(--border);
258 border-radius: 14px;
259 padding: var(--space-5);
260 max-width: 640px;
261 position: relative;
262 overflow: hidden;
263 }
264 .proj-form-card::before {
265 content: '';
266 position: absolute;
267 top: 0; left: 0; right: 0;
268 height: 2px;
269 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
270 opacity: 0.55;
271 }
272 .proj-field { margin-bottom: 14px; }
273 .proj-field-label {
274 display: block;
275 font-size: 11.5px;
276 color: var(--text-muted);
277 font-weight: 600;
278 text-transform: uppercase;
279 letter-spacing: 0.06em;
280 margin-bottom: 6px;
281 }
282 .proj-input, .proj-textarea {
283 width: 100%;
284 box-sizing: border-box;
285 padding: 9px 12px;
286 font: inherit;
287 font-size: 13.5px;
288 color: var(--text);
289 background: rgba(255,255,255,0.03);
290 border: 1px solid var(--border-strong);
291 border-radius: 10px;
292 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
293 }
294 .proj-textarea { font-family: inherit; resize: vertical; line-height: 1.5; }
295 .proj-input:focus, .proj-textarea:focus {
296 outline: none;
297 border-color: rgba(140,109,255,0.55);
298 background: rgba(255,255,255,0.05);
299 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
300 }
301
302 /* Board view */
303 .proj-board-head {
304 display: flex;
305 align-items: flex-end;
306 justify-content: space-between;
307 gap: var(--space-4);
308 flex-wrap: wrap;
309 margin-bottom: var(--space-4);
310 }
311 .proj-board-desc {
312 margin: 6px 0 0;
313 color: var(--text-muted);
314 font-size: 13.5px;
315 line-height: 1.5;
316 }
317
318 .proj-kanban {
319 display: flex;
320 gap: 14px;
321 overflow-x: auto;
322 padding-bottom: 12px;
323 scrollbar-width: thin;
324 }
325 .proj-kcol {
326 background: var(--bg-elevated);
327 border: 1px solid var(--border);
328 border-radius: 12px;
329 min-width: 280px;
330 max-width: 280px;
331 flex-shrink: 0;
332 padding: 12px;
333 position: relative;
334 overflow: hidden;
335 }
336 .proj-kcol::before {
337 content: '';
338 position: absolute;
339 top: 0; left: 0; right: 0;
340 height: 2px;
341 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
342 opacity: 0.5;
343 pointer-events: none;
344 }
345 .proj-kcol-new {
346 background: transparent;
347 border-style: dashed;
348 }
349 .proj-kcol-new::before { display: none; }
350 .proj-kcol-head {
351 display: flex;
352 align-items: center;
353 justify-content: space-between;
354 gap: 8px;
355 margin: 2px 4px 12px;
356 }
357 .proj-kcol-name {
358 font-family: var(--font-display);
359 font-size: 13.5px;
360 font-weight: 700;
361 color: var(--text-strong);
362 letter-spacing: -0.005em;
363 }
364 .proj-kcard {
365 background: rgba(255,255,255,0.03);
366 border: 1px solid var(--border);
367 border-radius: 10px;
368 padding: 10px 12px;
369 margin-bottom: 8px;
370 font-size: 13px;
371 transition: border-color 120ms ease, background 120ms ease;
372 }
373 .proj-kcard:hover {
374 border-color: var(--border-strong);
375 background: rgba(255,255,255,0.05);
376 }
377 .proj-kcard-title {
378 font-weight: 600;
379 color: var(--text-strong);
380 line-height: 1.35;
381 }
d9b3649Claude382 .proj-kcard-link {
383 color: var(--text-strong);
384 text-decoration: none;
385 }
386 .proj-kcard-link:hover { text-decoration: underline; color: var(--accent); }
387 .proj-kcard-meta {
388 display: flex;
389 align-items: center;
390 gap: 6px;
391 margin-top: 4px;
392 flex-wrap: wrap;
393 }
394 .proj-kcard-num {
395 font-family: var(--font-mono);
396 font-size: 11px;
397 color: var(--text-muted);
398 font-variant-numeric: tabular-nums;
399 }
400 .proj-kcard-state {
401 display: inline-flex;
402 align-items: center;
403 padding: 1px 7px;
404 border-radius: 9999px;
405 font-size: 10.5px;
406 font-weight: 600;
407 text-transform: capitalize;
408 }
409 .proj-kcard-state.is-open { background: rgba(52,211,153,0.14); color: #6ee7b7; }
410 .proj-kcard-state.is-closed { background: rgba(148,163,184,0.16); color: #94a3b8; }
411 .proj-kcard-state.is-merged { background: rgba(167,139,250,0.14); color: #c4b5fd; }
412 .proj-kcard-state.is-draft { background: rgba(148,163,184,0.12); color: #94a3b8; }
655a200Claude413 .proj-kcard-note {
414 margin-top: 4px;
415 color: var(--text-muted);
416 font-size: 12px;
417 line-height: 1.45;
418 }
419 .proj-kcard-actions {
420 margin-top: 8px;
421 display: flex;
422 gap: 4px;
423 flex-wrap: wrap;
d9b3649Claude424 align-items: center;
425 }
426 .proj-kcard-actions form { margin: 0; display: inline-flex; align-items: center; gap: 4px; }
427 .proj-kcard-select {
428 font: inherit;
429 font-size: 11.5px;
430 padding: 3px 6px;
431 color: var(--text);
432 background: var(--bg-elevated);
433 border: 1px solid var(--border);
434 border-radius: 6px;
435 cursor: pointer;
655a200Claude436 }
d9b3649Claude437 .proj-kcard-select:focus { outline: none; border-color: rgba(140,109,255,0.55); }
655a200Claude438
d9b3649Claude439 .proj-kadd-details summary::-webkit-details-marker { display: none; }
440 .proj-kadd-panel {
441 margin-top: 8px;
442 background: rgba(255,255,255,0.02);
443 border: 1px solid var(--border);
444 border-radius: 10px;
445 padding: 10px;
446 display: flex;
447 flex-direction: column;
448 gap: 8px;
449 }
450 .proj-kadd-sep {
451 border: none;
452 border-top: 1px solid var(--border);
453 margin: 2px 0;
454 }
455 .proj-kadd { display: flex; flex-direction: column; gap: 6px; }
655a200Claude456 .proj-kadd input {
457 width: 100%;
458 box-sizing: border-box;
459 padding: 7px 10px;
460 font: inherit;
461 font-size: 12.5px;
462 color: var(--text);
463 background: rgba(255,255,255,0.03);
464 border: 1px solid var(--border);
465 border-radius: 8px;
466 }
467 .proj-kadd input:focus {
468 outline: none;
469 border-color: rgba(140,109,255,0.55);
470 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
471 }
472
473 /* Empty */
474 .proj-empty {
475 position: relative;
476 overflow: hidden;
477 padding: clamp(32px, 6vw, 56px) clamp(20px, 4vw, 36px);
478 text-align: center;
479 background: var(--bg-elevated);
480 border: 1px dashed var(--border-strong);
481 border-radius: 16px;
482 }
483 .proj-empty-orb {
484 position: absolute;
485 inset: -40% 30% auto 30%;
486 height: 280px;
487 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
488 filter: blur(70px);
489 opacity: 0.7;
490 pointer-events: none;
491 z-index: 0;
492 }
493 .proj-empty-inner { position: relative; z-index: 1; }
494 .proj-empty-icon {
495 width: 56px; height: 56px;
496 border-radius: 9999px;
497 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
498 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
499 display: inline-flex;
500 align-items: center;
501 justify-content: center;
502 color: #c4b5fd;
503 margin-bottom: 14px;
504 }
505 .proj-empty-title {
506 font-family: var(--font-display);
507 font-size: 18px;
508 font-weight: 700;
509 margin: 0 0 6px;
510 color: var(--text-strong);
511 }
512 .proj-empty-sub {
513 margin: 0 auto 16px;
514 font-size: 13.5px;
515 color: var(--text-muted);
516 max-width: 420px;
517 line-height: 1.5;
518 }
519`;
520
521function IconBoard() {
522 return (
523 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
524 <rect x="3" y="3" width="7" height="18" rx="1" />
525 <rect x="14" y="3" width="7" height="11" rx="1" />
526 </svg>
527 );
528}
529function IconPlus() {
530 return (
531 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
532 <line x1="12" y1="5" x2="12" y2="19" />
533 <line x1="5" y1="12" x2="19" y2="12" />
534 </svg>
535 );
536}
537function IconArrowLeft() {
538 return (
539 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
540 <line x1="19" y1="12" x2="5" y2="12" />
541 <polyline points="12 19 5 12 12 5" />
542 </svg>
543 );
544}
545
1e162a8Claude546async function resolveRepo(ownerName: string, repoName: string) {
547 try {
548 const [owner] = await db
549 .select()
550 .from(users)
551 .where(eq(users.username, ownerName))
552 .limit(1);
553 if (!owner) return null;
554 const [repo] = await db
555 .select()
556 .from(repositories)
557 .where(
558 and(
559 eq(repositories.ownerId, owner.id),
560 eq(repositories.name, repoName)
561 )
562 )
563 .limit(1);
564 if (!repo) return null;
565 return { owner, repo };
566 } catch {
567 return null;
568 }
569}
570
571function notFound(user: any, label = "Not found") {
572 return (
573 <Layout title={label} user={user}>
655a200Claude574 <div class="proj-wrap">
575 <div class="proj-empty">
576 <div class="proj-empty-orb" aria-hidden="true" />
577 <div class="proj-empty-inner">
578 <h2 class="proj-empty-title">{label}</h2>
579 </div>
580 </div>
1e162a8Claude581 </div>
655a200Claude582 <style dangerouslySetInnerHTML={{ __html: projStyles }} />
1e162a8Claude583 </Layout>
584 );
585}
586
587// List
588projectRoutes.get("/:owner/:repo/projects", softAuth, async (c) => {
589 const { owner: ownerName, repo: repoName } = c.req.param();
590 const user = c.get("user");
591 const resolved = await resolveRepo(ownerName, repoName);
592 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
593 const { repo } = resolved;
594
595 let rows: any[] = [];
596 try {
597 rows = await db
598 .select({
599 p: projects,
600 columnCount: sql<number>`(SELECT count(*) FROM project_columns WHERE project_id = ${projects.id})`,
601 itemCount: sql<number>`(SELECT count(*) FROM project_items WHERE project_id = ${projects.id})`,
602 })
603 .from(projects)
604 .where(eq(projects.repositoryId, repo.id))
605 .orderBy(desc(projects.updatedAt));
606 } catch {
607 rows = [];
608 }
609
610 return c.html(
611 <Layout title={`Projects — ${ownerName}/${repoName}`} user={user}>
612 <RepoHeader owner={ownerName} repo={repoName} />
655a200Claude613 <div class="proj-wrap">
614 <header class="proj-head">
615 <div class="proj-head-text">
616 <div class="proj-eyebrow">
617 <span class="proj-eyebrow-dot" aria-hidden="true" />
618 Repository · Projects
619 </div>
620 <h1 class="proj-title">
621 <span class="proj-title-grad">Plan the work.</span>
622 </h1>
623 <p class="proj-sub">
624 Lightweight kanban boards scoped to {ownerName}/{repoName}. Each
625 board owns its own columns and cards.
626 </p>
627 </div>
628 {user && (
629 <a
630 href={`/${ownerName}/${repoName}/projects/new`}
631 class="proj-btn proj-btn-primary"
632 >
633 <IconPlus />
634 New project
635 </a>
636 )}
637 </header>
638
639 {rows.length === 0 ? (
640 <div class="proj-empty">
641 <div class="proj-empty-orb" aria-hidden="true" />
642 <div class="proj-empty-inner">
643 <div class="proj-empty-icon" aria-hidden="true">
644 <IconBoard />
645 </div>
646 <h3 class="proj-empty-title">Start your first project</h3>
647 <p class="proj-empty-sub">
648 Boards are perfect for grouping work — sprints, OKRs, release
649 trains. We seed the default <code>To Do</code> /{" "}
650 <code>In Progress</code> / <code>Done</code> columns for you.
651 </p>
652 {user && (
653 <a
654 href={`/${ownerName}/${repoName}/projects/new`}
655 class="proj-btn proj-btn-primary"
656 >
657 <IconPlus />
658 New project
659 </a>
660 )}
661 </div>
662 </div>
663 ) : (
664 <div class="proj-list">
1e162a8Claude665 {rows.map((r) => (
655a200Claude666 <div class="proj-card">
667 <div class="proj-num">#{r.p.number}</div>
668 <div class="proj-card-body">
669 <div class="proj-card-title">
670 <a href={`/${ownerName}/${repoName}/projects/${r.p.number}`}>
671 {r.p.title}
672 </a>
673 {r.p.state === "closed" ? (
674 <span class="proj-pill is-closed">
675 <span class="dot" aria-hidden="true" />
676 Closed
677 </span>
678 ) : (
679 <span class="proj-pill is-open">
680 <span class="dot" aria-hidden="true" />
681 Open
682 </span>
683 )}
684 </div>
1e162a8Claude685 {r.p.description && (
655a200Claude686 <div class="proj-card-desc">{r.p.description}</div>
1e162a8Claude687 )}
655a200Claude688 </div>
689 <div class="proj-card-meta">
690 <span class="proj-pill is-count">{r.columnCount} cols</span>
691 <span class="sep">·</span>
692 <span class="proj-pill is-count">{r.itemCount} items</span>
693 </div>
694 </div>
1e162a8Claude695 ))}
655a200Claude696 </div>
697 )}
698 </div>
699 <style dangerouslySetInnerHTML={{ __html: projStyles }} />
1e162a8Claude700 </Layout>
701 );
702});
703
704// New form
705projectRoutes.get(
706 "/:owner/:repo/projects/new",
707 requireAuth,
708 async (c) => {
709 const { owner: ownerName, repo: repoName } = c.req.param();
710 const user = c.get("user");
711 const resolved = await resolveRepo(ownerName, repoName);
712 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
713 return c.html(
714 <Layout title="New project" user={user}>
715 <RepoHeader owner={ownerName} repo={repoName} />
655a200Claude716 <div class="proj-wrap">
717 <div class="proj-crumbs">
718 <a href={`/${ownerName}/${repoName}/projects`}>
719 <IconArrowLeft />
720 All projects
721 </a>
722 </div>
723 <header class="proj-head">
724 <div class="proj-head-text">
725 <div class="proj-eyebrow">
726 <span class="proj-eyebrow-dot" aria-hidden="true" />
727 Projects · New
728 </div>
729 <h1 class="proj-title">
730 <span class="proj-title-grad">Create a board.</span>
731 </h1>
732 <p class="proj-sub">
733 Name your project — we seed default kanban columns you can
734 rename later.
735 </p>
736 </div>
737 </header>
738 <form
739 method="post"
740 action={`/${ownerName}/${repoName}/projects`}
741 class="proj-form-card"
742 >
743 <div class="proj-field">
744 <label class="proj-field-label" for="proj-title">Title</label>
745 <input
746 class="proj-input"
747 type="text"
748 id="proj-title"
749 name="title"
750 placeholder="Sprint 24 — Q3 release"
751 required
752 aria-label="Project title"
753 />
754 </div>
755 <div class="proj-field">
756 <label class="proj-field-label" for="proj-desc">Description</label>
757 <textarea
758 class="proj-textarea"
759 id="proj-desc"
760 name="description"
761 rows={4}
762 placeholder="What is this board for? (optional)"
763 ></textarea>
764 </div>
765 <button type="submit" class="proj-btn proj-btn-primary">
766 <IconPlus />
767 Create project
768 </button>
769 </form>
770 </div>
771 <style dangerouslySetInnerHTML={{ __html: projStyles }} />
1e162a8Claude772 </Layout>
773 );
774 }
775);
776
777// Create
778projectRoutes.post(
779 "/:owner/:repo/projects",
780 requireAuth,
781 async (c) => {
782 const { owner: ownerName, repo: repoName } = c.req.param();
783 const user = c.get("user")!;
784 const resolved = await resolveRepo(ownerName, repoName);
785 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
786
787 const form = await c.req.formData();
788 const title = (form.get("title") as string || "").trim();
789 const description = (form.get("description") as string || "").trim();
790
791 if (!title) {
792 return c.redirect(`/${ownerName}/${repoName}/projects/new`);
793 }
794
795 try {
796 const [row] = await db
797 .insert(projects)
798 .values({
799 repositoryId: resolved.repo.id,
800 ownerId: user.id,
801 title,
802 description,
803 })
804 .returning({ id: projects.id, number: projects.number });
805 // Seed default columns
806 await db.insert(projectColumns).values(
807 DEFAULT_COLUMNS.map((name, i) => ({
808 projectId: row.id,
809 name,
810 position: i,
811 }))
812 );
813 return c.redirect(`/${ownerName}/${repoName}/projects/${row.number}`);
814 } catch {
815 return c.redirect(`/${ownerName}/${repoName}/projects`);
816 }
817 }
818);
819
820// Board view
821projectRoutes.get(
822 "/:owner/:repo/projects/:number",
823 softAuth,
824 async (c) => {
825 const { owner: ownerName, repo: repoName } = c.req.param();
826 const user = c.get("user");
827 const numParam = Number(c.req.param("number"));
828 const resolved = await resolveRepo(ownerName, repoName);
829 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
830
831 let project: any = null;
832 let columns: any[] = [];
833 let items: any[] = [];
d9b3649Claude834 // Maps itemId → { number, title, state } for linked issues/PRs
835 const linkedIssueMap: Record<string, { number: number; title: string; state: string; isDraft?: boolean }> = {};
836 const linkedPrMap: Record<string, { number: number; title: string; state: string; isDraft?: boolean }> = {};
837
1e162a8Claude838 try {
839 const [row] = await db
840 .select()
841 .from(projects)
842 .where(
843 and(
844 eq(projects.repositoryId, resolved.repo.id),
845 eq(projects.number, numParam)
846 )
847 )
848 .limit(1);
849 if (row) {
850 project = row;
851 columns = await db
852 .select()
853 .from(projectColumns)
854 .where(eq(projectColumns.projectId, row.id))
855 .orderBy(asc(projectColumns.position), asc(projectColumns.createdAt));
856 items = await db
857 .select()
858 .from(projectItems)
859 .where(eq(projectItems.projectId, row.id))
860 .orderBy(asc(projectItems.position));
d9b3649Claude861
862 // Gather itemIds for linked issues / PRs so we can fetch their details.
863 const issueItemIds = items
864 .filter((it) => it.itemType === "issue" && it.itemId)
865 .map((it) => it.itemId as string);
866 const prItemIds = items
867 .filter((it) => it.itemType === "pr" && it.itemId)
868 .map((it) => it.itemId as string);
869
870 if (issueItemIds.length > 0) {
871 const issueRows = await db
872 .select({ id: issues.id, number: issues.number, title: issues.title, state: issues.state })
873 .from(issues)
874 .where(eq(issues.repositoryId, resolved.repo.id));
875 for (const ir of issueRows) {
876 if (issueItemIds.includes(ir.id)) {
877 linkedIssueMap[ir.id] = { number: ir.number, title: ir.title, state: ir.state };
878 }
879 }
880 }
881 if (prItemIds.length > 0) {
882 const prRows = await db
883 .select({ id: pullRequests.id, number: pullRequests.number, title: pullRequests.title, state: pullRequests.state, isDraft: pullRequests.isDraft })
884 .from(pullRequests)
885 .where(eq(pullRequests.repositoryId, resolved.repo.id));
886 for (const pr of prRows) {
887 if (prItemIds.includes(pr.id)) {
888 linkedPrMap[pr.id] = { number: pr.number, title: pr.title, state: pr.state, isDraft: pr.isDraft };
889 }
890 }
891 }
1e162a8Claude892 }
893 } catch {
894 // leave nulls
895 }
896
897 if (!project) return c.html(notFound(user, "Project not found"), 404);
898
899 const itemsByCol: Record<string, any[]> = {};
900 for (const col of columns) itemsByCol[col.id] = [];
901 for (const it of items) {
902 if (itemsByCol[it.columnId]) itemsByCol[it.columnId].push(it);
903 }
904
905 return c.html(
906 <Layout
907 title={`${project.title} — project #${project.number}`}
908 user={user}
909 >
910 <RepoHeader owner={ownerName} repo={repoName} />
655a200Claude911 <div class="proj-wrap">
912 <div class="proj-crumbs">
913 <a href={`/${ownerName}/${repoName}/projects`}>
914 <IconArrowLeft />
915 All projects
916 </a>
1e162a8Claude917 </div>
655a200Claude918 <div class="proj-board-head">
919 <div>
920 <div class="proj-eyebrow">
921 <span class="proj-eyebrow-dot" aria-hidden="true" />
922 Project · #{project.number}
923 </div>
924 <h1 class="proj-title">
925 <span class="proj-title-grad">{project.title}</span>
926 {project.state === "closed" && (
927 <span class="proj-pill is-closed" style="margin-left:12px;vertical-align:middle">
928 <span class="dot" aria-hidden="true" />
929 Closed
930 </span>
931 )}
932 </h1>
933 {project.description && (
934 <p class="proj-board-desc">{project.description}</p>
935 )}
936 </div>
937 {user && (
938 <form
939 method="post"
940 action={`/${ownerName}/${repoName}/projects/${project.number}/close`}
941 >
942 <button type="submit" class="proj-btn proj-btn-ghost">
943 {project.state === "open" ? "Close" : "Reopen"}
944 </button>
945 </form>
946 )}
947 </div>
948 <div class="proj-kanban">
949 {columns.map((col) => (
950 <div class="proj-kcol">
951 <div class="proj-kcol-head">
952 <span class="proj-kcol-name">{col.name}</span>
953 <span class="proj-pill is-count">
954 {(itemsByCol[col.id] || []).length}
955 </span>
956 </div>
d9b3649Claude957 {(itemsByCol[col.id] || []).map((it) => {
958 // Resolve linked issue/PR metadata for richer card display.
959 const linkedIssue = it.itemType === "issue" && it.itemId
960 ? linkedIssueMap[it.itemId]
961 : null;
962 const linkedPr = it.itemType === "pr" && it.itemId
963 ? linkedPrMap[it.itemId]
964 : null;
965 const cardTitle = linkedIssue?.title || linkedPr?.title || it.title || "(untitled)";
966 const cardNumber = linkedIssue?.number ?? linkedPr?.number ?? null;
967 const cardState = linkedIssue?.state ?? (linkedPr?.isDraft ? "draft" : linkedPr?.state) ?? null;
968 const cardHref = linkedIssue
969 ? `/${ownerName}/${repoName}/issues/${linkedIssue.number}`
970 : linkedPr
971 ? `/${ownerName}/${repoName}/pulls/${linkedPr.number}`
972 : null;
973
974 return (
975 <div class="proj-kcard">
976 <div class="proj-kcard-title">
977 {cardHref ? (
978 <a href={cardHref} class="proj-kcard-link">{cardTitle}</a>
979 ) : (
980 cardTitle
981 )}
982 </div>
983 {(cardNumber !== null || cardState) && (
984 <div class="proj-kcard-meta">
985 {cardNumber !== null && (
986 <span class="proj-kcard-num">#{cardNumber}</span>
987 )}
988 {cardState && (
989 <span class={`proj-kcard-state is-${cardState}`}>
990 {cardState}
991 </span>
992 )}
993 </div>
994 )}
995 {it.note && (
996 <div class="proj-kcard-note">{it.note}</div>
997 )}
998 {user && (
999 <div class="proj-kcard-actions">
1000 {columns.length > 1 && (
655a200Claude1001 <form
1002 method="post"
1003 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/move`}
d9b3649Claude1004 style="display:inline-flex;align-items:center;gap:4px"
1e162a8Claude1005 >
d9b3649Claude1006 <select
655a200Claude1007 name="column_id"
d9b3649Claude1008 class="proj-kcard-select"
1009 aria-label="Move to column"
1010 >
1011 {columns
1012 .filter((oc) => oc.id !== col.id)
1013 .map((oc) => (
1014 <option value={oc.id}>{oc.name}</option>
1015 ))}
1016 </select>
655a200Claude1017 <button
1018 type="submit"
1019 class="proj-btn proj-btn-ghost proj-btn-mini"
1020 >
d9b3649Claude1021 Move
655a200Claude1022 </button>
1023 </form>
d9b3649Claude1024 )}
1025 <form
1026 method="post"
1027 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/delete`}
655a200Claude1028 >
d9b3649Claude1029 <button
1030 type="submit"
1031 class="proj-btn proj-btn-ghost proj-btn-mini"
1032 aria-label="Delete item"
1033 >
1034 ×
1035 </button>
1036 </form>
1037 </div>
1038 )}
1039 </div>
1040 );
1041 })}
655a200Claude1042 {user && (
d9b3649Claude1043 <details class="proj-kadd-details">
1044 <summary class="proj-btn proj-btn-ghost proj-btn-mini" style="cursor:pointer;list-style:none;margin-top:8px">
655a200Claude1045 <IconPlus />
1046 Add card
d9b3649Claude1047 </summary>
1048 <div class="proj-kadd-panel">
1049 <form
1050 method="post"
1051 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
1052 class="proj-kadd"
1053 >
1054 <input type="hidden" name="column_id" value={col.id} />
1055 <input type="hidden" name="item_type" value="note" />
1056 <input
1057 type="text"
1058 name="title"
1059 placeholder="Note title"
1060 required
1061 aria-label="Note title"
1062 />
1063 <button
1064 type="submit"
1065 class="proj-btn proj-btn-ghost proj-btn-mini"
1066 >
1067 Add note
1068 </button>
1069 </form>
1070 <hr class="proj-kadd-sep" />
1071 <form
1072 method="post"
1073 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
1074 class="proj-kadd"
1075 >
1076 <input type="hidden" name="column_id" value={col.id} />
1077 <input type="hidden" name="item_type" value="issue" />
1078 <input
1079 type="number"
1080 name="issue_number"
1081 placeholder="Issue # (e.g. 42)"
1082 min="1"
1083 aria-label="Issue number"
1084 />
1085 <button
1086 type="submit"
1087 class="proj-btn proj-btn-ghost proj-btn-mini"
1088 >
1089 Link issue
1090 </button>
1091 </form>
1092 <form
1093 method="post"
1094 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
1095 class="proj-kadd"
1096 >
1097 <input type="hidden" name="column_id" value={col.id} />
1098 <input type="hidden" name="item_type" value="pr" />
1099 <input
1100 type="number"
1101 name="pr_number"
1102 placeholder="PR # (e.g. 7)"
1103 min="1"
1104 aria-label="Pull request number"
1105 />
1106 <button
1107 type="submit"
1108 class="proj-btn proj-btn-ghost proj-btn-mini"
1109 >
1110 Link PR
1111 </button>
1112 </form>
1113 </div>
1114 </details>
655a200Claude1115 )}
1116 </div>
1117 ))}
1118 {user && (
1119 <div class="proj-kcol proj-kcol-new">
1e162a8Claude1120 <form
e7e240eClaude1121 method="post"
655a200Claude1122 action={`/${ownerName}/${repoName}/projects/${project.number}/columns`}
1123 class="proj-kadd"
1e162a8Claude1124 >
1125 <input
1126 type="text"
655a200Claude1127 name="name"
1128 placeholder="New column"
1e162a8Claude1129 required
655a200Claude1130 aria-label="New column name"
1e162a8Claude1131 />
655a200Claude1132 <button type="submit" class="proj-btn proj-btn-ghost proj-btn-mini">
1133 <IconPlus />
1134 Add column
1e162a8Claude1135 </button>
1136 </form>
655a200Claude1137 </div>
1138 )}
1139 </div>
1e162a8Claude1140 </div>
655a200Claude1141 <style dangerouslySetInnerHTML={{ __html: projStyles }} />
1e162a8Claude1142 </Layout>
1143 );
1144 }
1145);
1146
1147// Add column
1148projectRoutes.post(
1149 "/:owner/:repo/projects/:number/columns",
1150 requireAuth,
1151 async (c) => {
1152 const { owner: ownerName, repo: repoName } = c.req.param();
1153 const numParam = Number(c.req.param("number"));
1154 const resolved = await resolveRepo(ownerName, repoName);
1155 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
1156
1157 const form = await c.req.formData();
1158 const name = (form.get("name") as string || "").trim();
1159 if (!name) {
1160 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1161 }
1162
1163 try {
1164 const [row] = await db
1165 .select()
1166 .from(projects)
1167 .where(
1168 and(
1169 eq(projects.repositoryId, resolved.repo.id),
1170 eq(projects.number, numParam)
1171 )
1172 )
1173 .limit(1);
1174 if (row) {
1175 const [maxPos] = await db
1176 .select({ p: sql<number>`coalesce(max(${projectColumns.position}), -1)` })
1177 .from(projectColumns)
1178 .where(eq(projectColumns.projectId, row.id));
1179 await db.insert(projectColumns).values({
1180 projectId: row.id,
1181 name,
1182 position: Number(maxPos?.p || -1) + 1,
1183 });
1184 }
1185 } catch {
1186 // swallow
1187 }
1188 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1189 }
1190);
1191
d9b3649Claude1192// Add item (note, linked issue, or linked PR)
1e162a8Claude1193projectRoutes.post(
1194 "/:owner/:repo/projects/:number/items",
1195 requireAuth,
1196 async (c) => {
1197 const { owner: ownerName, repo: repoName } = c.req.param();
1198 const numParam = Number(c.req.param("number"));
1199 const resolved = await resolveRepo(ownerName, repoName);
1200 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
1201
1202 const form = await c.req.formData();
1203 const columnId = (form.get("column_id") as string || "").trim();
d9b3649Claude1204 const itemType = (form.get("item_type") as string || "note").trim();
1e162a8Claude1205 const title = (form.get("title") as string || "").trim();
1206 const note = (form.get("note") as string || "").trim();
d9b3649Claude1207 const issueNumberRaw = parseInt(form.get("issue_number") as string || "", 10);
1208 const prNumberRaw = parseInt(form.get("pr_number") as string || "", 10);
1209
1210 if (!columnId) {
1211 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1212 }
1213
1214 // For notes, title is required. For issue/pr links, we look up by number.
1215 if (itemType === "note" && !title) {
1216 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1217 }
1218 if (itemType === "issue" && isNaN(issueNumberRaw)) {
1219 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1220 }
1221 if (itemType === "pr" && isNaN(prNumberRaw)) {
1e162a8Claude1222 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1223 }
1224
1225 try {
1226 const [row] = await db
1227 .select()
1228 .from(projects)
1229 .where(
1230 and(
1231 eq(projects.repositoryId, resolved.repo.id),
1232 eq(projects.number, numParam)
1233 )
1234 )
1235 .limit(1);
1236 if (row) {
1237 const [maxPos] = await db
1238 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
1239 .from(projectItems)
1240 .where(eq(projectItems.columnId, columnId));
d9b3649Claude1241 const position = Number(maxPos?.p || -1) + 1;
1242
1243 if (itemType === "issue" && !isNaN(issueNumberRaw)) {
1244 // Look up the issue by number in this repo
1245 const [issueRow] = await db
1246 .select({ id: issues.id, title: issues.title })
1247 .from(issues)
1248 .where(
1249 and(
1250 eq(issues.repositoryId, resolved.repo.id),
1251 eq(issues.number, issueNumberRaw)
1252 )
1253 )
1254 .limit(1);
1255 if (issueRow) {
1256 await db.insert(projectItems).values({
1257 projectId: row.id,
1258 columnId,
1259 itemType: "issue",
1260 itemId: issueRow.id,
1261 title: issueRow.title,
1262 position,
1263 });
1264 }
1265 } else if (itemType === "pr" && !isNaN(prNumberRaw)) {
1266 // Look up the PR by number in this repo
1267 const [prRow] = await db
1268 .select({ id: pullRequests.id, title: pullRequests.title })
1269 .from(pullRequests)
1270 .where(
1271 and(
1272 eq(pullRequests.repositoryId, resolved.repo.id),
1273 eq(pullRequests.number, prNumberRaw)
1274 )
1275 )
1276 .limit(1);
1277 if (prRow) {
1278 await db.insert(projectItems).values({
1279 projectId: row.id,
1280 columnId,
1281 itemType: "pr",
1282 itemId: prRow.id,
1283 title: prRow.title,
1284 position,
1285 });
1286 }
1287 } else {
1288 // Freeform note
1289 await db.insert(projectItems).values({
1290 projectId: row.id,
1291 columnId,
1292 itemType: "note",
1293 title,
1294 note,
1295 position,
1296 });
1297 }
1e162a8Claude1298 }
1299 } catch {
1300 // swallow
1301 }
1302 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1303 }
1304);
1305
1306// Move item
1307projectRoutes.post(
1308 "/:owner/:repo/projects/:number/items/:itemId/move",
1309 requireAuth,
1310 async (c) => {
1311 const { owner: ownerName, repo: repoName, itemId } = c.req.param();
1312 const numParam = Number(c.req.param("number"));
1313 const resolved = await resolveRepo(ownerName, repoName);
1314 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
1315
1316 const form = await c.req.formData();
1317 const columnId = (form.get("column_id") as string || "").trim();
1318 if (!columnId) {
1319 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1320 }
1321
1322 try {
1323 const [maxPos] = await db
1324 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
1325 .from(projectItems)
1326 .where(eq(projectItems.columnId, columnId));
1327 await db
1328 .update(projectItems)
1329 .set({
1330 columnId,
1331 position: Number(maxPos?.p || -1) + 1,
1332 updatedAt: new Date(),
1333 })
1334 .where(eq(projectItems.id, itemId));
1335 } catch {
1336 // swallow
1337 }
1338 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1339 }
1340);
1341
1342// Delete item
1343projectRoutes.post(
1344 "/:owner/:repo/projects/:number/items/:itemId/delete",
1345 requireAuth,
1346 async (c) => {
1347 const { owner: ownerName, repo: repoName, itemId } = c.req.param();
1348 const numParam = Number(c.req.param("number"));
1349 try {
1350 await db.delete(projectItems).where(eq(projectItems.id, itemId));
1351 } catch {
1352 // swallow
1353 }
1354 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1355 }
1356);
1357
1358// Toggle close
1359projectRoutes.post(
1360 "/:owner/:repo/projects/:number/close",
1361 requireAuth,
1362 async (c) => {
1363 const { owner: ownerName, repo: repoName } = c.req.param();
1364 const numParam = Number(c.req.param("number"));
1365 const resolved = await resolveRepo(ownerName, repoName);
1366 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
1367
1368 try {
1369 const [row] = await db
1370 .select()
1371 .from(projects)
1372 .where(
1373 and(
1374 eq(projects.repositoryId, resolved.repo.id),
1375 eq(projects.number, numParam)
1376 )
1377 )
1378 .limit(1);
1379 if (row) {
1380 await db
1381 .update(projects)
1382 .set({
1383 state: row.state === "open" ? "closed" : "open",
1384 updatedAt: new Date(),
1385 })
1386 .where(eq(projects.id, row.id));
1387 }
1388 } catch {
1389 // swallow
1390 }
1391 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1392 }
1393);
1394
1395export default projectRoutes;