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
|
Binary file
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/heartbeads.mjs
|
|
3
|
+
// CLI entry point for heartbeads — starts a Next.js server serving the graph UI
|
|
4
|
+
|
|
5
|
+
import { execSync, spawn } from "child_process";
|
|
6
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
7
|
+
import { resolve, join, dirname, basename, parse as pathParse } from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
|
|
10
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
11
|
+
const viewerRoot = resolve(__dirname, "..");
|
|
12
|
+
|
|
13
|
+
// Load .env.local and .env from the user's working directory.
|
|
14
|
+
// Next.js only loads these from its own cwd (the package dir), not from where
|
|
15
|
+
// the user invoked the CLI. This ensures PUBLIC_URL, ATPROTO_JWK_PRIVATE, etc.
|
|
16
|
+
// are available when running `npx heartbeads` from a project directory.
|
|
17
|
+
for (const envFile of [".env.local", ".env"]) {
|
|
18
|
+
const envPath = join(process.cwd(), envFile);
|
|
19
|
+
if (existsSync(envPath)) {
|
|
20
|
+
try {
|
|
21
|
+
const content = readFileSync(envPath, "utf-8");
|
|
22
|
+
for (const line of content.split("\n")) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
25
|
+
const eqIdx = trimmed.indexOf("=");
|
|
26
|
+
if (eqIdx === -1) continue;
|
|
27
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
28
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
29
|
+
// Strip surrounding quotes
|
|
30
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
31
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
32
|
+
val = val.slice(1, -1);
|
|
33
|
+
}
|
|
34
|
+
// Don't override existing env vars (explicit shell vars win)
|
|
35
|
+
if (!(key in process.env)) {
|
|
36
|
+
process.env[key] = val;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// ignore read errors
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Parse CLI args
|
|
46
|
+
const args = process.argv.slice(2);
|
|
47
|
+
let port = 3000;
|
|
48
|
+
let beadsDir = process.env.BEADS_DIR || null;
|
|
49
|
+
let dev = false;
|
|
50
|
+
let password = process.env.HEARTBEADS_PASSWORD || null;
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < args.length; i++) {
|
|
53
|
+
if (args[i] === "--port" && args[i + 1]) port = parseInt(args[++i]);
|
|
54
|
+
if (args[i] === "--beads-dir" && args[i + 1]) beadsDir = args[++i];
|
|
55
|
+
if (args[i] === "--password" && args[i + 1]) password = args[++i];
|
|
56
|
+
if (args[i] === "--dev") dev = true;
|
|
57
|
+
if (args[i] === "--help" || args[i] === "-h") {
|
|
58
|
+
printHelp();
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function printHelp() {
|
|
64
|
+
console.log(`
|
|
65
|
+
heartbeads - Interactive dependency graph viewer for beads (bd) issues
|
|
66
|
+
|
|
67
|
+
USAGE
|
|
68
|
+
heartbeads [options]
|
|
69
|
+
|
|
70
|
+
OPTIONS
|
|
71
|
+
--port <number> Port to serve on (default: 3000)
|
|
72
|
+
--beads-dir <path> Explicit .beads/ directory path
|
|
73
|
+
--password <string> Require a password to access the dashboard and API
|
|
74
|
+
--dev Run in development mode (hot reload)
|
|
75
|
+
--help, -h Show this help message
|
|
76
|
+
|
|
77
|
+
EXAMPLES
|
|
78
|
+
heartbeads # Auto-discover .beads/ from cwd
|
|
79
|
+
heartbeads --port 4000 # Serve on port 4000
|
|
80
|
+
heartbeads --beads-dir ~/projects/my-app/.beads # Explicit path
|
|
81
|
+
heartbeads --password secret # Password-protected mode
|
|
82
|
+
BEADS_DIR=../.beads heartbeads # Via environment variable
|
|
83
|
+
HEARTBEADS_PASSWORD=s3cret heartbeads # Password via env var
|
|
84
|
+
|
|
85
|
+
DISCOVERY
|
|
86
|
+
heartbeads walks up from the current directory looking for a .beads/ folder,
|
|
87
|
+
just like git finds .git/. Set BEADS_DIR to override.
|
|
88
|
+
`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Set BEADS_DIR so the API route can find it
|
|
92
|
+
if (beadsDir) process.env.BEADS_DIR = resolve(beadsDir);
|
|
93
|
+
|
|
94
|
+
// Set HEARTBEADS_PASSWORD so middleware can read it
|
|
95
|
+
if (password) process.env.HEARTBEADS_PASSWORD = password;
|
|
96
|
+
|
|
97
|
+
// Discover .beads/ for the startup message
|
|
98
|
+
function discover(startDir) {
|
|
99
|
+
const envDir = process.env.BEADS_DIR;
|
|
100
|
+
if (envDir) {
|
|
101
|
+
const resolved = resolve(envDir);
|
|
102
|
+
if (basename(resolved) === ".beads") return resolved;
|
|
103
|
+
if (existsSync(join(resolved, ".beads"))) return join(resolved, ".beads");
|
|
104
|
+
return resolved;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let current = resolve(startDir || process.cwd());
|
|
108
|
+
const { root } = pathParse(current);
|
|
109
|
+
|
|
110
|
+
while (true) {
|
|
111
|
+
const candidate = join(current, ".beads");
|
|
112
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory()) {
|
|
113
|
+
return candidate;
|
|
114
|
+
}
|
|
115
|
+
if (current === root) return null;
|
|
116
|
+
current = dirname(current);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const discovered = discover(process.cwd());
|
|
121
|
+
|
|
122
|
+
// Read package.json for version
|
|
123
|
+
let version = "0.1.0";
|
|
124
|
+
try {
|
|
125
|
+
const pkg = JSON.parse(
|
|
126
|
+
readFileSync(join(viewerRoot, "package.json"), "utf-8")
|
|
127
|
+
);
|
|
128
|
+
version = pkg.version || version;
|
|
129
|
+
} catch {
|
|
130
|
+
// ignore
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
console.log(`heartbeads v${version}`);
|
|
134
|
+
|
|
135
|
+
if (discovered) {
|
|
136
|
+
const repoRoot = dirname(discovered);
|
|
137
|
+
console.log(`Found beads in ${repoRoot}`);
|
|
138
|
+
|
|
139
|
+
// Count issues
|
|
140
|
+
const issuesPath = join(discovered, "issues.jsonl");
|
|
141
|
+
if (existsSync(issuesPath)) {
|
|
142
|
+
try {
|
|
143
|
+
const content = readFileSync(issuesPath, "utf-8");
|
|
144
|
+
const lines = content.split("\n").filter((l) => l.trim().length > 0);
|
|
145
|
+
const issueCount = lines.length;
|
|
146
|
+
|
|
147
|
+
// Count repos from config.yaml
|
|
148
|
+
let repoCount = 1;
|
|
149
|
+
const configPath = join(discovered, "config.yaml");
|
|
150
|
+
if (existsSync(configPath)) {
|
|
151
|
+
const configContent = readFileSync(configPath, "utf-8");
|
|
152
|
+
const additionalMatches = configContent.match(
|
|
153
|
+
/^\s+-\s+\.\.\//gm
|
|
154
|
+
);
|
|
155
|
+
if (additionalMatches) repoCount += additionalMatches.length;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
console.log(
|
|
159
|
+
`Loading ${issueCount} issues across ${repoCount} project${repoCount !== 1 ? "s" : ""}`
|
|
160
|
+
);
|
|
161
|
+
} catch {
|
|
162
|
+
// ignore count errors
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Set BEADS_DIR if not already set
|
|
167
|
+
if (!process.env.BEADS_DIR) {
|
|
168
|
+
process.env.BEADS_DIR = discovered;
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
console.log(
|
|
172
|
+
"Warning: No .beads/ directory found. Run bd init in your project first."
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Auto-build if no .next production build exists (unless --dev)
|
|
177
|
+
const buildIdPath = join(viewerRoot, ".next", "BUILD_ID");
|
|
178
|
+
if (!dev && !existsSync(buildIdPath)) {
|
|
179
|
+
console.log("No production build found, building...");
|
|
180
|
+
try {
|
|
181
|
+
execSync("npx next build", {
|
|
182
|
+
cwd: viewerRoot,
|
|
183
|
+
stdio: "inherit",
|
|
184
|
+
env: { ...process.env },
|
|
185
|
+
});
|
|
186
|
+
console.log();
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.error("Build failed. Try running with --dev instead.");
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const mode = dev ? "dev" : "start";
|
|
194
|
+
console.log(`Serving at http://localhost:${port}`);
|
|
195
|
+
if (password) {
|
|
196
|
+
console.log(`Dashboard is password-protected`);
|
|
197
|
+
console.log(`API auth: curl -H "Authorization: Bearer <password>" ...`);
|
|
198
|
+
}
|
|
199
|
+
console.log();
|
|
200
|
+
|
|
201
|
+
// Start Next.js
|
|
202
|
+
const next = spawn("npx", ["next", mode, "-p", String(port)], {
|
|
203
|
+
cwd: viewerRoot,
|
|
204
|
+
stdio: "inherit",
|
|
205
|
+
env: { ...process.env },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
next.on("error", (err) => {
|
|
209
|
+
console.error("Failed to start Next.js:", err.message);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
next.on("close", (code) => {
|
|
214
|
+
process.exit(code || 0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Clean shutdown on signals — kill child and exit
|
|
218
|
+
function shutdown() {
|
|
219
|
+
next.kill("SIGTERM");
|
|
220
|
+
// Force exit after 2s if child doesn't close
|
|
221
|
+
setTimeout(() => process.exit(0), 2000).unref();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
process.on("SIGINT", shutdown);
|
|
225
|
+
process.on("SIGTERM", shutdown);
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ActivityEvent, ActivityEventType } from "@/lib/activity";
|
|
4
|
+
import { formatRelativeTime } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Event type icons (inline SVG for each category)
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
function EventIcon({ type, className }: { type: ActivityEventType; className?: string }) {
|
|
11
|
+
const cls = className || "w-3.5 h-3.5 shrink-0";
|
|
12
|
+
|
|
13
|
+
switch (type) {
|
|
14
|
+
case "node-created":
|
|
15
|
+
return (
|
|
16
|
+
<svg className={`${cls} text-emerald-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
17
|
+
<circle cx="8" cy="8" r="6" />
|
|
18
|
+
<line x1="8" y1="5" x2="8" y2="11" />
|
|
19
|
+
<line x1="5" y1="8" x2="11" y2="8" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
case "node-closed":
|
|
23
|
+
return (
|
|
24
|
+
<svg className={`${cls} text-zinc-400`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
25
|
+
<circle cx="8" cy="8" r="6" />
|
|
26
|
+
<polyline points="5.5,8 7.5,10 10.5,6" />
|
|
27
|
+
</svg>
|
|
28
|
+
);
|
|
29
|
+
case "node-status-changed":
|
|
30
|
+
return (
|
|
31
|
+
<svg className={`${cls} text-amber-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
32
|
+
<path d="M3 8h10M10 5l3 3-3 3" />
|
|
33
|
+
</svg>
|
|
34
|
+
);
|
|
35
|
+
case "node-priority-changed":
|
|
36
|
+
return (
|
|
37
|
+
<svg className={`${cls} text-amber-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
38
|
+
<path d="M8 3v7M5 6l3-3 3 3M4 13h8" />
|
|
39
|
+
</svg>
|
|
40
|
+
);
|
|
41
|
+
case "node-title-changed":
|
|
42
|
+
return (
|
|
43
|
+
<svg className={`${cls} text-amber-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
44
|
+
<path d="M3 4h10M3 8h6M3 12h8" />
|
|
45
|
+
</svg>
|
|
46
|
+
);
|
|
47
|
+
case "node-owner-changed":
|
|
48
|
+
return (
|
|
49
|
+
<svg className={`${cls} text-amber-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
50
|
+
<circle cx="6" cy="6" r="2.5" />
|
|
51
|
+
<path d="M1.5 13c0-2 1.8-3.5 4.5-3.5M10 9l2 2 3-3" />
|
|
52
|
+
</svg>
|
|
53
|
+
);
|
|
54
|
+
case "link-added":
|
|
55
|
+
return (
|
|
56
|
+
<svg className={`${cls} text-blue-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
57
|
+
<path d="M6.5 9.5l3-3M4 11a2.5 2.5 0 003.5 0l1-1M8.5 6a2.5 2.5 0 013.5 0l0 0" />
|
|
58
|
+
</svg>
|
|
59
|
+
);
|
|
60
|
+
case "link-removed":
|
|
61
|
+
return (
|
|
62
|
+
<svg className={`${cls} text-red-400`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
63
|
+
<path d="M4 11a2.5 2.5 0 003.5 0l1-1M8.5 6a2.5 2.5 0 013.5 0l0 0" />
|
|
64
|
+
<line x1="3" y1="3" x2="13" y2="13" strokeWidth="1.5" />
|
|
65
|
+
</svg>
|
|
66
|
+
);
|
|
67
|
+
case "comment-added":
|
|
68
|
+
case "reply-added":
|
|
69
|
+
return (
|
|
70
|
+
<svg className={`${cls} text-blue-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
71
|
+
<path d="M2 4.5a1.5 1.5 0 011.5-1.5h9A1.5 1.5 0 0114 4.5v5a1.5 1.5 0 01-1.5 1.5H5L2 14V4.5z" />
|
|
72
|
+
</svg>
|
|
73
|
+
);
|
|
74
|
+
case "task-claimed":
|
|
75
|
+
return (
|
|
76
|
+
<svg className={`${cls} text-emerald-500`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
77
|
+
<circle cx="8" cy="5.5" r="2.5" />
|
|
78
|
+
<path d="M3 13.5c0-2.5 2.2-4.5 5-4.5s5 2 5 4.5" />
|
|
79
|
+
</svg>
|
|
80
|
+
);
|
|
81
|
+
case "task-unclaimed":
|
|
82
|
+
return (
|
|
83
|
+
<svg className={`${cls} text-red-400`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
84
|
+
<circle cx="8" cy="5.5" r="2.5" />
|
|
85
|
+
<path d="M3 13.5c0-2.5 2.2-4.5 5-4.5s5 2 5 4.5" />
|
|
86
|
+
<line x1="11" y1="3" x2="13" y2="5" />
|
|
87
|
+
</svg>
|
|
88
|
+
);
|
|
89
|
+
case "like-added":
|
|
90
|
+
return (
|
|
91
|
+
<svg className={`${cls} text-rose-400`} viewBox="0 0 16 16" fill="currentColor">
|
|
92
|
+
<path d="M8 13.7l-.6-.5C4 10.2 2 8.3 2 6a3 3 0 016-1 3 3 0 016 1c0 2.3-2 4.2-5.4 7.2l-.6.5z" />
|
|
93
|
+
</svg>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Event type -> accent color for left border/indicator
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
function getEventAccentColor(type: ActivityEventType): string {
|
|
103
|
+
switch (type) {
|
|
104
|
+
case "node-created":
|
|
105
|
+
case "task-claimed":
|
|
106
|
+
return "bg-emerald-400";
|
|
107
|
+
case "node-closed":
|
|
108
|
+
return "bg-zinc-300";
|
|
109
|
+
case "node-status-changed":
|
|
110
|
+
case "node-priority-changed":
|
|
111
|
+
case "node-title-changed":
|
|
112
|
+
case "node-owner-changed":
|
|
113
|
+
return "bg-amber-400";
|
|
114
|
+
case "link-added":
|
|
115
|
+
case "comment-added":
|
|
116
|
+
case "reply-added":
|
|
117
|
+
return "bg-blue-400";
|
|
118
|
+
case "link-removed":
|
|
119
|
+
case "task-unclaimed":
|
|
120
|
+
return "bg-red-300";
|
|
121
|
+
case "like-added":
|
|
122
|
+
return "bg-rose-300";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ============================================================================
|
|
127
|
+
// Event description text
|
|
128
|
+
// ============================================================================
|
|
129
|
+
|
|
130
|
+
function describeEvent(event: ActivityEvent): string {
|
|
131
|
+
switch (event.type) {
|
|
132
|
+
case "node-created": {
|
|
133
|
+
const typeLabel = event.meta?.issueType || "task";
|
|
134
|
+
return `${typeLabel} created`;
|
|
135
|
+
}
|
|
136
|
+
case "node-closed":
|
|
137
|
+
return `closed${event.detail && event.detail !== "Closed" ? ` (${event.detail})` : ""}`;
|
|
138
|
+
case "node-status-changed":
|
|
139
|
+
return event.detail || "status changed";
|
|
140
|
+
case "node-priority-changed":
|
|
141
|
+
return `priority ${event.detail || "changed"}`;
|
|
142
|
+
case "node-title-changed":
|
|
143
|
+
return "title updated";
|
|
144
|
+
case "node-owner-changed":
|
|
145
|
+
return `owner ${event.detail || "changed"}`;
|
|
146
|
+
case "link-added":
|
|
147
|
+
return `dep added: ${event.meta?.target || ""}`;
|
|
148
|
+
case "link-removed":
|
|
149
|
+
return `dep removed: ${event.meta?.target || ""}`;
|
|
150
|
+
case "comment-added":
|
|
151
|
+
return event.detail || "commented";
|
|
152
|
+
case "reply-added":
|
|
153
|
+
return event.detail || "replied";
|
|
154
|
+
case "task-claimed":
|
|
155
|
+
return "claimed this task";
|
|
156
|
+
case "task-unclaimed":
|
|
157
|
+
return "unclaimed this task";
|
|
158
|
+
case "like-added":
|
|
159
|
+
return event.detail || "liked a comment";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// Compact variant (single-line, for overlay) — with left accent bar
|
|
165
|
+
// ============================================================================
|
|
166
|
+
|
|
167
|
+
function CompactItem({
|
|
168
|
+
event,
|
|
169
|
+
onNodeClick,
|
|
170
|
+
}: {
|
|
171
|
+
event: ActivityEvent;
|
|
172
|
+
onNodeClick?: (nodeId: string) => void;
|
|
173
|
+
}) {
|
|
174
|
+
return (
|
|
175
|
+
<div className="flex items-center gap-2 py-2 px-3 group hover:bg-zinc-50/60 transition-colors">
|
|
176
|
+
{/* Left accent dot */}
|
|
177
|
+
<div className={`w-1 h-1 rounded-full shrink-0 ${getEventAccentColor(event.type)}`} />
|
|
178
|
+
|
|
179
|
+
{/* Icon */}
|
|
180
|
+
<EventIcon type={event.type} className="w-3 h-3 shrink-0" />
|
|
181
|
+
|
|
182
|
+
{/* Content */}
|
|
183
|
+
<div className="flex-1 min-w-0 text-[11px] text-zinc-600 truncate">
|
|
184
|
+
{event.actor ? (
|
|
185
|
+
<>
|
|
186
|
+
{event.actor.did ? (
|
|
187
|
+
<a
|
|
188
|
+
href={`https://www.impactindexer.org/data?did=${event.actor.did}`}
|
|
189
|
+
target="_blank"
|
|
190
|
+
rel="noopener noreferrer"
|
|
191
|
+
className="font-medium text-zinc-800 hover:text-emerald-600 transition-colors"
|
|
192
|
+
>
|
|
193
|
+
{event.actor.handle.split(".")[0]}
|
|
194
|
+
</a>
|
|
195
|
+
) : (
|
|
196
|
+
<span className="font-medium text-zinc-800">
|
|
197
|
+
{event.actor.handle.split(".")[0]}
|
|
198
|
+
</span>
|
|
199
|
+
)}
|
|
200
|
+
{" "}
|
|
201
|
+
</>
|
|
202
|
+
) : onNodeClick ? (
|
|
203
|
+
<>
|
|
204
|
+
<button
|
|
205
|
+
onClick={() => onNodeClick(event.nodeId)}
|
|
206
|
+
className="font-medium text-zinc-700 hover:text-emerald-600 transition-colors"
|
|
207
|
+
>
|
|
208
|
+
{event.nodeId}
|
|
209
|
+
</button>
|
|
210
|
+
{" "}
|
|
211
|
+
</>
|
|
212
|
+
) : null}
|
|
213
|
+
<span className="text-zinc-500">{describeEvent(event)}</span>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{/* Timestamp */}
|
|
217
|
+
<span className="text-[10px] text-zinc-400 shrink-0 tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
|
|
218
|
+
{formatRelativeTime(new Date(event.time).toISOString())}
|
|
219
|
+
</span>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ============================================================================
|
|
225
|
+
// Full variant (rich, for panel) — with left accent bar and richer layout
|
|
226
|
+
// ============================================================================
|
|
227
|
+
|
|
228
|
+
function FullItem({
|
|
229
|
+
event,
|
|
230
|
+
onNodeClick,
|
|
231
|
+
}: {
|
|
232
|
+
event: ActivityEvent;
|
|
233
|
+
onNodeClick?: (nodeId: string) => void;
|
|
234
|
+
}) {
|
|
235
|
+
return (
|
|
236
|
+
<div className="flex gap-3 py-3 px-4 hover:bg-zinc-50/50 transition-colors group">
|
|
237
|
+
{/* Left accent bar */}
|
|
238
|
+
<div className={`w-0.5 self-stretch rounded-full shrink-0 ${getEventAccentColor(event.type)}`} />
|
|
239
|
+
|
|
240
|
+
{/* Avatar or icon */}
|
|
241
|
+
<div className="mt-0.5 shrink-0">
|
|
242
|
+
{event.actor?.avatar ? (
|
|
243
|
+
/* eslint-disable-next-line @next/next/no-img-element */
|
|
244
|
+
<img
|
|
245
|
+
src={event.actor.avatar}
|
|
246
|
+
alt={event.actor.handle}
|
|
247
|
+
className="w-7 h-7 rounded-full ring-1 ring-zinc-100"
|
|
248
|
+
/>
|
|
249
|
+
) : event.actor ? (
|
|
250
|
+
<div className="w-7 h-7 rounded-full bg-zinc-100 flex items-center justify-center text-[11px] font-medium text-zinc-500">
|
|
251
|
+
{event.actor.handle.charAt(0).toUpperCase()}
|
|
252
|
+
</div>
|
|
253
|
+
) : (
|
|
254
|
+
<div className="w-7 h-7 rounded-full bg-zinc-50 flex items-center justify-center">
|
|
255
|
+
<EventIcon type={event.type} className="w-3.5 h-3.5" />
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
{/* Content */}
|
|
261
|
+
<div className="flex-1 min-w-0">
|
|
262
|
+
{/* Actor + action text */}
|
|
263
|
+
<div className="flex items-baseline gap-1.5 text-[12px] leading-snug">
|
|
264
|
+
{event.actor && (
|
|
265
|
+
event.actor.did ? (
|
|
266
|
+
<a
|
|
267
|
+
href={`https://www.impactindexer.org/data?did=${event.actor.did}`}
|
|
268
|
+
target="_blank"
|
|
269
|
+
rel="noopener noreferrer"
|
|
270
|
+
className="font-semibold text-zinc-800 hover:text-emerald-600 transition-colors truncate max-w-[140px]"
|
|
271
|
+
>
|
|
272
|
+
{event.actor.handle.split(".")[0]}
|
|
273
|
+
</a>
|
|
274
|
+
) : (
|
|
275
|
+
<span className="font-semibold text-zinc-800 truncate max-w-[140px]">
|
|
276
|
+
{event.actor.handle.split(".")[0]}
|
|
277
|
+
</span>
|
|
278
|
+
)
|
|
279
|
+
)}
|
|
280
|
+
<span className="text-zinc-500">{describeEvent(event)}</span>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{/* Node ID pill + title */}
|
|
284
|
+
<div className="flex items-center gap-1.5 mt-1">
|
|
285
|
+
{onNodeClick && (
|
|
286
|
+
<button
|
|
287
|
+
onClick={() => onNodeClick(event.nodeId)}
|
|
288
|
+
className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono font-medium text-emerald-700 bg-emerald-50 rounded-md hover:bg-emerald-100 transition-colors"
|
|
289
|
+
>
|
|
290
|
+
{event.nodeId}
|
|
291
|
+
</button>
|
|
292
|
+
)}
|
|
293
|
+
{event.nodeTitle && (
|
|
294
|
+
<span className="text-[11px] text-zinc-400 truncate">
|
|
295
|
+
{event.nodeTitle}
|
|
296
|
+
</span>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
{/* Timestamp */}
|
|
302
|
+
<span className="text-[10px] text-zinc-400 shrink-0 tabular-nums mt-0.5">
|
|
303
|
+
{formatRelativeTime(new Date(event.time).toISOString())}
|
|
304
|
+
</span>
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ============================================================================
|
|
310
|
+
// Exported component
|
|
311
|
+
// ============================================================================
|
|
312
|
+
|
|
313
|
+
export function ActivityItem({
|
|
314
|
+
event,
|
|
315
|
+
variant = "compact",
|
|
316
|
+
onNodeClick,
|
|
317
|
+
}: {
|
|
318
|
+
event: ActivityEvent;
|
|
319
|
+
variant?: "compact" | "full";
|
|
320
|
+
onNodeClick?: (nodeId: string) => void;
|
|
321
|
+
}) {
|
|
322
|
+
if (variant === "compact") {
|
|
323
|
+
return <CompactItem event={event} onNodeClick={onNodeClick} />;
|
|
324
|
+
}
|
|
325
|
+
return <FullItem event={event} onNodeClick={onNodeClick} />;
|
|
326
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ActivityEvent } from "@/lib/activity";
|
|
4
|
+
import { ActivityItem } from "./ActivityItem";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
events: ActivityEvent[];
|
|
8
|
+
collapsed: boolean;
|
|
9
|
+
compact?: boolean;
|
|
10
|
+
onToggleCollapse: () => void;
|
|
11
|
+
onExpandPanel: () => void;
|
|
12
|
+
onNodeClick: (nodeId: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compact always-visible activity card in the top-right of the graph canvas.
|
|
17
|
+
* Shows latest 5 events. Click "See all" to open the full ActivityPanel.
|
|
18
|
+
*/
|
|
19
|
+
export function ActivityOverlay({
|
|
20
|
+
events,
|
|
21
|
+
collapsed,
|
|
22
|
+
compact,
|
|
23
|
+
onToggleCollapse,
|
|
24
|
+
onExpandPanel,
|
|
25
|
+
onNodeClick,
|
|
26
|
+
}: Props) {
|
|
27
|
+
// Count events in last 5 minutes for the collapsed badge
|
|
28
|
+
const recentCount = events.filter(
|
|
29
|
+
(e) => Date.now() - e.time < 5 * 60 * 1000
|
|
30
|
+
).length;
|
|
31
|
+
|
|
32
|
+
if (collapsed) {
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
onClick={onToggleCollapse}
|
|
36
|
+
className="group flex items-center gap-2 px-3 py-2 bg-white/90 backdrop-blur-sm rounded-full border border-zinc-200/80 shadow-sm hover:bg-white hover:shadow-md transition-all"
|
|
37
|
+
title="Show activity feed"
|
|
38
|
+
>
|
|
39
|
+
<svg
|
|
40
|
+
className="w-3.5 h-3.5 text-zinc-400 group-hover:text-emerald-500 transition-colors"
|
|
41
|
+
viewBox="0 0 16 16"
|
|
42
|
+
fill="none"
|
|
43
|
+
stroke="currentColor"
|
|
44
|
+
strokeWidth="1.5"
|
|
45
|
+
>
|
|
46
|
+
<circle cx="8" cy="8" r="6" />
|
|
47
|
+
<polyline points="8,4 8,8 11,10" />
|
|
48
|
+
</svg>
|
|
49
|
+
<span className="text-xs text-zinc-500 group-hover:text-zinc-700 transition-colors">Activity</span>
|
|
50
|
+
{recentCount > 0 && (
|
|
51
|
+
<span className="text-[10px] font-medium text-white bg-emerald-500 rounded-full px-1.5 py-0.5 min-w-[18px] text-center leading-tight">
|
|
52
|
+
{recentCount}
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className={`${compact ? "w-60" : "w-72"} bg-white/95 backdrop-blur-sm rounded-xl border border-zinc-200/80 shadow-lg overflow-hidden`}>
|
|
61
|
+
{/* Header */}
|
|
62
|
+
<div className="flex items-center justify-between px-3.5 py-2.5 border-b border-zinc-100/80">
|
|
63
|
+
<div className="flex items-center gap-2">
|
|
64
|
+
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
|
65
|
+
<span className="text-[11px] font-semibold text-zinc-600">
|
|
66
|
+
Live Activity
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
<button
|
|
70
|
+
onClick={onToggleCollapse}
|
|
71
|
+
className="text-zinc-400 hover:text-zinc-600 transition-colors p-1 -mr-0.5 rounded-full hover:bg-zinc-100"
|
|
72
|
+
title="Collapse"
|
|
73
|
+
>
|
|
74
|
+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
|
75
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
|
76
|
+
</svg>
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{/* Event list */}
|
|
81
|
+
<div className="py-0.5 max-h-[260px] overflow-y-auto custom-scrollbar">
|
|
82
|
+
{events.length === 0 ? (
|
|
83
|
+
<div className="py-6 text-center">
|
|
84
|
+
<svg className="w-5 h-5 text-zinc-200 mx-auto mb-1.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
85
|
+
<circle cx="8" cy="8" r="6" />
|
|
86
|
+
<polyline points="8,4 8,8 11,10" />
|
|
87
|
+
</svg>
|
|
88
|
+
<p className="text-[11px] text-zinc-400">No activity yet</p>
|
|
89
|
+
</div>
|
|
90
|
+
) : (
|
|
91
|
+
events.slice(0, compact ? 3 : 6).map((event, i) => (
|
|
92
|
+
<div
|
|
93
|
+
key={event.id}
|
|
94
|
+
className={i === 0 ? "bg-emerald-50/30" : ""}
|
|
95
|
+
>
|
|
96
|
+
<ActivityItem
|
|
97
|
+
event={event}
|
|
98
|
+
variant="compact"
|
|
99
|
+
onNodeClick={onNodeClick}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
))
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{/* Footer */}
|
|
107
|
+
{events.length > 0 && (
|
|
108
|
+
<div className="flex items-center justify-between px-3.5 py-2 border-t border-zinc-100/80 bg-zinc-50/30">
|
|
109
|
+
<span className="text-[10px] text-zinc-400">
|
|
110
|
+
{events.length} event{events.length !== 1 ? "s" : ""}
|
|
111
|
+
</span>
|
|
112
|
+
<button
|
|
113
|
+
onClick={onExpandPanel}
|
|
114
|
+
className="text-[11px] text-emerald-600 hover:text-emerald-700 transition-colors font-medium flex items-center gap-1"
|
|
115
|
+
>
|
|
116
|
+
See all
|
|
117
|
+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
|
118
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
119
|
+
</svg>
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|