beads-map 0.2.0 → 0.2.2

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 (39) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +2 -2
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +2 -2
  5. package/.next/next-minimal-server.js.nft.json +1 -1
  6. package/.next/next-server.js.nft.json +1 -1
  7. package/.next/prerender-manifest.json +1 -1
  8. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_not-found.html +1 -1
  10. package/.next/server/app/_not-found.rsc +1 -1
  11. package/.next/server/app/api/beads.body +1 -1
  12. package/.next/server/app/index.html +1 -1
  13. package/.next/server/app/index.rsc +2 -2
  14. package/.next/server/app/page.js +3 -3
  15. package/.next/server/app/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app-paths-manifest.json +6 -6
  17. package/.next/server/pages/404.html +1 -1
  18. package/.next/server/pages/500.html +1 -1
  19. package/.next/server/pages-manifest.json +1 -1
  20. package/.next/server/server-reference-manifest.json +1 -1
  21. package/.next/static/chunks/app/page-13ee27a84e4a0c70.js +1 -0
  22. package/.next/static/css/dbf588b653aa4019.css +3 -0
  23. package/app/page.tsx +118 -6
  24. package/bin/beads-map.mjs +32 -0
  25. package/components/ActivityItem.tsx +326 -0
  26. package/components/ActivityOverlay.tsx +123 -0
  27. package/components/ActivityPanel.tsx +345 -0
  28. package/components/AllCommentsPanel.tsx +9 -4
  29. package/components/AuthButton.tsx +7 -2
  30. package/components/BeadsGraph.tsx +11 -5
  31. package/components/CommentTooltip.tsx +7 -2
  32. package/components/NodeDetail.tsx +14 -4
  33. package/lib/activity.ts +377 -0
  34. package/lib/diff-beads.ts +3 -0
  35. package/package.json +1 -1
  36. package/.next/static/chunks/app/page-f36cdcae49f1d2af.js +0 -1
  37. package/.next/static/css/a4e34aaaa51183d9.css +0 -3
  38. /package/.next/static/{YVdbDxCehgqcYmLncYRFB → dxp53pVl-eTmydUx_hpyJ}/_buildManifest.js +0 -0
  39. /package/.next/static/{YVdbDxCehgqcYmLncYRFB → dxp53pVl-eTmydUx_hpyJ}/_ssgManifest.js +0 -0
