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
|
@@ -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
|
+
|