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.
- package/.next/BUILD_ID +1 -1
- package/.next/app-build-manifest.json +2 -2
- package/.next/app-path-routes-manifest.json +1 -1
- package/.next/build-manifest.json +2 -2
- package/.next/next-minimal-server.js.nft.json +1 -1
- package/.next/next-server.js.nft.json +1 -1
- package/.next/prerender-manifest.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +1 -1
- package/.next/server/app/api/beads.body +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +2 -2
- package/.next/server/app/page.js +3 -3
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +6 -6
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/pages-manifest.json +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/app/page-13ee27a84e4a0c70.js +1 -0
- package/.next/static/css/dbf588b653aa4019.css +3 -0
- package/app/page.tsx +118 -6
- package/bin/beads-map.mjs +32 -0
- package/components/ActivityItem.tsx +326 -0
- package/components/ActivityOverlay.tsx +123 -0
- package/components/ActivityPanel.tsx +345 -0
- package/components/AllCommentsPanel.tsx +9 -4
- package/components/AuthButton.tsx +7 -2
- package/components/BeadsGraph.tsx +11 -5
- package/components/CommentTooltip.tsx +7 -2
- package/components/NodeDetail.tsx +14 -4
- package/lib/activity.ts +377 -0
- package/lib/diff-beads.ts +3 -0
- package/package.json +1 -1
- package/.next/static/chunks/app/page-f36cdcae49f1d2af.js +0 -1
- package/.next/static/css/a4e34aaaa51183d9.css +0 -3
- /package/.next/static/{YVdbDxCehgqcYmLncYRFB → dxp53pVl-eTmydUx_hpyJ}/_buildManifest.js +0 -0
- /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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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={
|
|
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
|
+
}
|