heartbeads 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +49 -0
  3. package/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/build-manifest.json +32 -0
  5. package/.next/export-marker.json +1 -0
  6. package/.next/images-manifest.json +1 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +1 -0
  11. package/.next/react-loadable-manifest.json +8 -0
  12. package/.next/required-server-files.json +1 -0
  13. package/.next/routes-manifest.json +1 -0
  14. package/.next/server/app/_not-found/page.js +1 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +6 -0
  19. package/.next/server/app/_not-found.rsc +9 -0
  20. package/.next/server/app/api/auth/route.js +1 -0
  21. package/.next/server/app/api/auth/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/route.js +8 -0
  23. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads/stream/route.js +10 -0
  25. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  26. package/.next/server/app/api/beads.body +1 -0
  27. package/.next/server/app/api/beads.meta +1 -0
  28. package/.next/server/app/api/config/route.js +8 -0
  29. package/.next/server/app/api/config/route.js.nft.json +1 -0
  30. package/.next/server/app/api/docs/page.js +120 -0
  31. package/.next/server/app/api/docs/page.js.nft.json +1 -0
  32. package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
  33. package/.next/server/app/api/docs.html +120 -0
  34. package/.next/server/app/api/docs.meta +5 -0
  35. package/.next/server/app/api/docs.rsc +70 -0
  36. package/.next/server/app/api/login/route.js +1 -0
  37. package/.next/server/app/api/login/route.js.nft.json +1 -0
  38. package/.next/server/app/api/logout/route.js +1 -0
  39. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  40. package/.next/server/app/api/oauth/callback/route.js +1 -0
  41. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  42. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  43. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  44. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  45. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  46. package/.next/server/app/api/records/route.js +1 -0
  47. package/.next/server/app/api/records/route.js.nft.json +1 -0
  48. package/.next/server/app/api/status/route.js +1 -0
  49. package/.next/server/app/api/status/route.js.nft.json +1 -0
  50. package/.next/server/app/api/v1/graph/route.js +1 -0
  51. package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
  52. package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
  53. package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
  54. package/.next/server/app/api/v1/ready/route.js +1 -0
  55. package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
  56. package/.next/server/app/index.html +1 -0
  57. package/.next/server/app/index.meta +5 -0
  58. package/.next/server/app/index.rsc +9 -0
  59. package/.next/server/app/login/page.js +1 -0
  60. package/.next/server/app/login/page.js.nft.json +1 -0
  61. package/.next/server/app/login/page_client-reference-manifest.js +1 -0
  62. package/.next/server/app/login.html +1 -0
  63. package/.next/server/app/login.meta +5 -0
  64. package/.next/server/app/login.rsc +9 -0
  65. package/.next/server/app/opengraph-image.png/route.js +1 -0
  66. package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
  67. package/.next/server/app/opengraph-image.png.body +0 -0
  68. package/.next/server/app/opengraph-image.png.meta +1 -0
  69. package/.next/server/app/page.js +24 -0
  70. package/.next/server/app/page.js.nft.json +1 -0
  71. package/.next/server/app/page_client-reference-manifest.js +1 -0
  72. package/.next/server/app/twitter-image.png/route.js +1 -0
  73. package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
  74. package/.next/server/app/twitter-image.png.body +0 -0
  75. package/.next/server/app/twitter-image.png.meta +1 -0
  76. package/.next/server/app-paths-manifest.json +22 -0
  77. package/.next/server/chunks/247.js +12 -0
  78. package/.next/server/chunks/29.js +1 -0
  79. package/.next/server/chunks/343.js +1 -0
  80. package/.next/server/chunks/460.js +12 -0
  81. package/.next/server/chunks/533.js +38 -0
  82. package/.next/server/chunks/542.js +27 -0
  83. package/.next/server/chunks/590.js +6 -0
  84. package/.next/server/chunks/615.js +15 -0
  85. package/.next/server/chunks/696.js +25 -0
  86. package/.next/server/chunks/719.js +2 -0
  87. package/.next/server/chunks/739.js +1 -0
  88. package/.next/server/chunks/950.js +2 -0
  89. package/.next/server/chunks/font-manifest.json +1 -0
  90. package/.next/server/edge-runtime-webpack.js +2 -0
  91. package/.next/server/edge-runtime-webpack.js.map +1 -0
  92. package/.next/server/font-manifest.json +1 -0
  93. package/.next/server/functions-config-manifest.json +1 -0
  94. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  95. package/.next/server/middleware-build-manifest.js +1 -0
  96. package/.next/server/middleware-manifest.json +32 -0
  97. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  98. package/.next/server/middleware.js +14 -0
  99. package/.next/server/middleware.js.map +1 -0
  100. package/.next/server/next-font-manifest.js +1 -0
  101. package/.next/server/next-font-manifest.json +1 -0
  102. package/.next/server/pages/404.html +1 -0
  103. package/.next/server/pages/500.html +1 -0
  104. package/.next/server/pages/_app.js +1 -0
  105. package/.next/server/pages/_app.js.nft.json +1 -0
  106. package/.next/server/pages/_document.js +1 -0
  107. package/.next/server/pages/_document.js.nft.json +1 -0
  108. package/.next/server/pages/_error.js +1 -0
  109. package/.next/server/pages/_error.js.nft.json +1 -0
  110. package/.next/server/pages-manifest.json +1 -0
  111. package/.next/server/server-reference-manifest.js +1 -0
  112. package/.next/server/server-reference-manifest.json +1 -0
  113. package/.next/server/webpack-runtime.js +1 -0
  114. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  115. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  116. package/.next/static/chunks/788-aa413085174e935a.js +1 -0
  117. package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
  118. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  119. package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
  120. package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
  121. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  122. package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
  123. package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
  124. package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
  125. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  126. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  127. package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
  128. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  129. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  130. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  131. package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
  132. package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
  133. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
  134. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
  135. package/README.md +389 -0
  136. package/app/api/auth/route.ts +103 -0
  137. package/app/api/beads/route.ts +27 -0
  138. package/app/api/beads/stream/route.ts +83 -0
  139. package/app/api/config/route.ts +48 -0
  140. package/app/api/docs/page.tsx +497 -0
  141. package/app/api/login/route.ts +42 -0
  142. package/app/api/logout/route.ts +14 -0
  143. package/app/api/oauth/callback/route.ts +97 -0
  144. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  145. package/app/api/oauth/jwks.json/route.ts +32 -0
  146. package/app/api/records/route.ts +168 -0
  147. package/app/api/status/route.ts +25 -0
  148. package/app/api/v1/graph/route.ts +251 -0
  149. package/app/api/v1/issues/[id]/route.ts +158 -0
  150. package/app/api/v1/ready/route.ts +229 -0
  151. package/app/globals.css +230 -0
  152. package/app/layout.tsx +51 -0
  153. package/app/login/page.tsx +164 -0
  154. package/app/not-found.tsx +91 -0
  155. package/app/opengraph-image.png +0 -0
  156. package/app/page.tsx +2041 -0
  157. package/app/twitter-image.png +0 -0
  158. package/bin/heartbeads.mjs +225 -0
  159. package/components/ActivityItem.tsx +326 -0
  160. package/components/ActivityOverlay.tsx +125 -0
  161. package/components/ActivityPanel.tsx +345 -0
  162. package/components/AllCommentsPanel.tsx +270 -0
  163. package/components/AuthButton.tsx +202 -0
  164. package/components/BeadTooltip.tsx +246 -0
  165. package/components/BeadsGraph.tsx +2493 -0
  166. package/components/BeadsLogo.tsx +94 -0
  167. package/components/CommentTooltip.tsx +338 -0
  168. package/components/ContextMenu.tsx +272 -0
  169. package/components/DescriptionModal.tsx +595 -0
  170. package/components/GraphStats.tsx +121 -0
  171. package/components/HeartIcon.tsx +33 -0
  172. package/components/HelpPanel.tsx +339 -0
  173. package/components/MobileActionSheet.tsx +255 -0
  174. package/components/NodeDetail.tsx +793 -0
  175. package/components/SettingsModal.tsx +315 -0
  176. package/components/StatusLegend.tsx +99 -0
  177. package/components/TimelineBar.tsx +116 -0
  178. package/components/TutorialOverlay.tsx +235 -0
  179. package/hooks/useBeadsComments.ts +81 -0
  180. package/hooks/useIsMobile.ts +19 -0
  181. package/lib/activity.ts +377 -0
  182. package/lib/agent.ts +29 -0
  183. package/lib/api-helpers.ts +46 -0
  184. package/lib/auth/client.ts +221 -0
  185. package/lib/auth.tsx +159 -0
  186. package/lib/comments.ts +413 -0
  187. package/lib/diff-beads.ts +128 -0
  188. package/lib/discover.ts +228 -0
  189. package/lib/env.ts +33 -0
  190. package/lib/gate.ts +55 -0
  191. package/lib/parse-beads.ts +234 -0
  192. package/lib/session.ts +52 -0
  193. package/lib/settings.ts +42 -0
  194. package/lib/timeline.ts +138 -0
  195. package/lib/tts.ts +397 -0
  196. package/lib/types.ts +271 -0
  197. package/lib/utils.ts +48 -0
  198. package/lib/watch-beads.ts +97 -0
  199. package/next.config.mjs +4 -0
  200. package/package.json +81 -0
  201. package/postcss.config.mjs +9 -0
  202. package/public/image.png +0 -0
  203. package/scripts/generate-jwk.js +38 -0
  204. package/tailwind.config.ts +41 -0
  205. package/tsconfig.json +24 -0
