heartbeads 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -0
- package/.next/app-build-manifest.json +49 -0
- package/.next/app-path-routes-manifest.json +1 -0
- package/.next/build-manifest.json +32 -0
- package/.next/export-marker.json +1 -0
- package/.next/images-manifest.json +1 -0
- package/.next/next-minimal-server.js.nft.json +1 -0
- package/.next/next-server.js.nft.json +1 -0
- package/.next/package.json +1 -0
- package/.next/prerender-manifest.json +1 -0
- package/.next/react-loadable-manifest.json +8 -0
- package/.next/required-server-files.json +1 -0
- package/.next/routes-manifest.json +1 -0
- package/.next/server/app/_not-found/page.js +1 -0
- package/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/.next/server/app/_not-found.html +1 -0
- package/.next/server/app/_not-found.meta +6 -0
- package/.next/server/app/_not-found.rsc +9 -0
- package/.next/server/app/api/auth/route.js +1 -0
- package/.next/server/app/api/auth/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/route.js +8 -0
- package/.next/server/app/api/beads/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/stream/route.js +10 -0
- package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
- package/.next/server/app/api/beads.body +1 -0
- package/.next/server/app/api/beads.meta +1 -0
- package/.next/server/app/api/config/route.js +8 -0
- package/.next/server/app/api/config/route.js.nft.json +1 -0
- package/.next/server/app/api/docs/page.js +120 -0
- package/.next/server/app/api/docs/page.js.nft.json +1 -0
- package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
- package/.next/server/app/api/docs.html +120 -0
- package/.next/server/app/api/docs.meta +5 -0
- package/.next/server/app/api/docs.rsc +70 -0
- package/.next/server/app/api/login/route.js +1 -0
- package/.next/server/app/api/login/route.js.nft.json +1 -0
- package/.next/server/app/api/logout/route.js +1 -0
- package/.next/server/app/api/logout/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/callback/route.js +1 -0
- package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
- package/.next/server/app/api/records/route.js +1 -0
- package/.next/server/app/api/records/route.js.nft.json +1 -0
- package/.next/server/app/api/status/route.js +1 -0
- package/.next/server/app/api/status/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/graph/route.js +1 -0
- package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/ready/route.js +1 -0
- package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
- package/.next/server/app/index.html +1 -0
- package/.next/server/app/index.meta +5 -0
- package/.next/server/app/index.rsc +9 -0
- package/.next/server/app/login/page.js +1 -0
- package/.next/server/app/login/page.js.nft.json +1 -0
- package/.next/server/app/login/page_client-reference-manifest.js +1 -0
- package/.next/server/app/login.html +1 -0
- package/.next/server/app/login.meta +5 -0
- package/.next/server/app/login.rsc +9 -0
- package/.next/server/app/opengraph-image.png/route.js +1 -0
- package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
- package/.next/server/app/opengraph-image.png.body +0 -0
- package/.next/server/app/opengraph-image.png.meta +1 -0
- package/.next/server/app/page.js +24 -0
- package/.next/server/app/page.js.nft.json +1 -0
- package/.next/server/app/page_client-reference-manifest.js +1 -0
- package/.next/server/app/twitter-image.png/route.js +1 -0
- package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
- package/.next/server/app/twitter-image.png.body +0 -0
- package/.next/server/app/twitter-image.png.meta +1 -0
- package/.next/server/app-paths-manifest.json +22 -0
- package/.next/server/chunks/247.js +12 -0
- package/.next/server/chunks/29.js +1 -0
- package/.next/server/chunks/343.js +1 -0
- package/.next/server/chunks/460.js +12 -0
- package/.next/server/chunks/533.js +38 -0
- package/.next/server/chunks/542.js +27 -0
- package/.next/server/chunks/590.js +6 -0
- package/.next/server/chunks/615.js +15 -0
- package/.next/server/chunks/696.js +25 -0
- package/.next/server/chunks/719.js +2 -0
- package/.next/server/chunks/739.js +1 -0
- package/.next/server/chunks/950.js +2 -0
- package/.next/server/chunks/font-manifest.json +1 -0
- package/.next/server/edge-runtime-webpack.js +2 -0
- package/.next/server/edge-runtime-webpack.js.map +1 -0
- package/.next/server/font-manifest.json +1 -0
- package/.next/server/functions-config-manifest.json +1 -0
- package/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/.next/server/middleware-build-manifest.js +1 -0
- package/.next/server/middleware-manifest.json +32 -0
- package/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/.next/server/middleware.js +14 -0
- package/.next/server/middleware.js.map +1 -0
- package/.next/server/next-font-manifest.js +1 -0
- package/.next/server/next-font-manifest.json +1 -0
- package/.next/server/pages/404.html +1 -0
- package/.next/server/pages/500.html +1 -0
- package/.next/server/pages/_app.js +1 -0
- package/.next/server/pages/_app.js.nft.json +1 -0
- package/.next/server/pages/_document.js +1 -0
- package/.next/server/pages/_document.js.nft.json +1 -0
- package/.next/server/pages/_error.js +1 -0
- package/.next/server/pages/_error.js.nft.json +1 -0
- package/.next/server/pages-manifest.json +1 -0
- package/.next/server/server-reference-manifest.js +1 -0
- package/.next/server/server-reference-manifest.json +1 -0
- package/.next/server/webpack-runtime.js +1 -0
- package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
- package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
- package/.next/static/chunks/788-aa413085174e935a.js +1 -0
- package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
- package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
- package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
- package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
- package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
- package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
- package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
- package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
- package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
- package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
- package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
- package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
- package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
- package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
- package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
- package/README.md +389 -0
- package/app/api/auth/route.ts +103 -0
- package/app/api/beads/route.ts +27 -0
- package/app/api/beads/stream/route.ts +83 -0
- package/app/api/config/route.ts +48 -0
- package/app/api/docs/page.tsx +497 -0
- package/app/api/login/route.ts +42 -0
- package/app/api/logout/route.ts +14 -0
- package/app/api/oauth/callback/route.ts +97 -0
- package/app/api/oauth/client-metadata.json/route.ts +33 -0
- package/app/api/oauth/jwks.json/route.ts +32 -0
- package/app/api/records/route.ts +168 -0
- package/app/api/status/route.ts +25 -0
- package/app/api/v1/graph/route.ts +251 -0
- package/app/api/v1/issues/[id]/route.ts +158 -0
- package/app/api/v1/ready/route.ts +229 -0
- package/app/globals.css +230 -0
- package/app/layout.tsx +51 -0
- package/app/login/page.tsx +164 -0
- package/app/not-found.tsx +91 -0
- package/app/opengraph-image.png +0 -0
- package/app/page.tsx +2041 -0
- package/app/twitter-image.png +0 -0
- package/bin/heartbeads.mjs +225 -0
- package/components/ActivityItem.tsx +326 -0
- package/components/ActivityOverlay.tsx +125 -0
- package/components/ActivityPanel.tsx +345 -0
- package/components/AllCommentsPanel.tsx +270 -0
- package/components/AuthButton.tsx +202 -0
- package/components/BeadTooltip.tsx +246 -0
- package/components/BeadsGraph.tsx +2493 -0
- package/components/BeadsLogo.tsx +94 -0
- package/components/CommentTooltip.tsx +338 -0
- package/components/ContextMenu.tsx +272 -0
- package/components/DescriptionModal.tsx +595 -0
- package/components/GraphStats.tsx +121 -0
- package/components/HeartIcon.tsx +33 -0
- package/components/HelpPanel.tsx +339 -0
- package/components/MobileActionSheet.tsx +255 -0
- package/components/NodeDetail.tsx +793 -0
- package/components/SettingsModal.tsx +315 -0
- package/components/StatusLegend.tsx +99 -0
- package/components/TimelineBar.tsx +116 -0
- package/components/TutorialOverlay.tsx +235 -0
- package/hooks/useBeadsComments.ts +81 -0
- package/hooks/useIsMobile.ts +19 -0
- package/lib/activity.ts +377 -0
- package/lib/agent.ts +29 -0
- package/lib/api-helpers.ts +46 -0
- package/lib/auth/client.ts +221 -0
- package/lib/auth.tsx +159 -0
- package/lib/comments.ts +413 -0
- package/lib/diff-beads.ts +128 -0
- package/lib/discover.ts +228 -0
- package/lib/env.ts +33 -0
- package/lib/gate.ts +55 -0
- package/lib/parse-beads.ts +234 -0
- package/lib/session.ts +52 -0
- package/lib/settings.ts +42 -0
- package/lib/timeline.ts +138 -0
- package/lib/tts.ts +397 -0
- package/lib/types.ts +271 -0
- package/lib/utils.ts +48 -0
- package/lib/watch-beads.ts +97 -0
- package/next.config.mjs +4 -0
- package/package.json +81 -0
- package/postcss.config.mjs +9 -0
- package/public/image.png +0 -0
- package/scripts/generate-jwk.js +38 -0
- package/tailwind.config.ts +41 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/v1/ready — Actionable issues for AI agents.
|
|
3
|
+
*
|
|
4
|
+
* Returns issues that are ready to work on: open or in_progress
|
|
5
|
+
* with no unresolved blockers. Sorted by priority (critical first).
|
|
6
|
+
* No auth required.
|
|
7
|
+
*
|
|
8
|
+
* Query params:
|
|
9
|
+
* ?unclaimed=true Only unclaimed issues
|
|
10
|
+
* ?type=bug,feature Filter by issue type (comma-separated)
|
|
11
|
+
* ?assignee=handle Filter by assignee
|
|
12
|
+
* ?prefix=beads-map Filter by repo prefix
|
|
13
|
+
* ?limit=20 Max issues returned (default: all)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { loadBeadsData } from "@/lib/parse-beads";
|
|
17
|
+
import { discoverBeadsDir } from "@/lib/discover";
|
|
18
|
+
import { fetchBeadsComments, getClaimedNodes } from "@/lib/comments";
|
|
19
|
+
import type { BeadsComment, ClaimInfo } from "@/lib/comments";
|
|
20
|
+
import type { GraphNode } from "@/lib/types";
|
|
21
|
+
import { jsonResponse, errorResponse, OPTIONS } from "@/lib/api-helpers";
|
|
22
|
+
import { readFileSync } from "fs";
|
|
23
|
+
import { join } from "path";
|
|
24
|
+
|
|
25
|
+
export const dynamic = "force-dynamic";
|
|
26
|
+
export { OPTIONS };
|
|
27
|
+
|
|
28
|
+
let heartbeadsVersion = "0.0.0";
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse(
|
|
31
|
+
readFileSync(join(process.cwd(), "package.json"), "utf-8")
|
|
32
|
+
);
|
|
33
|
+
heartbeadsVersion = pkg.version || heartbeadsVersion;
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function serializeComment(c: BeadsComment): Record<string, unknown> {
|
|
39
|
+
return {
|
|
40
|
+
author: {
|
|
41
|
+
handle: c.handle,
|
|
42
|
+
did: c.did,
|
|
43
|
+
...(c.displayName ? { displayName: c.displayName } : {}),
|
|
44
|
+
},
|
|
45
|
+
text: c.text,
|
|
46
|
+
createdAt: c.createdAt,
|
|
47
|
+
likes: c.likes.length,
|
|
48
|
+
replies: c.replies.map(serializeComment),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function GET(request: Request) {
|
|
53
|
+
try {
|
|
54
|
+
const discovery = discoverBeadsDir();
|
|
55
|
+
const url = new URL(request.url);
|
|
56
|
+
const params = url.searchParams;
|
|
57
|
+
|
|
58
|
+
// Parse query params
|
|
59
|
+
const unclaimedOnly = params.get("unclaimed") === "true";
|
|
60
|
+
const typeFilter = params.get("type")?.split(",").filter(Boolean);
|
|
61
|
+
const assigneeFilter = params.get("assignee") || null;
|
|
62
|
+
const prefixFilter = params.get("prefix") || null;
|
|
63
|
+
const limitParam = params.get("limit");
|
|
64
|
+
const limit = limitParam ? Math.max(parseInt(limitParam) || 0, 1) : null;
|
|
65
|
+
|
|
66
|
+
// Load beads data + comments in parallel
|
|
67
|
+
const [beadsData, commentsResult] = await Promise.all([
|
|
68
|
+
Promise.resolve(loadBeadsData(discovery.beadsDir)),
|
|
69
|
+
fetchBeadsComments().catch((err) => {
|
|
70
|
+
console.error("[api/v1/ready] Failed to fetch comments:", err);
|
|
71
|
+
return null;
|
|
72
|
+
}),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const claims = commentsResult
|
|
76
|
+
? getClaimedNodes(commentsResult.allComments)
|
|
77
|
+
: new Map<string, ClaimInfo>();
|
|
78
|
+
|
|
79
|
+
// Build status map for blocker resolution
|
|
80
|
+
const nodeMap = new Map<string, GraphNode>(
|
|
81
|
+
beadsData.graphData.nodes.map((n) => [n.id, n])
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Filter to "ready" issues:
|
|
85
|
+
// - status is open or in_progress
|
|
86
|
+
// - all blockers have status "closed"
|
|
87
|
+
let readyIssues = beadsData.graphData.nodes.filter((node) => {
|
|
88
|
+
if (node.status !== "open" && node.status !== "in_progress") {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check all blockers are closed
|
|
93
|
+
// blockerIds = IDs of issues that BLOCK this one (i.e. this depends on them)
|
|
94
|
+
// Actually in the GraphNode, blockerIds are "IDs of issues this blocks"
|
|
95
|
+
// and dependentIds are "IDs of issues blocking this"
|
|
96
|
+
// Let me verify: from types.ts comments:
|
|
97
|
+
// blockerCount: number; // issues this blocks
|
|
98
|
+
// dependentCount: number; // issues that depend on this
|
|
99
|
+
// blockerIds: string[]; // IDs of issues this blocks
|
|
100
|
+
// dependentIds: string[]; // IDs of issues blocking this
|
|
101
|
+
//
|
|
102
|
+
// Wait, the naming is confusing. Let me check parse-beads for the actual logic.
|
|
103
|
+
// In the graph: source = depends_on_id (blocker), target = issue_id (blocked)
|
|
104
|
+
// So for issue X:
|
|
105
|
+
// blockerIds = nodes that X blocks (X is upstream of them)
|
|
106
|
+
// dependentIds = nodes that block X (X depends on them, they are upstream)
|
|
107
|
+
//
|
|
108
|
+
// For "ready", we need: all of X's UPSTREAM blockers (dependentIds) are closed
|
|
109
|
+
for (const depId of node.dependentIds) {
|
|
110
|
+
const depNode = nodeMap.get(depId);
|
|
111
|
+
if (depNode && depNode.status !== "closed") {
|
|
112
|
+
return false; // Has an unresolved blocker
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Apply filters
|
|
120
|
+
if (typeFilter) {
|
|
121
|
+
readyIssues = readyIssues.filter((n) =>
|
|
122
|
+
typeFilter.includes(n.issueType)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (assigneeFilter) {
|
|
126
|
+
readyIssues = readyIssues.filter(
|
|
127
|
+
(n) => n.assignee === assigneeFilter
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (prefixFilter) {
|
|
131
|
+
readyIssues = readyIssues.filter((n) => n.prefix === prefixFilter);
|
|
132
|
+
}
|
|
133
|
+
if (unclaimedOnly) {
|
|
134
|
+
readyIssues = readyIssues.filter((n) => !claims.has(n.id));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Sort by priority ascending (0=critical first), then created_at ascending (oldest first)
|
|
138
|
+
readyIssues.sort((a, b) => {
|
|
139
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
140
|
+
return (
|
|
141
|
+
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Apply limit
|
|
146
|
+
if (limit) {
|
|
147
|
+
readyIssues = readyIssues.slice(0, limit);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Enrich issues
|
|
151
|
+
const enrichedIssues = readyIssues.map((node) => {
|
|
152
|
+
const nodeComments =
|
|
153
|
+
commentsResult?.commentsByNode.get(node.id) || [];
|
|
154
|
+
const claim = claims.get(node.id) || null;
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
id: node.id,
|
|
158
|
+
title: node.title,
|
|
159
|
+
description: node.description || null,
|
|
160
|
+
status: node.status,
|
|
161
|
+
priority: node.priority,
|
|
162
|
+
issue_type: node.issueType,
|
|
163
|
+
owner: node.owner || null,
|
|
164
|
+
assignee: node.assignee || null,
|
|
165
|
+
labels: [] as string[],
|
|
166
|
+
created_at: node.createdAt,
|
|
167
|
+
updated_at: node.updatedAt,
|
|
168
|
+
prefix: node.prefix,
|
|
169
|
+
blockers: node.dependentIds, // issues that block this one (upstream)
|
|
170
|
+
dependents: node.blockerIds, // issues this one blocks (downstream)
|
|
171
|
+
comments: nodeComments.map(serializeComment),
|
|
172
|
+
claimed_by: claim
|
|
173
|
+
? {
|
|
174
|
+
handle: claim.handle,
|
|
175
|
+
did: claim.did,
|
|
176
|
+
...(claim.displayName
|
|
177
|
+
? { displayName: claim.displayName }
|
|
178
|
+
: {}),
|
|
179
|
+
claimed_at: claim.claimedAt,
|
|
180
|
+
}
|
|
181
|
+
: null,
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Build summary stats
|
|
186
|
+
const byPriority: Record<string, number> = {};
|
|
187
|
+
const byType: Record<string, number> = {};
|
|
188
|
+
let unclaimed = 0;
|
|
189
|
+
for (const issue of readyIssues) {
|
|
190
|
+
const p = String(issue.priority);
|
|
191
|
+
byPriority[p] = (byPriority[p] || 0) + 1;
|
|
192
|
+
byType[issue.issueType] = (byType[issue.issueType] || 0) + 1;
|
|
193
|
+
if (!claims.has(issue.id)) unclaimed++;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const warnings: string[] = [];
|
|
197
|
+
if (!commentsResult) {
|
|
198
|
+
warnings.push("Failed to fetch comments from indexer");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return jsonResponse({
|
|
202
|
+
issues: enrichedIssues,
|
|
203
|
+
stats: {
|
|
204
|
+
total_ready: enrichedIssues.length,
|
|
205
|
+
unclaimed,
|
|
206
|
+
by_priority: byPriority,
|
|
207
|
+
by_type: byType,
|
|
208
|
+
},
|
|
209
|
+
_meta: {
|
|
210
|
+
generated_at: new Date().toISOString(),
|
|
211
|
+
api_version: "v1" as const,
|
|
212
|
+
heartbeads_version: heartbeadsVersion,
|
|
213
|
+
...(warnings.length ? { warnings } : {}),
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
} catch (error: unknown) {
|
|
217
|
+
const message =
|
|
218
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
219
|
+
if (message.includes("No .beads/ directory found")) {
|
|
220
|
+
return errorResponse(
|
|
221
|
+
"No .beads directory found",
|
|
222
|
+
404,
|
|
223
|
+
"Run bd init in your project first, or set BEADS_DIR."
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
console.error("[api/v1/ready] Error:", error);
|
|
227
|
+
return errorResponse("Internal server error", 500);
|
|
228
|
+
}
|
|
229
|
+
}
|
package/app/globals.css
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
/* ============================================================================
|
|
6
|
+
Beads Map - Custom Styles
|
|
7
|
+
Design: Clean, minimal graph visualization
|
|
8
|
+
============================================================================ */
|
|
9
|
+
|
|
10
|
+
@layer base {
|
|
11
|
+
html,
|
|
12
|
+
body {
|
|
13
|
+
@apply h-full overflow-hidden;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
body {
|
|
17
|
+
@apply bg-white text-zinc-800 antialiased;
|
|
18
|
+
font-feature-settings: "rlig" 1, "calt" 1;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@layer components {
|
|
23
|
+
/* Card with subtle hover lift */
|
|
24
|
+
.card-lift {
|
|
25
|
+
@apply transition-all duration-200 ease-out;
|
|
26
|
+
}
|
|
27
|
+
.card-lift:hover {
|
|
28
|
+
@apply shadow-md -translate-y-0.5;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Status badge base */
|
|
32
|
+
.status-badge {
|
|
33
|
+
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Scrollbar styling */
|
|
37
|
+
.custom-scrollbar::-webkit-scrollbar {
|
|
38
|
+
width: 4px;
|
|
39
|
+
}
|
|
40
|
+
.custom-scrollbar::-webkit-scrollbar-track {
|
|
41
|
+
@apply bg-transparent;
|
|
42
|
+
}
|
|
43
|
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
44
|
+
@apply bg-zinc-200 rounded-full;
|
|
45
|
+
}
|
|
46
|
+
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
47
|
+
@apply bg-zinc-300;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@layer utilities {
|
|
52
|
+
/* Fade in animation */
|
|
53
|
+
@keyframes fadeIn {
|
|
54
|
+
from {
|
|
55
|
+
opacity: 0;
|
|
56
|
+
transform: translateY(8px);
|
|
57
|
+
}
|
|
58
|
+
to {
|
|
59
|
+
opacity: 1;
|
|
60
|
+
transform: translateY(0);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
.animate-fade-in {
|
|
64
|
+
animation: fadeIn 0.3s ease-out forwards;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* Pulse subtle */
|
|
68
|
+
@keyframes pulseSoft {
|
|
69
|
+
0%,
|
|
70
|
+
100% {
|
|
71
|
+
opacity: 1;
|
|
72
|
+
}
|
|
73
|
+
50% {
|
|
74
|
+
opacity: 0.6;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
.animate-pulse-soft {
|
|
78
|
+
animation: pulseSoft 2s ease-in-out infinite;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Bead tooltip fade-in */
|
|
82
|
+
@keyframes beadTooltipFade {
|
|
83
|
+
from {
|
|
84
|
+
opacity: 0;
|
|
85
|
+
transform: translateY(4px);
|
|
86
|
+
}
|
|
87
|
+
to {
|
|
88
|
+
opacity: 1;
|
|
89
|
+
transform: translateY(0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* Mobile slide-in drawer from right */
|
|
94
|
+
@keyframes slideInRight {
|
|
95
|
+
from {
|
|
96
|
+
transform: translateX(100%);
|
|
97
|
+
}
|
|
98
|
+
to {
|
|
99
|
+
transform: translateX(0);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
.animate-slide-in-right {
|
|
103
|
+
animation: slideInRight 0.25s ease-out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* Mobile bottom action sheet slide-up */
|
|
107
|
+
@keyframes slideUpSheet {
|
|
108
|
+
from {
|
|
109
|
+
transform: translateY(100%);
|
|
110
|
+
}
|
|
111
|
+
to {
|
|
112
|
+
transform: translateY(0);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
.animate-slide-up-sheet {
|
|
116
|
+
animation: slideUpSheet 0.2s ease-out;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* Markdown prose inside description box */
|
|
121
|
+
.description-markdown h1,
|
|
122
|
+
.description-markdown h2,
|
|
123
|
+
.description-markdown h3,
|
|
124
|
+
.description-markdown h4 {
|
|
125
|
+
@apply font-semibold text-zinc-700 mt-2 mb-1;
|
|
126
|
+
}
|
|
127
|
+
.description-markdown h1 { font-size: 0.85rem; }
|
|
128
|
+
.description-markdown h2 { font-size: 0.8rem; }
|
|
129
|
+
.description-markdown h3 { font-size: 0.75rem; }
|
|
130
|
+
.description-markdown h4 { font-size: 0.7rem; }
|
|
131
|
+
|
|
132
|
+
.description-markdown p {
|
|
133
|
+
@apply mb-1.5;
|
|
134
|
+
}
|
|
135
|
+
.description-markdown p:last-child {
|
|
136
|
+
@apply mb-0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.description-markdown ul,
|
|
140
|
+
.description-markdown ol {
|
|
141
|
+
@apply pl-4 mb-1.5 space-y-0.5;
|
|
142
|
+
}
|
|
143
|
+
.description-markdown ul { @apply list-disc; }
|
|
144
|
+
.description-markdown ol { @apply list-decimal; }
|
|
145
|
+
|
|
146
|
+
.description-markdown code {
|
|
147
|
+
@apply bg-zinc-200/60 text-zinc-700 px-1 py-0.5 rounded text-[10px] font-mono;
|
|
148
|
+
}
|
|
149
|
+
.description-markdown pre {
|
|
150
|
+
@apply bg-zinc-200/60 rounded-md p-2 mb-1.5 overflow-x-auto;
|
|
151
|
+
}
|
|
152
|
+
.description-markdown pre code {
|
|
153
|
+
@apply bg-transparent p-0 text-[10px];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.description-markdown a {
|
|
157
|
+
@apply text-emerald-600 underline underline-offset-2 hover:text-emerald-700;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.description-markdown blockquote {
|
|
161
|
+
@apply border-l-2 border-zinc-300 pl-2 text-zinc-500 italic my-1.5;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.description-markdown table {
|
|
165
|
+
@apply w-full text-[10px] border-collapse mb-1.5;
|
|
166
|
+
}
|
|
167
|
+
.description-markdown th {
|
|
168
|
+
@apply text-left font-semibold text-zinc-600 border-b border-zinc-200 pb-0.5 pr-2;
|
|
169
|
+
}
|
|
170
|
+
.description-markdown td {
|
|
171
|
+
@apply border-b border-zinc-100 py-0.5 pr-2;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.description-markdown hr {
|
|
175
|
+
@apply border-zinc-200 my-2;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.description-markdown strong {
|
|
179
|
+
@apply font-semibold text-zinc-700;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.description-markdown img {
|
|
183
|
+
@apply max-w-full rounded;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* Timeline slider custom styling */
|
|
187
|
+
.timeline-slider {
|
|
188
|
+
-webkit-appearance: none;
|
|
189
|
+
appearance: none;
|
|
190
|
+
background: transparent;
|
|
191
|
+
cursor: pointer;
|
|
192
|
+
}
|
|
193
|
+
.timeline-slider::-webkit-slider-runnable-track {
|
|
194
|
+
height: 4px;
|
|
195
|
+
background: #e4e4e7; /* zinc-200 */
|
|
196
|
+
border-radius: 2px;
|
|
197
|
+
}
|
|
198
|
+
.timeline-slider::-webkit-slider-thumb {
|
|
199
|
+
-webkit-appearance: none;
|
|
200
|
+
width: 12px;
|
|
201
|
+
height: 12px;
|
|
202
|
+
background: #10b981; /* emerald-500 */
|
|
203
|
+
border-radius: 50%;
|
|
204
|
+
margin-top: -4px;
|
|
205
|
+
cursor: pointer;
|
|
206
|
+
transition: transform 0.1s ease;
|
|
207
|
+
}
|
|
208
|
+
.timeline-slider::-webkit-slider-thumb:hover {
|
|
209
|
+
transform: scale(1.2);
|
|
210
|
+
}
|
|
211
|
+
.timeline-slider::-moz-range-track {
|
|
212
|
+
height: 4px;
|
|
213
|
+
background: #e4e4e7;
|
|
214
|
+
border-radius: 2px;
|
|
215
|
+
border: none;
|
|
216
|
+
}
|
|
217
|
+
.timeline-slider::-moz-range-thumb {
|
|
218
|
+
width: 12px;
|
|
219
|
+
height: 12px;
|
|
220
|
+
background: #10b981;
|
|
221
|
+
border-radius: 50%;
|
|
222
|
+
border: none;
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* Force-graph container overrides */
|
|
227
|
+
.force-graph-container canvas {
|
|
228
|
+
@apply rounded-lg;
|
|
229
|
+
touch-action: none; /* Prevent browser gestures from conflicting with graph pan/zoom */
|
|
230
|
+
}
|
package/app/layout.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Metadata, Viewport } from "next";
|
|
2
|
+
import { AuthProvider } from "@/lib/auth";
|
|
3
|
+
import "./globals.css";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
metadataBase: new URL(process.env.PUBLIC_URL || "http://localhost:3000"),
|
|
7
|
+
title: "Heartbeads",
|
|
8
|
+
description:
|
|
9
|
+
"Interactive dependency graph viewer for beads issues — see your project's heartbeat",
|
|
10
|
+
openGraph: {
|
|
11
|
+
title: "Heartbeads",
|
|
12
|
+
description: "Interactive dependency graph viewer for beads issues — see your project's heartbeat",
|
|
13
|
+
type: "website",
|
|
14
|
+
siteName: "Heartbeads",
|
|
15
|
+
images: [
|
|
16
|
+
{
|
|
17
|
+
url: "/image.png",
|
|
18
|
+
width: 1200,
|
|
19
|
+
height: 630,
|
|
20
|
+
alt: "Heartbeads — interactive dependency graph viewer",
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
twitter: {
|
|
25
|
+
card: "summary_large_image",
|
|
26
|
+
title: "Heartbeads",
|
|
27
|
+
description: "Interactive dependency graph viewer for beads issues — see your project's heartbeat",
|
|
28
|
+
images: ["/image.png"],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const viewport: Viewport = {
|
|
33
|
+
width: "device-width",
|
|
34
|
+
initialScale: 1,
|
|
35
|
+
maximumScale: 1,
|
|
36
|
+
userScalable: false,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default function RootLayout({
|
|
40
|
+
children,
|
|
41
|
+
}: {
|
|
42
|
+
children: React.ReactNode;
|
|
43
|
+
}) {
|
|
44
|
+
return (
|
|
45
|
+
<html lang="en">
|
|
46
|
+
<body>
|
|
47
|
+
<AuthProvider>{children}</AuthProvider>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from "react";
|
|
4
|
+
import { BeadsLogo } from "@/components/BeadsLogo";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Password login page for dashboard access control.
|
|
8
|
+
*
|
|
9
|
+
* Shown when heartbeads is started with --password flag.
|
|
10
|
+
* Matches the design language of the 404 page and loading screen:
|
|
11
|
+
* ECG flatline + animated heartbeat logo, clean centered form.
|
|
12
|
+
*
|
|
13
|
+
* This is NOT the ATProto/Bluesky sign-in (that's in the navbar).
|
|
14
|
+
* This gate controls who can view the dashboard and API.
|
|
15
|
+
*/
|
|
16
|
+
export default function LoginPage() {
|
|
17
|
+
const [password, setPassword] = useState("");
|
|
18
|
+
const [error, setError] = useState("");
|
|
19
|
+
const [loading, setLoading] = useState(false);
|
|
20
|
+
const [from, setFrom] = useState("/");
|
|
21
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
|
|
23
|
+
// Read ?from= query param for post-login redirect (avoid useSearchParams / Suspense)
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const params = new URLSearchParams(window.location.search);
|
|
26
|
+
const fromParam = params.get("from");
|
|
27
|
+
if (fromParam) setFrom(fromParam);
|
|
28
|
+
|
|
29
|
+
// Auto-focus the password input
|
|
30
|
+
inputRef.current?.focus();
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
if (!password.trim()) return;
|
|
36
|
+
|
|
37
|
+
setError("");
|
|
38
|
+
setLoading(true);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch("/api/auth", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "Content-Type": "application/json" },
|
|
44
|
+
body: JSON.stringify({ password: password.trim() }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (res.ok) {
|
|
48
|
+
window.location.href = from;
|
|
49
|
+
} else {
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
setError(data.error || "Invalid password");
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
setError("Connection error");
|
|
55
|
+
} finally {
|
|
56
|
+
setLoading(false);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="h-screen flex flex-col items-center justify-center bg-white text-zinc-800 px-6 select-none">
|
|
62
|
+
{/* ECG flatline + animated heartbeat logo */}
|
|
63
|
+
<div className="relative w-full max-w-xs mb-6">
|
|
64
|
+
<svg
|
|
65
|
+
viewBox="0 0 320 40"
|
|
66
|
+
fill="none"
|
|
67
|
+
className="w-full text-zinc-200"
|
|
68
|
+
aria-hidden="true"
|
|
69
|
+
>
|
|
70
|
+
<line
|
|
71
|
+
x1="0"
|
|
72
|
+
y1="20"
|
|
73
|
+
x2="320"
|
|
74
|
+
y2="20"
|
|
75
|
+
stroke="currentColor"
|
|
76
|
+
strokeWidth="1.5"
|
|
77
|
+
strokeLinecap="round"
|
|
78
|
+
strokeDasharray="4 6"
|
|
79
|
+
opacity="0.6"
|
|
80
|
+
/>
|
|
81
|
+
</svg>
|
|
82
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
83
|
+
<div className="bg-white px-4">
|
|
84
|
+
<BeadsLogo className="w-10 h-10 text-emerald-500" />
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* Title */}
|
|
90
|
+
<h1
|
|
91
|
+
className="text-lg font-semibold text-zinc-900 mb-1 animate-fade-in"
|
|
92
|
+
>
|
|
93
|
+
heartbeads
|
|
94
|
+
</h1>
|
|
95
|
+
<p
|
|
96
|
+
className="text-sm text-zinc-400 mb-6 animate-fade-in"
|
|
97
|
+
style={{ animationDelay: "0.05s" }}
|
|
98
|
+
>
|
|
99
|
+
This dashboard is password-protected.
|
|
100
|
+
</p>
|
|
101
|
+
|
|
102
|
+
{/* Login form */}
|
|
103
|
+
<form
|
|
104
|
+
onSubmit={handleSubmit}
|
|
105
|
+
className="w-full max-w-xs animate-fade-in"
|
|
106
|
+
style={{ animationDelay: "0.1s" }}
|
|
107
|
+
>
|
|
108
|
+
<input
|
|
109
|
+
ref={inputRef}
|
|
110
|
+
type="password"
|
|
111
|
+
value={password}
|
|
112
|
+
onChange={(e) => {
|
|
113
|
+
setPassword(e.target.value);
|
|
114
|
+
if (error) setError("");
|
|
115
|
+
}}
|
|
116
|
+
placeholder="Enter password"
|
|
117
|
+
className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-2.5 text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition-colors"
|
|
118
|
+
autoComplete="current-password"
|
|
119
|
+
disabled={loading}
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
{/* Error message */}
|
|
123
|
+
{error && (
|
|
124
|
+
<p className="mt-2 text-xs text-red-500 animate-fade-in">
|
|
125
|
+
{error}
|
|
126
|
+
</p>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
<button
|
|
130
|
+
type="submit"
|
|
131
|
+
disabled={loading || !password.trim()}
|
|
132
|
+
className="mt-3 w-full inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-full bg-emerald-500 text-white text-sm font-medium shadow-sm hover:bg-emerald-600 hover:shadow-md transition-all duration-200 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-emerald-500 disabled:hover:shadow-sm"
|
|
133
|
+
>
|
|
134
|
+
{loading ? (
|
|
135
|
+
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
136
|
+
) : (
|
|
137
|
+
<svg
|
|
138
|
+
className="w-4 h-4"
|
|
139
|
+
fill="none"
|
|
140
|
+
viewBox="0 0 24 24"
|
|
141
|
+
strokeWidth={1.5}
|
|
142
|
+
stroke="currentColor"
|
|
143
|
+
>
|
|
144
|
+
<path
|
|
145
|
+
strokeLinecap="round"
|
|
146
|
+
strokeLinejoin="round"
|
|
147
|
+
d="M13.5 10.5V6.75a4.5 4.5 0 119 0v3.75M3.75 21.75h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H3.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
|
148
|
+
/>
|
|
149
|
+
</svg>
|
|
150
|
+
)}
|
|
151
|
+
{loading ? "Unlocking..." : "Unlock"}
|
|
152
|
+
</button>
|
|
153
|
+
</form>
|
|
154
|
+
|
|
155
|
+
{/* Footer */}
|
|
156
|
+
<p
|
|
157
|
+
className="mt-10 text-[11px] text-zinc-300 animate-fade-in"
|
|
158
|
+
style={{ animationDelay: "0.15s" }}
|
|
159
|
+
>
|
|
160
|
+
password protection enabled
|
|
161
|
+
</p>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|