Commitd9b3649unknown_key
Add Projects — Kanban board per repo (GitHub Projects equivalent)
Add Projects — Kanban board per repo (GitHub Projects equivalent) https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
1 file changed+315−62d9b36491415031a193358b018b9d9a2677033919
1 changed file+315−62
Modifiedsrc/routes/projects.tsx+315−62View fileUnifiedSplit
@@ -21,6 +21,8 @@ import {
2121 projectItems,
2222 repositories,
2323 users,
24 issues,
25 pullRequests,
2426} from "../db/schema";
2527import { Layout } from "../views/layout";
2628import { RepoHeader } from "../views/components";
@@ -377,6 +379,37 @@ const projStyles = `
377379 color: var(--text-strong);
378380 line-height: 1.35;
379381 }
382 .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; }
380413 .proj-kcard-note {
381414 margin-top: 4px;
382415 color: var(--text-muted);
@@ -388,10 +421,38 @@ const projStyles = `
388421 display: flex;
389422 gap: 4px;
390423 flex-wrap: wrap;
424 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;
391436 }
392 .proj-kcard-actions form { margin: 0; display: inline; }
437 .proj-kcard-select:focus { outline: none; border-color: rgba(140,109,255,0.55); }
393438
394 .proj-kadd { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
439 .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; }
395456 .proj-kadd input {
396457 width: 100%;
397458 box-sizing: border-box;
@@ -770,6 +831,10 @@ projectRoutes.get(
770831 let project: any = null;
771832 let columns: any[] = [];
772833 let items: any[] = [];
834 // 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
773838 try {
774839 const [row] = await db
775840 .select()
@@ -793,6 +858,37 @@ projectRoutes.get(
793858 .from(projectItems)
794859 .where(eq(projectItems.projectId, row.id))
795860 .orderBy(asc(projectItems.position));
861
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 }
796892 }
797893 } catch {
798894 // leave nulls
@@ -858,72 +954,164 @@ projectRoutes.get(
858954 {(itemsByCol[col.id] || []).length}
859955 </span>
860956 </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) => (
957 {(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 && (
8721001 <form
8731002 method="post"
8741003 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/move`}
1004 style="display:inline-flex;align-items:center;gap:4px"
8751005 >
876 <input
877 type="hidden"
1006 <select
8781007 name="column_id"
879 value={oc.id}
880 />
1008 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>
8811017 <button
8821018 type="submit"
8831019 class="proj-btn proj-btn-ghost proj-btn-mini"
8841020 >
885 → {oc.name}
1021 Move
8861022 </button>
8871023 </form>
888 ))}
889 <form
890 method="post"
891 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/delete`}
892 >
893 <button
894 type="submit"
895 class="proj-btn proj-btn-ghost proj-btn-mini"
896 aria-label="Delete item"
1024 )}
1025 <form
1026 method="post"
1027 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/delete`}
8971028 >
898 ×
899 </button>
900 </form>
901 </div>
902 )}
903 </div>
904 ))}
1029 <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 })}
9051042 {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 >
1043 <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">
9231045 <IconPlus />
9241046 Add card
925 </button>
926 </form>
1047 </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>
9271115 )}
9281116 </div>
9291117 ))}
@@ -1001,7 +1189,7 @@ projectRoutes.post(
10011189 }
10021190);
10031191
1004// Add item
1192// Add item (note, linked issue, or linked PR)
10051193projectRoutes.post(
10061194 "/:owner/:repo/projects/:number/items",
10071195 requireAuth,
@@ -1013,9 +1201,24 @@ projectRoutes.post(
10131201
10141202 const form = await c.req.formData();
10151203 const columnId = (form.get("column_id") as string || "").trim();
1204 const itemType = (form.get("item_type") as string || "note").trim();
10161205 const title = (form.get("title") as string || "").trim();
10171206 const note = (form.get("note") as string || "").trim();
1018 if (!columnId || !title) {
1207 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)) {
10191222 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
10201223 }
10211224
@@ -1035,13 +1238,63 @@ projectRoutes.post(
10351238 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
10361239 .from(projectItems)
10371240 .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 });
1241 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 }
10451298 }
10461299 } catch {
10471300 // swallow
10481301