Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

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.tsxBlame1142 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,
24} from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader } from "../views/components";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29
30const DEFAULT_COLUMNS = ["To Do", "In Progress", "Done"] as const;
31
32const projectRoutes = new Hono<AuthEnv>();
33
655a200Claude34// ─── Scoped CSS (.proj-*) ───────────────────────────────────────────────────
35const projStyles = `
36 .proj-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
37
38 .proj-head {
39 display: flex;
40 align-items: flex-end;
41 justify-content: space-between;
42 gap: var(--space-4);
43 flex-wrap: wrap;
44 margin-bottom: var(--space-5);
45 }
46 .proj-head-text { flex: 1; min-width: 280px; }
47 .proj-eyebrow {
48 display: inline-flex;
49 align-items: center;
50 gap: 8px;
51 text-transform: uppercase;
52 font-family: var(--font-mono);
53 font-size: 11px;
54 letter-spacing: 0.16em;
55 color: var(--text-muted);
56 font-weight: 600;
57 margin-bottom: 10px;
58 }
59 .proj-eyebrow-dot {
60 width: 8px; height: 8px;
61 border-radius: 9999px;
62 background: linear-gradient(135deg, #8c6dff, #36c5d6);
63 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
64 }
65 .proj-title {
66 font-family: var(--font-display);
67 font-size: clamp(24px, 3.4vw, 36px);
68 font-weight: 800;
69 letter-spacing: -0.028em;
70 line-height: 1.1;
71 margin: 0 0 6px;
72 color: var(--text-strong);
73 }
74 .proj-title-grad {
75 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
76 -webkit-background-clip: text;
77 background-clip: text;
78 -webkit-text-fill-color: transparent;
79 color: transparent;
80 }
81 .proj-sub {
82 margin: 0;
83 font-size: 14px;
84 color: var(--text-muted);
85 line-height: 1.5;
86 max-width: 640px;
87 }
88
89 /* Buttons */
90 .proj-btn {
91 display: inline-flex;
92 align-items: center;
93 justify-content: center;
94 gap: 6px;
95 padding: 9px 16px;
96 border-radius: 10px;
97 font-size: 13px;
98 font-weight: 600;
99 text-decoration: none;
100 border: 1px solid transparent;
101 cursor: pointer;
102 font: inherit;
103 line-height: 1;
104 white-space: nowrap;
105 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
106 }
107 .proj-btn-primary {
108 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
109 color: #ffffff;
110 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
111 }
112 .proj-btn-primary:hover {
113 transform: translateY(-1px);
114 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
115 text-decoration: none;
116 color: #ffffff;
117 }
118 .proj-btn-ghost {
119 background: transparent;
120 color: var(--text);
121 border-color: var(--border-strong);
122 }
123 .proj-btn-ghost:hover {
124 background: rgba(140,109,255,0.06);
125 border-color: rgba(140,109,255,0.45);
126 color: var(--text-strong);
127 text-decoration: none;
128 }
129 .proj-btn-mini {
130 padding: 4px 10px;
131 font-size: 11.5px;
132 border-radius: 8px;
133 }
134
135 /* Crumbs */
136 .proj-crumbs {
137 display: flex;
138 align-items: center;
139 gap: 12px;
140 flex-wrap: wrap;
141 margin-bottom: var(--space-4);
142 font-size: 12.5px;
143 }
144 .proj-crumbs a {
145 display: inline-flex;
146 align-items: center;
147 gap: 5px;
148 padding: 6px 11px;
149 background: rgba(255,255,255,0.025);
150 border: 1px solid var(--border);
151 border-radius: 8px;
152 color: var(--text-muted);
153 text-decoration: none;
154 font-weight: 500;
155 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
156 }
157 .proj-crumbs a:hover {
158 border-color: var(--border-strong);
159 color: var(--text-strong);
160 background: rgba(255,255,255,0.04);
161 text-decoration: none;
162 }
163
164 /* Project list cards */
165 .proj-list { display: flex; flex-direction: column; gap: 10px; }
166 .proj-card {
167 display: flex;
168 align-items: center;
169 gap: 16px;
170 padding: 14px 18px;
171 background: var(--bg-elevated);
172 border: 1px solid var(--border);
173 border-radius: 12px;
174 transition: border-color 120ms ease, background 120ms ease;
175 }
176 .proj-card:hover {
177 border-color: var(--border-strong);
178 background: rgba(255,255,255,0.03);
179 }
180 .proj-num {
181 font-family: var(--font-mono);
182 font-size: 12.5px;
183 color: var(--text-muted);
184 font-variant-numeric: tabular-nums;
185 flex-shrink: 0;
186 }
187 .proj-card-body { flex: 1; min-width: 0; }
188 .proj-card-title {
189 display: inline-flex;
190 align-items: center;
191 gap: 8px;
192 flex-wrap: wrap;
193 }
194 .proj-card-title a {
195 font-family: var(--font-display);
196 font-weight: 700;
197 font-size: 15.5px;
198 color: var(--text-strong);
199 text-decoration: none;
200 letter-spacing: -0.005em;
201 }
202 .proj-card-title a:hover { text-decoration: underline; }
203 .proj-card-desc {
204 margin-top: 3px;
205 font-size: 13px;
206 color: var(--text-muted);
207 line-height: 1.45;
208 }
209 .proj-card-meta {
210 display: flex;
211 align-items: center;
212 gap: 10px;
213 flex-wrap: wrap;
214 font-size: 12px;
215 color: var(--text-muted);
216 font-variant-numeric: tabular-nums;
217 white-space: nowrap;
218 }
219 .proj-card-meta .sep { opacity: 0.4; }
220
221 /* Pills */
222 .proj-pill {
223 display: inline-flex;
224 align-items: center;
225 gap: 5px;
226 padding: 2px 9px;
227 border-radius: 9999px;
228 font-size: 11px;
229 font-weight: 600;
230 letter-spacing: 0.02em;
231 text-transform: capitalize;
232 }
233 .proj-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
234 .proj-pill.is-open {
235 background: rgba(52,211,153,0.14);
236 color: #6ee7b7;
237 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
238 }
239 .proj-pill.is-closed {
240 background: rgba(148,163,184,0.16);
241 color: #cbd5e1;
242 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
243 }
244 .proj-pill.is-count {
245 background: rgba(140,109,255,0.12);
246 color: #c4b5fd;
247 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
248 font-family: var(--font-mono);
249 font-variant-numeric: tabular-nums;
250 }
251
252 /* Form card */
253 .proj-form-card {
254 background: var(--bg-elevated);
255 border: 1px solid var(--border);
256 border-radius: 14px;
257 padding: var(--space-5);
258 max-width: 640px;
259 position: relative;
260 overflow: hidden;
261 }
262 .proj-form-card::before {
263 content: '';
264 position: absolute;
265 top: 0; left: 0; right: 0;
266 height: 2px;
267 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
268 opacity: 0.55;
269 }
270 .proj-field { margin-bottom: 14px; }
271 .proj-field-label {
272 display: block;
273 font-size: 11.5px;
274 color: var(--text-muted);
275 font-weight: 600;
276 text-transform: uppercase;
277 letter-spacing: 0.06em;
278 margin-bottom: 6px;
279 }
280 .proj-input, .proj-textarea {
281 width: 100%;
282 box-sizing: border-box;
283 padding: 9px 12px;
284 font: inherit;
285 font-size: 13.5px;
286 color: var(--text);
287 background: rgba(255,255,255,0.03);
288 border: 1px solid var(--border-strong);
289 border-radius: 10px;
290 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
291 }
292 .proj-textarea { font-family: inherit; resize: vertical; line-height: 1.5; }
293 .proj-input:focus, .proj-textarea:focus {
294 outline: none;
295 border-color: rgba(140,109,255,0.55);
296 background: rgba(255,255,255,0.05);
297 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
298 }
299
300 /* Board view */
301 .proj-board-head {
302 display: flex;
303 align-items: flex-end;
304 justify-content: space-between;
305 gap: var(--space-4);
306 flex-wrap: wrap;
307 margin-bottom: var(--space-4);
308 }
309 .proj-board-desc {
310 margin: 6px 0 0;
311 color: var(--text-muted);
312 font-size: 13.5px;
313 line-height: 1.5;
314 }
315
316 .proj-kanban {
317 display: flex;
318 gap: 14px;
319 overflow-x: auto;
320 padding-bottom: 12px;
321 scrollbar-width: thin;
322 }
323 .proj-kcol {
324 background: var(--bg-elevated);
325 border: 1px solid var(--border);
326 border-radius: 12px;
327 min-width: 280px;
328 max-width: 280px;
329 flex-shrink: 0;
330 padding: 12px;
331 position: relative;
332 overflow: hidden;
333 }
334 .proj-kcol::before {
335 content: '';
336 position: absolute;
337 top: 0; left: 0; right: 0;
338 height: 2px;
339 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
340 opacity: 0.5;
341 pointer-events: none;
342 }
343 .proj-kcol-new {
344 background: transparent;
345 border-style: dashed;
346 }
347 .proj-kcol-new::before { display: none; }
348 .proj-kcol-head {
349 display: flex;
350 align-items: center;
351 justify-content: space-between;
352 gap: 8px;
353 margin: 2px 4px 12px;
354 }
355 .proj-kcol-name {
356 font-family: var(--font-display);
357 font-size: 13.5px;
358 font-weight: 700;
359 color: var(--text-strong);
360 letter-spacing: -0.005em;
361 }
362 .proj-kcard {
363 background: rgba(255,255,255,0.03);
364 border: 1px solid var(--border);
365 border-radius: 10px;
366 padding: 10px 12px;
367 margin-bottom: 8px;
368 font-size: 13px;
369 transition: border-color 120ms ease, background 120ms ease;
370 }
371 .proj-kcard:hover {
372 border-color: var(--border-strong);
373 background: rgba(255,255,255,0.05);
374 }
375 .proj-kcard-title {
376 font-weight: 600;
377 color: var(--text-strong);
378 line-height: 1.35;
379 }
380 .proj-kcard-note {
381 margin-top: 4px;
382 color: var(--text-muted);
383 font-size: 12px;
384 line-height: 1.45;
385 }
386 .proj-kcard-actions {
387 margin-top: 8px;
388 display: flex;
389 gap: 4px;
390 flex-wrap: wrap;
391 }
392 .proj-kcard-actions form { margin: 0; display: inline; }
393
394 .proj-kadd { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
395 .proj-kadd input {
396 width: 100%;
397 box-sizing: border-box;
398 padding: 7px 10px;
399 font: inherit;
400 font-size: 12.5px;
401 color: var(--text);
402 background: rgba(255,255,255,0.03);
403 border: 1px solid var(--border);
404 border-radius: 8px;
405 }
406 .proj-kadd input:focus {
407 outline: none;
408 border-color: rgba(140,109,255,0.55);
409 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
410 }
411
412 /* Empty */
413 .proj-empty {
414 position: relative;
415 overflow: hidden;
416 padding: clamp(32px, 6vw, 56px) clamp(20px, 4vw, 36px);
417 text-align: center;
418 background: var(--bg-elevated);
419 border: 1px dashed var(--border-strong);
420 border-radius: 16px;
421 }
422 .proj-empty-orb {
423 position: absolute;
424 inset: -40% 30% auto 30%;
425 height: 280px;
426 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
427 filter: blur(70px);
428 opacity: 0.7;
429 pointer-events: none;
430 z-index: 0;
431 }
432 .proj-empty-inner { position: relative; z-index: 1; }
433 .proj-empty-icon {
434 width: 56px; height: 56px;
435 border-radius: 9999px;
436 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
437 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
438 display: inline-flex;
439 align-items: center;
440 justify-content: center;
441 color: #c4b5fd;
442 margin-bottom: 14px;
443 }
444 .proj-empty-title {
445 font-family: var(--font-display);
446 font-size: 18px;
447 font-weight: 700;
448 margin: 0 0 6px;
449 color: var(--text-strong);
450 }
451 .proj-empty-sub {
452 margin: 0 auto 16px;
453 font-size: 13.5px;
454 color: var(--text-muted);
455 max-width: 420px;
456 line-height: 1.5;
457 }
458`;
459
460function IconBoard() {
461 return (
462 <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">
463 <rect x="3" y="3" width="7" height="18" rx="1" />
464 <rect x="14" y="3" width="7" height="11" rx="1" />
465 </svg>
466 );
467}
468function IconPlus() {
469 return (
470 <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">
471 <line x1="12" y1="5" x2="12" y2="19" />
472 <line x1="5" y1="12" x2="19" y2="12" />
473 </svg>
474 );
475}
476function IconArrowLeft() {
477 return (
478 <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">
479 <line x1="19" y1="12" x2="5" y2="12" />
480 <polyline points="12 19 5 12 12 5" />
481 </svg>
482 );
483}
484
1e162a8Claude485async function resolveRepo(ownerName: string, repoName: string) {
486 try {
487 const [owner] = await db
488 .select()
489 .from(users)
490 .where(eq(users.username, ownerName))
491 .limit(1);
492 if (!owner) return null;
493 const [repo] = await db
494 .select()
495 .from(repositories)
496 .where(
497 and(
498 eq(repositories.ownerId, owner.id),
499 eq(repositories.name, repoName)
500 )
501 )
502 .limit(1);
503 if (!repo) return null;
504 return { owner, repo };
505 } catch {
506 return null;
507 }
508}
509
510function notFound(user: any, label = "Not found") {
511 return (
512 <Layout title={label} user={user}>
655a200Claude513 <div class="proj-wrap">
514 <div class="proj-empty">
515 <div class="proj-empty-orb" aria-hidden="true" />
516 <div class="proj-empty-inner">
517 <h2 class="proj-empty-title">{label}</h2>
518 </div>
519 </div>
1e162a8Claude520 </div>
655a200Claude521 <style dangerouslySetInnerHTML={{ __html: projStyles }} />
1e162a8Claude522 </Layout>
523 );
524}
525
526// List
527projectRoutes.get("/:owner/:repo/projects", softAuth, async (c) => {
528 const { owner: ownerName, repo: repoName } = c.req.param();
529 const user = c.get("user");
530 const resolved = await resolveRepo(ownerName, repoName);
531 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
532 const { repo } = resolved;
533
534 let rows: any[] = [];
535 try {
536 rows = await db
537 .select({
538 p: projects,
539 columnCount: sql<number>`(SELECT count(*) FROM project_columns WHERE project_id = ${projects.id})`,
540 itemCount: sql<number>`(SELECT count(*) FROM project_items WHERE project_id = ${projects.id})`,
541 })
542 .from(projects)
543 .where(eq(projects.repositoryId, repo.id))
544 .orderBy(desc(projects.updatedAt));
545 } catch {
546 rows = [];
547 }
548
549 return c.html(
550 <Layout title={`Projects — ${ownerName}/${repoName}`} user={user}>
551 <RepoHeader owner={ownerName} repo={repoName} />
655a200Claude552 <div class="proj-wrap">
553 <header class="proj-head">
554 <div class="proj-head-text">
555 <div class="proj-eyebrow">
556 <span class="proj-eyebrow-dot" aria-hidden="true" />
557 Repository · Projects
558 </div>
559 <h1 class="proj-title">
560 <span class="proj-title-grad">Plan the work.</span>
561 </h1>
562 <p class="proj-sub">
563 Lightweight kanban boards scoped to {ownerName}/{repoName}. Each
564 board owns its own columns and cards.
565 </p>
566 </div>
567 {user && (
568 <a
569 href={`/${ownerName}/${repoName}/projects/new`}
570 class="proj-btn proj-btn-primary"
571 >
572 <IconPlus />
573 New project
574 </a>
575 )}
576 </header>
577
578 {rows.length === 0 ? (
579 <div class="proj-empty">
580 <div class="proj-empty-orb" aria-hidden="true" />
581 <div class="proj-empty-inner">
582 <div class="proj-empty-icon" aria-hidden="true">
583 <IconBoard />
584 </div>
585 <h3 class="proj-empty-title">Start your first project</h3>
586 <p class="proj-empty-sub">
587 Boards are perfect for grouping work — sprints, OKRs, release
588 trains. We seed the default <code>To Do</code> /{" "}
589 <code>In Progress</code> / <code>Done</code> columns for you.
590 </p>
591 {user && (
592 <a
593 href={`/${ownerName}/${repoName}/projects/new`}
594 class="proj-btn proj-btn-primary"
595 >
596 <IconPlus />
597 New project
598 </a>
599 )}
600 </div>
601 </div>
602 ) : (
603 <div class="proj-list">
1e162a8Claude604 {rows.map((r) => (
655a200Claude605 <div class="proj-card">
606 <div class="proj-num">#{r.p.number}</div>
607 <div class="proj-card-body">
608 <div class="proj-card-title">
609 <a href={`/${ownerName}/${repoName}/projects/${r.p.number}`}>
610 {r.p.title}
611 </a>
612 {r.p.state === "closed" ? (
613 <span class="proj-pill is-closed">
614 <span class="dot" aria-hidden="true" />
615 Closed
616 </span>
617 ) : (
618 <span class="proj-pill is-open">
619 <span class="dot" aria-hidden="true" />
620 Open
621 </span>
622 )}
623 </div>
1e162a8Claude624 {r.p.description && (
655a200Claude625 <div class="proj-card-desc">{r.p.description}</div>
1e162a8Claude626 )}
655a200Claude627 </div>
628 <div class="proj-card-meta">
629 <span class="proj-pill is-count">{r.columnCount} cols</span>
630 <span class="sep">·</span>
631 <span class="proj-pill is-count">{r.itemCount} items</span>
632 </div>
633 </div>
1e162a8Claude634 ))}
655a200Claude635 </div>
636 )}
637 </div>
638 <style dangerouslySetInnerHTML={{ __html: projStyles }} />
1e162a8Claude639 </Layout>
640 );
641});
642
643// New form
644projectRoutes.get(
645 "/:owner/:repo/projects/new",
646 requireAuth,
647 async (c) => {
648 const { owner: ownerName, repo: repoName } = c.req.param();
649 const user = c.get("user");
650 const resolved = await resolveRepo(ownerName, repoName);
651 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
652 return c.html(
653 <Layout title="New project" user={user}>
654 <RepoHeader owner={ownerName} repo={repoName} />
655a200Claude655 <div class="proj-wrap">
656 <div class="proj-crumbs">
657 <a href={`/${ownerName}/${repoName}/projects`}>
658 <IconArrowLeft />
659 All projects
660 </a>
661 </div>
662 <header class="proj-head">
663 <div class="proj-head-text">
664 <div class="proj-eyebrow">
665 <span class="proj-eyebrow-dot" aria-hidden="true" />
666 Projects · New
667 </div>
668 <h1 class="proj-title">
669 <span class="proj-title-grad">Create a board.</span>
670 </h1>
671 <p class="proj-sub">
672 Name your project — we seed default kanban columns you can
673 rename later.
674 </p>
675 </div>
676 </header>
677 <form
678 method="post"
679 action={`/${ownerName}/${repoName}/projects`}
680 class="proj-form-card"
681 >
682 <div class="proj-field">
683 <label class="proj-field-label" for="proj-title">Title</label>
684 <input
685 class="proj-input"
686 type="text"
687 id="proj-title"
688 name="title"
689 placeholder="Sprint 24 — Q3 release"
690 required
691 aria-label="Project title"
692 />
693 </div>
694 <div class="proj-field">
695 <label class="proj-field-label" for="proj-desc">Description</label>
696 <textarea
697 class="proj-textarea"
698 id="proj-desc"
699 name="description"
700 rows={4}
701 placeholder="What is this board for? (optional)"
702 ></textarea>
703 </div>
704 <button type="submit" class="proj-btn proj-btn-primary">
705 <IconPlus />
706 Create project
707 </button>
708 </form>
709 </div>
710 <style dangerouslySetInnerHTML={{ __html: projStyles }} />
1e162a8Claude711 </Layout>
712 );
713 }
714);
715
716// Create
717projectRoutes.post(
718 "/:owner/:repo/projects",
719 requireAuth,
720 async (c) => {
721 const { owner: ownerName, repo: repoName } = c.req.param();
722 const user = c.get("user")!;
723 const resolved = await resolveRepo(ownerName, repoName);
724 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
725
726 const form = await c.req.formData();
727 const title = (form.get("title") as string || "").trim();
728 const description = (form.get("description") as string || "").trim();
729
730 if (!title) {
731 return c.redirect(`/${ownerName}/${repoName}/projects/new`);
732 }
733
734 try {
735 const [row] = await db
736 .insert(projects)
737 .values({
738 repositoryId: resolved.repo.id,
739 ownerId: user.id,
740 title,
741 description,
742 })
743 .returning({ id: projects.id, number: projects.number });
744 // Seed default columns
745 await db.insert(projectColumns).values(
746 DEFAULT_COLUMNS.map((name, i) => ({
747 projectId: row.id,
748 name,
749 position: i,
750 }))
751 );
752 return c.redirect(`/${ownerName}/${repoName}/projects/${row.number}`);
753 } catch {
754 return c.redirect(`/${ownerName}/${repoName}/projects`);
755 }
756 }
757);
758
759// Board view
760projectRoutes.get(
761 "/:owner/:repo/projects/:number",
762 softAuth,
763 async (c) => {
764 const { owner: ownerName, repo: repoName } = c.req.param();
765 const user = c.get("user");
766 const numParam = Number(c.req.param("number"));
767 const resolved = await resolveRepo(ownerName, repoName);
768 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
769
770 let project: any = null;
771 let columns: any[] = [];
772 let items: any[] = [];
773 try {
774 const [row] = await db
775 .select()
776 .from(projects)
777 .where(
778 and(
779 eq(projects.repositoryId, resolved.repo.id),
780 eq(projects.number, numParam)
781 )
782 )
783 .limit(1);
784 if (row) {
785 project = row;
786 columns = await db
787 .select()
788 .from(projectColumns)
789 .where(eq(projectColumns.projectId, row.id))
790 .orderBy(asc(projectColumns.position), asc(projectColumns.createdAt));
791 items = await db
792 .select()
793 .from(projectItems)
794 .where(eq(projectItems.projectId, row.id))
795 .orderBy(asc(projectItems.position));
796 }
797 } catch {
798 // leave nulls
799 }
800
801 if (!project) return c.html(notFound(user, "Project not found"), 404);
802
803 const itemsByCol: Record<string, any[]> = {};
804 for (const col of columns) itemsByCol[col.id] = [];
805 for (const it of items) {
806 if (itemsByCol[it.columnId]) itemsByCol[it.columnId].push(it);
807 }
808
809 return c.html(
810 <Layout
811 title={`${project.title} — project #${project.number}`}
812 user={user}
813 >
814 <RepoHeader owner={ownerName} repo={repoName} />
655a200Claude815 <div class="proj-wrap">
816 <div class="proj-crumbs">
817 <a href={`/${ownerName}/${repoName}/projects`}>
818 <IconArrowLeft />
819 All projects
820 </a>
1e162a8Claude821 </div>
655a200Claude822 <div class="proj-board-head">
823 <div>
824 <div class="proj-eyebrow">
825 <span class="proj-eyebrow-dot" aria-hidden="true" />
826 Project · #{project.number}
827 </div>
828 <h1 class="proj-title">
829 <span class="proj-title-grad">{project.title}</span>
830 {project.state === "closed" && (
831 <span class="proj-pill is-closed" style="margin-left:12px;vertical-align:middle">
832 <span class="dot" aria-hidden="true" />
833 Closed
834 </span>
835 )}
836 </h1>
837 {project.description && (
838 <p class="proj-board-desc">{project.description}</p>
839 )}
840 </div>
841 {user && (
842 <form
843 method="post"
844 action={`/${ownerName}/${repoName}/projects/${project.number}/close`}
845 >
846 <button type="submit" class="proj-btn proj-btn-ghost">
847 {project.state === "open" ? "Close" : "Reopen"}
848 </button>
849 </form>
850 )}
851 </div>
852 <div class="proj-kanban">
853 {columns.map((col) => (
854 <div class="proj-kcol">
855 <div class="proj-kcol-head">
856 <span class="proj-kcol-name">{col.name}</span>
857 <span class="proj-pill is-count">
858 {(itemsByCol[col.id] || []).length}
859 </span>
860 </div>
861 {(itemsByCol[col.id] || []).map((it) => (
862 <div class="proj-kcard">
863 <div class="proj-kcard-title">{it.title || "(untitled)"}</div>
864 {it.note && (
865 <div class="proj-kcard-note">{it.note}</div>
866 )}
867 {user && (
868 <div class="proj-kcard-actions">
869 {columns
870 .filter((oc) => oc.id !== col.id)
871 .map((oc) => (
872 <form
873 method="post"
874 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/move`}
1e162a8Claude875 >
655a200Claude876 <input
877 type="hidden"
878 name="column_id"
879 value={oc.id}
880 />
881 <button
882 type="submit"
883 class="proj-btn proj-btn-ghost proj-btn-mini"
884 >
885 → {oc.name}
886 </button>
887 </form>
888 ))}
889 <form
890 method="post"
891 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/delete`}
1e162a8Claude892 >
655a200Claude893 <button
894 type="submit"
895 class="proj-btn proj-btn-ghost proj-btn-mini"
896 aria-label="Delete item"
897 >
898 ×
899 </button>
900 </form>
901 </div>
902 )}
903 </div>
904 ))}
905 {user && (
906 <form
907 method="post"
908 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
909 class="proj-kadd"
910 >
911 <input type="hidden" name="column_id" value={col.id} />
912 <input
913 type="text"
914 name="title"
915 placeholder="New card title"
916 required
917 aria-label="New card title"
918 />
919 <button
920 type="submit"
921 class="proj-btn proj-btn-ghost proj-btn-mini"
922 >
923 <IconPlus />
924 Add card
925 </button>
926 </form>
927 )}
928 </div>
929 ))}
930 {user && (
931 <div class="proj-kcol proj-kcol-new">
1e162a8Claude932 <form
e7e240eClaude933 method="post"
655a200Claude934 action={`/${ownerName}/${repoName}/projects/${project.number}/columns`}
935 class="proj-kadd"
1e162a8Claude936 >
937 <input
938 type="text"
655a200Claude939 name="name"
940 placeholder="New column"
1e162a8Claude941 required
655a200Claude942 aria-label="New column name"
1e162a8Claude943 />
655a200Claude944 <button type="submit" class="proj-btn proj-btn-ghost proj-btn-mini">
945 <IconPlus />
946 Add column
1e162a8Claude947 </button>
948 </form>
655a200Claude949 </div>
950 )}
951 </div>
1e162a8Claude952 </div>
655a200Claude953 <style dangerouslySetInnerHTML={{ __html: projStyles }} />
1e162a8Claude954 </Layout>
955 );
956 }
957);
958
959// Add column
960projectRoutes.post(
961 "/:owner/:repo/projects/:number/columns",
962 requireAuth,
963 async (c) => {
964 const { owner: ownerName, repo: repoName } = c.req.param();
965 const numParam = Number(c.req.param("number"));
966 const resolved = await resolveRepo(ownerName, repoName);
967 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
968
969 const form = await c.req.formData();
970 const name = (form.get("name") as string || "").trim();
971 if (!name) {
972 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
973 }
974
975 try {
976 const [row] = await db
977 .select()
978 .from(projects)
979 .where(
980 and(
981 eq(projects.repositoryId, resolved.repo.id),
982 eq(projects.number, numParam)
983 )
984 )
985 .limit(1);
986 if (row) {
987 const [maxPos] = await db
988 .select({ p: sql<number>`coalesce(max(${projectColumns.position}), -1)` })
989 .from(projectColumns)
990 .where(eq(projectColumns.projectId, row.id));
991 await db.insert(projectColumns).values({
992 projectId: row.id,
993 name,
994 position: Number(maxPos?.p || -1) + 1,
995 });
996 }
997 } catch {
998 // swallow
999 }
1000 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1001 }
1002);
1003
1004// Add item
1005projectRoutes.post(
1006 "/:owner/:repo/projects/:number/items",
1007 requireAuth,
1008 async (c) => {
1009 const { owner: ownerName, repo: repoName } = c.req.param();
1010 const numParam = Number(c.req.param("number"));
1011 const resolved = await resolveRepo(ownerName, repoName);
1012 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
1013
1014 const form = await c.req.formData();
1015 const columnId = (form.get("column_id") as string || "").trim();
1016 const title = (form.get("title") as string || "").trim();
1017 const note = (form.get("note") as string || "").trim();
1018 if (!columnId || !title) {
1019 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1020 }
1021
1022 try {
1023 const [row] = await db
1024 .select()
1025 .from(projects)
1026 .where(
1027 and(
1028 eq(projects.repositoryId, resolved.repo.id),
1029 eq(projects.number, numParam)
1030 )
1031 )
1032 .limit(1);
1033 if (row) {
1034 const [maxPos] = await db
1035 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
1036 .from(projectItems)
1037 .where(eq(projectItems.columnId, columnId));
1038 await db.insert(projectItems).values({
1039 projectId: row.id,
1040 columnId,
1041 title,
1042 note,
1043 position: Number(maxPos?.p || -1) + 1,
1044 });
1045 }
1046 } catch {
1047 // swallow
1048 }
1049 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1050 }
1051);
1052
1053// Move item
1054projectRoutes.post(
1055 "/:owner/:repo/projects/:number/items/:itemId/move",
1056 requireAuth,
1057 async (c) => {
1058 const { owner: ownerName, repo: repoName, itemId } = c.req.param();
1059 const numParam = Number(c.req.param("number"));
1060 const resolved = await resolveRepo(ownerName, repoName);
1061 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
1062
1063 const form = await c.req.formData();
1064 const columnId = (form.get("column_id") as string || "").trim();
1065 if (!columnId) {
1066 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1067 }
1068
1069 try {
1070 const [maxPos] = await db
1071 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
1072 .from(projectItems)
1073 .where(eq(projectItems.columnId, columnId));
1074 await db
1075 .update(projectItems)
1076 .set({
1077 columnId,
1078 position: Number(maxPos?.p || -1) + 1,
1079 updatedAt: new Date(),
1080 })
1081 .where(eq(projectItems.id, itemId));
1082 } catch {
1083 // swallow
1084 }
1085 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1086 }
1087);
1088
1089// Delete item
1090projectRoutes.post(
1091 "/:owner/:repo/projects/:number/items/:itemId/delete",
1092 requireAuth,
1093 async (c) => {
1094 const { owner: ownerName, repo: repoName, itemId } = c.req.param();
1095 const numParam = Number(c.req.param("number"));
1096 try {
1097 await db.delete(projectItems).where(eq(projectItems.id, itemId));
1098 } catch {
1099 // swallow
1100 }
1101 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1102 }
1103);
1104
1105// Toggle close
1106projectRoutes.post(
1107 "/:owner/:repo/projects/:number/close",
1108 requireAuth,
1109 async (c) => {
1110 const { owner: ownerName, repo: repoName } = c.req.param();
1111 const numParam = Number(c.req.param("number"));
1112 const resolved = await resolveRepo(ownerName, repoName);
1113 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
1114
1115 try {
1116 const [row] = await db
1117 .select()
1118 .from(projects)
1119 .where(
1120 and(
1121 eq(projects.repositoryId, resolved.repo.id),
1122 eq(projects.number, numParam)
1123 )
1124 )
1125 .limit(1);
1126 if (row) {
1127 await db
1128 .update(projects)
1129 .set({
1130 state: row.state === "open" ? "closed" : "open",
1131 updatedAt: new Date(),
1132 })
1133 .where(eq(projects.id, row.id));
1134 }
1135 } catch {
1136 // swallow
1137 }
1138 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1139 }
1140);
1141
1142export default projectRoutes;