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.
- package/.next/BUILD_ID +1 -0
- package/.next/app-build-manifest.json +27 -0
- package/.next/app-path-routes-manifest.json +1 -0
- package/.next/build-manifest.json +32 -0
- package/.next/export-marker.json +1 -0
- package/.next/images-manifest.json +1 -0
- package/.next/next-minimal-server.js.nft.json +1 -0
- package/.next/next-server.js.nft.json +1 -0
- package/.next/package.json +1 -0
- package/.next/prerender-manifest.json +1 -0
- package/.next/react-loadable-manifest.json +8 -0
- package/.next/required-server-files.json +1 -0
- package/.next/routes-manifest.json +1 -0
- package/.next/server/app/_not-found/page.js +1 -0
- package/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/.next/server/app/_not-found.html +1 -0
- package/.next/server/app/_not-found.meta +6 -0
- package/.next/server/app/_not-found.rsc +10 -0
- package/.next/server/app/api/beads/route.js +8 -0
- package/.next/server/app/api/beads/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/stream/route.js +10 -0
- package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
- package/.next/server/app/api/beads.body +1 -0
- package/.next/server/app/api/beads.meta +1 -0
- package/.next/server/app/api/config/route.js +8 -0
- package/.next/server/app/api/config/route.js.nft.json +1 -0
- package/.next/server/app/api/config.body +1 -0
- package/.next/server/app/api/config.meta +1 -0
- package/.next/server/app/api/login/route.js +1 -0
- package/.next/server/app/api/login/route.js.nft.json +1 -0
- package/.next/server/app/api/logout/route.js +1 -0
- package/.next/server/app/api/logout/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/callback/route.js +1 -0
- package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
- package/.next/server/app/api/records/route.js +1 -0
- package/.next/server/app/api/records/route.js.nft.json +1 -0
- package/.next/server/app/api/status/route.js +1 -0
- package/.next/server/app/api/status/route.js.nft.json +1 -0
- package/.next/server/app/index.html +1 -0
- package/.next/server/app/index.meta +5 -0
- package/.next/server/app/index.rsc +8 -0
- package/.next/server/app/page.js +24 -0
- package/.next/server/app/page.js.nft.json +1 -0
- package/.next/server/app/page_client-reference-manifest.js +1 -0
- package/.next/server/app-paths-manifest.json +14 -0
- package/.next/server/chunks/247.js +12 -0
- package/.next/server/chunks/251.js +2 -0
- package/.next/server/chunks/29.js +1 -0
- package/.next/server/chunks/343.js +1 -0
- package/.next/server/chunks/533.js +38 -0
- package/.next/server/chunks/590.js +6 -0
- package/.next/server/chunks/615.js +15 -0
- package/.next/server/chunks/696.js +25 -0
- package/.next/server/chunks/719.js +2 -0
- package/.next/server/chunks/739.js +1 -0
- package/.next/server/chunks/font-manifest.json +1 -0
- package/.next/server/font-manifest.json +1 -0
- package/.next/server/functions-config-manifest.json +1 -0
- package/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/.next/server/middleware-build-manifest.js +1 -0
- package/.next/server/middleware-manifest.json +6 -0
- package/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/.next/server/next-font-manifest.js +1 -0
- package/.next/server/next-font-manifest.json +1 -0
- package/.next/server/pages/404.html +1 -0
- package/.next/server/pages/500.html +1 -0
- package/.next/server/pages/_app.js +1 -0
- package/.next/server/pages/_app.js.nft.json +1 -0
- package/.next/server/pages/_document.js +1 -0
- package/.next/server/pages/_document.js.nft.json +1 -0
- package/.next/server/pages/_error.js +1 -0
- package/.next/server/pages/_error.js.nft.json +1 -0
- package/.next/server/pages-manifest.json +1 -0
- package/.next/server/server-reference-manifest.js +1 -0
- package/.next/server/server-reference-manifest.json +1 -0
- package/.next/server/webpack-runtime.js +1 -0
- package/.next/static/99eOjoTtoO32H-c1faxZ5/_buildManifest.js +1 -0
- package/.next/static/99eOjoTtoO32H-c1faxZ5/_ssgManifest.js +1 -0
- package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
- package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
- package/.next/static/chunks/666-fb778298a77f3754.js +1 -0
- package/.next/static/chunks/945-bf736d0119e7437b.js +2 -0
- package/.next/static/chunks/app/_not-found/page-b568fd9238f85f27.js +1 -0
- package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
- package/.next/static/chunks/app/page-49d569c912d5af9d.js +1 -0
- package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
- package/.next/static/chunks/main-62aa0e18004db880.js +1 -0
- package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
- package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
- package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
- package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/.next/static/chunks/webpack-c8b9ebfd35ae1d92.js +1 -0
- package/.next/static/css/10ef08b24212fe36.css +3 -0
- package/README.md +243 -0
- package/app/api/beads/route.ts +27 -0
- package/app/api/beads/stream/route.ts +83 -0
- package/app/api/config/route.ts +46 -0
- package/app/api/login/route.ts +42 -0
- package/app/api/logout/route.ts +14 -0
- package/app/api/oauth/callback/route.ts +94 -0
- package/app/api/oauth/client-metadata.json/route.ts +33 -0
- package/app/api/oauth/jwks.json/route.ts +32 -0
- package/app/api/records/route.ts +168 -0
- package/app/api/status/route.ts +25 -0
- package/app/globals.css +192 -0
- package/app/layout.tsx +30 -0
- package/app/page.tsx +1151 -0
- package/bin/beads-map.mjs +175 -0
- package/components/AllCommentsPanel.tsx +265 -0
- package/components/AuthButton.tsx +197 -0
- package/components/BeadsGraph.tsx +1539 -0
- package/components/CommentTooltip.tsx +310 -0
- package/components/GraphStats.tsx +121 -0
- package/components/HeartIcon.tsx +33 -0
- package/components/NodeDetail.tsx +741 -0
- package/components/StatusLegend.tsx +99 -0
- package/components/TimelineBar.tsx +116 -0
- package/hooks/useBeadsComments.ts +412 -0
- package/lib/agent.ts +29 -0
- package/lib/auth/client.ts +221 -0
- package/lib/auth.tsx +159 -0
- package/lib/diff-beads.ts +125 -0
- package/lib/discover.ts +228 -0
- package/lib/env.ts +28 -0
- package/lib/parse-beads.ts +232 -0
- package/lib/session.ts +52 -0
- package/lib/timeline.ts +138 -0
- package/lib/types.ts +202 -0
- package/lib/utils.ts +25 -0
- package/lib/watch-beads.ts +97 -0
- package/next.config.mjs +4 -0
- package/package.json +75 -0
- package/postcss.config.mjs +9 -0
- package/public/image.png +0 -0
- package/scripts/generate-jwk.js +38 -0
- package/tailwind.config.ts +41 -0
- 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
|
+
|