@@ -0,0 +1,123 @@
1
+ "use client";
2
+
3
+ import type { ActivityEvent } from "@/lib/activity";
4
+ import { ActivityItem } from "./ActivityItem";
5
+
6
+ interface Props {
7
+ events: ActivityEvent[];
8
+ collapsed: boolean;
9
+ onToggleCollapse: () => void;
10
+ onExpandPanel: () => void;
11
+ onNodeClick: (nodeId: string) => void;
12
+ }
13
+
14
+ /**
15
+ * Compact always-visible activity card in the top-right of the graph canvas.
16
+ * Shows latest 5 events. Click "See all" to open the full ActivityPanel.
17
+ */
18
+ export function ActivityOverlay({
19
+ events,
20
+ collapsed,
21
+ onToggleCollapse,
22
+ onExpandPanel,
23
+ onNodeClick,
24
+ }: Props) {
25
+ // Count events in last 5 minutes for the collapsed badge
26
+ const recentCount = events.filter(
27
+ (e) => Date.now() - e.time < 5 * 60 * 1000
28
+ ).length;
29
+
30
+ if (collapsed) {
31
+ return (
32
+ <button
33
+ onClick={onToggleCollapse}
34
+ className="group flex items-center gap-2 px-3 py-2 bg-white/90 backdrop-blur-sm rounded-full border border-zinc-200/80 shadow-sm hover:bg-white hover:shadow-md transition-all"
35
+ title="Show activity feed"
36
+ >
37
+ <svg
38
+ className="w-3.5 h-3.5 text-zinc-400 group-hover:text-emerald-500 transition-colors"
39
+ viewBox="0 0 16 16"
40
+ fill="none"
41
+ stroke="currentColor"
42
+ strokeWidth="1.5"
43
+ >
44
+ <circle cx="8" cy="8" r="6" />
45
+ <polyline points="8,4 8,8 11,10" />
46
+ </svg>
47
+ <span className="text-xs text-zinc-500 group-hover:text-zinc-700 transition-colors">Activity</span>
48
+ {recentCount > 0 && (
49
+ <span className="text-[10px] font-medium text-white bg-emerald-500 rounded-full px-1.5 py-0.5 min-w-[18px] text-center leading-tight">
50
+ {recentCount}
51
+ </span>
52
+ )}
53
+ </button>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <div className="w-72 bg-white/95 backdrop-blur-sm rounded-xl border border-zinc-200/80 shadow-lg overflow-hidden">
59
+ {/* Header */}
60
+ <div className="flex items-center justify-between px-3.5 py-2.5 border-b border-zinc-100/80">
61
+ <div className="flex items-center gap-2">
62
+ <div className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
63
+ <span className="text-[11px] font-semibold text-zinc-600">
64
+ Live Activity
65
+ </span>
66
+ </div>
67
+ <button
68
+ onClick={onToggleCollapse}
69
+ className="text-zinc-400 hover:text-zinc-600 transition-colors p-1 -mr-0.5 rounded-full hover:bg-zinc-100"
70
+ title="Collapse"
71
+ >
72
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
73
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
74
+ </svg>
75
+ </button>
76
+ </div>
77
+
78
+ {/* Event list */}
79
+ <div className="py-0.5 max-h-[260px] overflow-y-auto custom-scrollbar">
80
+ {events.length === 0 ? (
81
+ <div className="py-6 text-center">
82
+ <svg className="w-5 h-5 text-zinc-200 mx-auto mb-1.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
83
+ <circle cx="8" cy="8" r="6" />
84
+ <polyline points="8,4 8,8 11,10" />
85
+ </svg>
86
+ <p className="text-[11px] text-zinc-400">No activity yet</p>
87
+ </div>
88
+ ) : (
89
+ events.slice(0, 6).map((event, i) => (
90
+ <div
91
+ key={event.id}
92
+ className={i === 0 ? "bg-emerald-50/30" : ""}
93
+ >
94
+ <ActivityItem
95
+ event={event}
96
+ variant="compact"
97
+ onNodeClick={onNodeClick}
98
+ />
99
+ </div>
100
+ ))
101
+ )}
102
+ </div>
103
+
104
+ {/* Footer */}
105
+ {events.length > 0 && (
106
+ <div className="flex items-center justify-between px-3.5 py-2 border-t border-zinc-100/80 bg-zinc-50/30">
107
+ <span className="text-[10px] text-zinc-400">
108
+ {events.length} event{events.length !== 1 ? "s" : ""}
109
+ </span>
110
+ <button
111
+ onClick={onExpandPanel}
112
+ className="text-[11px] text-emerald-600 hover:text-emerald-700 transition-colors font-medium flex items-center gap-1"
113
+ >
114
+ See all
115
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
116
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
117
+ </svg>
118
+ </button>
119
+ </div>
120
+ )}
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,345 @@
1
+ "use client";
2
+
3
+ import { useState, useMemo } from "react";
4
+ import type { ActivityEvent, ActivityFilterCategory } from "@/lib/activity";
5
+ import { getEventCategory } from "@/lib/activity";
6
+ import { ActivityItem } from "./ActivityItem";
7
+
8
+ interface Props {
9
+ events: ActivityEvent[];
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ onNodeClick: (nodeId: string) => void;
13
+ }
14
+
15
+ const FILTER_CATEGORIES: {
16
+ key: ActivityFilterCategory;
17
+ label: string;
18
+ icon: string;
19
+ }[] = [
20
+ { key: "issues", label: "Issues", icon: "circle" },
21
+ { key: "deps", label: "Deps", icon: "link" },
22
+ { key: "comments", label: "Comments", icon: "chat" },
23
+ { key: "claims", label: "Claims", icon: "user" },
24
+ { key: "likes", label: "Likes", icon: "heart" },
25
+ ];
26
+
27
+ // ============================================================================
28
+ // Time grouping helpers
29
+ // ============================================================================
30
+
31
+ function getTimeGroup(time: number): string {
32
+ const now = Date.now();
33
+ const diff = now - time;
34
+ const hours = diff / (1000 * 60 * 60);
35
+ const days = diff / (1000 * 60 * 60 * 24);
36
+
37
+ if (hours < 1) return "Just now";
38
+ if (hours < 24) return "Today";
39
+ if (days < 2) return "Yesterday";
40
+ if (days < 7) return "This week";
41
+ if (days < 30) return "This month";
42
+ return "Older";
43
+ }
44
+
45
+ function groupEventsByTime(events: ActivityEvent[]): { label: string; events: ActivityEvent[] }[] {
46
+ const groups: Map<string, ActivityEvent[]> = new Map();
47
+ const order: string[] = [];
48
+
49
+ for (const event of events) {
50
+ const label = getTimeGroup(event.time);
51
+ if (!groups.has(label)) {
52
+ groups.set(label, []);
53
+ order.push(label);
54
+ }
55
+ groups.get(label)!.push(event);
56
+ }
57
+
58
+ return order.map((label) => ({ label, events: groups.get(label)! }));
59
+ }
60
+
61
+ /**
62
+ * Full slide-in sidebar panel for the activity feed.
63
+ * Includes search bar, filter chips, and time-grouped events.
64
+ * Same pattern as AllCommentsPanel / NodeDetail.
65
+ */
66
+ export function ActivityPanel({
67
+ events,
68
+ isOpen,
69
+ onClose,
70
+ onNodeClick,
71
+ }: Props) {
72
+ const [search, setSearch] = useState("");
73
+ const [activeFilters, setActiveFilters] = useState<Set<ActivityFilterCategory>>(
74
+ new Set(FILTER_CATEGORIES.map((c) => c.key))
75
+ );
76
+
77
+ const toggleFilter = (category: ActivityFilterCategory) => {
78
+ setActiveFilters((prev) => {
79
+ const next = new Set(prev);
80
+ if (next.has(category)) {
81
+ // Don't allow deactivating all filters
82
+ if (next.size > 1) next.delete(category);
83
+ } else {
84
+ next.add(category);
85
+ }
86
+ return next;
87
+ });
88
+ };
89
+
90
+ const filteredEvents = useMemo(() => {
91
+ let filtered = events;
92
+
93
+ // Apply category filters
94
+ filtered = filtered.filter((e) =>
95
+ activeFilters.has(getEventCategory(e.type))
96
+ );
97
+
98
+ // Apply text search
99
+ if (search.trim()) {
100
+ const q = search.trim().toLowerCase();
101
+ filtered = filtered.filter(
102
+ (e) =>
103
+ e.nodeId.toLowerCase().includes(q) ||
104
+ (e.nodeTitle && e.nodeTitle.toLowerCase().includes(q)) ||
105
+ (e.actor?.handle && e.actor.handle.toLowerCase().includes(q)) ||
106
+ (e.detail && e.detail.toLowerCase().includes(q))
107
+ );
108
+ }
109
+
110
+ return filtered;
111
+ }, [events, activeFilters, search]);
112
+
113
+ const groupedEvents = useMemo(
114
+ () => groupEventsByTime(filteredEvents),
115
+ [filteredEvents]
116
+ );
117
+
118
+ const allActive = activeFilters.size === FILTER_CATEGORIES.length;
119
+
120
+ return (
121
+ <>
122
+ {/* Desktop sidebar */}
123
+ <aside
124
+ className={`hidden md:flex absolute top-0 right-0 h-full w-[360px] bg-white border-l border-zinc-200 flex-col shadow-xl z-30 transform transition-transform duration-300 ease-out ${
125
+ isOpen ? "translate-x-0" : "translate-x-full"
126
+ }`}
127
+ >
128
+ {/* Header */}
129
+ <div className="shrink-0 px-5 py-3 border-b border-zinc-100 flex items-center justify-between">
130
+ <div className="flex items-center gap-2">
131
+ <h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
132
+ Activity
133
+ </h2>
134
+ {events.length > 0 && (
135
+ <span className="px-1.5 py-0.5 bg-emerald-50 text-emerald-600 rounded-full text-[10px] font-medium">
136
+ {events.length}
137
+ </span>
138
+ )}
139
+ </div>
140
+ <button
141
+ onClick={onClose}
142
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors rounded-full hover:bg-zinc-100"
143
+ >
144
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
145
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
146
+ </svg>
147
+ </button>
148
+ </div>
149
+
150
+ {/* Search */}
151
+ <div className="px-4 pt-3 pb-2 shrink-0">
152
+ <div className="flex items-center bg-zinc-50/80 rounded-full border border-zinc-200/60 overflow-hidden">
153
+ <div className="pl-3 pr-1 text-zinc-400">
154
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
155
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
156
+ </svg>
157
+ </div>
158
+ <input
159
+ type="text"
160
+ value={search}
161
+ onChange={(e) => setSearch(e.target.value)}
162
+ placeholder="Search activity..."
163
+ className="flex-1 px-2 py-2 text-xs text-zinc-800 bg-transparent outline-none placeholder:text-zinc-400"
164
+ />
165
+ {search && (
166
+ <button
167
+ onClick={() => setSearch("")}
168
+ className="pr-3 text-zinc-400 hover:text-zinc-600"
169
+ >
170
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
171
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
172
+ </svg>
173
+ </button>
174
+ )}
175
+ </div>
176
+ </div>
177
+
178
+ {/* Filter chips */}
179
+ <div className="flex flex-wrap gap-1.5 px-4 pb-3 shrink-0">
180
+ {FILTER_CATEGORIES.map((cat) => {
181
+ const isActive = activeFilters.has(cat.key);
182
+ return (
183
+ <button
184
+ key={cat.key}
185
+ onClick={() => toggleFilter(cat.key)}
186
+ className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-all ${
187
+ isActive
188
+ ? "bg-emerald-50 text-emerald-700 border-emerald-200 shadow-sm"
189
+ : "bg-white text-zinc-400 border-zinc-200 hover:text-zinc-600 hover:border-zinc-300"
190
+ }`}
191
+ >
192
+ {cat.label}
193
+ </button>
194
+ );
195
+ })}
196
+ </div>
197
+
198
+ {/* Event list with time groups */}
199
+ <div className="flex-1 overflow-y-auto custom-scrollbar border-t border-zinc-100">
200
+ {filteredEvents.length === 0 ? (
201
+ <div className="flex flex-col items-center justify-center py-12 text-center">
202
+ <svg
203
+ className="w-8 h-8 text-zinc-200 mb-3"
204
+ fill="none"
205
+ viewBox="0 0 24 24"
206
+ strokeWidth={1.5}
207
+ stroke="currentColor"
208
+ >
209
+ <path
210
+ strokeLinecap="round"
211
+ strokeLinejoin="round"
212
+ d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
213
+ />
214
+ </svg>
215
+ <p className="text-xs text-zinc-400">
216
+ {search.trim()
217
+ ? "No activity matching your search"
218
+ : "No activity yet"}
219
+ </p>
220
+ {search.trim() && (
221
+ <button
222
+ onClick={() => setSearch("")}
223
+ className="text-[11px] text-emerald-600 hover:text-emerald-700 mt-1.5"
224
+ >
225
+ Clear search
226
+ </button>
227
+ )}
228
+ </div>
229
+ ) : (
230
+ groupedEvents.map((group) => (
231
+ <div key={group.label}>
232
+ {/* Time group header */}
233
+ <div className="sticky top-0 z-[1] px-4 py-1.5 bg-zinc-50/95 backdrop-blur-sm border-b border-zinc-100/60">
234
+ <span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
235
+ {group.label}
236
+ </span>
237
+ </div>
238
+ {/* Events in this group */}
239
+ <div className="divide-y divide-zinc-50">
240
+ {group.events.map((event) => (
241
+ <ActivityItem
242
+ key={event.id}
243
+ event={event}
244
+ variant="full"
245
+ onNodeClick={onNodeClick}
246
+ />
247
+ ))}
248
+ </div>
249
+ </div>
250
+ ))
251
+ )}
252
+ </div>
253
+
254
+ {/* Footer stats */}
255
+ <div className="shrink-0 px-5 py-2.5 border-t border-zinc-100 bg-zinc-50/50">
256
+ <div className="text-[10px] text-zinc-400">
257
+ {filteredEvents.length} event{filteredEvents.length !== 1 ? "s" : ""}
258
+ {!allActive && ` (filtered)`}
259
+ {search.trim() && ` matching "${search.trim()}"`}
260
+ </div>
261
+ </div>
262
+ </aside>
263
+
264
+ {/* Mobile drawer */}
265
+ <div
266
+ className={`md:hidden fixed inset-x-0 bottom-0 z-20 transition-transform duration-300 ease-out ${
267
+ isOpen ? "translate-y-0" : "translate-y-full"
268
+ }`}
269
+ >
270
+ <div className="bg-white rounded-t-2xl shadow-xl border-t border-zinc-200 max-h-[60vh] flex flex-col">
271
+ {/* Drag handle */}
272
+ <div className="flex justify-center pt-3 pb-1">
273
+ <div className="w-8 h-1 bg-zinc-300 rounded-full" />
274
+ </div>
275
+
276
+ {/* Header */}
277
+ <div className="flex items-center justify-between px-5 py-2">
278
+ <div className="flex items-center gap-2">
279
+ <h2 className="text-sm font-semibold text-zinc-900">Activity</h2>
280
+ {events.length > 0 && (
281
+ <span className="px-1.5 py-0.5 bg-emerald-50 text-emerald-600 rounded-full text-[10px] font-medium">
282
+ {events.length}
283
+ </span>
284
+ )}
285
+ </div>
286
+ <button
287
+ onClick={onClose}
288
+ className="p-1 text-zinc-400 hover:text-zinc-600"
289
+ >
290
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
291
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
292
+ </svg>
293
+ </button>
294
+ </div>
295
+
296
+ {/* Filter chips */}
297
+ <div className="flex flex-wrap gap-1.5 px-5 pb-3">
298
+ {FILTER_CATEGORIES.map((cat) => (
299
+ <button
300
+ key={cat.key}
301
+ onClick={() => toggleFilter(cat.key)}
302
+ className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
303
+ activeFilters.has(cat.key)
304
+ ? "bg-emerald-50 text-emerald-700 border-emerald-200"
305
+ : "bg-white text-zinc-400 border-zinc-200"
306
+ }`}
307
+ >
308
+ {cat.label}
309
+ </button>
310
+ ))}
311
+ </div>
312
+
313
+ {/* Event list */}
314
+ <div className="flex-1 overflow-y-auto custom-scrollbar">
315
+ {filteredEvents.length === 0 ? (
316
+ <div className="py-8 text-center text-xs text-zinc-400">
317
+ No activity matching filters
318
+ </div>
319
+ ) : (
320
+ groupedEvents.map((group) => (
321
+ <div key={group.label}>
322
+ <div className="sticky top-0 z-[1] px-5 py-1.5 bg-zinc-50/95 backdrop-blur-sm border-b border-zinc-100/60">
323
+ <span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
324
+ {group.label}
325
+ </span>
326
+ </div>
327
+ <div className="divide-y divide-zinc-50">
328
+ {group.events.map((event) => (
329
+ <ActivityItem
330
+ key={event.id}
331
+ event={event}
332
+ variant="full"
333
+ onNodeClick={onNodeClick}
334
+ />
335
+ ))}
336
+ </div>
337
+ </div>
338
+ ))
339
+ )}
340
+ </div>
341
+ </div>
342
+ </div>
343
+ </>
344
+ );
345
+ }
@@ -46,14 +46,14 @@ export default function AllCommentsPanel({
46
46
  </div>
47
47
  <button
48
48
  onClick={onClose}
49
- className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
49
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors rounded-full hover:bg-zinc-100"
50
50
  >
51
51
  <svg
52
52
  className="w-4 h-4"
53
53
  fill="none"
54
54
  stroke="currentColor"
55
55
  viewBox="0 0 24 24"
56
- strokeWidth={2}
56
+ strokeWidth={1.5}
57
57
  >
58
58
  <path
59
59
  strokeLinecap="round"
@@ -197,11 +197,16 @@ function AllCommentCard({
197
197
  </div>
198
198
  )}
199
199
  </div>
200
- <span className="text-xs font-medium text-zinc-600 truncate">
200
+ <a
201
+ href={`https://www.impactindexer.org/data?did=${comment.did}`}
202
+ target="_blank"
203
+ rel="noopener noreferrer"
204
+ className="text-xs font-medium text-zinc-600 truncate hover:text-emerald-600 transition-colors"
205
+ >
201
206
  {comment.displayName ||
202
207
  comment.handle ||
203
208
  comment.did.slice(0, 16) + "..."}
204
- </span>
209
+ </a>
205
210
  <span className="text-[10px] text-zinc-300 shrink-0">
206
211
  {formatRelativeTime(comment.createdAt)}
207
212
  </span>
@@ -83,9 +83,14 @@ export function AuthButton() {
83
83
 
84
84
  {showDropdown && (
85
85
  <div className="absolute right-0 top-full mt-2 w-44 bg-white rounded-xl shadow-xl border border-zinc-100 py-2 z-50">
86
- <div className="px-3 py-1.5 text-xs text-zinc-400 truncate">
86
+ <a
87
+ href={session.did ? `https://www.impactindexer.org/data?did=${session.did}` : "#"}
88
+ target="_blank"
89
+ rel="noopener noreferrer"
90
+ className="block px-3 py-1.5 text-xs text-zinc-400 truncate hover:text-emerald-600 transition-colors"
91
+ >
87
92
  @{session.handle}
88
- </div>
93
+ </a>
89
94
  <div className="h-px bg-zinc-100 my-1" />
90
95
  <button
91
96
  onClick={handleLogout}
@@ -35,10 +35,12 @@ interface BeadsGraphProps {
35
35
  onBackgroundClick: () => void;
36
36
  onNodeRightClick?: (node: GraphNode, event: MouseEvent) => void;
37
37
  commentedNodeIds?: Map<string, number>;
38
- claimedNodeAvatars?: Map<string, { avatar?: string; handle: string; claimedAt: string }>;
39
- onAvatarHover?: (info: { handle: string; avatar?: string; claimedAt: string; x: number; y: number } | null) => void;
38
+ claimedNodeAvatars?: Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string }>;
39
+ onAvatarHover?: (info: { handle: string; avatar?: string; claimedAt: string; did?: string; x: number; y: number } | null) => void;
40
40
  timelineActive?: boolean;
41
41
  stats?: { total: number; edges: number; prefixes: string[] };
42
+ /** When a right sidebar (NodeDetail, Comments, Activity) is open, shift bottom-right legend inward */
43
+ sidebarOpen?: boolean;
42
44
  }
43
45
 
44
46
  // Node size calculation
@@ -205,6 +207,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
205
207
  onAvatarHover,
206
208
  timelineActive,
207
209
  stats,
210
+ sidebarOpen,
208
211
  }, ref) {
209
212
  const graphRef = useRef<any>(null);
210
213
  const containerRef = useRef<HTMLDivElement>(null);
@@ -255,7 +258,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
255
258
  const hoveredNodeRef = useRef<GraphNode | null>(hoveredNode);
256
259
  const connectedNodesRef = useRef<Set<string>>(new Set());
257
260
  const commentedNodeIdsRef = useRef<Map<string, number>>(commentedNodeIds || new Map());
258
- const claimedNodeAvatarsRef = useRef<Map<string, { avatar?: string; handle: string; claimedAt: string }>>(
261
+ const claimedNodeAvatarsRef = useRef<Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string }>>(
259
262
  claimedNodeAvatars || new Map()
260
263
  );
261
264
  // Callback ref for refreshing graph when avatar images finish loading
@@ -503,7 +506,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
503
506
  if (dx * dx + dy * dy <= avatarRadius * avatarRadius) {
504
507
  if (hoveredAvatarNodeRef.current !== node.id) {
505
508
  hoveredAvatarNodeRef.current = node.id;
506
- cb({ handle: claim.handle, avatar: claim.avatar, claimedAt: claim.claimedAt, x: e.clientX, y: e.clientY });
509
+ cb({ handle: claim.handle, avatar: claim.avatar, claimedAt: claim.claimedAt, did: claim.did, x: e.clientX, y: e.clientY });
507
510
  }
508
511
  return;
509
512
  }
@@ -1699,7 +1702,10 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1699
1702
 
1700
1703
  {/* Bottom-right info panel: stats + status colors + legend (hidden when timeline active) */}
1701
1704
  {!timelineActive && (
1702
- <div className="absolute bottom-4 right-4 z-10 bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm px-3 py-2 text-xs text-zinc-400">
1705
+ <div
1706
+ className="absolute bottom-4 z-10 bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm px-3 py-2 text-xs text-zinc-400 transition-[right] duration-300 ease-out"
1707
+ style={{ right: sidebarOpen ? "calc(360px + 1rem)" : "1rem" }}
1708
+ >
1703
1709
  {stats && (
1704
1710
  <div className="text-zinc-500 mb-1.5">
1705
1711
  <strong className="text-zinc-700">{stats.total}</strong> issues
@@ -206,9 +206,14 @@ export function CommentTooltip({
206
206
  )}
207
207
  </div>
208
208
  <div className="flex-1 min-w-0">
209
- <span className="text-[10px] font-medium text-zinc-500">
209
+ <a
210
+ href={`https://www.impactindexer.org/data?did=${comment.did}`}
211
+ target="_blank"
212
+ rel="noopener noreferrer"
213
+ className="text-[10px] font-medium text-zinc-500 hover:text-emerald-600 transition-colors"
214
+ >
210
215
  {comment.displayName || comment.handle}
211
- </span>
216
+ </a>
212
217
  <p className="text-[11px] text-zinc-500 line-clamp-2 leading-tight">
213
218
  {comment.text}
214
219
  </p>
@@ -445,9 +445,14 @@ function InlineReplyForm({
445
445
  <div className="mt-2 ml-4 pl-3 border-l border-emerald-200 space-y-1.5">
446
446
  <div className="flex items-center gap-1.5 text-[10px] text-zinc-400">
447
447
  <span>Replying to</span>
448
- <span className="font-medium text-zinc-600">
448
+ <a
449
+ href={`https://www.impactindexer.org/data?did=${replyingTo.did}`}
450
+ target="_blank"
451
+ rel="noopener noreferrer"
452
+ className="font-medium text-zinc-600 hover:text-emerald-600 transition-colors"
453
+ >
449
454
  {replyingTo.displayName || replyingTo.handle}
450
- </span>
455
+ </a>
451
456
  </div>
452
457
  <div className="flex gap-2">
453
458
  <input
@@ -614,11 +619,16 @@ function CommentItem({
614
619
  </div>
615
620
  )}
616
621
  </div>
617
- <span className="text-xs font-medium text-zinc-600 truncate">
622
+ <a
623
+ href={`https://www.impactindexer.org/data?did=${comment.did}`}
624
+ target="_blank"
625
+ rel="noopener noreferrer"
626
+ className="text-xs font-medium text-zinc-600 truncate hover:text-emerald-600 transition-colors"
627
+ >
618
628
  {comment.displayName ||
619
629
  comment.handle ||
620
630
  comment.did.slice(0, 16) + "..."}
621
- </span>
631
+ </a>
622
632
  <span className="text-[10px] text-zinc-300 shrink-0">
623
633
  {formatRelativeTime(comment.createdAt)}
624
634
  </span>