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