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.
Files changed (205) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +49 -0
  3. package/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/build-manifest.json +32 -0
  5. package/.next/export-marker.json +1 -0
  6. package/.next/images-manifest.json +1 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +1 -0
  11. package/.next/react-loadable-manifest.json +8 -0
  12. package/.next/required-server-files.json +1 -0
  13. package/.next/routes-manifest.json +1 -0
  14. package/.next/server/app/_not-found/page.js +1 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +6 -0
  19. package/.next/server/app/_not-found.rsc +9 -0
  20. package/.next/server/app/api/auth/route.js +1 -0
  21. package/.next/server/app/api/auth/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/route.js +8 -0
  23. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads/stream/route.js +10 -0
  25. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  26. package/.next/server/app/api/beads.body +1 -0
  27. package/.next/server/app/api/beads.meta +1 -0
  28. package/.next/server/app/api/config/route.js +8 -0
  29. package/.next/server/app/api/config/route.js.nft.json +1 -0
  30. package/.next/server/app/api/docs/page.js +120 -0
  31. package/.next/server/app/api/docs/page.js.nft.json +1 -0
  32. package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
  33. package/.next/server/app/api/docs.html +120 -0
  34. package/.next/server/app/api/docs.meta +5 -0
  35. package/.next/server/app/api/docs.rsc +70 -0
  36. package/.next/server/app/api/login/route.js +1 -0
  37. package/.next/server/app/api/login/route.js.nft.json +1 -0
  38. package/.next/server/app/api/logout/route.js +1 -0
  39. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  40. package/.next/server/app/api/oauth/callback/route.js +1 -0
  41. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  42. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  43. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  44. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  45. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  46. package/.next/server/app/api/records/route.js +1 -0
  47. package/.next/server/app/api/records/route.js.nft.json +1 -0
  48. package/.next/server/app/api/status/route.js +1 -0
  49. package/.next/server/app/api/status/route.js.nft.json +1 -0
  50. package/.next/server/app/api/v1/graph/route.js +1 -0
  51. package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
  52. package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
  53. package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
  54. package/.next/server/app/api/v1/ready/route.js +1 -0
  55. package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
  56. package/.next/server/app/index.html +1 -0
  57. package/.next/server/app/index.meta +5 -0
  58. package/.next/server/app/index.rsc +9 -0
  59. package/.next/server/app/login/page.js +1 -0
  60. package/.next/server/app/login/page.js.nft.json +1 -0
  61. package/.next/server/app/login/page_client-reference-manifest.js +1 -0
  62. package/.next/server/app/login.html +1 -0
  63. package/.next/server/app/login.meta +5 -0
  64. package/.next/server/app/login.rsc +9 -0
  65. package/.next/server/app/opengraph-image.png/route.js +1 -0
  66. package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
  67. package/.next/server/app/opengraph-image.png.body +0 -0
  68. package/.next/server/app/opengraph-image.png.meta +1 -0
  69. package/.next/server/app/page.js +24 -0
  70. package/.next/server/app/page.js.nft.json +1 -0
  71. package/.next/server/app/page_client-reference-manifest.js +1 -0
  72. package/.next/server/app/twitter-image.png/route.js +1 -0
  73. package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
  74. package/.next/server/app/twitter-image.png.body +0 -0
  75. package/.next/server/app/twitter-image.png.meta +1 -0
  76. package/.next/server/app-paths-manifest.json +22 -0
  77. package/.next/server/chunks/247.js +12 -0
  78. package/.next/server/chunks/29.js +1 -0
  79. package/.next/server/chunks/343.js +1 -0
  80. package/.next/server/chunks/460.js +12 -0
  81. package/.next/server/chunks/533.js +38 -0
  82. package/.next/server/chunks/542.js +27 -0
  83. package/.next/server/chunks/590.js +6 -0
  84. package/.next/server/chunks/615.js +15 -0
  85. package/.next/server/chunks/696.js +25 -0
  86. package/.next/server/chunks/719.js +2 -0
  87. package/.next/server/chunks/739.js +1 -0
  88. package/.next/server/chunks/950.js +2 -0
  89. package/.next/server/chunks/font-manifest.json +1 -0
  90. package/.next/server/edge-runtime-webpack.js +2 -0
  91. package/.next/server/edge-runtime-webpack.js.map +1 -0
  92. package/.next/server/font-manifest.json +1 -0
  93. package/.next/server/functions-config-manifest.json +1 -0
  94. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  95. package/.next/server/middleware-build-manifest.js +1 -0
  96. package/.next/server/middleware-manifest.json +32 -0
  97. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  98. package/.next/server/middleware.js +14 -0
  99. package/.next/server/middleware.js.map +1 -0
  100. package/.next/server/next-font-manifest.js +1 -0
  101. package/.next/server/next-font-manifest.json +1 -0
  102. package/.next/server/pages/404.html +1 -0
  103. package/.next/server/pages/500.html +1 -0
  104. package/.next/server/pages/_app.js +1 -0
  105. package/.next/server/pages/_app.js.nft.json +1 -0
  106. package/.next/server/pages/_document.js +1 -0
  107. package/.next/server/pages/_document.js.nft.json +1 -0
  108. package/.next/server/pages/_error.js +1 -0
  109. package/.next/server/pages/_error.js.nft.json +1 -0
  110. package/.next/server/pages-manifest.json +1 -0
  111. package/.next/server/server-reference-manifest.js +1 -0
  112. package/.next/server/server-reference-manifest.json +1 -0
  113. package/.next/server/webpack-runtime.js +1 -0
  114. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  115. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  116. package/.next/static/chunks/788-aa413085174e935a.js +1 -0
  117. package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
  118. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  119. package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
  120. package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
  121. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  122. package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
  123. package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
  124. package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
  125. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  126. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  127. package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
  128. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  129. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  130. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  131. package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
  132. package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
  133. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
  134. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
  135. package/README.md +389 -0
  136. package/app/api/auth/route.ts +103 -0
  137. package/app/api/beads/route.ts +27 -0
  138. package/app/api/beads/stream/route.ts +83 -0
  139. package/app/api/config/route.ts +48 -0
  140. package/app/api/docs/page.tsx +497 -0
  141. package/app/api/login/route.ts +42 -0
  142. package/app/api/logout/route.ts +14 -0
  143. package/app/api/oauth/callback/route.ts +97 -0
  144. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  145. package/app/api/oauth/jwks.json/route.ts +32 -0
  146. package/app/api/records/route.ts +168 -0
  147. package/app/api/status/route.ts +25 -0
  148. package/app/api/v1/graph/route.ts +251 -0
  149. package/app/api/v1/issues/[id]/route.ts +158 -0
  150. package/app/api/v1/ready/route.ts +229 -0
  151. package/app/globals.css +230 -0
  152. package/app/layout.tsx +51 -0
  153. package/app/login/page.tsx +164 -0
  154. package/app/not-found.tsx +91 -0
  155. package/app/opengraph-image.png +0 -0
  156. package/app/page.tsx +2041 -0
  157. package/app/twitter-image.png +0 -0
  158. package/bin/heartbeads.mjs +225 -0
  159. package/components/ActivityItem.tsx +326 -0
  160. package/components/ActivityOverlay.tsx +125 -0
  161. package/components/ActivityPanel.tsx +345 -0
  162. package/components/AllCommentsPanel.tsx +270 -0
  163. package/components/AuthButton.tsx +202 -0
  164. package/components/BeadTooltip.tsx +246 -0
  165. package/components/BeadsGraph.tsx +2493 -0
  166. package/components/BeadsLogo.tsx +94 -0
  167. package/components/CommentTooltip.tsx +338 -0
  168. package/components/ContextMenu.tsx +272 -0
  169. package/components/DescriptionModal.tsx +595 -0
  170. package/components/GraphStats.tsx +121 -0
  171. package/components/HeartIcon.tsx +33 -0
  172. package/components/HelpPanel.tsx +339 -0
  173. package/components/MobileActionSheet.tsx +255 -0
  174. package/components/NodeDetail.tsx +793 -0
  175. package/components/SettingsModal.tsx +315 -0
  176. package/components/StatusLegend.tsx +99 -0
  177. package/components/TimelineBar.tsx +116 -0
  178. package/components/TutorialOverlay.tsx +235 -0
  179. package/hooks/useBeadsComments.ts +81 -0
  180. package/hooks/useIsMobile.ts +19 -0
  181. package/lib/activity.ts +377 -0
  182. package/lib/agent.ts +29 -0
  183. package/lib/api-helpers.ts +46 -0
  184. package/lib/auth/client.ts +221 -0
  185. package/lib/auth.tsx +159 -0
  186. package/lib/comments.ts +413 -0
  187. package/lib/diff-beads.ts +128 -0
  188. package/lib/discover.ts +228 -0
  189. package/lib/env.ts +33 -0
  190. package/lib/gate.ts +55 -0
  191. package/lib/parse-beads.ts +234 -0
  192. package/lib/session.ts +52 -0
  193. package/lib/settings.ts +42 -0
  194. package/lib/timeline.ts +138 -0
  195. package/lib/tts.ts +397 -0
  196. package/lib/types.ts +271 -0
  197. package/lib/utils.ts +48 -0
  198. package/lib/watch-beads.ts +97 -0
  199. package/next.config.mjs +4 -0
  200. package/package.json +81 -0
  201. package/postcss.config.mjs +9 -0
  202. package/public/image.png +0 -0
  203. package/scripts/generate-jwk.js +38 -0
  204. package/tailwind.config.ts +41 -0
  205. package/tsconfig.json +24 -0
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Animated heartbeat (ECG) logo for heartbeads.
5
+ * A minimalistic, elegant heartbeat trace line that continuously animates
6
+ * with a drawing/pulse effect — representing the living pulse of a project.
7
+ */
8
+ export function BeadsLogo({ className }: { className?: string }) {
9
+ return (
10
+ <svg
11
+ viewBox="0 0 32 32"
12
+ fill="none"
13
+ className={className}
14
+ aria-hidden="true"
15
+ >
16
+ {/* Heartbeat ECG trace — the signature PQRST waveform */}
17
+ <polyline
18
+ points="1,16 6,16 8,16 9.5,14 11,18 12.5,6 14,26 15.5,10 17,16 19,16 20.5,14.5 21.5,17.5 22.5,16 31,16"
19
+ stroke="currentColor"
20
+ strokeWidth="1.5"
21
+ strokeLinecap="round"
22
+ strokeLinejoin="round"
23
+ fill="none"
24
+ opacity="0.9"
25
+ >
26
+ {/* Dash animation: line draws itself, then fades and redraws */}
27
+ <animate
28
+ attributeName="stroke-dasharray"
29
+ values="0 60;60 0"
30
+ dur="2s"
31
+ repeatCount="indefinite"
32
+ />
33
+ <animate
34
+ attributeName="opacity"
35
+ values="0.4;0.95;0.95;0.4"
36
+ dur="2s"
37
+ repeatCount="indefinite"
38
+ />
39
+ </polyline>
40
+
41
+ {/* Trailing glow — a second faded copy offset in time */}
42
+ <polyline
43
+ points="1,16 6,16 8,16 9.5,14 11,18 12.5,6 14,26 15.5,10 17,16 19,16 20.5,14.5 21.5,17.5 22.5,16 31,16"
44
+ stroke="currentColor"
45
+ strokeWidth="2.5"
46
+ strokeLinecap="round"
47
+ strokeLinejoin="round"
48
+ fill="none"
49
+ opacity="0.15"
50
+ >
51
+ <animate
52
+ attributeName="stroke-dasharray"
53
+ values="0 60;60 0"
54
+ dur="2s"
55
+ repeatCount="indefinite"
56
+ />
57
+ <animate
58
+ attributeName="opacity"
59
+ values="0;0.2;0.2;0"
60
+ dur="2s"
61
+ repeatCount="indefinite"
62
+ />
63
+ </polyline>
64
+
65
+ {/* Pulse dot — travels along the trace */}
66
+ <circle cx="0" cy="16" r="1.5" fill="currentColor" opacity="0">
67
+ <animate
68
+ attributeName="cx"
69
+ values="1;6;8;9.5;11;12.5;14;15.5;17;19;20.5;21.5;22.5;31"
70
+ dur="2s"
71
+ repeatCount="indefinite"
72
+ />
73
+ <animate
74
+ attributeName="cy"
75
+ values="16;16;16;14;18;6;26;10;16;16;14.5;17.5;16;16"
76
+ dur="2s"
77
+ repeatCount="indefinite"
78
+ />
79
+ <animate
80
+ attributeName="opacity"
81
+ values="0;0.8;1;1;1;1;1;1;0.8;0.6;0.6;0.6;0.4;0"
82
+ dur="2s"
83
+ repeatCount="indefinite"
84
+ />
85
+ <animate
86
+ attributeName="r"
87
+ values="1;1.5;2;2.5;2;2.5;2;1.5;1.5;1;1;1;1;0.5"
88
+ dur="2s"
89
+ repeatCount="indefinite"
90
+ />
91
+ </circle>
92
+ </svg>
93
+ );
94
+ }
@@ -0,0 +1,338 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect, useCallback } from "react";
4
+ import type { GraphNode } from "@/lib/types";
5
+ import { PREFIX_COLORS } from "@/lib/types";
6
+ import type { BeadsComment } from "@/hooks/useBeadsComments";
7
+ import { useIsMobile } from "@/hooks/useIsMobile";
8
+
9
+ interface CommentTooltipProps {
10
+ node: GraphNode;
11
+ x: number;
12
+ y: number;
13
+ onClose: () => void;
14
+ onSubmit: (text: string) => Promise<void>;
15
+ isAuthenticated: boolean;
16
+ existingComments?: BeadsComment[];
17
+ }
18
+
19
+ export function CommentTooltip({
20
+ node,
21
+ x,
22
+ y,
23
+ onClose,
24
+ onSubmit,
25
+ isAuthenticated,
26
+ existingComments,
27
+ }: CommentTooltipProps) {
28
+ const [text, setText] = useState("");
29
+ const [sending, setSending] = useState(false);
30
+ const tooltipRef = useRef<HTMLDivElement>(null);
31
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
32
+ const [pos, setPos] = useState({ x: 0, y: 0 });
33
+ const [visible, setVisible] = useState(false);
34
+ const isMobile = useIsMobile();
35
+
36
+ // Position the tooltip after first render (measure dimensions)
37
+ useEffect(() => {
38
+ if (!tooltipRef.current) return;
39
+ const tt = tooltipRef.current.getBoundingClientRect();
40
+ const vw = window.innerWidth;
41
+ const vh = window.innerHeight;
42
+
43
+ let nx = x + 14;
44
+ let ny = y - tt.height - 14;
45
+
46
+ // Clamp right
47
+ if (nx + tt.width > vw - 16) nx = vw - tt.width - 16;
48
+ // Clamp left
49
+ if (nx < 16) nx = 16;
50
+ // If overflows top, flip below cursor
51
+ if (ny < 16) ny = y + 28;
52
+ // Clamp bottom
53
+ if (ny + tt.height > vh - 16) ny = vh - tt.height - 16;
54
+
55
+ setPos({ x: nx, y: ny });
56
+ setVisible(true);
57
+ }, [x, y]);
58
+
59
+ // Auto-focus textarea on mount
60
+ useEffect(() => {
61
+ if (isAuthenticated) {
62
+ // Small delay to let position settle
63
+ const timer = setTimeout(() => textareaRef.current?.focus(), 100);
64
+ return () => clearTimeout(timer);
65
+ }
66
+ }, [isAuthenticated]);
67
+
68
+ // Close on Escape
69
+ useEffect(() => {
70
+ const handleKeyDown = (e: KeyboardEvent) => {
71
+ if (e.key === "Escape") onClose();
72
+ };
73
+ window.addEventListener("keydown", handleKeyDown);
74
+ return () => window.removeEventListener("keydown", handleKeyDown);
75
+ }, [onClose]);
76
+
77
+ // Close on click outside
78
+ useEffect(() => {
79
+ const handleMouseDown = (e: MouseEvent) => {
80
+ if (
81
+ tooltipRef.current &&
82
+ !tooltipRef.current.contains(e.target as Node)
83
+ ) {
84
+ onClose();
85
+ }
86
+ };
87
+ // Use a small delay so the right-click that opened the tooltip doesn't
88
+ // immediately close it
89
+ const timer = setTimeout(() => {
90
+ window.addEventListener("mousedown", handleMouseDown);
91
+ }, 50);
92
+ return () => {
93
+ clearTimeout(timer);
94
+ window.removeEventListener("mousedown", handleMouseDown);
95
+ };
96
+ }, [onClose]);
97
+
98
+ const handleSubmit = useCallback(async () => {
99
+ if (!text.trim() || sending) return;
100
+ setSending(true);
101
+ try {
102
+ await onSubmit(text.trim());
103
+ setText("");
104
+ } catch (err) {
105
+ console.error("Failed to post comment:", err);
106
+ } finally {
107
+ setSending(false);
108
+ }
109
+ }, [text, sending, onSubmit]);
110
+
111
+ // Handle Ctrl+Enter to submit
112
+ const handleKeyDown = useCallback(
113
+ (e: React.KeyboardEvent) => {
114
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
115
+ e.preventDefault();
116
+ handleSubmit();
117
+ }
118
+ },
119
+ [handleSubmit]
120
+ );
121
+
122
+ const prefixColor = PREFIX_COLORS[node.prefix] || "#a1a1aa";
123
+ const commentCount = existingComments?.length || 0;
124
+
125
+ // Shared form content used in both mobile and desktop layouts
126
+ const formContent = (
127
+ <>
128
+ {/* Node info */}
129
+ <div className="mb-3">
130
+ <span className="text-xs font-mono text-emerald-600">
131
+ {node.id}
132
+ </span>
133
+ <h3 className="text-sm font-semibold text-zinc-800 leading-tight mt-0.5">
134
+ {node.title}
135
+ </h3>
136
+ </div>
137
+
138
+ {/* Existing comments preview */}
139
+ {commentCount > 0 && existingComments && (
140
+ <div className="mb-3 pb-3 border-b border-zinc-100">
141
+ <div className="flex items-center gap-1.5 mb-2">
142
+ <svg
143
+ className="w-3.5 h-3.5 text-blue-500"
144
+ fill="none"
145
+ viewBox="0 0 24 24"
146
+ strokeWidth={1.5}
147
+ stroke="currentColor"
148
+ >
149
+ <path
150
+ strokeLinecap="round"
151
+ strokeLinejoin="round"
152
+ d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 01-.923 1.785A5.969 5.969 0 006 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337z"
153
+ />
154
+ </svg>
155
+ <span className="text-[10px] font-medium text-zinc-500 uppercase tracking-wider">
156
+ {commentCount} comment{commentCount !== 1 ? "s" : ""}
157
+ </span>
158
+ </div>
159
+ {/* Show most recent 2 comments */}
160
+ <div className="space-y-2">
161
+ {existingComments.slice(0, 2).map((comment) => (
162
+ <div key={comment.uri} className="flex gap-1.5">
163
+ <div className="shrink-0 w-4 h-4 rounded-full bg-zinc-100 overflow-hidden mt-0.5">
164
+ {comment.avatar ? (
165
+ <img
166
+ src={comment.avatar}
167
+ alt=""
168
+ className="w-full h-full object-cover"
169
+ />
170
+ ) : (
171
+ <div className="w-full h-full flex items-center justify-center text-[7px] font-medium text-zinc-400">
172
+ {(comment.handle || comment.did)
173
+ .charAt(0)
174
+ .toUpperCase()}
175
+ </div>
176
+ )}
177
+ </div>
178
+ <div className="flex-1 min-w-0">
179
+ <a
180
+ href={`https://www.impactindexer.org/data?did=${comment.did}`}
181
+ target="_blank"
182
+ rel="noopener noreferrer"
183
+ className="text-[10px] font-medium text-zinc-500 hover:text-emerald-600 transition-colors"
184
+ >
185
+ {comment.displayName || comment.handle}
186
+ </a>
187
+ <p className="text-[11px] text-zinc-500 line-clamp-2 leading-tight">
188
+ {comment.text}
189
+ </p>
190
+ </div>
191
+ </div>
192
+ ))}
193
+ {commentCount > 2 && (
194
+ <p className="text-[10px] text-zinc-400 italic">
195
+ +{commentCount - 2} more — see detail panel
196
+ </p>
197
+ )}
198
+ </div>
199
+ </div>
200
+ )}
201
+
202
+ {/* Compose area */}
203
+ {isAuthenticated ? (
204
+ <div>
205
+ <textarea
206
+ ref={textareaRef}
207
+ value={text}
208
+ onChange={(e) => setText(e.target.value)}
209
+ onKeyDown={handleKeyDown}
210
+ placeholder="Leave a comment..."
211
+ rows={3}
212
+ className="w-full px-2.5 py-2 text-xs border border-zinc-200 rounded-md bg-zinc-50 text-zinc-700 placeholder-zinc-400 resize-none focus:outline-none focus:ring-1 focus:ring-emerald-500 focus:border-emerald-500"
213
+ />
214
+ <div className="flex items-center justify-between mt-2">
215
+ <span className="text-[10px] text-zinc-300">
216
+ {typeof navigator !== "undefined" &&
217
+ navigator.platform?.includes("Mac")
218
+ ? "\u2318"
219
+ : "Ctrl"}
220
+ +Enter to send
221
+ </span>
222
+ <div className="flex items-center gap-2">
223
+ <button
224
+ onClick={onClose}
225
+ className="px-2.5 py-1 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors"
226
+ >
227
+ Cancel
228
+ </button>
229
+ <button
230
+ onClick={handleSubmit}
231
+ disabled={!text.trim() || sending}
232
+ className="px-3 py-1 text-[11px] font-medium text-white bg-emerald-500 rounded-md hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
233
+ >
234
+ {sending ? (
235
+ <span className="flex items-center gap-1">
236
+ <svg
237
+ className="w-3 h-3 animate-spin"
238
+ fill="none"
239
+ viewBox="0 0 24 24"
240
+ >
241
+ <circle
242
+ className="opacity-25"
243
+ cx="12"
244
+ cy="12"
245
+ r="10"
246
+ stroke="currentColor"
247
+ strokeWidth="4"
248
+ />
249
+ <path
250
+ className="opacity-75"
251
+ fill="currentColor"
252
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
253
+ />
254
+ </svg>
255
+ Sending
256
+ </span>
257
+ ) : (
258
+ "Send"
259
+ )}
260
+ </button>
261
+ </div>
262
+ </div>
263
+ </div>
264
+ ) : (
265
+ <div className="text-center py-3">
266
+ <svg
267
+ className="w-5 h-5 text-zinc-300 mx-auto mb-1.5"
268
+ fill="none"
269
+ viewBox="0 0 24 24"
270
+ strokeWidth={1.5}
271
+ stroke="currentColor"
272
+ >
273
+ <path
274
+ strokeLinecap="round"
275
+ strokeLinejoin="round"
276
+ d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
277
+ />
278
+ </svg>
279
+ <p className="text-xs text-zinc-400">Sign in to comment</p>
280
+ </div>
281
+ )}
282
+ </>
283
+ );
284
+
285
+ // Mobile: bottom sheet layout
286
+ if (isMobile) {
287
+ return (
288
+ <>
289
+ <div className="fixed inset-0 z-[99] bg-black/20" onClick={onClose} />
290
+ <div className="fixed inset-x-0 bottom-0 z-[100] bg-white rounded-t-2xl shadow-2xl animate-slide-up-sheet">
291
+ <div className="w-10 h-1 bg-zinc-300 rounded-full mx-auto mt-3 mb-1" />
292
+ <div className="px-4 pb-6 pt-2">
293
+ {formContent}
294
+ </div>
295
+ <div className="h-[env(safe-area-inset-bottom)]" />
296
+ </div>
297
+ </>
298
+ );
299
+ }
300
+
301
+ // Desktop: floating tooltip
302
+ return (
303
+ <div
304
+ ref={tooltipRef}
305
+ style={{
306
+ position: "fixed",
307
+ left: pos.x,
308
+ top: pos.y,
309
+ width: 320,
310
+ zIndex: 100,
311
+ opacity: visible ? 1 : 0,
312
+ transform: visible ? "translateY(0)" : "translateY(4px)",
313
+ transition: "opacity 0.2s ease, transform 0.2s ease",
314
+ }}
315
+ className="bg-white border border-zinc-200 rounded-lg overflow-hidden"
316
+ onContextMenu={(e) => e.preventDefault()}
317
+ >
318
+ <style>{`
319
+ .comment-tooltip-shadow {
320
+ box-shadow: 0 8px 32px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.08);
321
+ }
322
+ `}</style>
323
+ <div className="comment-tooltip-shadow p-[18px_20px]">
324
+ {/* Colored accent bar */}
325
+ <div
326
+ className="rounded-sm mb-2.5"
327
+ style={{
328
+ width: 24,
329
+ height: 2,
330
+ background: prefixColor,
331
+ opacity: 0.6,
332
+ }}
333
+ />
334
+ {formContent}
335
+ </div>
336
+ </div>
337
+ );
338
+ }
@@ -0,0 +1,272 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect } from "react";
4
+ import type { GraphNode } from "@/lib/types";
5
+
6
+ interface ContextMenuProps {
7
+ node: GraphNode;
8
+ x: number;
9
+ y: number;
10
+ onShowDescription: () => void;
11
+ onAddComment: () => void;
12
+ onClaimTask?: () => void;
13
+ onUnclaimTask?: () => void;
14
+ onCollapseEpic?: () => void;
15
+ onUncollapseEpic?: () => void;
16
+ onFocusEpic?: () => void;
17
+ onExitFocusEpic?: () => void;
18
+ onClose: () => void;
19
+ }
20
+
21
+ export function ContextMenu({
22
+ node,
23
+ x,
24
+ y,
25
+ onShowDescription,
26
+ onAddComment,
27
+ onClaimTask,
28
+ onUnclaimTask,
29
+ onCollapseEpic,
30
+ onUncollapseEpic,
31
+ onFocusEpic,
32
+ onExitFocusEpic,
33
+ onClose,
34
+ }: ContextMenuProps) {
35
+ const menuRef = useRef<HTMLDivElement>(null);
36
+ const [pos, setPos] = useState({ x: 0, y: 0 });
37
+ const [visible, setVisible] = useState(false);
38
+
39
+ // Position + clamp to viewport
40
+ useEffect(() => {
41
+ if (!menuRef.current) return;
42
+ const rect = menuRef.current.getBoundingClientRect();
43
+ const vw = window.innerWidth;
44
+ const vh = window.innerHeight;
45
+ let nx = x + 4;
46
+ let ny = y + 4;
47
+ if (nx + rect.width > vw - 16) nx = vw - rect.width - 16;
48
+ if (nx < 16) nx = 16;
49
+ if (ny + rect.height > vh - 16) ny = vh - rect.height - 16;
50
+ if (ny < 16) ny = 16;
51
+ setPos({ x: nx, y: ny });
52
+ setVisible(true);
53
+ }, [x, y]);
54
+
55
+ // Escape key
56
+ useEffect(() => {
57
+ const handler = (e: KeyboardEvent) => {
58
+ if (e.key === "Escape") onClose();
59
+ };
60
+ window.addEventListener("keydown", handler);
61
+ return () => window.removeEventListener("keydown", handler);
62
+ }, [onClose]);
63
+
64
+ // Click outside (with delay so triggering right-click doesn't immediately close)
65
+ useEffect(() => {
66
+ const handler = (e: MouseEvent) => {
67
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
68
+ onClose();
69
+ }
70
+ };
71
+ const timer = setTimeout(
72
+ () => window.addEventListener("mousedown", handler),
73
+ 50
74
+ );
75
+ return () => {
76
+ clearTimeout(timer);
77
+ window.removeEventListener("mousedown", handler);
78
+ };
79
+ }, [onClose]);
80
+
81
+ return (
82
+ <div
83
+ ref={menuRef}
84
+ style={{
85
+ position: "fixed",
86
+ left: pos.x,
87
+ top: pos.y,
88
+ zIndex: 100,
89
+ opacity: visible ? 1 : 0,
90
+ transform: visible ? "translateY(0)" : "translateY(2px)",
91
+ transition: "opacity 0.15s ease, transform 0.15s ease",
92
+ }}
93
+ onContextMenu={(e) => e.preventDefault()}
94
+ >
95
+ <div
96
+ className="bg-white border border-zinc-200 rounded-lg overflow-hidden"
97
+ style={{
98
+ minWidth: 180,
99
+ boxShadow:
100
+ "0 4px 16px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.06)",
101
+ }}
102
+ >
103
+ {node.description && (
104
+ <button
105
+ onClick={onShowDescription}
106
+ className="w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors border-b border-zinc-100"
107
+ >
108
+ <svg
109
+ className="w-3.5 h-3.5 text-zinc-400"
110
+ fill="none"
111
+ viewBox="0 0 24 24"
112
+ strokeWidth={1.5}
113
+ stroke="currentColor"
114
+ >
115
+ <path
116
+ strokeLinecap="round"
117
+ strokeLinejoin="round"
118
+ d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
119
+ />
120
+ </svg>
121
+ Show description
122
+ </button>
123
+ )}
124
+ <button
125
+ onClick={onAddComment}
126
+ className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onClaimTask || onUnclaimTask || onCollapseEpic || onUncollapseEpic || onFocusEpic || onExitFocusEpic ? " border-b border-zinc-100" : ""}`}
127
+ >
128
+ <svg
129
+ className="w-3.5 h-3.5 text-zinc-400"
130
+ fill="none"
131
+ viewBox="0 0 24 24"
132
+ strokeWidth={1.5}
133
+ stroke="currentColor"
134
+ >
135
+ <path
136
+ strokeLinecap="round"
137
+ strokeLinejoin="round"
138
+ d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 01-.923 1.785A5.969 5.969 0 006 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337z"
139
+ />
140
+ </svg>
141
+ Add comment
142
+ </button>
143
+ {onClaimTask && (
144
+ <button
145
+ onClick={onClaimTask}
146
+ className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onCollapseEpic || onUncollapseEpic || onFocusEpic || onExitFocusEpic ? " border-b border-zinc-100" : ""}`}
147
+ >
148
+ <svg
149
+ className="w-3.5 h-3.5 text-zinc-400"
150
+ fill="none"
151
+ viewBox="0 0 24 24"
152
+ strokeWidth={1.5}
153
+ stroke="currentColor"
154
+ >
155
+ <path
156
+ strokeLinecap="round"
157
+ strokeLinejoin="round"
158
+ d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
159
+ />
160
+ </svg>
161
+ Claim task
162
+ </button>
163
+ )}
164
+ {onUnclaimTask && (
165
+ <button
166
+ onClick={onUnclaimTask}
167
+ className={`w-full px-3 py-2.5 text-xs text-red-500 hover:bg-red-50 flex items-center gap-2 transition-colors${onCollapseEpic || onUncollapseEpic || onFocusEpic || onExitFocusEpic ? " border-b border-zinc-100" : ""}`}
168
+ >
169
+ <svg
170
+ className="w-3.5 h-3.5 text-red-400"
171
+ fill="none"
172
+ viewBox="0 0 24 24"
173
+ strokeWidth={1.5}
174
+ stroke="currentColor"
175
+ >
176
+ <path
177
+ strokeLinecap="round"
178
+ strokeLinejoin="round"
179
+ d="M22 10.5h-6m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z"
180
+ />
181
+ </svg>
182
+ Unclaim task
183
+ </button>
184
+ )}
185
+ {onCollapseEpic && (
186
+ <button
187
+ onClick={onCollapseEpic}
188
+ className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onFocusEpic || onExitFocusEpic ? " border-b border-zinc-100" : ""}`}
189
+ >
190
+ <svg
191
+ className="w-3.5 h-3.5 text-zinc-400"
192
+ fill="none"
193
+ viewBox="0 0 24 24"
194
+ strokeWidth={1.5}
195
+ stroke="currentColor"
196
+ >
197
+ <path
198
+ strokeLinecap="round"
199
+ strokeLinejoin="round"
200
+ d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25"
201
+ />
202
+ </svg>
203
+ Collapse epic
204
+ </button>
205
+ )}
206
+ {onUncollapseEpic && (
207
+ <button
208
+ onClick={onUncollapseEpic}
209
+ className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onFocusEpic || onExitFocusEpic ? " border-b border-zinc-100" : ""}`}
210
+ >
211
+ <svg
212
+ className="w-3.5 h-3.5 text-zinc-400"
213
+ fill="none"
214
+ viewBox="0 0 24 24"
215
+ strokeWidth={1.5}
216
+ stroke="currentColor"
217
+ >
218
+ <path
219
+ strokeLinecap="round"
220
+ strokeLinejoin="round"
221
+ d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
222
+ />
223
+ </svg>
224
+ Uncollapse epic
225
+ </button>
226
+ )}
227
+ {onFocusEpic && (
228
+ <button
229
+ onClick={onFocusEpic}
230
+ className="w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors"
231
+ >
232
+ <svg
233
+ className="w-3.5 h-3.5 text-zinc-400"
234
+ fill="none"
235
+ viewBox="0 0 24 24"
236
+ strokeWidth={1.5}
237
+ stroke="currentColor"
238
+ >
239
+ <path
240
+ strokeLinecap="round"
241
+ strokeLinejoin="round"
242
+ d="M7.5 3.75H6A2.25 2.25 0 003.75 6v1.5M16.5 3.75H18A2.25 2.25 0 0120.25 6v1.5M16.5 20.25H18A2.25 2.25 0 0020.25 18v-1.5M7.5 20.25H6A2.25 2.25 0 013.75 18v-1.5"
243
+ />
244
+ </svg>
245
+ Focus on epic
246
+ </button>
247
+ )}
248
+ {onExitFocusEpic && (
249
+ <button
250
+ onClick={onExitFocusEpic}
251
+ className="w-full px-3 py-2.5 text-xs text-emerald-600 hover:bg-emerald-50 flex items-center gap-2 transition-colors"
252
+ >
253
+ <svg
254
+ className="w-3.5 h-3.5 text-emerald-500"
255
+ fill="none"
256
+ viewBox="0 0 24 24"
257
+ strokeWidth={1.5}
258
+ stroke="currentColor"
259
+ >
260
+ <path
261
+ strokeLinecap="round"
262
+ strokeLinejoin="round"
263
+ d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
264
+ />
265
+ </svg>
266
+ Show full graph
267
+ </button>
268
+ )}
269
+ </div>
270
+ </div>
271
+ );
272
+ }