@@ -0,0 +1,793 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import ReactMarkdown from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import type { GraphNode } from "@/lib/types";
7
+ import { DescriptionModal } from "./DescriptionModal";
8
+ import { HeartIcon } from "@/components/HeartIcon";
9
+ import { formatRelativeTime, buildDescriptionCopyText } from "@/lib/utils";
10
+ import {
11
+ STATUS_LABELS,
12
+ STATUS_COLORS,
13
+ PRIORITY_LABELS,
14
+ PRIORITY_COLORS,
15
+ TYPE_ICONS,
16
+ PREFIX_LABELS,
17
+ PREFIX_COLORS,
18
+ } from "@/lib/types";
19
+ import type { BeadsComment } from "@/hooks/useBeadsComments";
20
+
21
+ interface NodeDetailProps {
22
+ node: GraphNode | null;
23
+ allNodes: GraphNode[];
24
+ onNodeNavigate: (nodeId: string) => void;
25
+ comments?: BeadsComment[];
26
+ onPostComment?: (text: string) => Promise<void>;
27
+ onDeleteComment?: (comment: BeadsComment) => Promise<void>;
28
+ onLikeComment?: (comment: BeadsComment) => Promise<void>;
29
+ onReplyComment?: (parentComment: BeadsComment, text: string) => Promise<void>;
30
+ isAuthenticated?: boolean;
31
+ currentDid?: string;
32
+ repoUrls?: Record<string, string>;
33
+ onOpenSettings?: () => void;
34
+ }
35
+
36
+ export default function NodeDetail({
37
+ node,
38
+ allNodes,
39
+ onNodeNavigate,
40
+ comments,
41
+ onPostComment,
42
+ onDeleteComment,
43
+ onLikeComment,
44
+ onReplyComment,
45
+ isAuthenticated,
46
+ currentDid,
47
+ repoUrls,
48
+ onOpenSettings,
49
+ }: NodeDetailProps) {
50
+ // Reply state — managed here so it's shared across the comment tree
51
+ const [replyingToUri, setReplyingToUri] = useState<string | null>(null);
52
+ const [replyText, setReplyText] = useState("");
53
+ const [isSubmittingReply, setIsSubmittingReply] = useState(false);
54
+ const [descriptionExpanded, setDescriptionExpanded] = useState(false);
55
+ const [descCopied, setDescCopied] = useState(false);
56
+
57
+ const handleStartReply = (comment: BeadsComment) => {
58
+ setReplyingToUri(comment.uri);
59
+ setReplyText("");
60
+ };
61
+
62
+ const handleCancelReply = () => {
63
+ setReplyingToUri(null);
64
+ setReplyText("");
65
+ };
66
+
67
+ const handleSubmitReply = async () => {
68
+ if (!replyText.trim() || !replyingToUri || !onReplyComment) return;
69
+ setIsSubmittingReply(true);
70
+ try {
71
+ // Find the comment we're replying to
72
+ const findComment = (
73
+ items: BeadsComment[]
74
+ ): BeadsComment | undefined => {
75
+ for (const c of items) {
76
+ if (c.uri === replyingToUri) return c;
77
+ const found = findComment(c.replies);
78
+ if (found) return found;
79
+ }
80
+ return undefined;
81
+ };
82
+ const parentComment = comments
83
+ ? findComment(comments)
84
+ : undefined;
85
+ if (parentComment) {
86
+ await onReplyComment(parentComment, replyText.trim());
87
+ }
88
+ setReplyingToUri(null);
89
+ setReplyText("");
90
+ } catch (err) {
91
+ console.error("Failed to post reply:", err);
92
+ } finally {
93
+ setIsSubmittingReply(false);
94
+ }
95
+ };
96
+
97
+ if (!node) {
98
+ return (
99
+ <div className="flex flex-col items-center justify-center py-12 text-center">
100
+ <div className="w-12 h-12 rounded-full bg-zinc-100 flex items-center justify-center mb-4">
101
+ <svg
102
+ className="w-6 h-6 text-zinc-400"
103
+ fill="none"
104
+ stroke="currentColor"
105
+ viewBox="0 0 24 24"
106
+ >
107
+ <path
108
+ strokeLinecap="round"
109
+ strokeLinejoin="round"
110
+ strokeWidth={1.5}
111
+ d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
112
+ />
113
+ </svg>
114
+ </div>
115
+ <p className="text-sm text-zinc-500 leading-relaxed">
116
+ Click a node to see details
117
+ </p>
118
+ <p className="text-xs text-zinc-400 mt-1">
119
+ Hover to highlight connections
120
+ </p>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ const typeIcon = TYPE_ICONS[node.issueType] || "\uD83D\uDCCB";
126
+ const statusColor = STATUS_COLORS[node.status] || STATUS_COLORS.open;
127
+ const statusLabel = STATUS_LABELS[node.status] || node.status;
128
+ const priorityLabel = PRIORITY_LABELS[node.priority] || `P${node.priority}`;
129
+ const priorityColor = PRIORITY_COLORS[node.priority] || "#a1a1aa";
130
+ const prefixLabel = PREFIX_LABELS[node.prefix] || node.prefix;
131
+ const prefixColor = PREFIX_COLORS[node.prefix] || "#a1a1aa";
132
+ const repoUrl = repoUrls?.[node.prefix];
133
+
134
+ // Find blocker and dependent nodes
135
+ const blockerNodes = node.blockerIds
136
+ .map((id) => allNodes.find((n) => n.id === id))
137
+ .filter(Boolean) as GraphNode[];
138
+ const dependentNodes = node.dependentIds
139
+ .map((id) => allNodes.find((n) => n.id === id))
140
+ .filter(Boolean) as GraphNode[];
141
+
142
+ // Format date with time
143
+ const formatDate = (dateStr: string) => {
144
+ try {
145
+ const d = new Date(dateStr);
146
+ const date = d.toLocaleDateString("en-US", {
147
+ month: "short",
148
+ day: "numeric",
149
+ year: "numeric",
150
+ });
151
+ const time = d.toLocaleTimeString("en-US", {
152
+ hour: "numeric",
153
+ minute: "2-digit",
154
+ hour12: false,
155
+ });
156
+ return `${date} at ${time}`;
157
+ } catch {
158
+ return dateStr;
159
+ }
160
+ };
161
+
162
+ return (
163
+ <div className="animate-fade-in">
164
+ {/* Header */}
165
+ <div className="flex items-start gap-3 mb-4">
166
+ <span className="text-2xl mt-0.5">{typeIcon}</span>
167
+ <div className="flex-1 min-w-0">
168
+ <div className="flex items-center gap-2">
169
+ <span className="text-xs font-mono font-semibold text-emerald-600">
170
+ {node.id}
171
+ </span>
172
+ </div>
173
+ <h3 className="text-sm font-semibold text-zinc-900 mt-1 leading-snug">
174
+ {node.title}
175
+ </h3>
176
+ </div>
177
+ </div>
178
+
179
+ {/* Badges */}
180
+ <div className="flex flex-wrap gap-2 mb-4">
181
+ {/* Status badge */}
182
+ <span
183
+ className="status-badge"
184
+ style={{
185
+ backgroundColor: statusColor + "18",
186
+ color: statusColor,
187
+ border: `1px solid ${statusColor}30`,
188
+ }}
189
+ >
190
+ <span
191
+ className="w-1.5 h-1.5 rounded-full mr-1.5"
192
+ style={{ backgroundColor: statusColor }}
193
+ />
194
+ {statusLabel}
195
+ </span>
196
+
197
+ {/* Priority */}
198
+ <span
199
+ className="status-badge"
200
+ style={{
201
+ backgroundColor: priorityColor + "15",
202
+ color: priorityColor,
203
+ border: `1px solid ${priorityColor}25`,
204
+ }}
205
+ >
206
+ {priorityLabel}
207
+ </span>
208
+
209
+ {/* Project prefix */}
210
+ {repoUrl ? (
211
+ <a
212
+ href={repoUrl}
213
+ target="_blank"
214
+ rel="noopener noreferrer"
215
+ className="status-badge hover:opacity-80 transition-opacity"
216
+ style={{
217
+ backgroundColor: prefixColor + "15",
218
+ color: prefixColor,
219
+ border: `1px solid ${prefixColor}25`,
220
+ textDecoration: "none",
221
+ }}
222
+ >
223
+ {prefixLabel}
224
+ <svg className="w-2.5 h-2.5 ml-1 opacity-50" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
225
+ <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
226
+ </svg>
227
+ </a>
228
+ ) : (
229
+ <span
230
+ className="status-badge"
231
+ style={{
232
+ backgroundColor: prefixColor + "15",
233
+ color: prefixColor,
234
+ border: `1px solid ${prefixColor}25`,
235
+ }}
236
+ >
237
+ {prefixLabel}
238
+ </span>
239
+ )}
240
+ </div>
241
+
242
+ {/* Repository link */}
243
+ {repoUrl && (
244
+ <div className="mb-4">
245
+ <a
246
+ href={repoUrl}
247
+ target="_blank"
248
+ rel="noopener noreferrer"
249
+ className="inline-flex items-center gap-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors"
250
+ >
251
+ <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
252
+ <path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z" />
253
+ </svg>
254
+ {repoUrl.replace(/^https?:\/\//, "")}
255
+ </a>
256
+ </div>
257
+ )}
258
+
259
+ {/* Metrics grid */}
260
+ <div className="grid grid-cols-2 gap-3 mb-4">
261
+ <MetricCard
262
+ label="Blocks"
263
+ value={node.blockerCount}
264
+ color={node.blockerCount > 0 ? "#f59e0b" : undefined}
265
+ />
266
+ <MetricCard
267
+ label="Blocked by"
268
+ value={node.dependentCount}
269
+ color={node.dependentCount > 0 ? "#ef4444" : undefined}
270
+ />
271
+ </div>
272
+
273
+ {/* Dates */}
274
+ <div className="space-y-1.5 mb-4 text-xs text-zinc-500">
275
+ <div className="flex justify-between">
276
+ <span>Created</span>
277
+ <span className="text-zinc-700 font-medium">
278
+ {formatDate(node.createdAt)}
279
+ </span>
280
+ </div>
281
+ <div className="flex justify-between">
282
+ <span>Updated</span>
283
+ <span className="text-zinc-700 font-medium">
284
+ {formatDate(node.updatedAt)}
285
+ </span>
286
+ </div>
287
+ {node.closedAt && (
288
+ <div className="flex justify-between">
289
+ <span>Closed</span>
290
+ <span className="text-zinc-700 font-medium">
291
+ {formatDate(node.closedAt)}
292
+ </span>
293
+ </div>
294
+ )}
295
+ {node.closeReason && (
296
+ <div className="flex justify-between">
297
+ <span>Reason</span>
298
+ <span className="text-zinc-700 font-medium truncate ml-2">
299
+ {node.closeReason}
300
+ </span>
301
+ </div>
302
+ )}
303
+ {node.owner && (
304
+ <div className="flex justify-between">
305
+ <span>Owner</span>
306
+ <span className="text-zinc-700 font-medium truncate ml-2">
307
+ {node.owner}
308
+ </span>
309
+ </div>
310
+ )}
311
+ </div>
312
+
313
+ {/* Description */}
314
+ {node.description && (
315
+ <div className="mb-4">
316
+ <div className="flex items-center justify-between mb-2">
317
+ <h4 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">
318
+ Description
319
+ </h4>
320
+ <div className="flex items-center gap-2">
321
+ <button
322
+ onClick={() => {
323
+ if (!node.description) return;
324
+ const repoUrl = repoUrls?.[node.prefix];
325
+ navigator.clipboard.writeText(buildDescriptionCopyText(node, repoUrl)).then(() => {
326
+ setDescCopied(true);
327
+ setTimeout(() => setDescCopied(false), 1500);
328
+ });
329
+ }}
330
+ className="text-zinc-400 hover:text-zinc-600 transition-colors"
331
+ title="Copy description"
332
+ >
333
+ {descCopied ? (
334
+ <svg className="w-3.5 h-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
335
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
336
+ </svg>
337
+ ) : (
338
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
339
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
340
+ </svg>
341
+ )}
342
+ </button>
343
+ <button
344
+ onClick={() => setDescriptionExpanded(true)}
345
+ className="text-[10px] text-zinc-400 hover:text-zinc-600 transition-colors"
346
+ >
347
+ View in window
348
+ </button>
349
+ </div>
350
+ </div>
351
+ <div className="text-xs text-zinc-600 leading-relaxed bg-zinc-50 rounded-lg p-3 max-h-40 overflow-y-auto custom-scrollbar border border-zinc-100 description-markdown">
352
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
353
+ {node.description}
354
+ </ReactMarkdown>
355
+ </div>
356
+ </div>
357
+ )}
358
+
359
+ {/* Description expanded modal */}
360
+ {descriptionExpanded && node.description && (
361
+ <DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} repoUrl={repoUrls?.[node.prefix]} onOpenSettings={onOpenSettings} />
362
+ )}
363
+
364
+ {/* Blocks (issues this blocks) */}
365
+ {blockerNodes.length > 0 && (
366
+ <div className="mb-4">
367
+ <h4 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">
368
+ Blocks ({blockerNodes.length})
369
+ </h4>
370
+ <div className="space-y-1">
371
+ {blockerNodes.map((dep) => (
372
+ <DependencyLink
373
+ key={dep.id}
374
+ node={dep}
375
+ onClick={() => onNodeNavigate(dep.id)}
376
+ />
377
+ ))}
378
+ </div>
379
+ </div>
380
+ )}
381
+
382
+ {/* Blocked by */}
383
+ {dependentNodes.length > 0 && (
384
+ <div className="mb-4">
385
+ <h4 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">
386
+ Blocked by ({dependentNodes.length})
387
+ </h4>
388
+ <div className="space-y-1">
389
+ {dependentNodes.map((dep) => (
390
+ <DependencyLink
391
+ key={dep.id}
392
+ node={dep}
393
+ onClick={() => onNodeNavigate(dep.id)}
394
+ />
395
+ ))}
396
+ </div>
397
+ </div>
398
+ )}
399
+
400
+ {/* Comments */}
401
+ <div className="mb-4">
402
+ <h4 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">
403
+ Comments{" "}
404
+ {comments && comments.length > 0 && (
405
+ <span className="ml-1 px-1.5 py-0.5 bg-emerald-50 text-emerald-600 rounded-full text-[10px] font-medium">
406
+ {comments.length}
407
+ </span>
408
+ )}
409
+ </h4>
410
+
411
+ {/* Comment list */}
412
+ {comments && comments.length > 0 ? (
413
+ <div className="space-y-1">
414
+ {comments.map((comment) => (
415
+ <CommentItem
416
+ key={comment.uri}
417
+ comment={comment}
418
+ currentDid={currentDid}
419
+ isAuthenticated={isAuthenticated}
420
+ onDelete={onDeleteComment}
421
+ onLike={onLikeComment}
422
+ onStartReply={handleStartReply}
423
+ replyingToUri={replyingToUri}
424
+ replyText={replyText}
425
+ onReplyTextChange={setReplyText}
426
+ onSubmitReply={handleSubmitReply}
427
+ onCancelReply={handleCancelReply}
428
+ isSubmittingReply={isSubmittingReply}
429
+ depth={0}
430
+ />
431
+ ))}
432
+ </div>
433
+ ) : (
434
+ <p className="text-xs text-zinc-400 italic">No comments yet</p>
435
+ )}
436
+
437
+ {/* Compose area */}
438
+ {isAuthenticated && onPostComment ? (
439
+ <CommentCompose onSubmit={onPostComment} />
440
+ ) : !isAuthenticated ? (
441
+ <p className="text-xs text-zinc-400 mt-2">
442
+ Sign in to leave a comment
443
+ </p>
444
+ ) : null}
445
+ </div>
446
+ </div>
447
+ );
448
+ }
449
+
450
+
451
+
452
+ // ============================================================================
453
+ // InlineReplyForm — ported from Hyperscan ReviewSection
454
+ // ============================================================================
455
+
456
+ function InlineReplyForm({
457
+ replyingTo,
458
+ replyText,
459
+ onTextChange,
460
+ onSubmit,
461
+ onCancel,
462
+ isSubmitting,
463
+ }: {
464
+ replyingTo: BeadsComment;
465
+ replyText: string;
466
+ onTextChange: (text: string) => void;
467
+ onSubmit: () => void;
468
+ onCancel: () => void;
469
+ isSubmitting: boolean;
470
+ }) {
471
+ return (
472
+ <div className="mt-2 ml-4 pl-3 border-l border-emerald-200 space-y-1.5">
473
+ <div className="flex items-center gap-1.5 text-[10px] text-zinc-400">
474
+ <span>Replying to</span>
475
+ <a
476
+ href={`https://www.impactindexer.org/data?did=${replyingTo.did}`}
477
+ target="_blank"
478
+ rel="noopener noreferrer"
479
+ className="font-medium text-zinc-600 hover:text-emerald-600 transition-colors"
480
+ >
481
+ {replyingTo.displayName || replyingTo.handle}
482
+ </a>
483
+ </div>
484
+ <div className="flex gap-2">
485
+ <input
486
+ type="text"
487
+ value={replyText}
488
+ onChange={(e) => onTextChange(e.target.value)}
489
+ onKeyDown={(e) => {
490
+ if (e.key === "Enter" && !e.shiftKey) onSubmit();
491
+ }}
492
+ placeholder="Write a reply..."
493
+ disabled={isSubmitting}
494
+ autoFocus
495
+ className="flex-1 px-2 py-1 text-xs bg-white border border-zinc-200 rounded placeholder-zinc-400 focus:outline-none focus:border-emerald-400 disabled:opacity-50"
496
+ />
497
+ <button
498
+ onClick={onSubmit}
499
+ disabled={!replyText.trim() || isSubmitting}
500
+ className="px-2 py-1 text-[10px] font-medium text-emerald-600 hover:text-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
501
+ >
502
+ {isSubmitting ? "..." : "Reply"}
503
+ </button>
504
+ <button
505
+ onClick={onCancel}
506
+ disabled={isSubmitting}
507
+ className="px-2 py-1 text-[10px] text-zinc-400 hover:text-zinc-600 disabled:opacity-50 transition-colors"
508
+ >
509
+ Cancel
510
+ </button>
511
+ </div>
512
+ </div>
513
+ );
514
+ }
515
+
516
+ // ============================================================================
517
+ // Sub-components
518
+ // ============================================================================
519
+
520
+ function MetricCard({
521
+ label,
522
+ value,
523
+ color,
524
+ }: {
525
+ label: string;
526
+ value: number;
527
+ color?: string;
528
+ }) {
529
+ return (
530
+ <div className="bg-zinc-50 rounded-lg p-3 border border-zinc-100">
531
+ <div
532
+ className="text-xl font-bold"
533
+ style={{ color: color || "#3f3f46" }}
534
+ >
535
+ {value}
536
+ </div>
537
+ <div className="text-[10px] text-zinc-400 uppercase tracking-wider mt-0.5">
538
+ {label}
539
+ </div>
540
+ </div>
541
+ );
542
+ }
543
+
544
+ function DependencyLink({
545
+ node,
546
+ onClick,
547
+ }: {
548
+ node: GraphNode;
549
+ onClick: () => void;
550
+ }) {
551
+ const statusColor = STATUS_COLORS[node.status] || STATUS_COLORS.open;
552
+
553
+ return (
554
+ <button
555
+ onClick={onClick}
556
+ className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-md hover:bg-zinc-50 transition-colors text-left group border border-transparent hover:border-zinc-200"
557
+ >
558
+ <span
559
+ className="w-2 h-2 rounded-full shrink-0"
560
+ style={{ backgroundColor: statusColor }}
561
+ />
562
+ <span className="text-xs font-mono text-emerald-600 group-hover:text-emerald-700 shrink-0">
563
+ {node.id}
564
+ </span>
565
+ <span className="text-xs text-zinc-500 truncate">{node.title}</span>
566
+ </button>
567
+ );
568
+ }
569
+
570
+ function CommentItem({
571
+ comment,
572
+ currentDid,
573
+ isAuthenticated,
574
+ onDelete,
575
+ onLike,
576
+ onStartReply,
577
+ replyingToUri,
578
+ replyText,
579
+ onReplyTextChange,
580
+ onSubmitReply,
581
+ onCancelReply,
582
+ isSubmittingReply,
583
+ depth,
584
+ }: {
585
+ comment: BeadsComment;
586
+ currentDid?: string;
587
+ isAuthenticated?: boolean;
588
+ onDelete?: (comment: BeadsComment) => Promise<void>;
589
+ onLike?: (comment: BeadsComment) => Promise<void>;
590
+ onStartReply: (comment: BeadsComment) => void;
591
+ replyingToUri: string | null;
592
+ replyText: string;
593
+ onReplyTextChange: (text: string) => void;
594
+ onSubmitReply: () => void;
595
+ onCancelReply: () => void;
596
+ isSubmittingReply: boolean;
597
+ depth: number;
598
+ }) {
599
+ const [deleting, setDeleting] = useState(false);
600
+ const [liking, setLiking] = useState(false);
601
+ const isOwn = currentDid && currentDid === comment.did;
602
+ const hasLiked = currentDid
603
+ ? comment.likes.some((l) => l.did === currentDid)
604
+ : false;
605
+ const isReplyingToThis = replyingToUri === comment.uri;
606
+
607
+ const handleDelete = async () => {
608
+ if (!onDelete || deleting) return;
609
+ setDeleting(true);
610
+ try {
611
+ await onDelete(comment);
612
+ } catch (err) {
613
+ console.error("Failed to delete comment:", err);
614
+ } finally {
615
+ setDeleting(false);
616
+ }
617
+ };
618
+
619
+ const handleLike = async () => {
620
+ if (!onLike || liking) return;
621
+ setLiking(true);
622
+ try {
623
+ await onLike(comment);
624
+ } catch (err) {
625
+ console.error("Failed to toggle like:", err);
626
+ } finally {
627
+ setLiking(false);
628
+ }
629
+ };
630
+
631
+ return (
632
+ <div className={`${depth > 0 ? "ml-4 pl-3 border-l border-zinc-100" : ""}`}>
633
+ <div className="py-2">
634
+ {/* Header: avatar + name + date */}
635
+ <div className="flex items-center gap-1.5 mb-1">
636
+ <div className="shrink-0 w-4 h-4 rounded-full bg-zinc-100 overflow-hidden">
637
+ {comment.avatar ? (
638
+ <img
639
+ src={comment.avatar}
640
+ alt=""
641
+ className="w-full h-full object-cover"
642
+ />
643
+ ) : (
644
+ <div className="w-full h-full flex items-center justify-center text-[8px] font-medium text-zinc-400">
645
+ {(comment.handle || comment.did).charAt(0).toUpperCase()}
646
+ </div>
647
+ )}
648
+ </div>
649
+ <a
650
+ href={`https://www.impactindexer.org/data?did=${comment.did}`}
651
+ target="_blank"
652
+ rel="noopener noreferrer"
653
+ className="text-xs font-medium text-zinc-600 truncate hover:text-emerald-600 transition-colors"
654
+ >
655
+ {comment.displayName ||
656
+ comment.handle ||
657
+ comment.did.slice(0, 16) + "..."}
658
+ </a>
659
+ <span className="text-[10px] text-zinc-300 shrink-0">
660
+ {formatRelativeTime(comment.createdAt)}
661
+ </span>
662
+ </div>
663
+
664
+ {/* Comment text */}
665
+ <p className="text-xs text-zinc-500 leading-relaxed whitespace-pre-wrap break-words">
666
+ {comment.text}
667
+ </p>
668
+
669
+ {/* Actions row: like, reply, delete */}
670
+ <div className="flex items-center gap-2 mt-1 text-[10px]">
671
+ {/* Like button */}
672
+ <button
673
+ onClick={handleLike}
674
+ disabled={!isAuthenticated || liking}
675
+ className={`flex items-center gap-0.5 transition-colors ${
676
+ hasLiked
677
+ ? "text-rose-500"
678
+ : "text-zinc-300 hover:text-rose-500"
679
+ } disabled:opacity-50`}
680
+ >
681
+ <HeartIcon className="w-3 h-3" filled={hasLiked} />
682
+ {comment.likes.length > 0 && (
683
+ <span>{comment.likes.length}</span>
684
+ )}
685
+ </button>
686
+
687
+ {/* Reply button */}
688
+ <button
689
+ onClick={() => onStartReply(comment)}
690
+ disabled={!isAuthenticated}
691
+ className={`transition-colors disabled:opacity-50 ${
692
+ isReplyingToThis
693
+ ? "text-emerald-500"
694
+ : "text-zinc-300 hover:text-zinc-500"
695
+ }`}
696
+ >
697
+ reply
698
+ </button>
699
+
700
+ {/* Delete button — only for own comments */}
701
+ {isOwn && onDelete && (
702
+ <button
703
+ onClick={handleDelete}
704
+ disabled={deleting}
705
+ className="ml-auto shrink-0 text-zinc-300 hover:text-red-400 disabled:opacity-50 transition-colors"
706
+ >
707
+ {deleting ? "..." : "delete"}
708
+ </button>
709
+ )}
710
+ </div>
711
+ </div>
712
+
713
+ {/* Inline reply form */}
714
+ {isReplyingToThis && (
715
+ <InlineReplyForm
716
+ replyingTo={comment}
717
+ replyText={replyText}
718
+ onTextChange={onReplyTextChange}
719
+ onSubmit={onSubmitReply}
720
+ onCancel={onCancelReply}
721
+ isSubmitting={isSubmittingReply}
722
+ />
723
+ )}
724
+
725
+ {/* Nested replies */}
726
+ {comment.replies.length > 0 && (
727
+ <div>
728
+ {comment.replies.map((reply) => (
729
+ <CommentItem
730
+ key={reply.uri}
731
+ comment={reply}
732
+ currentDid={currentDid}
733
+ isAuthenticated={isAuthenticated}
734
+ onDelete={onDelete}
735
+ onLike={onLike}
736
+ onStartReply={onStartReply}
737
+ replyingToUri={replyingToUri}
738
+ replyText={replyText}
739
+ onReplyTextChange={onReplyTextChange}
740
+ onSubmitReply={onSubmitReply}
741
+ onCancelReply={onCancelReply}
742
+ isSubmittingReply={isSubmittingReply}
743
+ depth={depth + 1}
744
+ />
745
+ ))}
746
+ </div>
747
+ )}
748
+ </div>
749
+ );
750
+ }
751
+
752
+ function CommentCompose({
753
+ onSubmit,
754
+ }: {
755
+ onSubmit: (text: string) => Promise<void>;
756
+ }) {
757
+ const [text, setText] = useState("");
758
+ const [sending, setSending] = useState(false);
759
+
760
+ const handleSubmit = async () => {
761
+ if (!text.trim() || sending) return;
762
+ setSending(true);
763
+ try {
764
+ await onSubmit(text.trim());
765
+ setText("");
766
+ } catch (err) {
767
+ console.error("Failed to post comment:", err);
768
+ } finally {
769
+ setSending(false);
770
+ }
771
+ };
772
+
773
+ return (
774
+ <div className="mt-3 space-y-2">
775
+ <textarea
776
+ value={text}
777
+ onChange={(e) => setText(e.target.value)}
778
+ placeholder="Leave a comment..."
779
+ rows={2}
780
+ className="w-full px-2.5 py-1.5 text-xs border border-zinc-200 rounded-md bg-zinc-50 text-zinc-700 placeholder-zinc-400 resize-none focus:outline-none focus:ring-1 focus:ring-emerald-500 focus:border-emerald-500"
781
+ />
782
+ <button
783
+ onClick={handleSubmit}
784
+ disabled={!text.trim() || sending}
785
+ className="px-3 py-1 text-xs font-medium text-white bg-emerald-500 rounded-md hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
786
+ >
787
+ {sending ? "Sending..." : "Comment"}
788
+ </button>
789
+ </div>
790
+ );
791
+ }
792
+
793
+