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,345 @@
1
+ "use client";
2
+
3
+ import { useState, useMemo } from "react";
4
+ import type { ActivityEvent, ActivityFilterCategory } from "@/lib/activity";
5
+ import { getEventCategory } from "@/lib/activity";
6
+ import { ActivityItem } from "./ActivityItem";
7
+
8
+ interface Props {
9
+ events: ActivityEvent[];
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ onNodeClick: (nodeId: string) => void;
13
+ }
14
+
15
+ const FILTER_CATEGORIES: {
16
+ key: ActivityFilterCategory;
17
+ label: string;
18
+ icon: string;
19
+ }[] = [
20
+ { key: "issues", label: "Issues", icon: "circle" },
21
+ { key: "deps", label: "Deps", icon: "link" },
22
+ { key: "comments", label: "Comments", icon: "chat" },
23
+ { key: "claims", label: "Claims", icon: "user" },
24
+ { key: "likes", label: "Likes", icon: "heart" },
25
+ ];
26
+
27
+ // ============================================================================
28
+ // Time grouping helpers
29
+ // ============================================================================
30
+
31
+ function getTimeGroup(time: number): string {
32
+ const now = Date.now();
33
+ const diff = now - time;
34
+ const hours = diff / (1000 * 60 * 60);
35
+ const days = diff / (1000 * 60 * 60 * 24);
36
+
37
+ if (hours < 1) return "Just now";
38
+ if (hours < 24) return "Today";
39
+ if (days < 2) return "Yesterday";
40
+ if (days < 7) return "This week";
41
+ if (days < 30) return "This month";
42
+ return "Older";
43
+ }
44
+
45
+ function groupEventsByTime(events: ActivityEvent[]): { label: string; events: ActivityEvent[] }[] {
46
+ const groups: Map<string, ActivityEvent[]> = new Map();
47
+ const order: string[] = [];
48
+
49
+ for (const event of events) {
50
+ const label = getTimeGroup(event.time);
51
+ if (!groups.has(label)) {
52
+ groups.set(label, []);
53
+ order.push(label);
54
+ }
55
+ groups.get(label)!.push(event);
56
+ }
57
+
58
+ return order.map((label) => ({ label, events: groups.get(label)! }));
59
+ }
60
+
61
+ /**
62
+ * Full slide-in sidebar panel for the activity feed.
63
+ * Includes search bar, filter chips, and time-grouped events.
64
+ * Same pattern as AllCommentsPanel / NodeDetail.
65
+ */
66
+ export function ActivityPanel({
67
+ events,
68
+ isOpen,
69
+ onClose,
70
+ onNodeClick,
71
+ }: Props) {
72
+ const [search, setSearch] = useState("");
73
+ const [activeFilters, setActiveFilters] = useState<Set<ActivityFilterCategory>>(
74
+ new Set(FILTER_CATEGORIES.map((c) => c.key))
75
+ );
76
+
77
+ const toggleFilter = (category: ActivityFilterCategory) => {
78
+ setActiveFilters((prev) => {
79
+ const next = new Set(prev);
80
+ if (next.has(category)) {
81
+ // Don't allow deactivating all filters
82
+ if (next.size > 1) next.delete(category);
83
+ } else {
84
+ next.add(category);
85
+ }
86
+ return next;
87
+ });
88
+ };
89
+
90
+ const filteredEvents = useMemo(() => {
91
+ let filtered = events;
92
+
93
+ // Apply category filters
94
+ filtered = filtered.filter((e) =>
95
+ activeFilters.has(getEventCategory(e.type))
96
+ );
97
+
98
+ // Apply text search
99
+ if (search.trim()) {
100
+ const q = search.trim().toLowerCase();
101
+ filtered = filtered.filter(
102
+ (e) =>
103
+ e.nodeId.toLowerCase().includes(q) ||
104
+ (e.nodeTitle && e.nodeTitle.toLowerCase().includes(q)) ||
105
+ (e.actor?.handle && e.actor.handle.toLowerCase().includes(q)) ||
106
+ (e.detail && e.detail.toLowerCase().includes(q))
107
+ );
108
+ }
109
+
110
+ return filtered;
111
+ }, [events, activeFilters, search]);
112
+
113
+ const groupedEvents = useMemo(
114
+ () => groupEventsByTime(filteredEvents),
115
+ [filteredEvents]
116
+ );
117
+
118
+ const allActive = activeFilters.size === FILTER_CATEGORIES.length;
119
+
120
+ return (
121
+ <>
122
+ {/* Desktop sidebar */}
123
+ <aside
124
+ className={`hidden md:flex absolute top-0 right-0 h-full w-[360px] bg-white border-l border-zinc-200 flex-col shadow-xl z-30 transform transition-transform duration-300 ease-out ${
125
+ isOpen ? "translate-x-0" : "translate-x-full"
126
+ }`}
127
+ >
128
+ {/* Header */}
129
+ <div className="shrink-0 px-5 py-3 border-b border-zinc-100 flex items-center justify-between">
130
+ <div className="flex items-center gap-2">
131
+ <h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
132
+ Activity
133
+ </h2>
134
+ {events.length > 0 && (
135
+ <span className="px-1.5 py-0.5 bg-emerald-50 text-emerald-600 rounded-full text-[10px] font-medium">
136
+ {events.length}
137
+ </span>
138
+ )}
139
+ </div>
140
+ <button
141
+ onClick={onClose}
142
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors rounded-full hover:bg-zinc-100"
143
+ >
144
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
145
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
146
+ </svg>
147
+ </button>
148
+ </div>
149
+
150
+ {/* Search */}
151
+ <div className="px-4 pt-3 pb-2 shrink-0">
152
+ <div className="flex items-center bg-zinc-50/80 rounded-full border border-zinc-200/60 overflow-hidden">
153
+ <div className="pl-3 pr-1 text-zinc-400">
154
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
155
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
156
+ </svg>
157
+ </div>
158
+ <input
159
+ type="text"
160
+ value={search}
161
+ onChange={(e) => setSearch(e.target.value)}
162
+ placeholder="Search activity..."
163
+ className="flex-1 px-2 py-2 text-xs text-zinc-800 bg-transparent outline-none placeholder:text-zinc-400"
164
+ />
165
+ {search && (
166
+ <button
167
+ onClick={() => setSearch("")}
168
+ className="pr-3 text-zinc-400 hover:text-zinc-600"
169
+ >
170
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
171
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
172
+ </svg>
173
+ </button>
174
+ )}
175
+ </div>
176
+ </div>
177
+
178
+ {/* Filter chips */}
179
+ <div className="flex flex-wrap gap-1.5 px-4 pb-3 shrink-0">
180
+ {FILTER_CATEGORIES.map((cat) => {
181
+ const isActive = activeFilters.has(cat.key);
182
+ return (
183
+ <button
184
+ key={cat.key}
185
+ onClick={() => toggleFilter(cat.key)}
186
+ className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-all ${
187
+ isActive
188
+ ? "bg-emerald-50 text-emerald-700 border-emerald-200 shadow-sm"
189
+ : "bg-white text-zinc-400 border-zinc-200 hover:text-zinc-600 hover:border-zinc-300"
190
+ }`}
191
+ >
192
+ {cat.label}
193
+ </button>
194
+ );
195
+ })}
196
+ </div>
197
+
198
+ {/* Event list with time groups */}
199
+ <div className="flex-1 overflow-y-auto custom-scrollbar border-t border-zinc-100">
200
+ {filteredEvents.length === 0 ? (
201
+ <div className="flex flex-col items-center justify-center py-12 text-center">
202
+ <svg
203
+ className="w-8 h-8 text-zinc-200 mb-3"
204
+ fill="none"
205
+ viewBox="0 0 24 24"
206
+ strokeWidth={1.5}
207
+ stroke="currentColor"
208
+ >
209
+ <path
210
+ strokeLinecap="round"
211
+ strokeLinejoin="round"
212
+ d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
213
+ />
214
+ </svg>
215
+ <p className="text-xs text-zinc-400">
216
+ {search.trim()
217
+ ? "No activity matching your search"
218
+ : "No activity yet"}
219
+ </p>
220
+ {search.trim() && (
221
+ <button
222
+ onClick={() => setSearch("")}
223
+ className="text-[11px] text-emerald-600 hover:text-emerald-700 mt-1.5"
224
+ >
225
+ Clear search
226
+ </button>
227
+ )}
228
+ </div>
229
+ ) : (
230
+ groupedEvents.map((group) => (
231
+ <div key={group.label}>
232
+ {/* Time group header */}
233
+ <div className="sticky top-0 z-[1] px-4 py-1.5 bg-zinc-50/95 backdrop-blur-sm border-b border-zinc-100/60">
234
+ <span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
235
+ {group.label}
236
+ </span>
237
+ </div>
238
+ {/* Events in this group */}
239
+ <div className="divide-y divide-zinc-50">
240
+ {group.events.map((event) => (
241
+ <ActivityItem
242
+ key={event.id}
243
+ event={event}
244
+ variant="full"
245
+ onNodeClick={onNodeClick}
246
+ />
247
+ ))}
248
+ </div>
249
+ </div>
250
+ ))
251
+ )}
252
+ </div>
253
+
254
+ {/* Footer stats */}
255
+ <div className="shrink-0 px-5 py-2.5 border-t border-zinc-100 bg-zinc-50/50">
256
+ <div className="text-[10px] text-zinc-400">
257
+ {filteredEvents.length} event{filteredEvents.length !== 1 ? "s" : ""}
258
+ {!allActive && ` (filtered)`}
259
+ {search.trim() && ` matching "${search.trim()}"`}
260
+ </div>
261
+ </div>
262
+ </aside>
263
+
264
+ {/* Mobile drawer */}
265
+ <div
266
+ className={`md:hidden fixed inset-x-0 bottom-0 z-20 transition-transform duration-300 ease-out ${
267
+ isOpen ? "translate-y-0" : "translate-y-full"
268
+ }`}
269
+ >
270
+ <div className="bg-white rounded-t-2xl shadow-xl border-t border-zinc-200 max-h-[60vh] flex flex-col">
271
+ {/* Drag handle */}
272
+ <div className="flex justify-center pt-3 pb-1">
273
+ <div className="w-8 h-1 bg-zinc-300 rounded-full" />
274
+ </div>
275
+
276
+ {/* Header */}
277
+ <div className="flex items-center justify-between px-5 py-2">
278
+ <div className="flex items-center gap-2">
279
+ <h2 className="text-sm font-semibold text-zinc-900">Activity</h2>
280
+ {events.length > 0 && (
281
+ <span className="px-1.5 py-0.5 bg-emerald-50 text-emerald-600 rounded-full text-[10px] font-medium">
282
+ {events.length}
283
+ </span>
284
+ )}
285
+ </div>
286
+ <button
287
+ onClick={onClose}
288
+ className="p-1 text-zinc-400 hover:text-zinc-600"
289
+ >
290
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
291
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
292
+ </svg>
293
+ </button>
294
+ </div>
295
+
296
+ {/* Filter chips */}
297
+ <div className="flex flex-wrap gap-1.5 px-5 pb-3">
298
+ {FILTER_CATEGORIES.map((cat) => (
299
+ <button
300
+ key={cat.key}
301
+ onClick={() => toggleFilter(cat.key)}
302
+ className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
303
+ activeFilters.has(cat.key)
304
+ ? "bg-emerald-50 text-emerald-700 border-emerald-200"
305
+ : "bg-white text-zinc-400 border-zinc-200"
306
+ }`}
307
+ >
308
+ {cat.label}
309
+ </button>
310
+ ))}
311
+ </div>
312
+
313
+ {/* Event list */}
314
+ <div className="flex-1 overflow-y-auto custom-scrollbar">
315
+ {filteredEvents.length === 0 ? (
316
+ <div className="py-8 text-center text-xs text-zinc-400">
317
+ No activity matching filters
318
+ </div>
319
+ ) : (
320
+ groupedEvents.map((group) => (
321
+ <div key={group.label}>
322
+ <div className="sticky top-0 z-[1] px-5 py-1.5 bg-zinc-50/95 backdrop-blur-sm border-b border-zinc-100/60">
323
+ <span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
324
+ {group.label}
325
+ </span>
326
+ </div>
327
+ <div className="divide-y divide-zinc-50">
328
+ {group.events.map((event) => (
329
+ <ActivityItem
330
+ key={event.id}
331
+ event={event}
332
+ variant="full"
333
+ onNodeClick={onNodeClick}
334
+ />
335
+ ))}
336
+ </div>
337
+ </div>
338
+ ))
339
+ )}
340
+ </div>
341
+ </div>
342
+ </div>
343
+ </>
344
+ );
345
+ }
@@ -0,0 +1,270 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import type { BeadsComment } from "@/hooks/useBeadsComments";
5
+ import { HeartIcon } from "@/components/HeartIcon";
6
+ import { formatRelativeTime } from "@/lib/utils";
7
+
8
+ interface AllCommentsPanelProps {
9
+ isOpen: boolean;
10
+ onClose: () => void;
11
+ allComments: BeadsComment[];
12
+ onNodeNavigate: (nodeId: string) => void;
13
+ isAuthenticated?: boolean;
14
+ currentDid?: string;
15
+ onLikeComment?: (comment: BeadsComment) => Promise<void>;
16
+ onDeleteComment?: (comment: BeadsComment) => Promise<void>;
17
+ }
18
+
19
+ export default function AllCommentsPanel({
20
+ isOpen,
21
+ onClose,
22
+ allComments,
23
+ onNodeNavigate,
24
+ isAuthenticated,
25
+ currentDid,
26
+ onLikeComment,
27
+ onDeleteComment,
28
+ }: AllCommentsPanelProps) {
29
+ return (
30
+ <aside
31
+ 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 ${
32
+ isOpen ? "translate-x-0" : "translate-x-full"
33
+ }`}
34
+ >
35
+ {/* Header */}
36
+ <div className="shrink-0 px-5 py-3 border-b border-zinc-100 flex items-center justify-between">
37
+ <div className="flex items-center gap-2">
38
+ <h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
39
+ All Comments
40
+ </h2>
41
+ {allComments.length > 0 && (
42
+ <span className="px-1.5 py-0.5 bg-emerald-50 text-emerald-600 rounded-full text-[10px] font-medium">
43
+ {allComments.length}
44
+ </span>
45
+ )}
46
+ </div>
47
+ <button
48
+ onClick={onClose}
49
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors rounded-full hover:bg-zinc-100"
50
+ >
51
+ <svg
52
+ className="w-4 h-4"
53
+ fill="none"
54
+ stroke="currentColor"
55
+ viewBox="0 0 24 24"
56
+ strokeWidth={1.5}
57
+ >
58
+ <path
59
+ strokeLinecap="round"
60
+ strokeLinejoin="round"
61
+ d="M6 18L18 6M6 6l12 12"
62
+ />
63
+ </svg>
64
+ </button>
65
+ </div>
66
+
67
+ {/* Comment list */}
68
+ <div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-3">
69
+ {allComments.length > 0 ? (
70
+ <div className="space-y-0">
71
+ {/* Only show root comments (without replyTo) — replies are nested */}
72
+ {allComments.filter(c => !c.replyTo).map((comment) => (
73
+ <AllCommentCard
74
+ key={comment.uri}
75
+ comment={comment}
76
+ currentDid={currentDid}
77
+ isAuthenticated={isAuthenticated}
78
+ onNodeNavigate={onNodeNavigate}
79
+ onLike={onLikeComment}
80
+ onDelete={onDeleteComment}
81
+ depth={0}
82
+ />
83
+ ))}
84
+ </div>
85
+ ) : (
86
+ <div className="flex flex-col items-center justify-center py-12 text-center">
87
+ <svg
88
+ className="w-8 h-8 text-zinc-200 mb-3"
89
+ fill="none"
90
+ viewBox="0 0 24 24"
91
+ strokeWidth={1.5}
92
+ stroke="currentColor"
93
+ >
94
+ <path
95
+ strokeLinecap="round"
96
+ strokeLinejoin="round"
97
+ 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"
98
+ />
99
+ </svg>
100
+ <p className="text-xs text-zinc-400">No comments yet</p>
101
+ <p className="text-[10px] text-zinc-300 mt-1">
102
+ Right-click a node to leave a comment
103
+ </p>
104
+ </div>
105
+ )}
106
+ </div>
107
+
108
+ {/* Footer */}
109
+ <div className="shrink-0 px-5 py-2.5 border-t border-zinc-100 bg-zinc-50/50">
110
+ <div className="text-[10px] text-zinc-400">
111
+ {allComments.length} comment{allComments.length !== 1 ? "s" : ""} across all issues
112
+ </div>
113
+ </div>
114
+ </aside>
115
+ );
116
+ }
117
+
118
+
119
+
120
+ // ============================================================================
121
+ // AllCommentCard — individual comment in the all-comments feed
122
+ // ============================================================================
123
+
124
+ function AllCommentCard({
125
+ comment,
126
+ currentDid,
127
+ isAuthenticated,
128
+ onNodeNavigate,
129
+ onLike,
130
+ onDelete,
131
+ depth,
132
+ }: {
133
+ comment: BeadsComment;
134
+ currentDid?: string;
135
+ isAuthenticated?: boolean;
136
+ onNodeNavigate: (nodeId: string) => void;
137
+ onLike?: (comment: BeadsComment) => Promise<void>;
138
+ onDelete?: (comment: BeadsComment) => Promise<void>;
139
+ depth: number;
140
+ }) {
141
+ const [liking, setLiking] = useState(false);
142
+ const [deleting, setDeleting] = useState(false);
143
+ const isOwn = currentDid && currentDid === comment.did;
144
+ const hasLiked = currentDid
145
+ ? comment.likes.some((l) => l.did === currentDid)
146
+ : false;
147
+
148
+ const handleLike = async () => {
149
+ if (!onLike || liking) return;
150
+ setLiking(true);
151
+ try {
152
+ await onLike(comment);
153
+ } catch (err) {
154
+ console.error("Failed to toggle like:", err);
155
+ } finally {
156
+ setLiking(false);
157
+ }
158
+ };
159
+
160
+ const handleDelete = async () => {
161
+ if (!onDelete || deleting) return;
162
+ setDeleting(true);
163
+ try {
164
+ await onDelete(comment);
165
+ } catch (err) {
166
+ console.error("Failed to delete comment:", err);
167
+ } finally {
168
+ setDeleting(false);
169
+ }
170
+ };
171
+
172
+ return (
173
+ <div className={`${depth > 0 ? "ml-4 pl-3 border-l border-zinc-100" : ""}`}>
174
+ <div className={`py-3 ${depth === 0 ? "border-b border-zinc-50" : ""}`}>
175
+ {/* Node target pill — only show for root comments */}
176
+ {depth === 0 && (
177
+ <button
178
+ onClick={() => onNodeNavigate(comment.nodeId)}
179
+ className="inline-flex items-center px-1.5 py-0.5 mb-1.5 rounded text-[10px] font-mono bg-emerald-50 text-emerald-600 hover:bg-emerald-100 transition-colors"
180
+ >
181
+ {comment.nodeId}
182
+ </button>
183
+ )}
184
+
185
+ {/* Author + time */}
186
+ <div className="flex items-center gap-1.5 mb-1">
187
+ <div className="shrink-0 w-4 h-4 rounded-full bg-zinc-100 overflow-hidden">
188
+ {comment.avatar ? (
189
+ <img
190
+ src={comment.avatar}
191
+ alt=""
192
+ className="w-full h-full object-cover"
193
+ />
194
+ ) : (
195
+ <div className="w-full h-full flex items-center justify-center text-[8px] font-medium text-zinc-400">
196
+ {(comment.handle || comment.did).charAt(0).toUpperCase()}
197
+ </div>
198
+ )}
199
+ </div>
200
+ <a
201
+ href={`https://www.impactindexer.org/data?did=${comment.did}`}
202
+ target="_blank"
203
+ rel="noopener noreferrer"
204
+ className="text-xs font-medium text-zinc-600 truncate hover:text-emerald-600 transition-colors"
205
+ >
206
+ {comment.displayName ||
207
+ comment.handle ||
208
+ comment.did.slice(0, 16) + "..."}
209
+ </a>
210
+ <span className="text-[10px] text-zinc-300 shrink-0">
211
+ {formatRelativeTime(comment.createdAt)}
212
+ </span>
213
+ </div>
214
+
215
+ {/* Comment text */}
216
+ <p className="text-xs text-zinc-500 leading-relaxed whitespace-pre-wrap break-words">
217
+ {comment.text}
218
+ </p>
219
+
220
+ {/* Actions */}
221
+ <div className="flex items-center gap-2 mt-1 text-[10px]">
222
+ {/* Like */}
223
+ <button
224
+ onClick={handleLike}
225
+ disabled={!isAuthenticated || liking}
226
+ className={`flex items-center gap-0.5 transition-colors ${
227
+ hasLiked
228
+ ? "text-rose-500"
229
+ : "text-zinc-300 hover:text-rose-500"
230
+ } disabled:opacity-50`}
231
+ >
232
+ <HeartIcon className="w-3 h-3" filled={hasLiked} />
233
+ {comment.likes.length > 0 && <span>{comment.likes.length}</span>}
234
+ </button>
235
+
236
+ {/* Delete — own only */}
237
+ {isOwn && onDelete && (
238
+ <button
239
+ onClick={handleDelete}
240
+ disabled={deleting}
241
+ className="ml-auto text-zinc-300 hover:text-red-400 disabled:opacity-50 transition-colors"
242
+ >
243
+ {deleting ? "..." : "delete"}
244
+ </button>
245
+ )}
246
+ </div>
247
+ </div>
248
+
249
+ {/* Nested replies */}
250
+ {comment.replies.length > 0 && (
251
+ <div>
252
+ {comment.replies.map((reply) => (
253
+ <AllCommentCard
254
+ key={reply.uri}
255
+ comment={reply}
256
+ currentDid={currentDid}
257
+ isAuthenticated={isAuthenticated}
258
+ onNodeNavigate={onNodeNavigate}
259
+ onLike={onLike}
260
+ onDelete={onDelete}
261
+ depth={depth + 1}
262
+ />
263
+ ))}
264
+ </div>
265
+ )}
266
+ </div>
267
+ );
268
+ }
269
+
270
+