beads-map 0.1.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 +27 -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 +10 -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/config.body +1 -0
- package/.next/server/app/api/config.meta +1 -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/index.html +1 -0
- package/.next/server/app/index.meta +5 -0
- package/.next/server/app/index.rsc +8 -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-paths-manifest.json +14 -0
- package/.next/server/chunks/247.js +12 -0
- package/.next/server/chunks/251.js +2 -0
- package/.next/server/chunks/29.js +1 -0
- package/.next/server/chunks/343.js +1 -0
- package/.next/server/chunks/533.js +38 -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/font-manifest.json +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 +6 -0
- package/.next/server/middleware-react-loadable-manifest.js +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/99eOjoTtoO32H-c1faxZ5/_buildManifest.js +1 -0
- package/.next/static/99eOjoTtoO32H-c1faxZ5/_ssgManifest.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/666-fb778298a77f3754.js +1 -0
- package/.next/static/chunks/945-bf736d0119e7437b.js +2 -0
- package/.next/static/chunks/app/_not-found/page-b568fd9238f85f27.js +1 -0
- package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
- package/.next/static/chunks/app/page-49d569c912d5af9d.js +1 -0
- package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
- package/.next/static/chunks/main-62aa0e18004db880.js +1 -0
- package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.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-c8b9ebfd35ae1d92.js +1 -0
- package/.next/static/css/10ef08b24212fe36.css +3 -0
- package/README.md +243 -0
- package/app/api/beads/route.ts +27 -0
- package/app/api/beads/stream/route.ts +83 -0
- package/app/api/config/route.ts +46 -0
- package/app/api/login/route.ts +42 -0
- package/app/api/logout/route.ts +14 -0
- package/app/api/oauth/callback/route.ts +94 -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/globals.css +192 -0
- package/app/layout.tsx +30 -0
- package/app/page.tsx +1151 -0
- package/bin/beads-map.mjs +175 -0
- package/components/AllCommentsPanel.tsx +265 -0
- package/components/AuthButton.tsx +197 -0
- package/components/BeadsGraph.tsx +1539 -0
- package/components/CommentTooltip.tsx +310 -0
- package/components/GraphStats.tsx +121 -0
- package/components/HeartIcon.tsx +33 -0
- package/components/NodeDetail.tsx +741 -0
- package/components/StatusLegend.tsx +99 -0
- package/components/TimelineBar.tsx +116 -0
- package/hooks/useBeadsComments.ts +412 -0
- package/lib/agent.ts +29 -0
- package/lib/auth/client.ts +221 -0
- package/lib/auth.tsx +159 -0
- package/lib/diff-beads.ts +125 -0
- package/lib/discover.ts +228 -0
- package/lib/env.ts +28 -0
- package/lib/parse-beads.ts +232 -0
- package/lib/session.ts +52 -0
- package/lib/timeline.ts +138 -0
- package/lib/types.ts +202 -0
- package/lib/utils.ts +25 -0
- package/lib/watch-beads.ts +97 -0
- package/next.config.mjs +4 -0
- package/package.json +75 -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,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/beads-map.mjs
|
|
3
|
+
// CLI entry point for beads-map — 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
|
+
// Parse CLI args
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
let port = 3000;
|
|
16
|
+
let beadsDir = process.env.BEADS_DIR || null;
|
|
17
|
+
let dev = false;
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
|
+
if (args[i] === "--port" && args[i + 1]) port = parseInt(args[++i]);
|
|
21
|
+
if (args[i] === "--beads-dir" && args[i + 1]) beadsDir = args[++i];
|
|
22
|
+
if (args[i] === "--dev") dev = true;
|
|
23
|
+
if (args[i] === "--help" || args[i] === "-h") {
|
|
24
|
+
printHelp();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function printHelp() {
|
|
30
|
+
console.log(`
|
|
31
|
+
beads-map - Interactive dependency graph viewer for beads (bd) issues
|
|
32
|
+
|
|
33
|
+
USAGE
|
|
34
|
+
beads-map [options]
|
|
35
|
+
|
|
36
|
+
OPTIONS
|
|
37
|
+
--port <number> Port to serve on (default: 3000)
|
|
38
|
+
--beads-dir <path> Explicit .beads/ directory path
|
|
39
|
+
--dev Run in development mode (hot reload)
|
|
40
|
+
--help, -h Show this help message
|
|
41
|
+
|
|
42
|
+
EXAMPLES
|
|
43
|
+
beads-map # Auto-discover .beads/ from cwd
|
|
44
|
+
beads-map --port 4000 # Serve on port 4000
|
|
45
|
+
beads-map --beads-dir ~/projects/my-app/.beads # Explicit path
|
|
46
|
+
BEADS_DIR=../.beads beads-map # Via environment variable
|
|
47
|
+
|
|
48
|
+
DISCOVERY
|
|
49
|
+
beads-map walks up from the current directory looking for a .beads/ folder,
|
|
50
|
+
just like git finds .git/. Set BEADS_DIR to override.
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Set BEADS_DIR so the API route can find it
|
|
55
|
+
if (beadsDir) process.env.BEADS_DIR = resolve(beadsDir);
|
|
56
|
+
|
|
57
|
+
// Discover .beads/ for the startup message
|
|
58
|
+
function discover(startDir) {
|
|
59
|
+
const envDir = process.env.BEADS_DIR;
|
|
60
|
+
if (envDir) {
|
|
61
|
+
const resolved = resolve(envDir);
|
|
62
|
+
if (basename(resolved) === ".beads") return resolved;
|
|
63
|
+
if (existsSync(join(resolved, ".beads"))) return join(resolved, ".beads");
|
|
64
|
+
return resolved;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let current = resolve(startDir || process.cwd());
|
|
68
|
+
const { root } = pathParse(current);
|
|
69
|
+
|
|
70
|
+
while (true) {
|
|
71
|
+
const candidate = join(current, ".beads");
|
|
72
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory()) {
|
|
73
|
+
return candidate;
|
|
74
|
+
}
|
|
75
|
+
if (current === root) return null;
|
|
76
|
+
current = dirname(current);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const discovered = discover(process.cwd());
|
|
81
|
+
|
|
82
|
+
// Read package.json for version
|
|
83
|
+
let version = "0.1.0";
|
|
84
|
+
try {
|
|
85
|
+
const pkg = JSON.parse(
|
|
86
|
+
readFileSync(join(viewerRoot, "package.json"), "utf-8")
|
|
87
|
+
);
|
|
88
|
+
version = pkg.version || version;
|
|
89
|
+
} catch {
|
|
90
|
+
// ignore
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(`beads-map v${version}`);
|
|
94
|
+
|
|
95
|
+
if (discovered) {
|
|
96
|
+
const repoRoot = dirname(discovered);
|
|
97
|
+
console.log(`Found beads in ${repoRoot}`);
|
|
98
|
+
|
|
99
|
+
// Count issues
|
|
100
|
+
const issuesPath = join(discovered, "issues.jsonl");
|
|
101
|
+
if (existsSync(issuesPath)) {
|
|
102
|
+
try {
|
|
103
|
+
const content = readFileSync(issuesPath, "utf-8");
|
|
104
|
+
const lines = content.split("\n").filter((l) => l.trim().length > 0);
|
|
105
|
+
const issueCount = lines.length;
|
|
106
|
+
|
|
107
|
+
// Count repos from config.yaml
|
|
108
|
+
let repoCount = 1;
|
|
109
|
+
const configPath = join(discovered, "config.yaml");
|
|
110
|
+
if (existsSync(configPath)) {
|
|
111
|
+
const configContent = readFileSync(configPath, "utf-8");
|
|
112
|
+
const additionalMatches = configContent.match(
|
|
113
|
+
/^\s+-\s+\.\.\//gm
|
|
114
|
+
);
|
|
115
|
+
if (additionalMatches) repoCount += additionalMatches.length;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log(
|
|
119
|
+
`Loading ${issueCount} issues across ${repoCount} project${repoCount !== 1 ? "s" : ""}`
|
|
120
|
+
);
|
|
121
|
+
} catch {
|
|
122
|
+
// ignore count errors
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Set BEADS_DIR if not already set
|
|
127
|
+
if (!process.env.BEADS_DIR) {
|
|
128
|
+
process.env.BEADS_DIR = discovered;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
console.log(
|
|
132
|
+
"Warning: No .beads/ directory found. Run bd init in your project first."
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Auto-build if no .next production build exists (unless --dev)
|
|
137
|
+
const buildIdPath = join(viewerRoot, ".next", "BUILD_ID");
|
|
138
|
+
if (!dev && !existsSync(buildIdPath)) {
|
|
139
|
+
console.log("No production build found, building...");
|
|
140
|
+
try {
|
|
141
|
+
execSync("npx next build", {
|
|
142
|
+
cwd: viewerRoot,
|
|
143
|
+
stdio: "inherit",
|
|
144
|
+
env: { ...process.env },
|
|
145
|
+
});
|
|
146
|
+
console.log();
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error("Build failed. Try running with --dev instead.");
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const mode = dev ? "dev" : "start";
|
|
154
|
+
console.log(`Serving at http://localhost:${port}`);
|
|
155
|
+
console.log();
|
|
156
|
+
|
|
157
|
+
// Start Next.js
|
|
158
|
+
const next = spawn("npx", ["next", mode, "-p", String(port)], {
|
|
159
|
+
cwd: viewerRoot,
|
|
160
|
+
stdio: "inherit",
|
|
161
|
+
env: { ...process.env },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
next.on("error", (err) => {
|
|
165
|
+
console.error("Failed to start Next.js:", err.message);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
next.on("close", (code) => {
|
|
170
|
+
process.exit(code || 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Forward signals
|
|
174
|
+
process.on("SIGINT", () => next.kill("SIGINT"));
|
|
175
|
+
process.on("SIGTERM", () => next.kill("SIGTERM"));
|
|
@@ -0,0 +1,265 @@
|
|
|
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"
|
|
50
|
+
>
|
|
51
|
+
<svg
|
|
52
|
+
className="w-4 h-4"
|
|
53
|
+
fill="none"
|
|
54
|
+
stroke="currentColor"
|
|
55
|
+
viewBox="0 0 24 24"
|
|
56
|
+
strokeWidth={2}
|
|
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
|
+
<span className="text-xs font-medium text-zinc-600 truncate">
|
|
201
|
+
{comment.displayName ||
|
|
202
|
+
comment.handle ||
|
|
203
|
+
comment.did.slice(0, 16) + "..."}
|
|
204
|
+
</span>
|
|
205
|
+
<span className="text-[10px] text-zinc-300 shrink-0">
|
|
206
|
+
{formatRelativeTime(comment.createdAt)}
|
|
207
|
+
</span>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{/* Comment text */}
|
|
211
|
+
<p className="text-xs text-zinc-500 leading-relaxed whitespace-pre-wrap break-words">
|
|
212
|
+
{comment.text}
|
|
213
|
+
</p>
|
|
214
|
+
|
|
215
|
+
{/* Actions */}
|
|
216
|
+
<div className="flex items-center gap-2 mt-1 text-[10px]">
|
|
217
|
+
{/* Like */}
|
|
218
|
+
<button
|
|
219
|
+
onClick={handleLike}
|
|
220
|
+
disabled={!isAuthenticated || liking}
|
|
221
|
+
className={`flex items-center gap-0.5 transition-colors ${
|
|
222
|
+
hasLiked
|
|
223
|
+
? "text-rose-500"
|
|
224
|
+
: "text-zinc-300 hover:text-rose-500"
|
|
225
|
+
} disabled:opacity-50`}
|
|
226
|
+
>
|
|
227
|
+
<HeartIcon className="w-3 h-3" filled={hasLiked} />
|
|
228
|
+
{comment.likes.length > 0 && <span>{comment.likes.length}</span>}
|
|
229
|
+
</button>
|
|
230
|
+
|
|
231
|
+
{/* Delete — own only */}
|
|
232
|
+
{isOwn && onDelete && (
|
|
233
|
+
<button
|
|
234
|
+
onClick={handleDelete}
|
|
235
|
+
disabled={deleting}
|
|
236
|
+
className="ml-auto text-zinc-300 hover:text-red-400 disabled:opacity-50 transition-colors"
|
|
237
|
+
>
|
|
238
|
+
{deleting ? "..." : "delete"}
|
|
239
|
+
</button>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
{/* Nested replies */}
|
|
245
|
+
{comment.replies.length > 0 && (
|
|
246
|
+
<div>
|
|
247
|
+
{comment.replies.map((reply) => (
|
|
248
|
+
<AllCommentCard
|
|
249
|
+
key={reply.uri}
|
|
250
|
+
comment={reply}
|
|
251
|
+
currentDid={currentDid}
|
|
252
|
+
isAuthenticated={isAuthenticated}
|
|
253
|
+
onNodeNavigate={onNodeNavigate}
|
|
254
|
+
onLike={onLike}
|
|
255
|
+
onDelete={onDelete}
|
|
256
|
+
depth={depth + 1}
|
|
257
|
+
/>
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from "react";
|
|
4
|
+
import { useAuth } from "@/lib/auth";
|
|
5
|
+
|
|
6
|
+
export function AuthButton() {
|
|
7
|
+
const { isAuthenticated, isLoading, session, login, logout } = useAuth();
|
|
8
|
+
const [showModal, setShowModal] = useState(false);
|
|
9
|
+
const [showDropdown, setShowDropdown] = useState(false);
|
|
10
|
+
const [handle, setHandle] = useState("");
|
|
11
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
12
|
+
const [error, setError] = useState("");
|
|
13
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
14
|
+
|
|
15
|
+
// Close dropdown on outside click
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!showDropdown) return;
|
|
18
|
+
|
|
19
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
20
|
+
if (
|
|
21
|
+
dropdownRef.current &&
|
|
22
|
+
!dropdownRef.current.contains(e.target as Node)
|
|
23
|
+
) {
|
|
24
|
+
setShowDropdown(false);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
29
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
30
|
+
}, [showDropdown]);
|
|
31
|
+
|
|
32
|
+
const handleLogin = async (e: React.FormEvent) => {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
if (!handle.trim()) return;
|
|
35
|
+
|
|
36
|
+
setIsSubmitting(true);
|
|
37
|
+
setError("");
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await login(handle.trim());
|
|
41
|
+
} catch (err) {
|
|
42
|
+
setError(err instanceof Error ? err.message : "Login failed");
|
|
43
|
+
setIsSubmitting(false);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleLogout = async () => {
|
|
48
|
+
setShowDropdown(false);
|
|
49
|
+
await logout();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (isLoading) {
|
|
53
|
+
return (
|
|
54
|
+
<div className="w-4 h-4 rounded-full border-2 border-zinc-200 border-t-zinc-400 animate-spin" />
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isAuthenticated && session) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="relative" ref={dropdownRef}>
|
|
61
|
+
<button
|
|
62
|
+
onClick={() => setShowDropdown((prev) => !prev)}
|
|
63
|
+
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity cursor-pointer"
|
|
64
|
+
>
|
|
65
|
+
{session.avatar ? (
|
|
66
|
+
/* eslint-disable-next-line @next/next/no-img-element */
|
|
67
|
+
<img
|
|
68
|
+
src={session.avatar}
|
|
69
|
+
alt={session.handle}
|
|
70
|
+
width={20}
|
|
71
|
+
height={20}
|
|
72
|
+
className="rounded-full"
|
|
73
|
+
/>
|
|
74
|
+
) : (
|
|
75
|
+
<div className="w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center text-[10px] text-emerald-700 font-medium">
|
|
76
|
+
{(session.displayName || session.handle).charAt(0).toUpperCase()}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
<span className="text-xs text-zinc-600 max-w-[100px] truncate">
|
|
80
|
+
{session.displayName || session.handle}
|
|
81
|
+
</span>
|
|
82
|
+
</button>
|
|
83
|
+
|
|
84
|
+
{showDropdown && (
|
|
85
|
+
<div className="absolute right-0 top-full mt-2 w-40 bg-white rounded-lg shadow-lg border border-zinc-200 py-1 z-50">
|
|
86
|
+
<div className="px-3 py-1.5 text-xs text-zinc-400 truncate">
|
|
87
|
+
@{session.handle}
|
|
88
|
+
</div>
|
|
89
|
+
<div className="h-px bg-zinc-100 my-1" />
|
|
90
|
+
<button
|
|
91
|
+
onClick={handleLogout}
|
|
92
|
+
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-zinc-600 hover:bg-zinc-50 transition-colors cursor-pointer"
|
|
93
|
+
>
|
|
94
|
+
<svg
|
|
95
|
+
className="w-3.5 h-3.5 text-zinc-400"
|
|
96
|
+
fill="none"
|
|
97
|
+
viewBox="0 0 24 24"
|
|
98
|
+
strokeWidth={1.5}
|
|
99
|
+
stroke="currentColor"
|
|
100
|
+
>
|
|
101
|
+
<path
|
|
102
|
+
strokeLinecap="round"
|
|
103
|
+
strokeLinejoin="round"
|
|
104
|
+
d="M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15"
|
|
105
|
+
/>
|
|
106
|
+
</svg>
|
|
107
|
+
Sign out
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<>
|
|
117
|
+
<button
|
|
118
|
+
onClick={() => setShowModal(true)}
|
|
119
|
+
className="text-xs text-zinc-400 hover:text-zinc-600 transition-colors cursor-pointer whitespace-nowrap"
|
|
120
|
+
>
|
|
121
|
+
Sign in
|
|
122
|
+
</button>
|
|
123
|
+
|
|
124
|
+
{showModal && (
|
|
125
|
+
<div
|
|
126
|
+
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
|
|
127
|
+
onClick={() => setShowModal(false)}
|
|
128
|
+
>
|
|
129
|
+
{/* Backdrop */}
|
|
130
|
+
<div className="absolute inset-0 bg-black/20 backdrop-blur-sm" />
|
|
131
|
+
|
|
132
|
+
{/* Modal */}
|
|
133
|
+
<div
|
|
134
|
+
className="relative w-full max-w-sm mx-4 bg-white rounded-xl shadow-lg border border-zinc-200 p-6"
|
|
135
|
+
onClick={(e) => e.stopPropagation()}
|
|
136
|
+
>
|
|
137
|
+
<h2 className="text-lg font-semibold text-zinc-900 mb-1">
|
|
138
|
+
Sign in with ATProto
|
|
139
|
+
</h2>
|
|
140
|
+
<p className="text-sm text-zinc-400 mb-5">
|
|
141
|
+
Enter your Bluesky handle to connect.
|
|
142
|
+
</p>
|
|
143
|
+
|
|
144
|
+
<form onSubmit={handleLogin}>
|
|
145
|
+
<label
|
|
146
|
+
htmlFor="auth-handle"
|
|
147
|
+
className="block text-sm text-zinc-600 mb-1.5"
|
|
148
|
+
>
|
|
149
|
+
Handle
|
|
150
|
+
</label>
|
|
151
|
+
<input
|
|
152
|
+
id="auth-handle"
|
|
153
|
+
type="text"
|
|
154
|
+
value={handle}
|
|
155
|
+
onChange={(e) => setHandle(e.target.value)}
|
|
156
|
+
placeholder="alice.bsky.social"
|
|
157
|
+
disabled={isSubmitting}
|
|
158
|
+
autoFocus
|
|
159
|
+
className="w-full px-3 py-2 text-sm bg-white border border-zinc-200 rounded-lg
|
|
160
|
+
placeholder:text-zinc-300
|
|
161
|
+
focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-400
|
|
162
|
+
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
163
|
+
/>
|
|
164
|
+
<p className="text-xs text-zinc-300 mt-1.5">
|
|
165
|
+
Just a username? We'll add .bsky.social for you.
|
|
166
|
+
</p>
|
|
167
|
+
|
|
168
|
+
{error && <p className="text-sm text-red-500 mt-2">{error}</p>}
|
|
169
|
+
|
|
170
|
+
<div className="flex gap-2 mt-5">
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={() => setShowModal(false)}
|
|
174
|
+
disabled={isSubmitting}
|
|
175
|
+
className="flex-1 px-3 py-2 text-sm text-zinc-600 bg-zinc-50 rounded-lg
|
|
176
|
+
hover:bg-zinc-100 transition-colors
|
|
177
|
+
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
178
|
+
>
|
|
179
|
+
Cancel
|
|
180
|
+
</button>
|
|
181
|
+
<button
|
|
182
|
+
type="submit"
|
|
183
|
+
disabled={isSubmitting || !handle.trim()}
|
|
184
|
+
className="flex-1 px-3 py-2 text-sm text-white bg-emerald-600 rounded-lg
|
|
185
|
+
hover:bg-emerald-700 transition-colors
|
|
186
|
+
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
187
|
+
>
|
|
188
|
+
{isSubmitting ? "Connecting..." : "Connect"}
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
</form>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
</>
|
|
196
|
+
);
|
|
197
|
+
}
|