heartbeads 0.4.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 +49 -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 +9 -0
- package/.next/server/app/api/auth/route.js +1 -0
- package/.next/server/app/api/auth/route.js.nft.json +1 -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/docs/page.js +120 -0
- package/.next/server/app/api/docs/page.js.nft.json +1 -0
- package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
- package/.next/server/app/api/docs.html +120 -0
- package/.next/server/app/api/docs.meta +5 -0
- package/.next/server/app/api/docs.rsc +70 -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/api/v1/graph/route.js +1 -0
- package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/ready/route.js +1 -0
- package/.next/server/app/api/v1/ready/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 +9 -0
- package/.next/server/app/login/page.js +1 -0
- package/.next/server/app/login/page.js.nft.json +1 -0
- package/.next/server/app/login/page_client-reference-manifest.js +1 -0
- package/.next/server/app/login.html +1 -0
- package/.next/server/app/login.meta +5 -0
- package/.next/server/app/login.rsc +9 -0
- package/.next/server/app/opengraph-image.png/route.js +1 -0
- package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
- package/.next/server/app/opengraph-image.png.body +0 -0
- package/.next/server/app/opengraph-image.png.meta +1 -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/twitter-image.png/route.js +1 -0
- package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
- package/.next/server/app/twitter-image.png.body +0 -0
- package/.next/server/app/twitter-image.png.meta +1 -0
- package/.next/server/app-paths-manifest.json +22 -0
- package/.next/server/chunks/247.js +12 -0
- package/.next/server/chunks/29.js +1 -0
- package/.next/server/chunks/343.js +1 -0
- package/.next/server/chunks/460.js +12 -0
- package/.next/server/chunks/533.js +38 -0
- package/.next/server/chunks/542.js +27 -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/950.js +2 -0
- package/.next/server/chunks/font-manifest.json +1 -0
- package/.next/server/edge-runtime-webpack.js +2 -0
- package/.next/server/edge-runtime-webpack.js.map +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 +32 -0
- package/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/.next/server/middleware.js +14 -0
- package/.next/server/middleware.js.map +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/chunks/149.a3e3a5dc03e21086.js +1 -0
- package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
- package/.next/static/chunks/788-aa413085174e935a.js +1 -0
- package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
- package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
- package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
- package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
- package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
- package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
- package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
- package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
- package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
- package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
- package/.next/static/chunks/main-e680fb049d7426e1.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-117444a4bfe51057.js +1 -0
- package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
- package/README.md +389 -0
- package/app/api/auth/route.ts +103 -0
- package/app/api/beads/route.ts +27 -0
- package/app/api/beads/stream/route.ts +83 -0
- package/app/api/config/route.ts +48 -0
- package/app/api/docs/page.tsx +497 -0
- package/app/api/login/route.ts +42 -0
- package/app/api/logout/route.ts +14 -0
- package/app/api/oauth/callback/route.ts +97 -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/api/v1/graph/route.ts +251 -0
- package/app/api/v1/issues/[id]/route.ts +158 -0
- package/app/api/v1/ready/route.ts +229 -0
- package/app/globals.css +230 -0
- package/app/layout.tsx +51 -0
- package/app/login/page.tsx +164 -0
- package/app/not-found.tsx +91 -0
- package/app/opengraph-image.png +0 -0
- package/app/page.tsx +2041 -0
- package/app/twitter-image.png +0 -0
- package/bin/heartbeads.mjs +225 -0
- package/components/ActivityItem.tsx +326 -0
- package/components/ActivityOverlay.tsx +125 -0
- package/components/ActivityPanel.tsx +345 -0
- package/components/AllCommentsPanel.tsx +270 -0
- package/components/AuthButton.tsx +202 -0
- package/components/BeadTooltip.tsx +246 -0
- package/components/BeadsGraph.tsx +2493 -0
- package/components/BeadsLogo.tsx +94 -0
- package/components/CommentTooltip.tsx +338 -0
- package/components/ContextMenu.tsx +272 -0
- package/components/DescriptionModal.tsx +595 -0
- package/components/GraphStats.tsx +121 -0
- package/components/HeartIcon.tsx +33 -0
- package/components/HelpPanel.tsx +339 -0
- package/components/MobileActionSheet.tsx +255 -0
- package/components/NodeDetail.tsx +793 -0
- package/components/SettingsModal.tsx +315 -0
- package/components/StatusLegend.tsx +99 -0
- package/components/TimelineBar.tsx +116 -0
- package/components/TutorialOverlay.tsx +235 -0
- package/hooks/useBeadsComments.ts +81 -0
- package/hooks/useIsMobile.ts +19 -0
- package/lib/activity.ts +377 -0
- package/lib/agent.ts +29 -0
- package/lib/api-helpers.ts +46 -0
- package/lib/auth/client.ts +221 -0
- package/lib/auth.tsx +159 -0
- package/lib/comments.ts +413 -0
- package/lib/diff-beads.ts +128 -0
- package/lib/discover.ts +228 -0
- package/lib/env.ts +33 -0
- package/lib/gate.ts +55 -0
- package/lib/parse-beads.ts +234 -0
- package/lib/session.ts +52 -0
- package/lib/settings.ts +42 -0
- package/lib/timeline.ts +138 -0
- package/lib/tts.ts +397 -0
- package/lib/types.ts +271 -0
- package/lib/utils.ts +48 -0
- package/lib/watch-beads.ts +97 -0
- package/next.config.mjs +4 -0
- package/package.json +81 -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
package/app/page.tsx
ADDED
|
@@ -0,0 +1,2041 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
|
4
|
+
import type { BeadsApiResponse, GraphNode, GraphLink, ColorMode } from "@/lib/types";
|
|
5
|
+
import { getCatppuccinPrefixColor } from "@/lib/types";
|
|
6
|
+
import { diffBeadsData, linkKey } from "@/lib/diff-beads";
|
|
7
|
+
import type { BeadsDiff } from "@/lib/diff-beads";
|
|
8
|
+
import BeadsGraph from "@/components/BeadsGraph";
|
|
9
|
+
import type { BeadsGraphHandle } from "@/components/BeadsGraph";
|
|
10
|
+
import NodeDetail from "@/components/NodeDetail";
|
|
11
|
+
|
|
12
|
+
import { AuthButton } from "@/components/AuthButton";
|
|
13
|
+
import { BeadsLogo } from "@/components/BeadsLogo";
|
|
14
|
+
import { CommentTooltip } from "@/components/CommentTooltip";
|
|
15
|
+
import { ContextMenu } from "@/components/ContextMenu";
|
|
16
|
+
import { DescriptionModal } from "@/components/DescriptionModal";
|
|
17
|
+
import { BeadTooltip } from "@/components/BeadTooltip";
|
|
18
|
+
import AllCommentsPanel from "@/components/AllCommentsPanel";
|
|
19
|
+
import { ActivityOverlay } from "@/components/ActivityOverlay";
|
|
20
|
+
import { ActivityPanel } from "@/components/ActivityPanel";
|
|
21
|
+
import { HelpPanel } from "@/components/HelpPanel";
|
|
22
|
+
import { SettingsModal } from "@/components/SettingsModal";
|
|
23
|
+
import { TutorialOverlay, TUTORIAL_STEPS } from "@/components/TutorialOverlay";
|
|
24
|
+
import { MobileActionSheet } from "@/components/MobileActionSheet";
|
|
25
|
+
import { useBeadsComments } from "@/hooks/useBeadsComments";
|
|
26
|
+
import type { BeadsComment } from "@/hooks/useBeadsComments";
|
|
27
|
+
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
28
|
+
import { useAuth } from "@/lib/auth";
|
|
29
|
+
import {
|
|
30
|
+
buildHistoricalFeed,
|
|
31
|
+
diffToActivityEvents,
|
|
32
|
+
mergeFeedEvents,
|
|
33
|
+
} from "@/lib/activity";
|
|
34
|
+
import type { ActivityEvent } from "@/lib/activity";
|
|
35
|
+
import { buildTimelineEvents, filterDataAtTime } from "@/lib/timeline";
|
|
36
|
+
import type { TimelineRange } from "@/lib/timeline";
|
|
37
|
+
import TimelineBar from "@/components/TimelineBar";
|
|
38
|
+
import { formatRelativeTime } from "@/lib/utils";
|
|
39
|
+
|
|
40
|
+
// Check if a node has been claimed (has a comment that is just "@handle")
|
|
41
|
+
function isNodeClaimed(comments?: BeadsComment[]): boolean {
|
|
42
|
+
if (!comments) return false;
|
|
43
|
+
return comments.some(
|
|
44
|
+
(c) => c.text.startsWith("@") && c.text.trim().indexOf(" ") === -1
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Find position of a neighbor node (for placing new nodes near connections)
|
|
49
|
+
function findNeighborPosition(
|
|
50
|
+
nodeId: string,
|
|
51
|
+
links: GraphLink[],
|
|
52
|
+
nodeMap: Map<string, GraphNode>
|
|
53
|
+
): { x: number; y: number } | null {
|
|
54
|
+
for (const link of links) {
|
|
55
|
+
const src =
|
|
56
|
+
typeof link.source === "object"
|
|
57
|
+
? (link.source as { id: string }).id
|
|
58
|
+
: link.source;
|
|
59
|
+
const tgt =
|
|
60
|
+
typeof link.target === "object"
|
|
61
|
+
? (link.target as { id: string }).id
|
|
62
|
+
: link.target;
|
|
63
|
+
if (src === nodeId && nodeMap.has(tgt)) {
|
|
64
|
+
const n = nodeMap.get(tgt)!;
|
|
65
|
+
if (n.x != null && n.y != null)
|
|
66
|
+
return { x: n.x as number, y: n.y as number };
|
|
67
|
+
}
|
|
68
|
+
if (tgt === nodeId && nodeMap.has(src)) {
|
|
69
|
+
const n = nodeMap.get(src)!;
|
|
70
|
+
if (n.x != null && n.y != null)
|
|
71
|
+
return { x: n.x as number, y: n.y as number };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Merge old (with simulation positions) and new (from server) beads data,
|
|
78
|
+
// stamping animation metadata for spawn/exit/change transitions.
|
|
79
|
+
function mergeBeadsData(
|
|
80
|
+
oldData: BeadsApiResponse,
|
|
81
|
+
newData: BeadsApiResponse,
|
|
82
|
+
diff: BeadsDiff
|
|
83
|
+
): BeadsApiResponse {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
|
|
86
|
+
// Build position map from old nodes (preserves x/y/fx/fy from simulation)
|
|
87
|
+
const oldNodeMap = new Map(oldData.graphData.nodes.map((n) => [n.id, n]));
|
|
88
|
+
const oldLinkKeySet = new Set(oldData.graphData.links.map(linkKey));
|
|
89
|
+
|
|
90
|
+
// Merge nodes: carry over positions, stamp animation metadata
|
|
91
|
+
const mergedNodes: GraphNode[] = newData.graphData.nodes.map((node) => {
|
|
92
|
+
const oldNode = oldNodeMap.get(node.id);
|
|
93
|
+
|
|
94
|
+
if (!oldNode) {
|
|
95
|
+
// NEW NODE — stamp spawn time, place near a connected neighbor
|
|
96
|
+
const neighbor = findNeighborPosition(
|
|
97
|
+
node.id,
|
|
98
|
+
newData.graphData.links,
|
|
99
|
+
oldNodeMap
|
|
100
|
+
);
|
|
101
|
+
return {
|
|
102
|
+
...node,
|
|
103
|
+
_spawnTime: now,
|
|
104
|
+
x: neighbor ? neighbor.x + (Math.random() - 0.5) * 40 : undefined,
|
|
105
|
+
y: neighbor ? neighbor.y + (Math.random() - 0.5) * 40 : undefined,
|
|
106
|
+
} as GraphNode;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// EXISTING NODE — preserve position, check for changes
|
|
110
|
+
const merged: GraphNode = {
|
|
111
|
+
...node,
|
|
112
|
+
x: oldNode.x,
|
|
113
|
+
y: oldNode.y,
|
|
114
|
+
fx: oldNode.fx,
|
|
115
|
+
fy: oldNode.fy,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Stamp change metadata if status changed
|
|
119
|
+
if (diff.changedNodes.has(node.id)) {
|
|
120
|
+
const changes = diff.changedNodes.get(node.id)!;
|
|
121
|
+
const statusChange = changes.find((c) => c.field === "status");
|
|
122
|
+
if (statusChange) {
|
|
123
|
+
merged._changedAt = now;
|
|
124
|
+
merged._prevStatus = statusChange.from;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return merged;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Handle removed nodes: keep them briefly for exit animation
|
|
132
|
+
for (const removedId of diff.removedNodeIds) {
|
|
133
|
+
const oldNode = oldNodeMap.get(removedId);
|
|
134
|
+
if (oldNode) {
|
|
135
|
+
mergedNodes.push({
|
|
136
|
+
...oldNode,
|
|
137
|
+
_removeTime: now,
|
|
138
|
+
} as GraphNode);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Merge links: stamp spawn time on new links
|
|
143
|
+
const mergedLinks = newData.graphData.links.map((link) => {
|
|
144
|
+
const key = linkKey(link);
|
|
145
|
+
if (!oldLinkKeySet.has(key)) {
|
|
146
|
+
return { ...link, _spawnTime: now };
|
|
147
|
+
}
|
|
148
|
+
return link;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Handle removed links: keep briefly for exit animation
|
|
152
|
+
for (const removedKey of diff.removedLinkKeys) {
|
|
153
|
+
const oldLink = oldData.graphData.links.find(
|
|
154
|
+
(l) => linkKey(l) === removedKey
|
|
155
|
+
);
|
|
156
|
+
if (oldLink) {
|
|
157
|
+
mergedLinks.push({
|
|
158
|
+
source:
|
|
159
|
+
typeof oldLink.source === "object"
|
|
160
|
+
? (oldLink.source as { id: string }).id
|
|
161
|
+
: oldLink.source,
|
|
162
|
+
target:
|
|
163
|
+
typeof oldLink.target === "object"
|
|
164
|
+
? (oldLink.target as { id: string }).id
|
|
165
|
+
: oldLink.target,
|
|
166
|
+
type: oldLink.type,
|
|
167
|
+
_removeTime: now,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
...newData,
|
|
174
|
+
graphData: {
|
|
175
|
+
nodes: mergedNodes as GraphNode[],
|
|
176
|
+
links: mergedLinks as GraphLink[],
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Status badge colors for search results
|
|
182
|
+
const STATUS_DOT_COLORS: Record<string, string> = {
|
|
183
|
+
open: "bg-emerald-500",
|
|
184
|
+
in_progress: "bg-amber-500",
|
|
185
|
+
blocked: "bg-red-500",
|
|
186
|
+
deferred: "bg-violet-500",
|
|
187
|
+
closed: "bg-zinc-400",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export default function Home() {
|
|
191
|
+
const [data, setData] = useState<BeadsApiResponse | null>(null);
|
|
192
|
+
const [loading, setLoading] = useState(true);
|
|
193
|
+
const [error, setError] = useState<string | null>(null);
|
|
194
|
+
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
|
195
|
+
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
|
196
|
+
const [collapsedEpicIds, setCollapsedEpicIds] = useState<Set<string>>(new Set());
|
|
197
|
+
const [focusedEpicId, setFocusedEpicId] = useState<string | null>(null);
|
|
198
|
+
const [colorMode, setColorMode] = useState<ColorMode>("status");
|
|
199
|
+
const [projectName, setProjectName] = useState("Beads");
|
|
200
|
+
const [repoCount, setRepoCount] = useState(0);
|
|
201
|
+
const [repoUrls, setRepoUrls] = useState<Record<string, string>>({});
|
|
202
|
+
|
|
203
|
+
// Auth state
|
|
204
|
+
const { isAuthenticated, session } = useAuth();
|
|
205
|
+
|
|
206
|
+
// Comments from ATProto indexer
|
|
207
|
+
const { commentsByNode, commentedNodeIds, allComments, refetch: refetchComments } =
|
|
208
|
+
useBeadsComments();
|
|
209
|
+
|
|
210
|
+
// Optimistic claims — immediately show avatar after user claims, before indexer picks it up
|
|
211
|
+
// rkey is undefined for optimistic (not yet indexed), set once comment is fetched
|
|
212
|
+
const [optimisticClaims, setOptimisticClaims] = useState<
|
|
213
|
+
Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string; rkey?: string }>
|
|
214
|
+
>(new Map());
|
|
215
|
+
|
|
216
|
+
// Optimistic unclaims — suppress nodes where user just unclaimed (until refetch clears them)
|
|
217
|
+
const [optimisticUnclaims, setOptimisticUnclaims] = useState<Set<string>>(new Set());
|
|
218
|
+
|
|
219
|
+
// Compute claimed node avatars from comments + optimistic claims
|
|
220
|
+
// A claim comment has text "@handle" (starts with @, no spaces)
|
|
221
|
+
const claimedNodeAvatars = useMemo(() => {
|
|
222
|
+
const map = new Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string; rkey?: string }>();
|
|
223
|
+
// First: add from comments (has rkey for deletion)
|
|
224
|
+
if (allComments) {
|
|
225
|
+
for (const comment of allComments) {
|
|
226
|
+
if (map.has(comment.nodeId)) continue;
|
|
227
|
+
if (optimisticUnclaims.has(comment.nodeId)) continue; // suppressed by unclaim
|
|
228
|
+
const text = comment.text.trim();
|
|
229
|
+
if (text.startsWith("@") && text.indexOf(" ") === -1) {
|
|
230
|
+
map.set(comment.nodeId, {
|
|
231
|
+
avatar: comment.avatar,
|
|
232
|
+
handle: comment.handle,
|
|
233
|
+
claimedAt: comment.createdAt,
|
|
234
|
+
did: comment.did,
|
|
235
|
+
rkey: comment.rkey,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Then: add optimistic claims (only if not already from comments and not unclaimed)
|
|
241
|
+
for (const [nodeId, info] of optimisticClaims) {
|
|
242
|
+
if (!map.has(nodeId) && !optimisticUnclaims.has(nodeId)) {
|
|
243
|
+
map.set(nodeId, info);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return map;
|
|
247
|
+
}, [allComments, optimisticClaims, optimisticUnclaims]);
|
|
248
|
+
|
|
249
|
+
// All Comments panel state
|
|
250
|
+
const [allCommentsPanelOpen, setAllCommentsPanelOpen] = useState(false);
|
|
251
|
+
|
|
252
|
+
// Activity feed state
|
|
253
|
+
const [activityFeed, setActivityFeed] = useState<ActivityEvent[]>([]);
|
|
254
|
+
const [activityPanelOpen, setActivityPanelOpen] = useState(false);
|
|
255
|
+
const [activityOverlayCollapsed, setActivityOverlayCollapsed] = useState(false);
|
|
256
|
+
const [helpPanelOpen, setHelpPanelOpen] = useState(false);
|
|
257
|
+
const [tutorialStep, setTutorialStep] = useState<number | null>(null);
|
|
258
|
+
|
|
259
|
+
// Mobile responsiveness
|
|
260
|
+
const isMobile = useIsMobile();
|
|
261
|
+
const isMobileRef = useRef(false);
|
|
262
|
+
useEffect(() => { isMobileRef.current = isMobile; }, [isMobile]);
|
|
263
|
+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
264
|
+
const [mobileActionSheet, setMobileActionSheet] = useState<{ node: GraphNode } | null>(null);
|
|
265
|
+
|
|
266
|
+
// On mobile, collapse the activity overlay by default (show just the pill button)
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (isMobile) {
|
|
269
|
+
setActivityOverlayCollapsed(true);
|
|
270
|
+
}
|
|
271
|
+
}, [isMobile]);
|
|
272
|
+
|
|
273
|
+
// Set of node IDs in the local graph — used to filter global comments/activity
|
|
274
|
+
// to only events relevant to beads in this repo
|
|
275
|
+
const localNodeIds = useMemo(() => {
|
|
276
|
+
if (!data) return new Set<string>();
|
|
277
|
+
return new Set(data.graphData.nodes.map((n) => n.id));
|
|
278
|
+
}, [data]);
|
|
279
|
+
|
|
280
|
+
// Rebuild historical feed when data or comments change
|
|
281
|
+
// Filter comments to only those targeting nodes in our graph, since the
|
|
282
|
+
// Hypergoat indexer returns comments globally across all repos using beads
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (!data) return;
|
|
285
|
+
const localComments = allComments
|
|
286
|
+
? allComments.filter((c) => localNodeIds.has(c.nodeId))
|
|
287
|
+
: null;
|
|
288
|
+
const historical = buildHistoricalFeed(
|
|
289
|
+
data.graphData.nodes,
|
|
290
|
+
data.graphData.links,
|
|
291
|
+
localComments
|
|
292
|
+
);
|
|
293
|
+
setActivityFeed((prev) => mergeFeedEvents(prev, historical));
|
|
294
|
+
}, [data, allComments, localNodeIds]);
|
|
295
|
+
|
|
296
|
+
// Context menu state for right-click (phase 1: shows ContextMenu)
|
|
297
|
+
const [contextMenu, setContextMenu] = useState<{
|
|
298
|
+
node: GraphNode;
|
|
299
|
+
x: number;
|
|
300
|
+
y: number;
|
|
301
|
+
} | null>(null);
|
|
302
|
+
|
|
303
|
+
// Comment tooltip state (phase 2a: opened from context menu "Add comment")
|
|
304
|
+
const [commentTooltipState, setCommentTooltipState] = useState<{
|
|
305
|
+
node: GraphNode;
|
|
306
|
+
x: number;
|
|
307
|
+
y: number;
|
|
308
|
+
} | null>(null);
|
|
309
|
+
|
|
310
|
+
// Description modal state (phase 2b: opened from context menu "Show description")
|
|
311
|
+
const [descriptionModalNode, setDescriptionModalNode] =
|
|
312
|
+
useState<GraphNode | null>(null);
|
|
313
|
+
|
|
314
|
+
// Settings modal state
|
|
315
|
+
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
|
316
|
+
|
|
317
|
+
// Avatar hover tooltip state
|
|
318
|
+
const [avatarTooltip, setAvatarTooltip] = useState<{
|
|
319
|
+
handle: string;
|
|
320
|
+
avatar?: string;
|
|
321
|
+
claimedAt: string;
|
|
322
|
+
did?: string;
|
|
323
|
+
x: number;
|
|
324
|
+
y: number;
|
|
325
|
+
} | null>(null);
|
|
326
|
+
|
|
327
|
+
// Node hover tooltip state
|
|
328
|
+
const [nodeTooltip, setNodeTooltip] = useState<{
|
|
329
|
+
node: GraphNode;
|
|
330
|
+
x: number;
|
|
331
|
+
y: number;
|
|
332
|
+
} | null>(null);
|
|
333
|
+
|
|
334
|
+
// Search state
|
|
335
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
336
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
337
|
+
const [searchHighlightIndex, setSearchHighlightIndex] = useState(0);
|
|
338
|
+
|
|
339
|
+
// Timeline replay state
|
|
340
|
+
const [timelineActive, setTimelineActive] = useState(false);
|
|
341
|
+
const [timelineStep, setTimelineStep] = useState(0);
|
|
342
|
+
const [timelinePlaying, setTimelinePlaying] = useState(false);
|
|
343
|
+
const [timelineSpeed, setTimelineSpeed] = useState(1);
|
|
344
|
+
const [timelineData, setTimelineData] = useState<BeadsApiResponse | null>(null);
|
|
345
|
+
|
|
346
|
+
// Auto-fit: when true, graph auto-zooms to fit after data updates and layout changes
|
|
347
|
+
const [autoFit, setAutoFit] = useState(true);
|
|
348
|
+
// Pulse: highlight most recently active node with a ripple animation
|
|
349
|
+
const [showPulse, setShowPulse] = useState(true);
|
|
350
|
+
|
|
351
|
+
const graphRef = useRef<BeadsGraphHandle>(null);
|
|
352
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
353
|
+
const prevDataRef = useRef<BeadsApiResponse | null>(null);
|
|
354
|
+
|
|
355
|
+
// Live-streaming beads data via SSE
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
let eventSource: EventSource | null = null;
|
|
358
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
359
|
+
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
360
|
+
let mounted = true;
|
|
361
|
+
|
|
362
|
+
function connect() {
|
|
363
|
+
eventSource = new EventSource("/api/beads/stream");
|
|
364
|
+
|
|
365
|
+
eventSource.onmessage = (event) => {
|
|
366
|
+
if (!mounted) return;
|
|
367
|
+
try {
|
|
368
|
+
const parsed = JSON.parse(event.data);
|
|
369
|
+
if (parsed.error) {
|
|
370
|
+
setError(parsed.error as string);
|
|
371
|
+
setLoading(false);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const newData = parsed as BeadsApiResponse;
|
|
376
|
+
const oldData = prevDataRef.current;
|
|
377
|
+
const diff = diffBeadsData(oldData, newData);
|
|
378
|
+
|
|
379
|
+
if (!oldData) {
|
|
380
|
+
// Initial load — no animations, just set data
|
|
381
|
+
prevDataRef.current = newData;
|
|
382
|
+
setData(newData);
|
|
383
|
+
setLoading(false);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!diff.hasChanges) return; // No-op if nothing changed
|
|
388
|
+
|
|
389
|
+
// Append real-time activity events from the diff
|
|
390
|
+
const diffEvents = diffToActivityEvents(diff, newData.graphData.nodes);
|
|
391
|
+
if (diffEvents.length > 0) {
|
|
392
|
+
setActivityFeed((prev) => mergeFeedEvents(prev, diffEvents));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Merge: stamp animation metadata and preserve positions
|
|
396
|
+
const mergedData = mergeBeadsData(oldData, newData, diff);
|
|
397
|
+
prevDataRef.current = mergedData;
|
|
398
|
+
setData(mergedData);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
console.error("Failed to parse SSE message:", err);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
eventSource.onerror = () => {
|
|
405
|
+
// EventSource auto-reconnects, but handle permanent failure
|
|
406
|
+
if (eventSource?.readyState === EventSource.CLOSED) {
|
|
407
|
+
reconnectTimer = setTimeout(connect, 5000);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// If still loading after 5s, fall back to one-shot fetch
|
|
412
|
+
fallbackTimer = setTimeout(() => {
|
|
413
|
+
if (!mounted) return;
|
|
414
|
+
if (!prevDataRef.current) {
|
|
415
|
+
fetch("/api/beads")
|
|
416
|
+
.then((res) => res.json())
|
|
417
|
+
.then((fallbackData) => {
|
|
418
|
+
if (mounted && !prevDataRef.current) {
|
|
419
|
+
prevDataRef.current = fallbackData;
|
|
420
|
+
setData(fallbackData);
|
|
421
|
+
setLoading(false);
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
.catch(() => {});
|
|
425
|
+
}
|
|
426
|
+
}, 5000);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
connect();
|
|
430
|
+
|
|
431
|
+
return () => {
|
|
432
|
+
mounted = false;
|
|
433
|
+
eventSource?.close();
|
|
434
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
435
|
+
if (fallbackTimer) clearTimeout(fallbackTimer);
|
|
436
|
+
};
|
|
437
|
+
}, []);
|
|
438
|
+
|
|
439
|
+
// Fetch project config for dynamic name
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
fetch("/api/config")
|
|
442
|
+
.then((res) => res.json())
|
|
443
|
+
.then((config) => {
|
|
444
|
+
if (config.name) setProjectName(config.name);
|
|
445
|
+
if (config.repoCount) setRepoCount(config.repoCount);
|
|
446
|
+
if (config.repoUrls) setRepoUrls(config.repoUrls);
|
|
447
|
+
})
|
|
448
|
+
.catch(() => {
|
|
449
|
+
// Fallback to defaults
|
|
450
|
+
});
|
|
451
|
+
}, []);
|
|
452
|
+
|
|
453
|
+
// Clean up expired exit animations (nodes/links with _removeTime older than 600ms)
|
|
454
|
+
useEffect(() => {
|
|
455
|
+
if (!data) return;
|
|
456
|
+
const timer = setTimeout(() => {
|
|
457
|
+
const now = Date.now();
|
|
458
|
+
const EXPIRE_MS = 600;
|
|
459
|
+
const nodes = data.graphData.nodes.filter(
|
|
460
|
+
(n) => !n._removeTime || now - n._removeTime < EXPIRE_MS
|
|
461
|
+
);
|
|
462
|
+
const links = data.graphData.links.filter(
|
|
463
|
+
(l) => !l._removeTime || now - l._removeTime < EXPIRE_MS
|
|
464
|
+
);
|
|
465
|
+
if (
|
|
466
|
+
nodes.length !== data.graphData.nodes.length ||
|
|
467
|
+
links.length !== data.graphData.links.length
|
|
468
|
+
) {
|
|
469
|
+
setData((prev) =>
|
|
470
|
+
prev
|
|
471
|
+
? {
|
|
472
|
+
...prev,
|
|
473
|
+
graphData: { nodes, links },
|
|
474
|
+
}
|
|
475
|
+
: prev
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}, 700); // slightly after animation duration
|
|
479
|
+
return () => clearTimeout(timer);
|
|
480
|
+
}, [data]);
|
|
481
|
+
|
|
482
|
+
// --- Timeline replay logic ---
|
|
483
|
+
|
|
484
|
+
// Compute timeline event range from full data
|
|
485
|
+
const timelineRange = useMemo<TimelineRange | null>(() => {
|
|
486
|
+
if (!data) return null;
|
|
487
|
+
return buildTimelineEvents(data.graphData.nodes, data.graphData.links);
|
|
488
|
+
}, [data]);
|
|
489
|
+
|
|
490
|
+
// Toggle timeline mode on/off
|
|
491
|
+
const handleTimelineToggle = useCallback(() => {
|
|
492
|
+
setTimelineActive((prev) => {
|
|
493
|
+
const next = !prev;
|
|
494
|
+
if (next) {
|
|
495
|
+
setTimelineStep(-1);
|
|
496
|
+
setTimelinePlaying(false);
|
|
497
|
+
setTimelineData(null);
|
|
498
|
+
} else {
|
|
499
|
+
setTimelinePlaying(false);
|
|
500
|
+
setTimelineData(null);
|
|
501
|
+
}
|
|
502
|
+
return next;
|
|
503
|
+
});
|
|
504
|
+
}, []);
|
|
505
|
+
|
|
506
|
+
// Event-step playback: advance one step every 5s/speed
|
|
507
|
+
useEffect(() => {
|
|
508
|
+
if (!timelinePlaying || !timelineActive || !timelineRange) return;
|
|
509
|
+
const intervalMs = 2000 / timelineSpeed;
|
|
510
|
+
const interval = setInterval(() => {
|
|
511
|
+
setTimelineStep((prev) => {
|
|
512
|
+
const next = prev + 1;
|
|
513
|
+
if (next >= timelineRange.events.length) {
|
|
514
|
+
setTimelinePlaying(false);
|
|
515
|
+
return prev;
|
|
516
|
+
}
|
|
517
|
+
return next;
|
|
518
|
+
});
|
|
519
|
+
}, intervalMs);
|
|
520
|
+
return () => clearInterval(interval);
|
|
521
|
+
}, [timelinePlaying, timelineActive, timelineSpeed, timelineRange]);
|
|
522
|
+
|
|
523
|
+
// Compute timelineData via diff/merge pipeline when step changes
|
|
524
|
+
useEffect(() => {
|
|
525
|
+
if (!timelineActive || !data || !timelineRange) return;
|
|
526
|
+
|
|
527
|
+
// Preamble step: empty canvas
|
|
528
|
+
if (timelineStep === -1) {
|
|
529
|
+
setTimelineData((prev) => {
|
|
530
|
+
const empty: BeadsApiResponse = {
|
|
531
|
+
...data,
|
|
532
|
+
graphData: { nodes: [], links: [] },
|
|
533
|
+
};
|
|
534
|
+
if (!prev) return empty;
|
|
535
|
+
const diff = diffBeadsData(prev, empty);
|
|
536
|
+
if (!diff.hasChanges) return prev;
|
|
537
|
+
return mergeBeadsData(prev, empty, diff);
|
|
538
|
+
});
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (timelineRange.events.length === 0) return;
|
|
543
|
+
const event = timelineRange.events[timelineStep];
|
|
544
|
+
if (!event) return;
|
|
545
|
+
|
|
546
|
+
const filtered = filterDataAtTime(
|
|
547
|
+
data.graphData.nodes,
|
|
548
|
+
data.graphData.links,
|
|
549
|
+
event.time
|
|
550
|
+
);
|
|
551
|
+
const newSnapshot: BeadsApiResponse = {
|
|
552
|
+
...data,
|
|
553
|
+
graphData: { nodes: filtered.nodes, links: filtered.links },
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
setTimelineData((prev) => {
|
|
557
|
+
if (!prev) return newSnapshot; // first frame — no merge needed
|
|
558
|
+
const diff = diffBeadsData(prev, newSnapshot);
|
|
559
|
+
if (!diff.hasChanges) return prev;
|
|
560
|
+
return mergeBeadsData(prev, newSnapshot, diff);
|
|
561
|
+
});
|
|
562
|
+
}, [timelineActive, data, timelineRange, timelineStep]);
|
|
563
|
+
|
|
564
|
+
// --- End timeline replay logic ---
|
|
565
|
+
|
|
566
|
+
const handleNodeClick = useCallback((node: GraphNode) => {
|
|
567
|
+
setSelectedNode((prev) => (prev?.id === node.id ? null : node));
|
|
568
|
+
setAllCommentsPanelOpen(false);
|
|
569
|
+
setActivityPanelOpen(false);
|
|
570
|
+
setHelpPanelOpen(false);
|
|
571
|
+
setTutorialStep(null);
|
|
572
|
+
setMobileMenuOpen(false);
|
|
573
|
+
setMobileActionSheet(null);
|
|
574
|
+
}, []);
|
|
575
|
+
|
|
576
|
+
const handleNodeHover = useCallback((node: GraphNode | null, x: number, y: number) => {
|
|
577
|
+
setHoveredNode(node);
|
|
578
|
+
if (!isMobileRef.current) {
|
|
579
|
+
setNodeTooltip(node ? { node, x, y } : null);
|
|
580
|
+
}
|
|
581
|
+
}, []);
|
|
582
|
+
|
|
583
|
+
const handleToggleEpicCollapse = useCallback((epicId: string) => {
|
|
584
|
+
setCollapsedEpicIds((prev) => {
|
|
585
|
+
const next = new Set(prev);
|
|
586
|
+
if (next.has(epicId)) next.delete(epicId);
|
|
587
|
+
else next.add(epicId);
|
|
588
|
+
return next;
|
|
589
|
+
});
|
|
590
|
+
}, []);
|
|
591
|
+
|
|
592
|
+
// Compute all epic IDs that have children (for collapse-all)
|
|
593
|
+
const allParentEpicIds = useMemo(() => {
|
|
594
|
+
if (!data) return new Set<string>();
|
|
595
|
+
const { nodes, links } = data.graphData;
|
|
596
|
+
const parentIds = new Set<string>();
|
|
597
|
+
// From parent-child links
|
|
598
|
+
for (const link of links) {
|
|
599
|
+
if (link.type === "parent-child") {
|
|
600
|
+
const src = typeof link.source === "object" ? (link.source as any).id : link.source;
|
|
601
|
+
parentIds.add(src);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// From hierarchical IDs
|
|
605
|
+
const nodeIds = new Set(nodes.map((n) => n.id));
|
|
606
|
+
for (const node of nodes) {
|
|
607
|
+
if (node.id.includes(".")) {
|
|
608
|
+
const parentId = node.id.split(".")[0];
|
|
609
|
+
if (nodeIds.has(parentId)) parentIds.add(parentId);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return parentIds;
|
|
613
|
+
}, [data]);
|
|
614
|
+
|
|
615
|
+
const handleCollapseAll = useCallback(() => {
|
|
616
|
+
setCollapsedEpicIds(new Set(allParentEpicIds));
|
|
617
|
+
}, [allParentEpicIds]);
|
|
618
|
+
|
|
619
|
+
const handleExpandAll = useCallback(() => {
|
|
620
|
+
setCollapsedEpicIds(new Set());
|
|
621
|
+
}, []);
|
|
622
|
+
|
|
623
|
+
const handleFocusEpic = useCallback((epicId: string) => {
|
|
624
|
+
setFocusedEpicId(epicId);
|
|
625
|
+
}, []);
|
|
626
|
+
|
|
627
|
+
const handleExitFocusedEpic = useCallback(() => {
|
|
628
|
+
setFocusedEpicId(null);
|
|
629
|
+
}, []);
|
|
630
|
+
|
|
631
|
+
// --- Tutorial callbacks ---
|
|
632
|
+
const handleStartTutorial = useCallback(() => {
|
|
633
|
+
setHelpPanelOpen(true);
|
|
634
|
+
setSelectedNode(null);
|
|
635
|
+
setAllCommentsPanelOpen(false);
|
|
636
|
+
setActivityPanelOpen(false);
|
|
637
|
+
setTutorialStep(0);
|
|
638
|
+
}, []);
|
|
639
|
+
|
|
640
|
+
const handleNextTutorialStep = useCallback(() => {
|
|
641
|
+
setTutorialStep((prev) => {
|
|
642
|
+
if (prev === null) return null;
|
|
643
|
+
if (prev >= TUTORIAL_STEPS.length - 1) return prev;
|
|
644
|
+
return prev + 1;
|
|
645
|
+
});
|
|
646
|
+
}, []);
|
|
647
|
+
|
|
648
|
+
const handlePrevTutorialStep = useCallback(() => {
|
|
649
|
+
setTutorialStep((prev) => {
|
|
650
|
+
if (prev === null || prev <= 0) return prev;
|
|
651
|
+
return prev - 1;
|
|
652
|
+
});
|
|
653
|
+
}, []);
|
|
654
|
+
|
|
655
|
+
const handleEndTutorial = useCallback(() => {
|
|
656
|
+
setTutorialStep(null);
|
|
657
|
+
}, []);
|
|
658
|
+
|
|
659
|
+
const handleBackgroundClick = useCallback(() => {
|
|
660
|
+
setSelectedNode(null);
|
|
661
|
+
setContextMenu(null);
|
|
662
|
+
setCommentTooltipState(null);
|
|
663
|
+
setMobileActionSheet(null);
|
|
664
|
+
}, []);
|
|
665
|
+
|
|
666
|
+
const handleNodeRightClick = useCallback(
|
|
667
|
+
(node: GraphNode, event: MouseEvent) => {
|
|
668
|
+
if (isMobileRef.current) return; // No right-click menu on mobile
|
|
669
|
+
// Dismiss any open comment tooltip and hover tooltip
|
|
670
|
+
setCommentTooltipState(null);
|
|
671
|
+
setNodeTooltip(null);
|
|
672
|
+
if (!node.description && !isAuthenticated && node.issueType !== "epic") {
|
|
673
|
+
// No description and not logged in → only action is comment → skip menu
|
|
674
|
+
setCommentTooltipState({
|
|
675
|
+
node,
|
|
676
|
+
x: event.clientX,
|
|
677
|
+
y: event.clientY,
|
|
678
|
+
});
|
|
679
|
+
} else {
|
|
680
|
+
setContextMenu({ node, x: event.clientX, y: event.clientY });
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
[isAuthenticated]
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
const handleNodeDoubleTap = useCallback(
|
|
687
|
+
(node: GraphNode) => {
|
|
688
|
+
// On mobile double-tap: show bottom action sheet
|
|
689
|
+
setCommentTooltipState(null);
|
|
690
|
+
setNodeTooltip(null);
|
|
691
|
+
setMobileActionSheet({ node });
|
|
692
|
+
},
|
|
693
|
+
[]
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
const handlePostComment = useCallback(
|
|
697
|
+
async (nodeId: string, text: string) => {
|
|
698
|
+
const response = await fetch("/api/records", {
|
|
699
|
+
method: "POST",
|
|
700
|
+
headers: { "Content-Type": "application/json" },
|
|
701
|
+
body: JSON.stringify({
|
|
702
|
+
collection: "org.impactindexer.review.comment",
|
|
703
|
+
record: {
|
|
704
|
+
$type: "org.impactindexer.review.comment",
|
|
705
|
+
subject: {
|
|
706
|
+
uri: `beads:${nodeId}`,
|
|
707
|
+
type: "record",
|
|
708
|
+
},
|
|
709
|
+
text,
|
|
710
|
+
createdAt: new Date().toISOString(),
|
|
711
|
+
},
|
|
712
|
+
}),
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
if (!response.ok) {
|
|
716
|
+
const errData = await response.json();
|
|
717
|
+
throw new Error(errData.error || "Failed to post comment");
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Refetch comments to update the UI
|
|
721
|
+
await refetchComments();
|
|
722
|
+
},
|
|
723
|
+
[refetchComments]
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
const handleClaimTask = useCallback(
|
|
727
|
+
async (nodeId: string) => {
|
|
728
|
+
if (!session?.handle) return;
|
|
729
|
+
// Resolve avatar: use session avatar, or fetch from Bluesky public API
|
|
730
|
+
let avatar = session.avatar;
|
|
731
|
+
if (!avatar && session.did) {
|
|
732
|
+
try {
|
|
733
|
+
const res = await fetch(
|
|
734
|
+
`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(session.did)}`
|
|
735
|
+
);
|
|
736
|
+
if (res.ok) {
|
|
737
|
+
const profile = await res.json();
|
|
738
|
+
avatar = profile.avatar;
|
|
739
|
+
}
|
|
740
|
+
} catch {
|
|
741
|
+
// ignore — fallback letter will show
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
// Optimistically show avatar immediately
|
|
745
|
+
setOptimisticClaims((prev) => {
|
|
746
|
+
const next = new Map(prev);
|
|
747
|
+
next.set(nodeId, {
|
|
748
|
+
avatar,
|
|
749
|
+
claimedAt: new Date().toISOString(),
|
|
750
|
+
handle: session.handle,
|
|
751
|
+
});
|
|
752
|
+
return next;
|
|
753
|
+
});
|
|
754
|
+
await handlePostComment(nodeId, `@${session.handle}`);
|
|
755
|
+
// Delayed refetch — indexer may need a few seconds to pick up the new comment
|
|
756
|
+
setTimeout(() => refetchComments(), 3000);
|
|
757
|
+
},
|
|
758
|
+
[session?.handle, session?.did, session?.avatar, handlePostComment, refetchComments]
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
const handleDeleteComment = useCallback(
|
|
762
|
+
async (comment: { rkey: string }) => {
|
|
763
|
+
const response = await fetch(
|
|
764
|
+
`/api/records?collection=${encodeURIComponent("org.impactindexer.review.comment")}&rkey=${encodeURIComponent(comment.rkey)}`,
|
|
765
|
+
{ method: "DELETE" }
|
|
766
|
+
);
|
|
767
|
+
if (!response.ok) {
|
|
768
|
+
const errData = await response.json();
|
|
769
|
+
throw new Error(errData.error || "Failed to delete comment");
|
|
770
|
+
}
|
|
771
|
+
await refetchComments();
|
|
772
|
+
},
|
|
773
|
+
[refetchComments]
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
const handleUnclaimTask = useCallback(
|
|
777
|
+
async (nodeId: string) => {
|
|
778
|
+
const claim = claimedNodeAvatars.get(nodeId);
|
|
779
|
+
if (!claim) return;
|
|
780
|
+
|
|
781
|
+
// Optimistically suppress the claim immediately
|
|
782
|
+
setOptimisticUnclaims((prev) => new Set(prev).add(nodeId));
|
|
783
|
+
setOptimisticClaims((prev) => {
|
|
784
|
+
const next = new Map(prev);
|
|
785
|
+
next.delete(nodeId);
|
|
786
|
+
return next;
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// Delete the comment if we have the rkey
|
|
790
|
+
if (claim.rkey) {
|
|
791
|
+
await handleDeleteComment({ rkey: claim.rkey });
|
|
792
|
+
// Refetch clears the comment from allComments, so remove from unclaims set
|
|
793
|
+
setOptimisticUnclaims((prev) => {
|
|
794
|
+
const next = new Set(prev);
|
|
795
|
+
next.delete(nodeId);
|
|
796
|
+
return next;
|
|
797
|
+
});
|
|
798
|
+
} else {
|
|
799
|
+
// Optimistic claim not yet indexed — refetch after a delay, then clear unclaim
|
|
800
|
+
setTimeout(async () => {
|
|
801
|
+
await refetchComments();
|
|
802
|
+
setOptimisticUnclaims((prev) => {
|
|
803
|
+
const next = new Set(prev);
|
|
804
|
+
next.delete(nodeId);
|
|
805
|
+
return next;
|
|
806
|
+
});
|
|
807
|
+
}, 3000);
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
[claimedNodeAvatars, handleDeleteComment, refetchComments]
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
const handleLikeComment = useCallback(
|
|
814
|
+
async (comment: BeadsComment) => {
|
|
815
|
+
// Check if already liked by current user
|
|
816
|
+
const existingLike = comment.likes.find(
|
|
817
|
+
(l) => l.did === session?.did
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
if (existingLike) {
|
|
821
|
+
// Unlike: DELETE the like record
|
|
822
|
+
const response = await fetch(
|
|
823
|
+
`/api/records?collection=${encodeURIComponent("org.impactindexer.review.like")}&rkey=${encodeURIComponent(existingLike.rkey)}`,
|
|
824
|
+
{ method: "DELETE" }
|
|
825
|
+
);
|
|
826
|
+
if (!response.ok) {
|
|
827
|
+
const errData = await response.json();
|
|
828
|
+
throw new Error(errData.error || "Failed to unlike");
|
|
829
|
+
}
|
|
830
|
+
} else {
|
|
831
|
+
// Like: POST a new like record
|
|
832
|
+
const response = await fetch("/api/records", {
|
|
833
|
+
method: "POST",
|
|
834
|
+
headers: { "Content-Type": "application/json" },
|
|
835
|
+
body: JSON.stringify({
|
|
836
|
+
collection: "org.impactindexer.review.like",
|
|
837
|
+
record: {
|
|
838
|
+
subject: { uri: comment.uri, type: "record" },
|
|
839
|
+
createdAt: new Date().toISOString(),
|
|
840
|
+
},
|
|
841
|
+
}),
|
|
842
|
+
});
|
|
843
|
+
if (!response.ok) {
|
|
844
|
+
const errData = await response.json();
|
|
845
|
+
throw new Error(errData.error || "Failed to like");
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
await refetchComments();
|
|
850
|
+
},
|
|
851
|
+
[session?.did, refetchComments]
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
const handleReplyComment = useCallback(
|
|
855
|
+
async (parentComment: BeadsComment, text: string) => {
|
|
856
|
+
const response = await fetch("/api/records", {
|
|
857
|
+
method: "POST",
|
|
858
|
+
headers: { "Content-Type": "application/json" },
|
|
859
|
+
body: JSON.stringify({
|
|
860
|
+
collection: "org.impactindexer.review.comment",
|
|
861
|
+
record: {
|
|
862
|
+
subject: {
|
|
863
|
+
uri: `beads:${parentComment.nodeId}`,
|
|
864
|
+
type: "record",
|
|
865
|
+
},
|
|
866
|
+
text,
|
|
867
|
+
replyTo: parentComment.uri,
|
|
868
|
+
createdAt: new Date().toISOString(),
|
|
869
|
+
},
|
|
870
|
+
}),
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
if (!response.ok) {
|
|
874
|
+
const errData = await response.json();
|
|
875
|
+
throw new Error(errData.error || "Failed to post reply");
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
await refetchComments();
|
|
879
|
+
},
|
|
880
|
+
[refetchComments]
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
const handleNodeNavigate = useCallback(
|
|
884
|
+
(nodeId: string) => {
|
|
885
|
+
if (!data) return;
|
|
886
|
+
const node = data.graphData.nodes.find((n) => n.id === nodeId);
|
|
887
|
+
if (node) {
|
|
888
|
+
setSelectedNode(node);
|
|
889
|
+
}
|
|
890
|
+
},
|
|
891
|
+
[data]
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
// Build a map of nodeId -> commenter handles string for search
|
|
895
|
+
const commenterHandlesByNode = useMemo(() => {
|
|
896
|
+
const map = new Map<string, string>();
|
|
897
|
+
if (!allComments) return map;
|
|
898
|
+
const handlesMap = new Map<string, Set<string>>();
|
|
899
|
+
for (const comment of allComments) {
|
|
900
|
+
if (!handlesMap.has(comment.nodeId)) {
|
|
901
|
+
handlesMap.set(comment.nodeId, new Set());
|
|
902
|
+
}
|
|
903
|
+
handlesMap.get(comment.nodeId)!.add(comment.handle);
|
|
904
|
+
if (comment.displayName) {
|
|
905
|
+
handlesMap.get(comment.nodeId)!.add(comment.displayName);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
for (const [nodeId, handles] of handlesMap) {
|
|
909
|
+
map.set(nodeId, Array.from(handles).join(" "));
|
|
910
|
+
}
|
|
911
|
+
return map;
|
|
912
|
+
}, [allComments]);
|
|
913
|
+
|
|
914
|
+
// Search results - fuzzy match on id, title, people, and commenter handles
|
|
915
|
+
const searchResults = useMemo(() => {
|
|
916
|
+
if (!data || !searchQuery.trim()) return [];
|
|
917
|
+
const term = searchQuery.toLowerCase();
|
|
918
|
+
return data.graphData.nodes
|
|
919
|
+
.filter((n) => {
|
|
920
|
+
const commenters = commenterHandlesByNode.get(n.id) || "";
|
|
921
|
+
const searchable = `${n.id} ${n.title} ${n.prefix} ${n.owner || ""} ${n.assignee || ""} ${n.createdBy || ""} ${commenters}`.toLowerCase();
|
|
922
|
+
return searchable.includes(term);
|
|
923
|
+
})
|
|
924
|
+
.slice(0, 8);
|
|
925
|
+
}, [searchQuery, data, commenterHandlesByNode]);
|
|
926
|
+
|
|
927
|
+
// Reset highlight index when query changes
|
|
928
|
+
useEffect(() => {
|
|
929
|
+
setSearchHighlightIndex(0);
|
|
930
|
+
}, [searchQuery]);
|
|
931
|
+
|
|
932
|
+
// Focus node via graph ref, then close search
|
|
933
|
+
const focusNode = useCallback(
|
|
934
|
+
(node: GraphNode) => {
|
|
935
|
+
graphRef.current?.focusNode(node);
|
|
936
|
+
setSearchOpen(false);
|
|
937
|
+
setSearchQuery("");
|
|
938
|
+
setSearchHighlightIndex(0);
|
|
939
|
+
},
|
|
940
|
+
[]
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
// Keyboard shortcut: Ctrl/Cmd+F to open search, Escape to close
|
|
944
|
+
useEffect(() => {
|
|
945
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
946
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
|
|
947
|
+
e.preventDefault();
|
|
948
|
+
setSearchOpen(true);
|
|
949
|
+
setTimeout(() => searchInputRef.current?.focus(), 50);
|
|
950
|
+
}
|
|
951
|
+
if (e.key === "Escape" && searchOpen) {
|
|
952
|
+
setSearchOpen(false);
|
|
953
|
+
setSearchQuery("");
|
|
954
|
+
setSearchHighlightIndex(0);
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
958
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
959
|
+
}, [searchOpen]);
|
|
960
|
+
|
|
961
|
+
// Handle search result selection via keyboard
|
|
962
|
+
const handleSearchKeyDown = useCallback(
|
|
963
|
+
(e: React.KeyboardEvent) => {
|
|
964
|
+
if (e.key === "ArrowDown") {
|
|
965
|
+
e.preventDefault();
|
|
966
|
+
setSearchHighlightIndex((prev) =>
|
|
967
|
+
Math.min(prev + 1, searchResults.length - 1)
|
|
968
|
+
);
|
|
969
|
+
} else if (e.key === "ArrowUp") {
|
|
970
|
+
e.preventDefault();
|
|
971
|
+
setSearchHighlightIndex((prev) => Math.max(prev - 1, 0));
|
|
972
|
+
} else if (e.key === "Enter" && searchResults.length > 0) {
|
|
973
|
+
e.preventDefault();
|
|
974
|
+
focusNode(searchResults[searchHighlightIndex]);
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
[searchResults, searchHighlightIndex, focusNode]
|
|
978
|
+
);
|
|
979
|
+
|
|
980
|
+
// Loading state
|
|
981
|
+
if (loading) {
|
|
982
|
+
return (
|
|
983
|
+
<div className="h-screen flex flex-col items-center justify-center bg-white text-zinc-800 px-6 select-none">
|
|
984
|
+
{/* Animated ECG trace with heartbeat logo */}
|
|
985
|
+
<div className="relative w-full max-w-sm mb-8">
|
|
986
|
+
<svg
|
|
987
|
+
viewBox="0 0 400 40"
|
|
988
|
+
fill="none"
|
|
989
|
+
className="w-full text-zinc-200"
|
|
990
|
+
aria-hidden="true"
|
|
991
|
+
>
|
|
992
|
+
<line
|
|
993
|
+
x1="0"
|
|
994
|
+
y1="20"
|
|
995
|
+
x2="400"
|
|
996
|
+
y2="20"
|
|
997
|
+
stroke="currentColor"
|
|
998
|
+
strokeWidth="1.5"
|
|
999
|
+
strokeLinecap="round"
|
|
1000
|
+
strokeDasharray="4 6"
|
|
1001
|
+
opacity="0.6"
|
|
1002
|
+
/>
|
|
1003
|
+
</svg>
|
|
1004
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
1005
|
+
<div className="bg-white px-4">
|
|
1006
|
+
<BeadsLogo className="w-10 h-10 text-emerald-500" />
|
|
1007
|
+
</div>
|
|
1008
|
+
</div>
|
|
1009
|
+
</div>
|
|
1010
|
+
|
|
1011
|
+
<p className="text-sm text-zinc-500 font-light animate-fade-in">
|
|
1012
|
+
Warming up the heartbeat...
|
|
1013
|
+
</p>
|
|
1014
|
+
<p
|
|
1015
|
+
className="text-xs text-zinc-400 mt-1.5 animate-fade-in"
|
|
1016
|
+
style={{ animationDelay: "0.1s" }}
|
|
1017
|
+
>
|
|
1018
|
+
Discovering issues and mapping dependencies
|
|
1019
|
+
</p>
|
|
1020
|
+
</div>
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Error state
|
|
1025
|
+
if (error) {
|
|
1026
|
+
return (
|
|
1027
|
+
<div className="h-screen flex items-center justify-center bg-white">
|
|
1028
|
+
<div className="max-w-sm text-center">
|
|
1029
|
+
<div className="w-14 h-14 mx-auto mb-4 bg-red-50 rounded-full flex items-center justify-center">
|
|
1030
|
+
<svg
|
|
1031
|
+
className="w-7 h-7 text-red-400"
|
|
1032
|
+
fill="none"
|
|
1033
|
+
stroke="currentColor"
|
|
1034
|
+
viewBox="0 0 24 24"
|
|
1035
|
+
>
|
|
1036
|
+
<path
|
|
1037
|
+
strokeLinecap="round"
|
|
1038
|
+
strokeLinejoin="round"
|
|
1039
|
+
strokeWidth={1.5}
|
|
1040
|
+
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
1041
|
+
/>
|
|
1042
|
+
</svg>
|
|
1043
|
+
</div>
|
|
1044
|
+
<h2 className="text-lg font-semibold text-zinc-900 mb-1">
|
|
1045
|
+
Unable to load data
|
|
1046
|
+
</h2>
|
|
1047
|
+
<p className="text-sm text-zinc-500 mb-4">{error}</p>
|
|
1048
|
+
<button
|
|
1049
|
+
onClick={() => window.location.reload()}
|
|
1050
|
+
className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors"
|
|
1051
|
+
>
|
|
1052
|
+
<svg
|
|
1053
|
+
className="w-4 h-4"
|
|
1054
|
+
fill="none"
|
|
1055
|
+
stroke="currentColor"
|
|
1056
|
+
viewBox="0 0 24 24"
|
|
1057
|
+
>
|
|
1058
|
+
<path
|
|
1059
|
+
strokeLinecap="round"
|
|
1060
|
+
strokeLinejoin="round"
|
|
1061
|
+
strokeWidth={2}
|
|
1062
|
+
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
1063
|
+
/>
|
|
1064
|
+
</svg>
|
|
1065
|
+
Try Again
|
|
1066
|
+
</button>
|
|
1067
|
+
</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (!data) return null;
|
|
1073
|
+
|
|
1074
|
+
return (
|
|
1075
|
+
<div className="h-screen flex flex-col overflow-hidden bg-white">
|
|
1076
|
+
{/* Header */}
|
|
1077
|
+
<header className="sticky top-0 z-50 shrink-0 bg-white/95 backdrop-blur-sm border-b border-zinc-200/80">
|
|
1078
|
+
<div className="px-3 sm:px-6 h-14 flex items-center">
|
|
1079
|
+
{/* Left: Logo */}
|
|
1080
|
+
<div className="flex items-center gap-3 shrink-0 group">
|
|
1081
|
+
<BeadsLogo className="w-8 h-8 text-emerald-500 transition-transform group-hover:scale-105" />
|
|
1082
|
+
<div className="flex items-baseline gap-2">
|
|
1083
|
+
<h1 className="text-[15px] font-semibold text-zinc-900 tracking-tight">
|
|
1084
|
+
{projectName}
|
|
1085
|
+
</h1>
|
|
1086
|
+
<span className="font-normal text-zinc-400 text-[15px] hidden sm:inline">
|
|
1087
|
+
Heartbeads
|
|
1088
|
+
</span>
|
|
1089
|
+
{repoCount > 1 && (
|
|
1090
|
+
<span className="text-[10px] text-zinc-400 bg-zinc-100 rounded-full px-1.5 py-0.5 font-medium hidden sm:inline">
|
|
1091
|
+
{repoCount} repos
|
|
1092
|
+
</span>
|
|
1093
|
+
)}
|
|
1094
|
+
</div>
|
|
1095
|
+
</div>
|
|
1096
|
+
|
|
1097
|
+
{/* Center: Search */}
|
|
1098
|
+
<div className="flex-1 flex justify-center px-2 sm:px-4">
|
|
1099
|
+
<div className="relative w-full max-w-md" data-tutorial="search">
|
|
1100
|
+
{searchOpen ? (
|
|
1101
|
+
<div className="flex flex-col">
|
|
1102
|
+
<div className="flex items-center bg-white rounded-full border border-zinc-200 shadow-sm overflow-hidden">
|
|
1103
|
+
<div className="pl-3 pr-1 text-zinc-400">
|
|
1104
|
+
<svg
|
|
1105
|
+
className="w-3.5 h-3.5"
|
|
1106
|
+
fill="none"
|
|
1107
|
+
stroke="currentColor"
|
|
1108
|
+
viewBox="0 0 24 24"
|
|
1109
|
+
strokeWidth={2}
|
|
1110
|
+
>
|
|
1111
|
+
<path
|
|
1112
|
+
strokeLinecap="round"
|
|
1113
|
+
strokeLinejoin="round"
|
|
1114
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
1115
|
+
/>
|
|
1116
|
+
</svg>
|
|
1117
|
+
</div>
|
|
1118
|
+
<input
|
|
1119
|
+
ref={searchInputRef}
|
|
1120
|
+
type="text"
|
|
1121
|
+
value={searchQuery}
|
|
1122
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
1123
|
+
onKeyDown={handleSearchKeyDown}
|
|
1124
|
+
placeholder="Search issues..."
|
|
1125
|
+
className="flex-1 px-2 py-1.5 text-xs text-zinc-800 bg-transparent outline-none placeholder:text-zinc-400"
|
|
1126
|
+
autoFocus
|
|
1127
|
+
/>
|
|
1128
|
+
<button
|
|
1129
|
+
onClick={() => {
|
|
1130
|
+
setSearchOpen(false);
|
|
1131
|
+
setSearchQuery("");
|
|
1132
|
+
setSearchHighlightIndex(0);
|
|
1133
|
+
}}
|
|
1134
|
+
className="px-2 py-1.5 text-zinc-400 hover:text-zinc-600"
|
|
1135
|
+
>
|
|
1136
|
+
<svg
|
|
1137
|
+
className="w-3.5 h-3.5"
|
|
1138
|
+
fill="none"
|
|
1139
|
+
stroke="currentColor"
|
|
1140
|
+
viewBox="0 0 24 24"
|
|
1141
|
+
strokeWidth={2}
|
|
1142
|
+
>
|
|
1143
|
+
<path
|
|
1144
|
+
strokeLinecap="round"
|
|
1145
|
+
strokeLinejoin="round"
|
|
1146
|
+
d="M6 18L18 6M6 6l12 12"
|
|
1147
|
+
/>
|
|
1148
|
+
</svg>
|
|
1149
|
+
</button>
|
|
1150
|
+
</div>
|
|
1151
|
+
|
|
1152
|
+
{/* Search results dropdown */}
|
|
1153
|
+
{searchQuery.trim() && (
|
|
1154
|
+
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl border border-zinc-100 shadow-xl overflow-hidden max-h-72 overflow-y-auto z-50">
|
|
1155
|
+
{searchResults.length === 0 ? (
|
|
1156
|
+
<div className="px-3 py-3 text-xs text-zinc-400 text-center">
|
|
1157
|
+
No matching issues
|
|
1158
|
+
</div>
|
|
1159
|
+
) : (
|
|
1160
|
+
searchResults.map((node, i) => (
|
|
1161
|
+
<button
|
|
1162
|
+
key={node.id}
|
|
1163
|
+
onClick={() => focusNode(node)}
|
|
1164
|
+
onMouseEnter={() => setSearchHighlightIndex(i)}
|
|
1165
|
+
className={`w-full text-left px-3 py-2 text-xs transition-colors flex items-start gap-2.5 ${
|
|
1166
|
+
i === searchHighlightIndex
|
|
1167
|
+
? "bg-emerald-50"
|
|
1168
|
+
: "hover:bg-zinc-50"
|
|
1169
|
+
}`}
|
|
1170
|
+
>
|
|
1171
|
+
<div className="shrink-0 mt-1">
|
|
1172
|
+
<div
|
|
1173
|
+
className={`w-2 h-2 rounded-full ${
|
|
1174
|
+
STATUS_DOT_COLORS[node.status] || "bg-zinc-400"
|
|
1175
|
+
}`}
|
|
1176
|
+
/>
|
|
1177
|
+
</div>
|
|
1178
|
+
<div className="min-w-0 flex-1">
|
|
1179
|
+
<div className="flex items-center gap-1.5">
|
|
1180
|
+
<span className="font-medium text-zinc-600 shrink-0">
|
|
1181
|
+
{node.id}
|
|
1182
|
+
</span>
|
|
1183
|
+
{node.priority <= 1 && (
|
|
1184
|
+
<span className="text-[10px]">
|
|
1185
|
+
{node.priority === 0
|
|
1186
|
+
? "\uD83D\uDD25\uD83D\uDD25"
|
|
1187
|
+
: "\uD83D\uDD25"}
|
|
1188
|
+
</span>
|
|
1189
|
+
)}
|
|
1190
|
+
</div>
|
|
1191
|
+
<div className="text-zinc-400 truncate mt-0.5">
|
|
1192
|
+
{node.title}
|
|
1193
|
+
</div>
|
|
1194
|
+
</div>
|
|
1195
|
+
<span className="shrink-0 text-[10px] text-zinc-400 bg-zinc-100 rounded px-1 py-0.5 mt-0.5">
|
|
1196
|
+
{node.prefix}
|
|
1197
|
+
</span>
|
|
1198
|
+
</button>
|
|
1199
|
+
))
|
|
1200
|
+
)}
|
|
1201
|
+
{searchResults.length > 0 && (
|
|
1202
|
+
<div className="px-3 py-1.5 text-[10px] text-zinc-400 border-t border-zinc-100 bg-zinc-50/50 flex items-center justify-between">
|
|
1203
|
+
<span>
|
|
1204
|
+
{searchResults.length} result
|
|
1205
|
+
{searchResults.length !== 1 ? "s" : ""}
|
|
1206
|
+
</span>
|
|
1207
|
+
<span>
|
|
1208
|
+
<kbd className="px-1 py-0.5 bg-white rounded border border-zinc-200 text-[9px] font-mono">
|
|
1209
|
+
Enter
|
|
1210
|
+
</kbd>{" "}
|
|
1211
|
+
to focus
|
|
1212
|
+
</span>
|
|
1213
|
+
</div>
|
|
1214
|
+
)}
|
|
1215
|
+
</div>
|
|
1216
|
+
)}
|
|
1217
|
+
</div>
|
|
1218
|
+
) : (
|
|
1219
|
+
<button
|
|
1220
|
+
onClick={() => {
|
|
1221
|
+
setSearchOpen(true);
|
|
1222
|
+
setTimeout(() => searchInputRef.current?.focus(), 50);
|
|
1223
|
+
}}
|
|
1224
|
+
className="w-full flex items-center gap-2 px-3.5 py-1.5 text-sm text-zinc-400 rounded-full bg-zinc-50/80 border border-zinc-200/60 hover:text-zinc-500 hover:border-zinc-300 hover:bg-zinc-100/50 transition-all"
|
|
1225
|
+
title="Search issues (Ctrl+F)"
|
|
1226
|
+
>
|
|
1227
|
+
<svg
|
|
1228
|
+
className="w-3.5 h-3.5 shrink-0"
|
|
1229
|
+
fill="none"
|
|
1230
|
+
stroke="currentColor"
|
|
1231
|
+
viewBox="0 0 24 24"
|
|
1232
|
+
strokeWidth={2}
|
|
1233
|
+
>
|
|
1234
|
+
<path
|
|
1235
|
+
strokeLinecap="round"
|
|
1236
|
+
strokeLinejoin="round"
|
|
1237
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
1238
|
+
/>
|
|
1239
|
+
</svg>
|
|
1240
|
+
<span className="flex-1 text-left">Search issues...</span>
|
|
1241
|
+
<kbd className="hidden sm:inline-block px-1 py-0.5 bg-zinc-100 rounded border border-zinc-200 text-[9px] font-mono text-zinc-400">
|
|
1242
|
+
{typeof navigator !== "undefined" &&
|
|
1243
|
+
navigator.platform?.includes("Mac")
|
|
1244
|
+
? "\u2318"
|
|
1245
|
+
: "Ctrl"}
|
|
1246
|
+
F
|
|
1247
|
+
</kbd>
|
|
1248
|
+
</button>
|
|
1249
|
+
)}
|
|
1250
|
+
</div>
|
|
1251
|
+
</div>
|
|
1252
|
+
|
|
1253
|
+
{/* Mobile hamburger button */}
|
|
1254
|
+
<button
|
|
1255
|
+
onClick={() => setMobileMenuOpen(true)}
|
|
1256
|
+
className="md:hidden p-2 text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50 rounded-full transition-colors shrink-0"
|
|
1257
|
+
aria-label="Open menu"
|
|
1258
|
+
>
|
|
1259
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
1260
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
|
1261
|
+
</svg>
|
|
1262
|
+
</button>
|
|
1263
|
+
|
|
1264
|
+
{/* Right: Nav items (desktop) */}
|
|
1265
|
+
<div className="hidden md:flex items-center gap-1 shrink-0" data-tutorial="nav-pills">
|
|
1266
|
+
{/* Replay pill */}
|
|
1267
|
+
<button
|
|
1268
|
+
onClick={handleTimelineToggle}
|
|
1269
|
+
data-tutorial="nav-replay"
|
|
1270
|
+
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
|
|
1271
|
+
timelineActive
|
|
1272
|
+
? "text-emerald-700 bg-emerald-50"
|
|
1273
|
+
: "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50"
|
|
1274
|
+
}`}
|
|
1275
|
+
>
|
|
1276
|
+
<svg
|
|
1277
|
+
className="w-3.5 h-3.5"
|
|
1278
|
+
viewBox="0 0 16 16"
|
|
1279
|
+
fill="none"
|
|
1280
|
+
stroke="currentColor"
|
|
1281
|
+
strokeWidth="1.5"
|
|
1282
|
+
>
|
|
1283
|
+
<circle cx="8" cy="8" r="6" />
|
|
1284
|
+
<polyline points="8,4 8,8 11,10" />
|
|
1285
|
+
</svg>
|
|
1286
|
+
<span className="hidden sm:inline">Replay</span>
|
|
1287
|
+
</button>
|
|
1288
|
+
{/* Comments pill */}
|
|
1289
|
+
<button
|
|
1290
|
+
onClick={() => {
|
|
1291
|
+
setAllCommentsPanelOpen((prev) => !prev);
|
|
1292
|
+
if (!allCommentsPanelOpen) {
|
|
1293
|
+
setSelectedNode(null);
|
|
1294
|
+
setActivityPanelOpen(false);
|
|
1295
|
+
setHelpPanelOpen(false);
|
|
1296
|
+
setTutorialStep(null);
|
|
1297
|
+
}
|
|
1298
|
+
}}
|
|
1299
|
+
data-tutorial="nav-comments"
|
|
1300
|
+
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
|
|
1301
|
+
allCommentsPanelOpen
|
|
1302
|
+
? "text-emerald-700 bg-emerald-50"
|
|
1303
|
+
: "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50"
|
|
1304
|
+
}`}
|
|
1305
|
+
>
|
|
1306
|
+
<svg
|
|
1307
|
+
className="w-3.5 h-3.5"
|
|
1308
|
+
fill="none"
|
|
1309
|
+
viewBox="0 0 24 24"
|
|
1310
|
+
strokeWidth={1.5}
|
|
1311
|
+
stroke="currentColor"
|
|
1312
|
+
>
|
|
1313
|
+
<path
|
|
1314
|
+
strokeLinecap="round"
|
|
1315
|
+
strokeLinejoin="round"
|
|
1316
|
+
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
|
1317
|
+
/>
|
|
1318
|
+
</svg>
|
|
1319
|
+
<span className="hidden sm:inline">Comments</span>
|
|
1320
|
+
</button>
|
|
1321
|
+
{/* Activity pill */}
|
|
1322
|
+
<button
|
|
1323
|
+
onClick={() => {
|
|
1324
|
+
setActivityPanelOpen((prev) => !prev);
|
|
1325
|
+
if (!activityPanelOpen) {
|
|
1326
|
+
setSelectedNode(null);
|
|
1327
|
+
setAllCommentsPanelOpen(false);
|
|
1328
|
+
setHelpPanelOpen(false);
|
|
1329
|
+
setTutorialStep(null);
|
|
1330
|
+
}
|
|
1331
|
+
}}
|
|
1332
|
+
data-tutorial="nav-activity"
|
|
1333
|
+
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
|
|
1334
|
+
activityPanelOpen
|
|
1335
|
+
? "text-emerald-700 bg-emerald-50"
|
|
1336
|
+
: "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50"
|
|
1337
|
+
}`}
|
|
1338
|
+
>
|
|
1339
|
+
<svg
|
|
1340
|
+
className="w-3.5 h-3.5"
|
|
1341
|
+
viewBox="0 0 16 16"
|
|
1342
|
+
fill="none"
|
|
1343
|
+
stroke="currentColor"
|
|
1344
|
+
strokeWidth="1.5"
|
|
1345
|
+
>
|
|
1346
|
+
<circle cx="8" cy="8" r="6" />
|
|
1347
|
+
<polyline points="8,4 8,8 11,10" />
|
|
1348
|
+
</svg>
|
|
1349
|
+
<span className="hidden sm:inline">Activity</span>
|
|
1350
|
+
</button>
|
|
1351
|
+
{/* Learn pill */}
|
|
1352
|
+
<button
|
|
1353
|
+
onClick={() => {
|
|
1354
|
+
setHelpPanelOpen((prev) => !prev);
|
|
1355
|
+
if (!helpPanelOpen) {
|
|
1356
|
+
setSelectedNode(null);
|
|
1357
|
+
setAllCommentsPanelOpen(false);
|
|
1358
|
+
setActivityPanelOpen(false);
|
|
1359
|
+
}
|
|
1360
|
+
}}
|
|
1361
|
+
data-tutorial="nav-learn"
|
|
1362
|
+
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
|
|
1363
|
+
helpPanelOpen
|
|
1364
|
+
? "text-emerald-700 bg-emerald-50"
|
|
1365
|
+
: "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50"
|
|
1366
|
+
}`}
|
|
1367
|
+
>
|
|
1368
|
+
<svg
|
|
1369
|
+
className="w-3.5 h-3.5"
|
|
1370
|
+
fill="none"
|
|
1371
|
+
viewBox="0 0 24 24"
|
|
1372
|
+
strokeWidth={1.5}
|
|
1373
|
+
stroke="currentColor"
|
|
1374
|
+
>
|
|
1375
|
+
<path
|
|
1376
|
+
strokeLinecap="round"
|
|
1377
|
+
strokeLinejoin="round"
|
|
1378
|
+
d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"
|
|
1379
|
+
/>
|
|
1380
|
+
</svg>
|
|
1381
|
+
<span className="hidden sm:inline">Learn</span>
|
|
1382
|
+
</button>
|
|
1383
|
+
<div className="w-px h-5 bg-zinc-200 mx-2" />
|
|
1384
|
+
<button
|
|
1385
|
+
onClick={() => setSettingsModalOpen(true)}
|
|
1386
|
+
className="p-2 text-zinc-400 hover:text-zinc-600 hover:bg-zinc-50 rounded-full transition-colors"
|
|
1387
|
+
title="Settings"
|
|
1388
|
+
>
|
|
1389
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
1390
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
|
|
1391
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
1392
|
+
</svg>
|
|
1393
|
+
</button>
|
|
1394
|
+
<AuthButton />
|
|
1395
|
+
</div>
|
|
1396
|
+
</div>
|
|
1397
|
+
</header>
|
|
1398
|
+
|
|
1399
|
+
{/* Mobile nav drawer — slides in from right */}
|
|
1400
|
+
{mobileMenuOpen && (
|
|
1401
|
+
<div className="md:hidden fixed inset-0 z-[60]">
|
|
1402
|
+
{/* Backdrop */}
|
|
1403
|
+
<div
|
|
1404
|
+
className="absolute inset-0 bg-black/30 backdrop-blur-[2px]"
|
|
1405
|
+
onClick={() => setMobileMenuOpen(false)}
|
|
1406
|
+
/>
|
|
1407
|
+
{/* Drawer */}
|
|
1408
|
+
<div className="absolute top-0 right-0 h-full w-72 bg-white shadow-2xl flex flex-col animate-slide-in-right">
|
|
1409
|
+
{/* Drawer header */}
|
|
1410
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100">
|
|
1411
|
+
<span className="text-sm font-semibold text-zinc-700">Menu</span>
|
|
1412
|
+
<button
|
|
1413
|
+
onClick={() => setMobileMenuOpen(false)}
|
|
1414
|
+
className="p-1.5 text-zinc-400 hover:text-zinc-600 rounded-full hover:bg-zinc-100 transition-colors"
|
|
1415
|
+
>
|
|
1416
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
1417
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
1418
|
+
</svg>
|
|
1419
|
+
</button>
|
|
1420
|
+
</div>
|
|
1421
|
+
{/* Nav items */}
|
|
1422
|
+
<nav className="flex-1 overflow-y-auto py-2">
|
|
1423
|
+
{/* Replay */}
|
|
1424
|
+
<button
|
|
1425
|
+
onClick={() => {
|
|
1426
|
+
handleTimelineToggle();
|
|
1427
|
+
setMobileMenuOpen(false);
|
|
1428
|
+
}}
|
|
1429
|
+
className={`w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium transition-colors ${
|
|
1430
|
+
timelineActive ? "text-emerald-700 bg-emerald-50" : "text-zinc-600 hover:bg-zinc-50"
|
|
1431
|
+
}`}
|
|
1432
|
+
>
|
|
1433
|
+
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
1434
|
+
<circle cx="8" cy="8" r="6" />
|
|
1435
|
+
<polyline points="8,4 8,8 11,10" />
|
|
1436
|
+
</svg>
|
|
1437
|
+
Replay
|
|
1438
|
+
</button>
|
|
1439
|
+
{/* Comments */}
|
|
1440
|
+
<button
|
|
1441
|
+
onClick={() => {
|
|
1442
|
+
setAllCommentsPanelOpen((prev) => !prev);
|
|
1443
|
+
if (!allCommentsPanelOpen) {
|
|
1444
|
+
setSelectedNode(null);
|
|
1445
|
+
setActivityPanelOpen(false);
|
|
1446
|
+
setHelpPanelOpen(false);
|
|
1447
|
+
setTutorialStep(null);
|
|
1448
|
+
}
|
|
1449
|
+
setMobileMenuOpen(false);
|
|
1450
|
+
}}
|
|
1451
|
+
className={`w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium transition-colors ${
|
|
1452
|
+
allCommentsPanelOpen ? "text-emerald-700 bg-emerald-50" : "text-zinc-600 hover:bg-zinc-50"
|
|
1453
|
+
}`}
|
|
1454
|
+
>
|
|
1455
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
1456
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
|
1457
|
+
</svg>
|
|
1458
|
+
Comments
|
|
1459
|
+
</button>
|
|
1460
|
+
{/* Activity */}
|
|
1461
|
+
<button
|
|
1462
|
+
onClick={() => {
|
|
1463
|
+
setActivityPanelOpen((prev) => !prev);
|
|
1464
|
+
if (!activityPanelOpen) {
|
|
1465
|
+
setSelectedNode(null);
|
|
1466
|
+
setAllCommentsPanelOpen(false);
|
|
1467
|
+
setHelpPanelOpen(false);
|
|
1468
|
+
setTutorialStep(null);
|
|
1469
|
+
}
|
|
1470
|
+
setMobileMenuOpen(false);
|
|
1471
|
+
}}
|
|
1472
|
+
className={`w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium transition-colors ${
|
|
1473
|
+
activityPanelOpen ? "text-emerald-700 bg-emerald-50" : "text-zinc-600 hover:bg-zinc-50"
|
|
1474
|
+
}`}
|
|
1475
|
+
>
|
|
1476
|
+
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
1477
|
+
<circle cx="8" cy="8" r="6" />
|
|
1478
|
+
<polyline points="8,4 8,8 11,10" />
|
|
1479
|
+
</svg>
|
|
1480
|
+
Activity
|
|
1481
|
+
</button>
|
|
1482
|
+
{/* Learn */}
|
|
1483
|
+
<button
|
|
1484
|
+
onClick={() => {
|
|
1485
|
+
setHelpPanelOpen((prev) => !prev);
|
|
1486
|
+
if (!helpPanelOpen) {
|
|
1487
|
+
setSelectedNode(null);
|
|
1488
|
+
setAllCommentsPanelOpen(false);
|
|
1489
|
+
setActivityPanelOpen(false);
|
|
1490
|
+
}
|
|
1491
|
+
setMobileMenuOpen(false);
|
|
1492
|
+
}}
|
|
1493
|
+
className={`w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium transition-colors ${
|
|
1494
|
+
helpPanelOpen ? "text-emerald-700 bg-emerald-50" : "text-zinc-600 hover:bg-zinc-50"
|
|
1495
|
+
}`}
|
|
1496
|
+
>
|
|
1497
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
1498
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
|
1499
|
+
</svg>
|
|
1500
|
+
Learn
|
|
1501
|
+
</button>
|
|
1502
|
+
{/* Divider */}
|
|
1503
|
+
<div className="h-px bg-zinc-100 mx-4 my-2" />
|
|
1504
|
+
{/* Settings */}
|
|
1505
|
+
<button
|
|
1506
|
+
onClick={() => {
|
|
1507
|
+
setSettingsModalOpen(true);
|
|
1508
|
+
setMobileMenuOpen(false);
|
|
1509
|
+
}}
|
|
1510
|
+
className="w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium text-zinc-600 hover:bg-zinc-50 transition-colors"
|
|
1511
|
+
>
|
|
1512
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
1513
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
|
|
1514
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
1515
|
+
</svg>
|
|
1516
|
+
Settings
|
|
1517
|
+
</button>
|
|
1518
|
+
{/* Auth */}
|
|
1519
|
+
<div className="px-5 py-3.5">
|
|
1520
|
+
<AuthButton />
|
|
1521
|
+
</div>
|
|
1522
|
+
</nav>
|
|
1523
|
+
</div>
|
|
1524
|
+
</div>
|
|
1525
|
+
)}
|
|
1526
|
+
|
|
1527
|
+
{/* Main content */}
|
|
1528
|
+
<div className="flex-1 flex overflow-hidden relative">
|
|
1529
|
+
{/* Graph area — full width on mobile, flex-1 on desktop */}
|
|
1530
|
+
<div className="flex-1 relative bg-zinc-50/50">
|
|
1531
|
+
{/* Subtle grid pattern */}
|
|
1532
|
+
<div
|
|
1533
|
+
className="absolute inset-0 opacity-[0.03]"
|
|
1534
|
+
style={{
|
|
1535
|
+
backgroundImage:
|
|
1536
|
+
"radial-gradient(circle, #000 1px, transparent 1px)",
|
|
1537
|
+
backgroundSize: "24px 24px",
|
|
1538
|
+
}}
|
|
1539
|
+
/>
|
|
1540
|
+
|
|
1541
|
+
<BeadsGraph
|
|
1542
|
+
ref={graphRef}
|
|
1543
|
+
nodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}
|
|
1544
|
+
links={timelineActive && timelineData ? timelineData.graphData.links : data.graphData.links}
|
|
1545
|
+
selectedNode={selectedNode}
|
|
1546
|
+
hoveredNode={hoveredNode}
|
|
1547
|
+
onNodeClick={handleNodeClick}
|
|
1548
|
+
onNodeHover={handleNodeHover}
|
|
1549
|
+
onBackgroundClick={handleBackgroundClick}
|
|
1550
|
+
onNodeRightClick={handleNodeRightClick}
|
|
1551
|
+
commentedNodeIds={commentedNodeIds}
|
|
1552
|
+
claimedNodeAvatars={claimedNodeAvatars}
|
|
1553
|
+
onAvatarHover={setAvatarTooltip}
|
|
1554
|
+
timelineActive={timelineActive}
|
|
1555
|
+
stats={data.stats}
|
|
1556
|
+
sidebarOpen={!!selectedNode || allCommentsPanelOpen || activityPanelOpen || helpPanelOpen}
|
|
1557
|
+
collapsedEpicIds={collapsedEpicIds}
|
|
1558
|
+
onCollapseAll={handleCollapseAll}
|
|
1559
|
+
onExpandAll={handleExpandAll}
|
|
1560
|
+
focusedEpicId={focusedEpicId}
|
|
1561
|
+
onExitFocusedEpic={handleExitFocusedEpic}
|
|
1562
|
+
colorMode={colorMode}
|
|
1563
|
+
onColorModeChange={setColorMode}
|
|
1564
|
+
autoFit={autoFit}
|
|
1565
|
+
onAutoFitToggle={() => setAutoFit((v) => !v)}
|
|
1566
|
+
pulseNodeId={activityFeed.length > 0 ? activityFeed[0].nodeId : null}
|
|
1567
|
+
showPulse={showPulse}
|
|
1568
|
+
onShowPulseToggle={() => setShowPulse((v) => !v)}
|
|
1569
|
+
isMobile={isMobile}
|
|
1570
|
+
onNodeDoubleTap={handleNodeDoubleTap}
|
|
1571
|
+
/>
|
|
1572
|
+
|
|
1573
|
+
{/* Timeline bar — replaces legend hint when active */}
|
|
1574
|
+
{timelineActive && timelineRange && timelineRange.events.length > 0 && (
|
|
1575
|
+
<div
|
|
1576
|
+
className="absolute bottom-4 z-10 transition-[right] duration-300 ease-out"
|
|
1577
|
+
style={{ right: selectedNode || allCommentsPanelOpen || activityPanelOpen ? "calc(360px + 1rem)" : "1rem" }}
|
|
1578
|
+
>
|
|
1579
|
+
<TimelineBar
|
|
1580
|
+
totalSteps={timelineRange.events.length}
|
|
1581
|
+
currentStep={Math.max(timelineStep, 0)}
|
|
1582
|
+
currentTime={timelineStep >= 0 ? (timelineRange.events[timelineStep]?.time ?? timelineRange.minTime) : timelineRange.minTime}
|
|
1583
|
+
isPlaying={timelinePlaying}
|
|
1584
|
+
speed={timelineSpeed}
|
|
1585
|
+
onStepChange={setTimelineStep}
|
|
1586
|
+
onPlayPause={() => setTimelinePlaying((prev) => !prev)}
|
|
1587
|
+
onSpeedChange={setTimelineSpeed}
|
|
1588
|
+
/>
|
|
1589
|
+
</div>
|
|
1590
|
+
)}
|
|
1591
|
+
|
|
1592
|
+
{/* Activity overlay — top-right of canvas */}
|
|
1593
|
+
{!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !helpPanelOpen && !timelineActive && (
|
|
1594
|
+
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 z-10">
|
|
1595
|
+
<ActivityOverlay
|
|
1596
|
+
events={activityFeed}
|
|
1597
|
+
collapsed={activityOverlayCollapsed}
|
|
1598
|
+
compact={isMobile}
|
|
1599
|
+
onToggleCollapse={() => setActivityOverlayCollapsed((prev) => !prev)}
|
|
1600
|
+
onExpandPanel={() => {
|
|
1601
|
+
setActivityPanelOpen(true);
|
|
1602
|
+
setSelectedNode(null);
|
|
1603
|
+
setAllCommentsPanelOpen(false);
|
|
1604
|
+
setHelpPanelOpen(false);
|
|
1605
|
+
}}
|
|
1606
|
+
onNodeClick={(nodeId) => {
|
|
1607
|
+
const node = data?.graphData.nodes.find((n) => n.id === nodeId);
|
|
1608
|
+
if (node) focusNode(node);
|
|
1609
|
+
}}
|
|
1610
|
+
/>
|
|
1611
|
+
</div>
|
|
1612
|
+
)}
|
|
1613
|
+
|
|
1614
|
+
{/* Right-click context menu */}
|
|
1615
|
+
{contextMenu && (
|
|
1616
|
+
<ContextMenu
|
|
1617
|
+
node={contextMenu.node}
|
|
1618
|
+
x={contextMenu.x}
|
|
1619
|
+
y={contextMenu.y}
|
|
1620
|
+
onShowDescription={() => {
|
|
1621
|
+
setDescriptionModalNode(contextMenu.node);
|
|
1622
|
+
setContextMenu(null);
|
|
1623
|
+
}}
|
|
1624
|
+
onAddComment={() => {
|
|
1625
|
+
setCommentTooltipState({
|
|
1626
|
+
node: contextMenu.node,
|
|
1627
|
+
x: contextMenu.x,
|
|
1628
|
+
y: contextMenu.y,
|
|
1629
|
+
});
|
|
1630
|
+
setContextMenu(null);
|
|
1631
|
+
}}
|
|
1632
|
+
onClaimTask={
|
|
1633
|
+
isAuthenticated &&
|
|
1634
|
+
!claimedNodeAvatars.has(contextMenu.node.id)
|
|
1635
|
+
? () => {
|
|
1636
|
+
handleClaimTask(contextMenu.node.id);
|
|
1637
|
+
setContextMenu(null);
|
|
1638
|
+
}
|
|
1639
|
+
: undefined
|
|
1640
|
+
}
|
|
1641
|
+
onUnclaimTask={(() => {
|
|
1642
|
+
if (!isAuthenticated) return undefined;
|
|
1643
|
+
const claim = claimedNodeAvatars.get(contextMenu.node.id);
|
|
1644
|
+
if (!claim) return undefined;
|
|
1645
|
+
// Show "Unclaim" if claim.did matches current user, or if no did (optimistic = mine)
|
|
1646
|
+
const isMyClaim = claim.did === session?.did || !claim.did;
|
|
1647
|
+
if (!isMyClaim) return undefined;
|
|
1648
|
+
return () => {
|
|
1649
|
+
handleUnclaimTask(contextMenu.node.id);
|
|
1650
|
+
setContextMenu(null);
|
|
1651
|
+
};
|
|
1652
|
+
})()}
|
|
1653
|
+
onCollapseEpic={
|
|
1654
|
+
contextMenu.node.issueType === "epic" &&
|
|
1655
|
+
!collapsedEpicIds.has(contextMenu.node.id)
|
|
1656
|
+
? () => {
|
|
1657
|
+
handleToggleEpicCollapse(contextMenu.node.id);
|
|
1658
|
+
setContextMenu(null);
|
|
1659
|
+
}
|
|
1660
|
+
: undefined
|
|
1661
|
+
}
|
|
1662
|
+
onUncollapseEpic={
|
|
1663
|
+
contextMenu.node.issueType === "epic" &&
|
|
1664
|
+
collapsedEpicIds.has(contextMenu.node.id)
|
|
1665
|
+
? () => {
|
|
1666
|
+
handleToggleEpicCollapse(contextMenu.node.id);
|
|
1667
|
+
setContextMenu(null);
|
|
1668
|
+
}
|
|
1669
|
+
: undefined
|
|
1670
|
+
}
|
|
1671
|
+
onFocusEpic={
|
|
1672
|
+
contextMenu.node.issueType === "epic" && !focusedEpicId
|
|
1673
|
+
? () => {
|
|
1674
|
+
handleFocusEpic(contextMenu.node.id);
|
|
1675
|
+
setContextMenu(null);
|
|
1676
|
+
}
|
|
1677
|
+
: undefined
|
|
1678
|
+
}
|
|
1679
|
+
onExitFocusEpic={
|
|
1680
|
+
contextMenu.node.issueType === "epic" &&
|
|
1681
|
+
focusedEpicId === contextMenu.node.id
|
|
1682
|
+
? () => {
|
|
1683
|
+
handleExitFocusedEpic();
|
|
1684
|
+
setContextMenu(null);
|
|
1685
|
+
}
|
|
1686
|
+
: undefined
|
|
1687
|
+
}
|
|
1688
|
+
onClose={() => setContextMenu(null)}
|
|
1689
|
+
/>
|
|
1690
|
+
)}
|
|
1691
|
+
|
|
1692
|
+
{/* Mobile action sheet (double-tap on mobile) */}
|
|
1693
|
+
{mobileActionSheet && (
|
|
1694
|
+
<MobileActionSheet
|
|
1695
|
+
node={mobileActionSheet.node}
|
|
1696
|
+
onShowDescription={
|
|
1697
|
+
mobileActionSheet.node.description
|
|
1698
|
+
? () => {
|
|
1699
|
+
setDescriptionModalNode(mobileActionSheet.node);
|
|
1700
|
+
setMobileActionSheet(null);
|
|
1701
|
+
}
|
|
1702
|
+
: undefined
|
|
1703
|
+
}
|
|
1704
|
+
onAddComment={() => {
|
|
1705
|
+
setCommentTooltipState({
|
|
1706
|
+
node: mobileActionSheet.node,
|
|
1707
|
+
x: window.innerWidth / 2,
|
|
1708
|
+
y: window.innerHeight / 2,
|
|
1709
|
+
});
|
|
1710
|
+
setMobileActionSheet(null);
|
|
1711
|
+
}}
|
|
1712
|
+
onClaimTask={
|
|
1713
|
+
isAuthenticated &&
|
|
1714
|
+
!claimedNodeAvatars.has(mobileActionSheet.node.id)
|
|
1715
|
+
? () => {
|
|
1716
|
+
handleClaimTask(mobileActionSheet.node.id);
|
|
1717
|
+
setMobileActionSheet(null);
|
|
1718
|
+
}
|
|
1719
|
+
: undefined
|
|
1720
|
+
}
|
|
1721
|
+
onUnclaimTask={(() => {
|
|
1722
|
+
if (!isAuthenticated) return undefined;
|
|
1723
|
+
const claim = claimedNodeAvatars.get(mobileActionSheet.node.id);
|
|
1724
|
+
if (!claim) return undefined;
|
|
1725
|
+
const isMyClaim = claim.did === session?.did || !claim.did;
|
|
1726
|
+
if (!isMyClaim) return undefined;
|
|
1727
|
+
return () => {
|
|
1728
|
+
handleUnclaimTask(mobileActionSheet.node.id);
|
|
1729
|
+
setMobileActionSheet(null);
|
|
1730
|
+
};
|
|
1731
|
+
})()}
|
|
1732
|
+
onCollapseEpic={
|
|
1733
|
+
mobileActionSheet.node.issueType === "epic" &&
|
|
1734
|
+
!collapsedEpicIds.has(mobileActionSheet.node.id)
|
|
1735
|
+
? () => {
|
|
1736
|
+
handleToggleEpicCollapse(mobileActionSheet.node.id);
|
|
1737
|
+
setMobileActionSheet(null);
|
|
1738
|
+
}
|
|
1739
|
+
: undefined
|
|
1740
|
+
}
|
|
1741
|
+
onUncollapseEpic={
|
|
1742
|
+
mobileActionSheet.node.issueType === "epic" &&
|
|
1743
|
+
collapsedEpicIds.has(mobileActionSheet.node.id)
|
|
1744
|
+
? () => {
|
|
1745
|
+
handleToggleEpicCollapse(mobileActionSheet.node.id);
|
|
1746
|
+
setMobileActionSheet(null);
|
|
1747
|
+
}
|
|
1748
|
+
: undefined
|
|
1749
|
+
}
|
|
1750
|
+
onFocusEpic={
|
|
1751
|
+
mobileActionSheet.node.issueType === "epic" && !focusedEpicId
|
|
1752
|
+
? () => {
|
|
1753
|
+
handleFocusEpic(mobileActionSheet.node.id);
|
|
1754
|
+
setMobileActionSheet(null);
|
|
1755
|
+
}
|
|
1756
|
+
: undefined
|
|
1757
|
+
}
|
|
1758
|
+
onExitFocusEpic={
|
|
1759
|
+
mobileActionSheet.node.issueType === "epic" &&
|
|
1760
|
+
focusedEpicId === mobileActionSheet.node.id
|
|
1761
|
+
? () => {
|
|
1762
|
+
handleExitFocusedEpic();
|
|
1763
|
+
setMobileActionSheet(null);
|
|
1764
|
+
}
|
|
1765
|
+
: undefined
|
|
1766
|
+
}
|
|
1767
|
+
onClose={() => setMobileActionSheet(null)}
|
|
1768
|
+
/>
|
|
1769
|
+
)}
|
|
1770
|
+
|
|
1771
|
+
{/* Comment tooltip (opened from context menu "Add comment") */}
|
|
1772
|
+
{commentTooltipState && (
|
|
1773
|
+
<CommentTooltip
|
|
1774
|
+
node={commentTooltipState.node}
|
|
1775
|
+
x={commentTooltipState.x}
|
|
1776
|
+
y={commentTooltipState.y}
|
|
1777
|
+
onClose={() => setCommentTooltipState(null)}
|
|
1778
|
+
onSubmit={async (text) => {
|
|
1779
|
+
await handlePostComment(commentTooltipState.node.id, text);
|
|
1780
|
+
setCommentTooltipState(null);
|
|
1781
|
+
}}
|
|
1782
|
+
isAuthenticated={isAuthenticated}
|
|
1783
|
+
existingComments={commentsByNode.get(
|
|
1784
|
+
commentTooltipState.node.id
|
|
1785
|
+
)}
|
|
1786
|
+
/>
|
|
1787
|
+
)}
|
|
1788
|
+
|
|
1789
|
+
{/* Description modal (opened from context menu "Show description") */}
|
|
1790
|
+
{descriptionModalNode && (
|
|
1791
|
+
<DescriptionModal
|
|
1792
|
+
node={descriptionModalNode}
|
|
1793
|
+
onClose={() => setDescriptionModalNode(null)}
|
|
1794
|
+
repoUrl={repoUrls[descriptionModalNode.prefix]}
|
|
1795
|
+
onOpenSettings={() => setSettingsModalOpen(true)}
|
|
1796
|
+
/>
|
|
1797
|
+
)}
|
|
1798
|
+
|
|
1799
|
+
{/* Settings modal */}
|
|
1800
|
+
<SettingsModal
|
|
1801
|
+
isOpen={settingsModalOpen}
|
|
1802
|
+
onClose={() => setSettingsModalOpen(false)}
|
|
1803
|
+
/>
|
|
1804
|
+
|
|
1805
|
+
{/* Node hover tooltip */}
|
|
1806
|
+
{nodeTooltip && !avatarTooltip && !isMobile && (
|
|
1807
|
+
<BeadTooltip
|
|
1808
|
+
node={nodeTooltip.node}
|
|
1809
|
+
x={nodeTooltip.x}
|
|
1810
|
+
y={nodeTooltip.y}
|
|
1811
|
+
prefixColor={getCatppuccinPrefixColor(nodeTooltip.node.prefix)}
|
|
1812
|
+
allNodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}
|
|
1813
|
+
/>
|
|
1814
|
+
)}
|
|
1815
|
+
|
|
1816
|
+
{/* Avatar hover tooltip */}
|
|
1817
|
+
{avatarTooltip && !isMobile && (
|
|
1818
|
+
<div
|
|
1819
|
+
style={{
|
|
1820
|
+
position: "fixed",
|
|
1821
|
+
left: avatarTooltip.x + 12,
|
|
1822
|
+
top: avatarTooltip.y - 8,
|
|
1823
|
+
zIndex: 90,
|
|
1824
|
+
pointerEvents: "none",
|
|
1825
|
+
}}
|
|
1826
|
+
>
|
|
1827
|
+
<div className="flex items-center gap-2 bg-white border border-zinc-200 rounded-lg shadow-lg px-2.5 py-2">
|
|
1828
|
+
{avatarTooltip.avatar ? (
|
|
1829
|
+
/* eslint-disable-next-line @next/next/no-img-element */
|
|
1830
|
+
<img
|
|
1831
|
+
src={avatarTooltip.avatar}
|
|
1832
|
+
alt=""
|
|
1833
|
+
className="w-6 h-6 rounded-full shrink-0"
|
|
1834
|
+
/>
|
|
1835
|
+
) : (
|
|
1836
|
+
<div className="w-6 h-6 rounded-full bg-zinc-200 flex items-center justify-center text-[10px] font-medium text-zinc-500 shrink-0">
|
|
1837
|
+
{avatarTooltip.handle.charAt(0).toUpperCase()}
|
|
1838
|
+
</div>
|
|
1839
|
+
)}
|
|
1840
|
+
<div className="flex flex-col">
|
|
1841
|
+
<span className="text-xs text-zinc-700 whitespace-nowrap">
|
|
1842
|
+
{avatarTooltip.did ? (
|
|
1843
|
+
<a
|
|
1844
|
+
href={`https://www.impactindexer.org/data?did=${avatarTooltip.did}`}
|
|
1845
|
+
target="_blank"
|
|
1846
|
+
rel="noopener noreferrer"
|
|
1847
|
+
className="font-semibold text-zinc-800 hover:text-emerald-600 transition-colors"
|
|
1848
|
+
style={{ pointerEvents: "auto" }}
|
|
1849
|
+
>
|
|
1850
|
+
{avatarTooltip.handle}
|
|
1851
|
+
</a>
|
|
1852
|
+
) : (
|
|
1853
|
+
<span className="font-semibold text-zinc-800">{avatarTooltip.handle}</span>
|
|
1854
|
+
)} claimed this task
|
|
1855
|
+
</span>
|
|
1856
|
+
<span className="text-[10px] text-zinc-400">
|
|
1857
|
+
{formatRelativeTime(avatarTooltip.claimedAt)}
|
|
1858
|
+
</span>
|
|
1859
|
+
</div>
|
|
1860
|
+
</div>
|
|
1861
|
+
</div>
|
|
1862
|
+
)}
|
|
1863
|
+
|
|
1864
|
+
</div>
|
|
1865
|
+
|
|
1866
|
+
{/* Desktop sidebar — slides in from right as an overlay when a node is selected */}
|
|
1867
|
+
<aside
|
|
1868
|
+
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 ${
|
|
1869
|
+
selectedNode ? "translate-x-0" : "translate-x-full"
|
|
1870
|
+
}`}
|
|
1871
|
+
>
|
|
1872
|
+
{/* Sidebar header with close button */}
|
|
1873
|
+
<div className="shrink-0 px-5 py-3 border-b border-zinc-100 flex items-center justify-between">
|
|
1874
|
+
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
|
1875
|
+
Node Detail
|
|
1876
|
+
</h2>
|
|
1877
|
+
<button
|
|
1878
|
+
onClick={() => {
|
|
1879
|
+
setSelectedNode(null);
|
|
1880
|
+
}}
|
|
1881
|
+
className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors rounded-full hover:bg-zinc-100"
|
|
1882
|
+
>
|
|
1883
|
+
<svg
|
|
1884
|
+
className="w-4 h-4"
|
|
1885
|
+
fill="none"
|
|
1886
|
+
stroke="currentColor"
|
|
1887
|
+
viewBox="0 0 24 24"
|
|
1888
|
+
strokeWidth={1.5}
|
|
1889
|
+
>
|
|
1890
|
+
<path
|
|
1891
|
+
strokeLinecap="round"
|
|
1892
|
+
strokeLinejoin="round"
|
|
1893
|
+
d="M6 18L18 6M6 6l12 12"
|
|
1894
|
+
/>
|
|
1895
|
+
</svg>
|
|
1896
|
+
</button>
|
|
1897
|
+
</div>
|
|
1898
|
+
|
|
1899
|
+
{/* Sidebar content */}
|
|
1900
|
+
<div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-5">
|
|
1901
|
+
{/* Selected node detail */}
|
|
1902
|
+
<NodeDetail
|
|
1903
|
+
node={selectedNode}
|
|
1904
|
+
allNodes={data.graphData.nodes}
|
|
1905
|
+
onNodeNavigate={handleNodeNavigate}
|
|
1906
|
+
comments={
|
|
1907
|
+
selectedNode
|
|
1908
|
+
? commentsByNode.get(selectedNode.id)
|
|
1909
|
+
: undefined
|
|
1910
|
+
}
|
|
1911
|
+
onPostComment={
|
|
1912
|
+
selectedNode
|
|
1913
|
+
? (text: string) =>
|
|
1914
|
+
handlePostComment(selectedNode.id, text)
|
|
1915
|
+
: undefined
|
|
1916
|
+
}
|
|
1917
|
+
onDeleteComment={handleDeleteComment}
|
|
1918
|
+
onLikeComment={handleLikeComment}
|
|
1919
|
+
onReplyComment={handleReplyComment}
|
|
1920
|
+
isAuthenticated={isAuthenticated}
|
|
1921
|
+
currentDid={session?.did}
|
|
1922
|
+
repoUrls={repoUrls}
|
|
1923
|
+
onOpenSettings={() => setSettingsModalOpen(true)}
|
|
1924
|
+
/>
|
|
1925
|
+
|
|
1926
|
+
</div>
|
|
1927
|
+
</aside>
|
|
1928
|
+
|
|
1929
|
+
{/* Mobile bottom drawer — slides up when a node is selected */}
|
|
1930
|
+
<div
|
|
1931
|
+
className={`md:hidden fixed inset-x-0 bottom-0 z-20 transform transition-transform duration-300 ease-out ${
|
|
1932
|
+
selectedNode ? "translate-y-0" : "translate-y-full"
|
|
1933
|
+
}`}
|
|
1934
|
+
>
|
|
1935
|
+
<div className="bg-white rounded-t-2xl border-t border-zinc-200 shadow-lg max-h-[60vh] flex flex-col">
|
|
1936
|
+
{/* Drag handle + close */}
|
|
1937
|
+
<div className="shrink-0 flex items-center justify-between px-4 pt-3 pb-2">
|
|
1938
|
+
<div className="w-8 h-1 bg-zinc-300 rounded-full mx-auto" />
|
|
1939
|
+
<button
|
|
1940
|
+
onClick={() => setSelectedNode(null)}
|
|
1941
|
+
className="absolute right-3 top-3 p-1 text-zinc-400 hover:text-zinc-600"
|
|
1942
|
+
>
|
|
1943
|
+
<svg
|
|
1944
|
+
className="w-5 h-5"
|
|
1945
|
+
fill="none"
|
|
1946
|
+
stroke="currentColor"
|
|
1947
|
+
viewBox="0 0 24 24"
|
|
1948
|
+
strokeWidth={2}
|
|
1949
|
+
>
|
|
1950
|
+
<path
|
|
1951
|
+
strokeLinecap="round"
|
|
1952
|
+
strokeLinejoin="round"
|
|
1953
|
+
d="M6 18L18 6M6 6l12 12"
|
|
1954
|
+
/>
|
|
1955
|
+
</svg>
|
|
1956
|
+
</button>
|
|
1957
|
+
</div>
|
|
1958
|
+
|
|
1959
|
+
{/* Drawer content */}
|
|
1960
|
+
<div className="flex-1 overflow-y-auto custom-scrollbar px-4 pb-6 space-y-4">
|
|
1961
|
+
<NodeDetail
|
|
1962
|
+
node={selectedNode}
|
|
1963
|
+
allNodes={data.graphData.nodes}
|
|
1964
|
+
onNodeNavigate={handleNodeNavigate}
|
|
1965
|
+
comments={
|
|
1966
|
+
selectedNode
|
|
1967
|
+
? commentsByNode.get(selectedNode.id)
|
|
1968
|
+
: undefined
|
|
1969
|
+
}
|
|
1970
|
+
onPostComment={
|
|
1971
|
+
selectedNode
|
|
1972
|
+
? (text: string) =>
|
|
1973
|
+
handlePostComment(selectedNode.id, text)
|
|
1974
|
+
: undefined
|
|
1975
|
+
}
|
|
1976
|
+
onDeleteComment={handleDeleteComment}
|
|
1977
|
+
onLikeComment={handleLikeComment}
|
|
1978
|
+
onReplyComment={handleReplyComment}
|
|
1979
|
+
isAuthenticated={isAuthenticated}
|
|
1980
|
+
currentDid={session?.did}
|
|
1981
|
+
repoUrls={repoUrls}
|
|
1982
|
+
onOpenSettings={() => setSettingsModalOpen(true)}
|
|
1983
|
+
/>
|
|
1984
|
+
</div>
|
|
1985
|
+
</div>
|
|
1986
|
+
</div>
|
|
1987
|
+
|
|
1988
|
+
{/* All Comments panel */}
|
|
1989
|
+
<AllCommentsPanel
|
|
1990
|
+
isOpen={allCommentsPanelOpen}
|
|
1991
|
+
onClose={() => setAllCommentsPanelOpen(false)}
|
|
1992
|
+
allComments={allComments.filter((c) => localNodeIds.has(c.nodeId))}
|
|
1993
|
+
onNodeNavigate={(nodeId) => {
|
|
1994
|
+
handleNodeNavigate(nodeId);
|
|
1995
|
+
setAllCommentsPanelOpen(false);
|
|
1996
|
+
}}
|
|
1997
|
+
isAuthenticated={isAuthenticated}
|
|
1998
|
+
currentDid={session?.did}
|
|
1999
|
+
onLikeComment={handleLikeComment}
|
|
2000
|
+
onDeleteComment={handleDeleteComment}
|
|
2001
|
+
/>
|
|
2002
|
+
|
|
2003
|
+
{/* Activity panel */}
|
|
2004
|
+
<ActivityPanel
|
|
2005
|
+
events={activityFeed}
|
|
2006
|
+
isOpen={activityPanelOpen}
|
|
2007
|
+
onClose={() => setActivityPanelOpen(false)}
|
|
2008
|
+
onNodeClick={(nodeId) => {
|
|
2009
|
+
const node = data?.graphData.nodes.find((n) => n.id === nodeId);
|
|
2010
|
+
if (node) {
|
|
2011
|
+
focusNode(node);
|
|
2012
|
+
setActivityPanelOpen(false);
|
|
2013
|
+
}
|
|
2014
|
+
}}
|
|
2015
|
+
/>
|
|
2016
|
+
|
|
2017
|
+
{/* Help panel */}
|
|
2018
|
+
<HelpPanel
|
|
2019
|
+
isOpen={helpPanelOpen}
|
|
2020
|
+
onClose={() => {
|
|
2021
|
+
setHelpPanelOpen(false);
|
|
2022
|
+
setTutorialStep(null);
|
|
2023
|
+
}}
|
|
2024
|
+
tutorialStep={tutorialStep}
|
|
2025
|
+
onStartTutorial={handleStartTutorial}
|
|
2026
|
+
onNextStep={handleNextTutorialStep}
|
|
2027
|
+
onPrevStep={handlePrevTutorialStep}
|
|
2028
|
+
onEndTutorial={handleEndTutorial}
|
|
2029
|
+
/>
|
|
2030
|
+
</div>
|
|
2031
|
+
|
|
2032
|
+
{/* Tutorial spotlight overlay */}
|
|
2033
|
+
<TutorialOverlay
|
|
2034
|
+
step={tutorialStep}
|
|
2035
|
+
onNext={handleNextTutorialStep}
|
|
2036
|
+
onPrev={handlePrevTutorialStep}
|
|
2037
|
+
onEnd={handleEndTutorial}
|
|
2038
|
+
/>
|
|
2039
|
+
</div>
|
|
2040
|
+
);
|
|
2041
|
+
}
|