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
package/app/page.tsx CHANGED
@@ -14,9 +14,17 @@ import { CommentTooltip } from "@/components/CommentTooltip";
14
14
  import { ContextMenu } from "@/components/ContextMenu";
15
15
  import { DescriptionModal } from "@/components/DescriptionModal";
16
16
  import AllCommentsPanel from "@/components/AllCommentsPanel";
17
+ import { ActivityOverlay } from "@/components/ActivityOverlay";
18
+ import { ActivityPanel } from "@/components/ActivityPanel";
17
19
  import { useBeadsComments } from "@/hooks/useBeadsComments";
18
20
  import type { BeadsComment } from "@/hooks/useBeadsComments";
19
21
  import { useAuth } from "@/lib/auth";
22
+ import {
23
+ buildHistoricalFeed,
24
+ diffToActivityEvents,
25
+ mergeFeedEvents,
26
+ } from "@/lib/activity";
27
+ import type { ActivityEvent } from "@/lib/activity";
20
28
  import { buildTimelineEvents, filterDataAtTime } from "@/lib/timeline";
21
29
  import type { TimelineRange } from "@/lib/timeline";
22
30
  import TimelineBar from "@/components/TimelineBar";
@@ -231,6 +239,22 @@ export default function Home() {
231
239
  // All Comments panel state
232
240
  const [allCommentsPanelOpen, setAllCommentsPanelOpen] = useState(false);
233
241
 
242
+ // Activity feed state
243
+ const [activityFeed, setActivityFeed] = useState<ActivityEvent[]>([]);
244
+ const [activityPanelOpen, setActivityPanelOpen] = useState(false);
245
+ const [activityOverlayCollapsed, setActivityOverlayCollapsed] = useState(false);
246
+
247
+ // Rebuild historical feed when data or comments change
248
+ useEffect(() => {
249
+ if (!data) return;
250
+ const historical = buildHistoricalFeed(
251
+ data.graphData.nodes,
252
+ data.graphData.links,
253
+ allComments
254
+ );
255
+ setActivityFeed((prev) => mergeFeedEvents(prev, historical));
256
+ }, [data, allComments]);
257
+
234
258
  // Context menu state for right-click (phase 1: shows ContextMenu)
235
259
  const [contextMenu, setContextMenu] = useState<{
236
260
  node: GraphNode;
@@ -254,6 +278,7 @@ export default function Home() {
254
278
  handle: string;
255
279
  avatar?: string;
256
280
  claimedAt: string;
281
+ did?: string;
257
282
  x: number;
258
283
  y: number;
259
284
  } | null>(null);
@@ -308,6 +333,12 @@ export default function Home() {
308
333
 
309
334
  if (!diff.hasChanges) return; // No-op if nothing changed
310
335
 
336
+ // Append real-time activity events from the diff
337
+ const diffEvents = diffToActivityEvents(diff, newData.graphData.nodes);
338
+ if (diffEvents.length > 0) {
339
+ setActivityFeed((prev) => mergeFeedEvents(prev, diffEvents));
340
+ }
341
+
311
342
  // Merge: stamp animation metadata and preserve positions
312
343
  const mergedData = mergeBeadsData(oldData, newData, diff);
313
344
  prevDataRef.current = mergedData;
@@ -482,6 +513,7 @@ export default function Home() {
482
513
  const handleNodeClick = useCallback((node: GraphNode) => {
483
514
  setSelectedNode((prev) => (prev?.id === node.id ? null : node));
484
515
  setAllCommentsPanelOpen(false);
516
+ setActivityPanelOpen(false);
485
517
  }, []);
486
518
 
487
519
  const handleNodeHover = useCallback((node: GraphNode | null) => {
@@ -858,7 +890,7 @@ export default function Home() {
858
890
  {projectName}
859
891
  </h1>
860
892
  <span className="font-normal text-zinc-400 text-[15px] hidden sm:inline">
861
- Beads
893
+ heartbeads
862
894
  </span>
863
895
  {repoCount > 1 && (
864
896
  <span className="text-[10px] text-zinc-400 bg-zinc-100 rounded-full px-1.5 py-0.5 font-medium hidden sm:inline">
@@ -1051,7 +1083,10 @@ export default function Home() {
1051
1083
  <button
1052
1084
  onClick={() => {
1053
1085
  setAllCommentsPanelOpen((prev) => !prev);
1054
- if (!allCommentsPanelOpen) setSelectedNode(null);
1086
+ if (!allCommentsPanelOpen) {
1087
+ setSelectedNode(null);
1088
+ setActivityPanelOpen(false);
1089
+ }
1055
1090
  }}
1056
1091
  className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
1057
1092
  allCommentsPanelOpen
@@ -1074,6 +1109,33 @@ export default function Home() {
1074
1109
  </svg>
1075
1110
  <span className="hidden sm:inline">Comments</span>
1076
1111
  </button>
1112
+ {/* Activity pill */}
1113
+ <button
1114
+ onClick={() => {
1115
+ setActivityPanelOpen((prev) => !prev);
1116
+ if (!activityPanelOpen) {
1117
+ setSelectedNode(null);
1118
+ setAllCommentsPanelOpen(false);
1119
+ }
1120
+ }}
1121
+ className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
1122
+ activityPanelOpen
1123
+ ? "text-emerald-700 bg-emerald-50"
1124
+ : "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50"
1125
+ }`}
1126
+ >
1127
+ <svg
1128
+ className="w-3.5 h-3.5"
1129
+ viewBox="0 0 16 16"
1130
+ fill="none"
1131
+ stroke="currentColor"
1132
+ strokeWidth="1.5"
1133
+ >
1134
+ <circle cx="8" cy="8" r="6" />
1135
+ <polyline points="8,4 8,8 11,10" />
1136
+ </svg>
1137
+ <span className="hidden sm:inline">Activity</span>
1138
+ </button>
1077
1139
  <div className="w-px h-5 bg-zinc-200 mx-2" />
1078
1140
  <AuthButton />
1079
1141
  </div>
@@ -1109,11 +1171,15 @@ export default function Home() {
1109
1171
  onAvatarHover={setAvatarTooltip}
1110
1172
  timelineActive={timelineActive}
1111
1173
  stats={data.stats}
1174
+ sidebarOpen={!!selectedNode || allCommentsPanelOpen || activityPanelOpen}
1112
1175
  />
1113
1176
 
1114
1177
  {/* Timeline bar — replaces legend hint when active */}
1115
1178
  {timelineActive && timelineRange && timelineRange.events.length > 0 && (
1116
- <div className="absolute bottom-4 right-4 z-10">
1179
+ <div
1180
+ className="absolute bottom-4 z-10 transition-[right] duration-300 ease-out"
1181
+ style={{ right: selectedNode || allCommentsPanelOpen || activityPanelOpen ? "calc(360px + 1rem)" : "1rem" }}
1182
+ >
1117
1183
  <TimelineBar
1118
1184
  totalSteps={timelineRange.events.length}
1119
1185
  currentStep={Math.max(timelineStep, 0)}
@@ -1127,6 +1193,26 @@ export default function Home() {
1127
1193
  </div>
1128
1194
  )}
1129
1195
 
1196
+ {/* Activity overlay — top-right of canvas */}
1197
+ {!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !timelineActive && (
1198
+ <div className="absolute top-3 right-3 sm:top-4 sm:right-4 z-10">
1199
+ <ActivityOverlay
1200
+ events={activityFeed}
1201
+ collapsed={activityOverlayCollapsed}
1202
+ onToggleCollapse={() => setActivityOverlayCollapsed((prev) => !prev)}
1203
+ onExpandPanel={() => {
1204
+ setActivityPanelOpen(true);
1205
+ setSelectedNode(null);
1206
+ setAllCommentsPanelOpen(false);
1207
+ }}
1208
+ onNodeClick={(nodeId) => {
1209
+ const node = data?.graphData.nodes.find((n) => n.id === nodeId);
1210
+ if (node) focusNode(node);
1211
+ }}
1212
+ />
1213
+ </div>
1214
+ )}
1215
+
1130
1216
  {/* Right-click context menu */}
1131
1217
  {contextMenu && (
1132
1218
  <ContextMenu
@@ -1222,7 +1308,19 @@ export default function Home() {
1222
1308
  )}
1223
1309
  <div className="flex flex-col">
1224
1310
  <span className="text-xs text-zinc-700 whitespace-nowrap">
1225
- <span className="font-semibold text-zinc-800">{avatarTooltip.handle}</span> claimed this task
1311
+ {avatarTooltip.did ? (
1312
+ <a
1313
+ href={`https://www.impactindexer.org/data?did=${avatarTooltip.did}`}
1314
+ target="_blank"
1315
+ rel="noopener noreferrer"
1316
+ className="font-semibold text-zinc-800 hover:text-emerald-600 transition-colors"
1317
+ style={{ pointerEvents: "auto" }}
1318
+ >
1319
+ {avatarTooltip.handle}
1320
+ </a>
1321
+ ) : (
1322
+ <span className="font-semibold text-zinc-800">{avatarTooltip.handle}</span>
1323
+ )} claimed this task
1226
1324
  </span>
1227
1325
  <span className="text-[10px] text-zinc-400">
1228
1326
  {formatRelativeTime(avatarTooltip.claimedAt)}
@@ -1249,14 +1347,14 @@ export default function Home() {
1249
1347
  onClick={() => {
1250
1348
  setSelectedNode(null);
1251
1349
  }}
1252
- className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
1350
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors rounded-full hover:bg-zinc-100"
1253
1351
  >
1254
1352
  <svg
1255
1353
  className="w-4 h-4"
1256
1354
  fill="none"
1257
1355
  stroke="currentColor"
1258
1356
  viewBox="0 0 24 24"
1259
- strokeWidth={2}
1357
+ strokeWidth={1.5}
1260
1358
  >
1261
1359
  <path
1262
1360
  strokeLinecap="round"
@@ -1368,6 +1466,20 @@ export default function Home() {
1368
1466
  onLikeComment={handleLikeComment}
1369
1467
  onDeleteComment={handleDeleteComment}
1370
1468
  />
1469
+
1470
+ {/* Activity panel */}
1471
+ <ActivityPanel
1472
+ events={activityFeed}
1473
+ isOpen={activityPanelOpen}
1474
+ onClose={() => setActivityPanelOpen(false)}
1475
+ onNodeClick={(nodeId) => {
1476
+ const node = data?.graphData.nodes.find((n) => n.id === nodeId);
1477
+ if (node) {
1478
+ focusNode(node);
1479
+ setActivityPanelOpen(false);
1480
+ }
1481
+ }}
1482
+ />
1371
1483
  </div>
1372
1484
  </div>
1373
1485
  );
package/bin/beads-map.mjs CHANGED
@@ -10,6 +10,38 @@ import { fileURLToPath } from "url";
10
10
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
11
11
  const viewerRoot = resolve(__dirname, "..");
12
12
 
13
+ // Load .env.local and .env from the user's working directory.
14
+ // Next.js only loads these from its own cwd (the package dir), not from where
15
+ // the user invoked the CLI. This ensures PUBLIC_URL, ATPROTO_JWK_PRIVATE, etc.
16
+ // are available when running `npx beads-map` from a project directory.
17
+ for (const envFile of [".env.local", ".env"]) {
18
+ const envPath = join(process.cwd(), envFile);
19
+ if (existsSync(envPath)) {
20
+ try {
21
+ const content = readFileSync(envPath, "utf-8");
22
+ for (const line of content.split("\n")) {
23
+ const trimmed = line.trim();
24
+ if (!trimmed || trimmed.startsWith("#")) continue;
25
+ const eqIdx = trimmed.indexOf("=");
26
+ if (eqIdx === -1) continue;
27
+ const key = trimmed.slice(0, eqIdx).trim();
28
+ let val = trimmed.slice(eqIdx + 1).trim();
29
+ // Strip surrounding quotes
30
+ if ((val.startsWith('"') && val.endsWith('"')) ||
31
+ (val.startsWith("'") && val.endsWith("'"))) {
32
+ val = val.slice(1, -1);
33
+ }
34
+ // Don't override existing env vars (explicit shell vars win)
35
+ if (!(key in process.env)) {
36
+ process.env[key] = val;
37
+ }
38
+ }
39
+ } catch {
40
+ // ignore read errors
41
+ }
42
+ }
43
+ }
44
+
13
45
  // Parse CLI args
14
46
  const args = process.argv.slice(2);
15
47
  let port = 3000;
@@ -0,0 +1,326 @@
1
+ "use client";
2
+
3
+ import type { ActivityEvent, ActivityEventType } from "@/lib/activity";
4
+ import { formatRelativeTime } from "@/lib/utils";
5
+
6
+ // ============================================================================
7
+ // Event type icons (inline SVG for each category)
8
+ // ============================================================================
9
+
10
+ function EventIcon({ type, className }: { type: ActivityEventType; className?: string }) {
11
+ const cls = className || "w-3.5 h-3.5 shrink-0";
12
+
13
+ switch (type) {
14
+ case "node-created":
15
+ return (
16
+ <svg className={`${cls} text-emerald-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
17
+ <circle cx="8" cy="8" r="6" />
18
+ <line x1="8" y1="5" x2="8" y2="11" />
19
+ <line x1="5" y1="8" x2="11" y2="8" />
20
+ </svg>
21
+ );
22
+ case "node-closed":
23
+ return (
24
+ <svg className={`${cls} text-zinc-400`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
25
+ <circle cx="8" cy="8" r="6" />
26
+ <polyline points="5.5,8 7.5,10 10.5,6" />
27
+ </svg>
28
+ );
29
+ case "node-status-changed":
30
+ return (
31
+ <svg className={`${cls} text-amber-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
32
+ <path d="M3 8h10M10 5l3 3-3 3" />
33
+ </svg>
34
+ );
35
+ case "node-priority-changed":
36
+ return (
37
+ <svg className={`${cls} text-amber-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
38
+ <path d="M8 3v7M5 6l3-3 3 3M4 13h8" />
39
+ </svg>
40
+ );
41
+ case "node-title-changed":
42
+ return (
43
+ <svg className={`${cls} text-amber-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
44
+ <path d="M3 4h10M3 8h6M3 12h8" />
45
+ </svg>
46
+ );
47
+ case "node-owner-changed":
48
+ return (
49
+ <svg className={`${cls} text-amber-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
50
+ <circle cx="6" cy="6" r="2.5" />
51
+ <path d="M1.5 13c0-2 1.8-3.5 4.5-3.5M10 9l2 2 3-3" />
52
+ </svg>
53
+ );
54
+ case "link-added":
55
+ return (
56
+ <svg className={`${cls} text-blue-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
57
+ <path d="M6.5 9.5l3-3M4 11a2.5 2.5 0 003.5 0l1-1M8.5 6a2.5 2.5 0 013.5 0l0 0" />
58
+ </svg>
59
+ );
60
+ case "link-removed":
61
+ return (
62
+ <svg className={`${cls} text-red-400`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
63
+ <path d="M4 11a2.5 2.5 0 003.5 0l1-1M8.5 6a2.5 2.5 0 013.5 0l0 0" />
64
+ <line x1="3" y1="3" x2="13" y2="13" strokeWidth="1.5" />
65
+ </svg>
66
+ );
67
+ case "comment-added":
68
+ case "reply-added":
69
+ return (
70
+ <svg className={`${cls} text-blue-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
71
+ <path d="M2 4.5a1.5 1.5 0 011.5-1.5h9A1.5 1.5 0 0114 4.5v5a1.5 1.5 0 01-1.5 1.5H5L2 14V4.5z" />
72
+ </svg>
73
+ );
74
+ case "task-claimed":
75
+ return (
76
+ <svg className={`${cls} text-emerald-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
77
+ <circle cx="8" cy="5.5" r="2.5" />
78
+ <path d="M3 13.5c0-2.5 2.2-4.5 5-4.5s5 2 5 4.5" />
79
+ </svg>
80
+ );
81
+ case "task-unclaimed":
82
+ return (
83
+ <svg className={`${cls} text-red-400`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
84
+ <circle cx="8" cy="5.5" r="2.5" />
85
+ <path d="M3 13.5c0-2.5 2.2-4.5 5-4.5s5 2 5 4.5" />
86
+ <line x1="11" y1="3" x2="13" y2="5" />
87
+ </svg>
88
+ );
89
+ case "like-added":
90
+ return (
91
+ <svg className={`${cls} text-rose-400`} viewBox="0 0 16 16" fill="currentColor">
92
+ <path d="M8 13.7l-.6-.5C4 10.2 2 8.3 2 6a3 3 0 016-1 3 3 0 016 1c0 2.3-2 4.2-5.4 7.2l-.6.5z" />
93
+ </svg>
94
+ );
95
+ }
96
+ }
97
+
98
+ // ============================================================================
99
+ // Event type -> accent color for left border/indicator
100
+ // ============================================================================
101
+
102
+ function getEventAccentColor(type: ActivityEventType): string {
103
+ switch (type) {
104
+ case "node-created":
105
+ case "task-claimed":
106
+ return "bg-emerald-400";
107
+ case "node-closed":
108
+ return "bg-zinc-300";
109
+ case "node-status-changed":
110
+ case "node-priority-changed":
111
+ case "node-title-changed":
112
+ case "node-owner-changed":
113
+ return "bg-amber-400";
114
+ case "link-added":
115
+ case "comment-added":
116
+ case "reply-added":
117
+ return "bg-blue-400";
118
+ case "link-removed":
119
+ case "task-unclaimed":
120
+ return "bg-red-300";
121
+ case "like-added":
122
+ return "bg-rose-300";
123
+ }
124
+ }
125
+
126
+ // ============================================================================
127
+ // Event description text
128
+ // ============================================================================
129
+
130
+ function describeEvent(event: ActivityEvent): string {
131
+ switch (event.type) {
132
+ case "node-created": {
133
+ const typeLabel = event.meta?.issueType || "task";
134
+ return `${typeLabel} created`;
135
+ }
136
+ case "node-closed":
137
+ return `closed${event.detail && event.detail !== "Closed" ? ` (${event.detail})` : ""}`;
138
+ case "node-status-changed":
139
+ return event.detail || "status changed";
140
+ case "node-priority-changed":
141
+ return `priority ${event.detail || "changed"}`;
142
+ case "node-title-changed":
143
+ return "title updated";
144
+ case "node-owner-changed":
145
+ return `owner ${event.detail || "changed"}`;
146
+ case "link-added":
147
+ return `dep added: ${event.meta?.target || ""}`;
148
+ case "link-removed":
149
+ return `dep removed: ${event.meta?.target || ""}`;
150
+ case "comment-added":
151
+ return event.detail || "commented";
152
+ case "reply-added":
153
+ return event.detail || "replied";
154
+ case "task-claimed":
155
+ return "claimed this task";
156
+ case "task-unclaimed":
157
+ return "unclaimed this task";
158
+ case "like-added":
159
+ return event.detail || "liked a comment";
160
+ }
161
+ }
162
+
163
+ // ============================================================================
164
+ // Compact variant (single-line, for overlay) — with left accent bar
165
+ // ============================================================================
166
+
167
+ function CompactItem({
168
+ event,
169
+ onNodeClick,
170
+ }: {
171
+ event: ActivityEvent;
172
+ onNodeClick?: (nodeId: string) => void;
173
+ }) {
174
+ return (
175
+ <div className="flex items-center gap-2 py-2 px-3 group hover:bg-zinc-50/60 transition-colors">
176
+ {/* Left accent dot */}
177
+ <div className={`w-1 h-1 rounded-full shrink-0 ${getEventAccentColor(event.type)}`} />
178
+
179
+ {/* Icon */}
180
+ <EventIcon type={event.type} className="w-3 h-3 shrink-0" />
181
+
182
+ {/* Content */}
183
+ <div className="flex-1 min-w-0 text-[11px] text-zinc-600 truncate">
184
+ {event.actor ? (
185
+ <>
186
+ {event.actor.did ? (
187
+ <a
188
+ href={`https://www.impactindexer.org/data?did=${event.actor.did}`}
189
+ target="_blank"
190
+ rel="noopener noreferrer"
191
+ className="font-medium text-zinc-800 hover:text-emerald-600 transition-colors"
192
+ >
193
+ {event.actor.handle.split(".")[0]}
194
+ </a>
195
+ ) : (
196
+ <span className="font-medium text-zinc-800">
197
+ {event.actor.handle.split(".")[0]}
198
+ </span>
199
+ )}
200
+ {" "}
201
+ </>
202
+ ) : onNodeClick ? (
203
+ <>
204
+ <button
205
+ onClick={() => onNodeClick(event.nodeId)}
206
+ className="font-medium text-zinc-700 hover:text-emerald-600 transition-colors"
207
+ >
208
+ {event.nodeId}
209
+ </button>
210
+ {" "}
211
+ </>
212
+ ) : null}
213
+ <span className="text-zinc-500">{describeEvent(event)}</span>
214
+ </div>
215
+
216
+ {/* Timestamp */}
217
+ <span className="text-[10px] text-zinc-400 shrink-0 tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
218
+ {formatRelativeTime(new Date(event.time).toISOString())}
219
+ </span>
220
+ </div>
221
+ );
222
+ }
223
+
224
+ // ============================================================================
225
+ // Full variant (rich, for panel) — with left accent bar and richer layout
226
+ // ============================================================================
227
+
228
+ function FullItem({
229
+ event,
230
+ onNodeClick,
231
+ }: {
232
+ event: ActivityEvent;
233
+ onNodeClick?: (nodeId: string) => void;
234
+ }) {
235
+ return (
236
+ <div className="flex gap-3 py-3 px-4 hover:bg-zinc-50/50 transition-colors group">
237
+ {/* Left accent bar */}
238
+ <div className={`w-0.5 self-stretch rounded-full shrink-0 ${getEventAccentColor(event.type)}`} />
239
+
240
+ {/* Avatar or icon */}
241
+ <div className="mt-0.5 shrink-0">
242
+ {event.actor?.avatar ? (
243
+ /* eslint-disable-next-line @next/next/no-img-element */
244
+ <img
245
+ src={event.actor.avatar}
246
+ alt={event.actor.handle}
247
+ className="w-7 h-7 rounded-full ring-1 ring-zinc-100"
248
+ />
249
+ ) : event.actor ? (
250
+ <div className="w-7 h-7 rounded-full bg-zinc-100 flex items-center justify-center text-[11px] font-medium text-zinc-500">
251
+ {event.actor.handle.charAt(0).toUpperCase()}
252
+ </div>
253
+ ) : (
254
+ <div className="w-7 h-7 rounded-full bg-zinc-50 flex items-center justify-center">
255
+ <EventIcon type={event.type} className="w-3.5 h-3.5" />
256
+ </div>
257
+ )}
258
+ </div>
259
+
260
+ {/* Content */}
261
+ <div className="flex-1 min-w-0">
262
+ {/* Actor + action text */}
263
+ <div className="flex items-baseline gap-1.5 text-[12px] leading-snug">
264
+ {event.actor && (
265
+ event.actor.did ? (
266
+ <a
267
+ href={`https://www.impactindexer.org/data?did=${event.actor.did}`}
268
+ target="_blank"
269
+ rel="noopener noreferrer"
270
+ className="font-semibold text-zinc-800 hover:text-emerald-600 transition-colors truncate max-w-[140px]"
271
+ >
272
+ {event.actor.handle.split(".")[0]}
273
+ </a>
274
+ ) : (
275
+ <span className="font-semibold text-zinc-800 truncate max-w-[140px]">
276
+ {event.actor.handle.split(".")[0]}
277
+ </span>
278
+ )
279
+ )}
280
+ <span className="text-zinc-500">{describeEvent(event)}</span>
281
+ </div>
282
+
283
+ {/* Node ID pill + title */}
284
+ <div className="flex items-center gap-1.5 mt-1">
285
+ {onNodeClick && (
286
+ <button
287
+ onClick={() => onNodeClick(event.nodeId)}
288
+ className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono font-medium text-emerald-700 bg-emerald-50 rounded-md hover:bg-emerald-100 transition-colors"
289
+ >
290
+ {event.nodeId}
291
+ </button>
292
+ )}
293
+ {event.nodeTitle && (
294
+ <span className="text-[11px] text-zinc-400 truncate">
295
+ {event.nodeTitle}
296
+ </span>
297
+ )}
298
+ </div>
299
+ </div>
300
+
301
+ {/* Timestamp */}
302
+ <span className="text-[10px] text-zinc-400 shrink-0 tabular-nums mt-0.5">
303
+ {formatRelativeTime(new Date(event.time).toISOString())}
304
+ </span>
305
+ </div>
306
+ );
307
+ }
308
+
309
+ // ============================================================================
310
+ // Exported component
311
+ // ============================================================================
312
+
313
+ export function ActivityItem({
314
+ event,
315
+ variant = "compact",
316
+ onNodeClick,
317
+ }: {
318
+ event: ActivityEvent;
319
+ variant?: "compact" | "full";
320
+ onNodeClick?: (nodeId: string) => void;
321
+ }) {
322
+ if (variant === "compact") {
323
+ return <CompactItem event={event} onNodeClick={onNodeClick} />;
324
+ }
325
+ return <FullItem event={event} onNodeClick={onNodeClick} />;
326
+ }