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.
Files changed (142) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +27 -0
  3. package/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/build-manifest.json +32 -0
  5. package/.next/export-marker.json +1 -0
  6. package/.next/images-manifest.json +1 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +1 -0
  11. package/.next/react-loadable-manifest.json +8 -0
  12. package/.next/required-server-files.json +1 -0
  13. package/.next/routes-manifest.json +1 -0
  14. package/.next/server/app/_not-found/page.js +1 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +6 -0
  19. package/.next/server/app/_not-found.rsc +10 -0
  20. package/.next/server/app/api/beads/route.js +8 -0
  21. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/stream/route.js +10 -0
  23. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads.body +1 -0
  25. package/.next/server/app/api/beads.meta +1 -0
  26. package/.next/server/app/api/config/route.js +8 -0
  27. package/.next/server/app/api/config/route.js.nft.json +1 -0
  28. package/.next/server/app/api/config.body +1 -0
  29. package/.next/server/app/api/config.meta +1 -0
  30. package/.next/server/app/api/login/route.js +1 -0
  31. package/.next/server/app/api/login/route.js.nft.json +1 -0
  32. package/.next/server/app/api/logout/route.js +1 -0
  33. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  34. package/.next/server/app/api/oauth/callback/route.js +1 -0
  35. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  36. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  37. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  38. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  39. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  40. package/.next/server/app/api/records/route.js +1 -0
  41. package/.next/server/app/api/records/route.js.nft.json +1 -0
  42. package/.next/server/app/api/status/route.js +1 -0
  43. package/.next/server/app/api/status/route.js.nft.json +1 -0
  44. package/.next/server/app/index.html +1 -0
  45. package/.next/server/app/index.meta +5 -0
  46. package/.next/server/app/index.rsc +8 -0
  47. package/.next/server/app/page.js +24 -0
  48. package/.next/server/app/page.js.nft.json +1 -0
  49. package/.next/server/app/page_client-reference-manifest.js +1 -0
  50. package/.next/server/app-paths-manifest.json +14 -0
  51. package/.next/server/chunks/247.js +12 -0
  52. package/.next/server/chunks/251.js +2 -0
  53. package/.next/server/chunks/29.js +1 -0
  54. package/.next/server/chunks/343.js +1 -0
  55. package/.next/server/chunks/533.js +38 -0
  56. package/.next/server/chunks/590.js +6 -0
  57. package/.next/server/chunks/615.js +15 -0
  58. package/.next/server/chunks/696.js +25 -0
  59. package/.next/server/chunks/719.js +2 -0
  60. package/.next/server/chunks/739.js +1 -0
  61. package/.next/server/chunks/font-manifest.json +1 -0
  62. package/.next/server/font-manifest.json +1 -0
  63. package/.next/server/functions-config-manifest.json +1 -0
  64. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  65. package/.next/server/middleware-build-manifest.js +1 -0
  66. package/.next/server/middleware-manifest.json +6 -0
  67. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  68. package/.next/server/next-font-manifest.js +1 -0
  69. package/.next/server/next-font-manifest.json +1 -0
  70. package/.next/server/pages/404.html +1 -0
  71. package/.next/server/pages/500.html +1 -0
  72. package/.next/server/pages/_app.js +1 -0
  73. package/.next/server/pages/_app.js.nft.json +1 -0
  74. package/.next/server/pages/_document.js +1 -0
  75. package/.next/server/pages/_document.js.nft.json +1 -0
  76. package/.next/server/pages/_error.js +1 -0
  77. package/.next/server/pages/_error.js.nft.json +1 -0
  78. package/.next/server/pages-manifest.json +1 -0
  79. package/.next/server/server-reference-manifest.js +1 -0
  80. package/.next/server/server-reference-manifest.json +1 -0
  81. package/.next/server/webpack-runtime.js +1 -0
  82. package/.next/static/99eOjoTtoO32H-c1faxZ5/_buildManifest.js +1 -0
  83. package/.next/static/99eOjoTtoO32H-c1faxZ5/_ssgManifest.js +1 -0
  84. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  85. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  86. package/.next/static/chunks/666-fb778298a77f3754.js +1 -0
  87. package/.next/static/chunks/945-bf736d0119e7437b.js +2 -0
  88. package/.next/static/chunks/app/_not-found/page-b568fd9238f85f27.js +1 -0
  89. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  90. package/.next/static/chunks/app/page-49d569c912d5af9d.js +1 -0
  91. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  92. package/.next/static/chunks/main-62aa0e18004db880.js +1 -0
  93. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  94. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  95. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  96. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  97. package/.next/static/chunks/webpack-c8b9ebfd35ae1d92.js +1 -0
  98. package/.next/static/css/10ef08b24212fe36.css +3 -0
  99. package/README.md +243 -0
  100. package/app/api/beads/route.ts +27 -0
  101. package/app/api/beads/stream/route.ts +83 -0
  102. package/app/api/config/route.ts +46 -0
  103. package/app/api/login/route.ts +42 -0
  104. package/app/api/logout/route.ts +14 -0
  105. package/app/api/oauth/callback/route.ts +94 -0
  106. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  107. package/app/api/oauth/jwks.json/route.ts +32 -0
  108. package/app/api/records/route.ts +168 -0
  109. package/app/api/status/route.ts +25 -0
  110. package/app/globals.css +192 -0
  111. package/app/layout.tsx +30 -0
  112. package/app/page.tsx +1151 -0
  113. package/bin/beads-map.mjs +175 -0
  114. package/components/AllCommentsPanel.tsx +265 -0
  115. package/components/AuthButton.tsx +197 -0
  116. package/components/BeadsGraph.tsx +1539 -0
  117. package/components/CommentTooltip.tsx +310 -0
  118. package/components/GraphStats.tsx +121 -0
  119. package/components/HeartIcon.tsx +33 -0
  120. package/components/NodeDetail.tsx +741 -0
  121. package/components/StatusLegend.tsx +99 -0
  122. package/components/TimelineBar.tsx +116 -0
  123. package/hooks/useBeadsComments.ts +412 -0
  124. package/lib/agent.ts +29 -0
  125. package/lib/auth/client.ts +221 -0
  126. package/lib/auth.tsx +159 -0
  127. package/lib/diff-beads.ts +125 -0
  128. package/lib/discover.ts +228 -0
  129. package/lib/env.ts +28 -0
  130. package/lib/parse-beads.ts +232 -0
  131. package/lib/session.ts +52 -0
  132. package/lib/timeline.ts +138 -0
  133. package/lib/types.ts +202 -0
  134. package/lib/utils.ts +25 -0
  135. package/lib/watch-beads.ts +97 -0
  136. package/next.config.mjs +4 -0
  137. package/package.json +75 -0
  138. package/postcss.config.mjs +9 -0
  139. package/public/image.png +0 -0
  140. package/scripts/generate-jwk.js +38 -0
  141. package/tailwind.config.ts +41 -0
  142. 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&apos;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
+ }