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.
Files changed (205) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +49 -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 +9 -0
  20. package/.next/server/app/api/auth/route.js +1 -0
  21. package/.next/server/app/api/auth/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/route.js +8 -0
  23. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads/stream/route.js +10 -0
  25. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  26. package/.next/server/app/api/beads.body +1 -0
  27. package/.next/server/app/api/beads.meta +1 -0
  28. package/.next/server/app/api/config/route.js +8 -0
  29. package/.next/server/app/api/config/route.js.nft.json +1 -0
  30. package/.next/server/app/api/docs/page.js +120 -0
  31. package/.next/server/app/api/docs/page.js.nft.json +1 -0
  32. package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
  33. package/.next/server/app/api/docs.html +120 -0
  34. package/.next/server/app/api/docs.meta +5 -0
  35. package/.next/server/app/api/docs.rsc +70 -0
  36. package/.next/server/app/api/login/route.js +1 -0
  37. package/.next/server/app/api/login/route.js.nft.json +1 -0
  38. package/.next/server/app/api/logout/route.js +1 -0
  39. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  40. package/.next/server/app/api/oauth/callback/route.js +1 -0
  41. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  42. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  43. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  44. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  45. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  46. package/.next/server/app/api/records/route.js +1 -0
  47. package/.next/server/app/api/records/route.js.nft.json +1 -0
  48. package/.next/server/app/api/status/route.js +1 -0
  49. package/.next/server/app/api/status/route.js.nft.json +1 -0
  50. package/.next/server/app/api/v1/graph/route.js +1 -0
  51. package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
  52. package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
  53. package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
  54. package/.next/server/app/api/v1/ready/route.js +1 -0
  55. package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
  56. package/.next/server/app/index.html +1 -0
  57. package/.next/server/app/index.meta +5 -0
  58. package/.next/server/app/index.rsc +9 -0
  59. package/.next/server/app/login/page.js +1 -0
  60. package/.next/server/app/login/page.js.nft.json +1 -0
  61. package/.next/server/app/login/page_client-reference-manifest.js +1 -0
  62. package/.next/server/app/login.html +1 -0
  63. package/.next/server/app/login.meta +5 -0
  64. package/.next/server/app/login.rsc +9 -0
  65. package/.next/server/app/opengraph-image.png/route.js +1 -0
  66. package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
  67. package/.next/server/app/opengraph-image.png.body +0 -0
  68. package/.next/server/app/opengraph-image.png.meta +1 -0
  69. package/.next/server/app/page.js +24 -0
  70. package/.next/server/app/page.js.nft.json +1 -0
  71. package/.next/server/app/page_client-reference-manifest.js +1 -0
  72. package/.next/server/app/twitter-image.png/route.js +1 -0
  73. package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
  74. package/.next/server/app/twitter-image.png.body +0 -0
  75. package/.next/server/app/twitter-image.png.meta +1 -0
  76. package/.next/server/app-paths-manifest.json +22 -0
  77. package/.next/server/chunks/247.js +12 -0
  78. package/.next/server/chunks/29.js +1 -0
  79. package/.next/server/chunks/343.js +1 -0
  80. package/.next/server/chunks/460.js +12 -0
  81. package/.next/server/chunks/533.js +38 -0
  82. package/.next/server/chunks/542.js +27 -0
  83. package/.next/server/chunks/590.js +6 -0
  84. package/.next/server/chunks/615.js +15 -0
  85. package/.next/server/chunks/696.js +25 -0
  86. package/.next/server/chunks/719.js +2 -0
  87. package/.next/server/chunks/739.js +1 -0
  88. package/.next/server/chunks/950.js +2 -0
  89. package/.next/server/chunks/font-manifest.json +1 -0
  90. package/.next/server/edge-runtime-webpack.js +2 -0
  91. package/.next/server/edge-runtime-webpack.js.map +1 -0
  92. package/.next/server/font-manifest.json +1 -0
  93. package/.next/server/functions-config-manifest.json +1 -0
  94. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  95. package/.next/server/middleware-build-manifest.js +1 -0
  96. package/.next/server/middleware-manifest.json +32 -0
  97. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  98. package/.next/server/middleware.js +14 -0
  99. package/.next/server/middleware.js.map +1 -0
  100. package/.next/server/next-font-manifest.js +1 -0
  101. package/.next/server/next-font-manifest.json +1 -0
  102. package/.next/server/pages/404.html +1 -0
  103. package/.next/server/pages/500.html +1 -0
  104. package/.next/server/pages/_app.js +1 -0
  105. package/.next/server/pages/_app.js.nft.json +1 -0
  106. package/.next/server/pages/_document.js +1 -0
  107. package/.next/server/pages/_document.js.nft.json +1 -0
  108. package/.next/server/pages/_error.js +1 -0
  109. package/.next/server/pages/_error.js.nft.json +1 -0
  110. package/.next/server/pages-manifest.json +1 -0
  111. package/.next/server/server-reference-manifest.js +1 -0
  112. package/.next/server/server-reference-manifest.json +1 -0
  113. package/.next/server/webpack-runtime.js +1 -0
  114. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  115. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  116. package/.next/static/chunks/788-aa413085174e935a.js +1 -0
  117. package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
  118. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  119. package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
  120. package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
  121. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  122. package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
  123. package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
  124. package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
  125. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  126. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  127. package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
  128. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  129. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  130. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  131. package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
  132. package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
  133. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
  134. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
  135. package/README.md +389 -0
  136. package/app/api/auth/route.ts +103 -0
  137. package/app/api/beads/route.ts +27 -0
  138. package/app/api/beads/stream/route.ts +83 -0
  139. package/app/api/config/route.ts +48 -0
  140. package/app/api/docs/page.tsx +497 -0
  141. package/app/api/login/route.ts +42 -0
  142. package/app/api/logout/route.ts +14 -0
  143. package/app/api/oauth/callback/route.ts +97 -0
  144. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  145. package/app/api/oauth/jwks.json/route.ts +32 -0
  146. package/app/api/records/route.ts +168 -0
  147. package/app/api/status/route.ts +25 -0
  148. package/app/api/v1/graph/route.ts +251 -0
  149. package/app/api/v1/issues/[id]/route.ts +158 -0
  150. package/app/api/v1/ready/route.ts +229 -0
  151. package/app/globals.css +230 -0
  152. package/app/layout.tsx +51 -0
  153. package/app/login/page.tsx +164 -0
  154. package/app/not-found.tsx +91 -0
  155. package/app/opengraph-image.png +0 -0
  156. package/app/page.tsx +2041 -0
  157. package/app/twitter-image.png +0 -0
  158. package/bin/heartbeads.mjs +225 -0
  159. package/components/ActivityItem.tsx +326 -0
  160. package/components/ActivityOverlay.tsx +125 -0
  161. package/components/ActivityPanel.tsx +345 -0
  162. package/components/AllCommentsPanel.tsx +270 -0
  163. package/components/AuthButton.tsx +202 -0
  164. package/components/BeadTooltip.tsx +246 -0
  165. package/components/BeadsGraph.tsx +2493 -0
  166. package/components/BeadsLogo.tsx +94 -0
  167. package/components/CommentTooltip.tsx +338 -0
  168. package/components/ContextMenu.tsx +272 -0
  169. package/components/DescriptionModal.tsx +595 -0
  170. package/components/GraphStats.tsx +121 -0
  171. package/components/HeartIcon.tsx +33 -0
  172. package/components/HelpPanel.tsx +339 -0
  173. package/components/MobileActionSheet.tsx +255 -0
  174. package/components/NodeDetail.tsx +793 -0
  175. package/components/SettingsModal.tsx +315 -0
  176. package/components/StatusLegend.tsx +99 -0
  177. package/components/TimelineBar.tsx +116 -0
  178. package/components/TutorialOverlay.tsx +235 -0
  179. package/hooks/useBeadsComments.ts +81 -0
  180. package/hooks/useIsMobile.ts +19 -0
  181. package/lib/activity.ts +377 -0
  182. package/lib/agent.ts +29 -0
  183. package/lib/api-helpers.ts +46 -0
  184. package/lib/auth/client.ts +221 -0
  185. package/lib/auth.tsx +159 -0
  186. package/lib/comments.ts +413 -0
  187. package/lib/diff-beads.ts +128 -0
  188. package/lib/discover.ts +228 -0
  189. package/lib/env.ts +33 -0
  190. package/lib/gate.ts +55 -0
  191. package/lib/parse-beads.ts +234 -0
  192. package/lib/session.ts +52 -0
  193. package/lib/settings.ts +42 -0
  194. package/lib/timeline.ts +138 -0
  195. package/lib/tts.ts +397 -0
  196. package/lib/types.ts +271 -0
  197. package/lib/utils.ts +48 -0
  198. package/lib/watch-beads.ts +97 -0
  199. package/next.config.mjs +4 -0
  200. package/package.json +81 -0
  201. package/postcss.config.mjs +9 -0
  202. package/public/image.png +0 -0
  203. package/scripts/generate-jwk.js +38 -0
  204. package/tailwind.config.ts +41 -0
  205. package/tsconfig.json +24 -0
@@ -0,0 +1,315 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import {
6
+ getSettings,
7
+ saveSettings,
8
+ type BeadsMapSettings,
9
+ } from "@/lib/settings";
10
+
11
+ interface SettingsModalProps {
12
+ isOpen: boolean;
13
+ onClose: () => void;
14
+ }
15
+
16
+ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
17
+ const [apiKey, setApiKey] = useState("");
18
+ const [voiceId, setVoiceId] = useState("");
19
+ const [model, setModel] = useState("");
20
+ const [showKey, setShowKey] = useState(false);
21
+ const [mounted, setMounted] = useState(false);
22
+ const [isGateProtected, setIsGateProtected] = useState(false);
23
+
24
+ // SSR guard for createPortal
25
+ useEffect(() => {
26
+ setMounted(true);
27
+ }, []);
28
+
29
+ // Load current settings when modal opens
30
+ useEffect(() => {
31
+ if (isOpen) {
32
+ const s = getSettings();
33
+ setApiKey(s.elevenLabsApiKey || "");
34
+ setVoiceId(s.elevenLabsVoiceId);
35
+ setModel(s.elevenLabsModel);
36
+ setShowKey(false);
37
+
38
+ // Check if dashboard is password-protected
39
+ fetch("/api/auth")
40
+ .then((r) => r.json())
41
+ .then((data) => setIsGateProtected(data.protected === true))
42
+ .catch(() => setIsGateProtected(false));
43
+ }
44
+ }, [isOpen]);
45
+
46
+ // Escape key
47
+ useEffect(() => {
48
+ if (!isOpen) return;
49
+ const handler = (e: KeyboardEvent) => {
50
+ if (e.key === "Escape") onClose();
51
+ };
52
+ window.addEventListener("keydown", handler);
53
+ return () => window.removeEventListener("keydown", handler);
54
+ }, [isOpen, onClose]);
55
+
56
+ const handleLockDashboard = useCallback(async () => {
57
+ try {
58
+ await fetch("/api/auth", { method: "DELETE" });
59
+ } catch {
60
+ // If delete fails, still redirect — middleware will catch it
61
+ }
62
+ window.location.href = "/login";
63
+ }, []);
64
+
65
+ const handleSave = useCallback(() => {
66
+ const updates: Partial<BeadsMapSettings> = {
67
+ elevenLabsApiKey: apiKey.trim() || undefined,
68
+ elevenLabsVoiceId: voiceId.trim() || "UgBBYS2sOqTuMpoF3BR0",
69
+ elevenLabsModel: model || "eleven_flash_v2_5",
70
+ };
71
+ saveSettings(updates);
72
+ onClose();
73
+ }, [apiKey, voiceId, model, onClose]);
74
+
75
+ if (!isOpen || !mounted) return null;
76
+
77
+ return createPortal(
78
+ <div
79
+ className="fixed inset-0 z-[100] flex items-center justify-center"
80
+ style={{ backgroundColor: "rgba(0,0,0,0.4)", backdropFilter: "blur(4px)" }}
81
+ onClick={onClose}
82
+ >
83
+ <div
84
+ className="bg-white rounded-xl shadow-2xl w-[90vw] max-w-md flex flex-col"
85
+ onClick={(e) => e.stopPropagation()}
86
+ >
87
+ {/* Header */}
88
+ <div className="flex items-center justify-between px-5 py-3.5 border-b border-zinc-100">
89
+ <h2 className="text-sm font-semibold text-zinc-900">Settings</h2>
90
+ <button
91
+ onClick={onClose}
92
+ className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
93
+ >
94
+ <svg
95
+ className="w-4 h-4"
96
+ fill="none"
97
+ stroke="currentColor"
98
+ viewBox="0 0 24 24"
99
+ strokeWidth={2}
100
+ >
101
+ <path
102
+ strokeLinecap="round"
103
+ strokeLinejoin="round"
104
+ d="M6 18L18 6M6 6l12 12"
105
+ />
106
+ </svg>
107
+ </button>
108
+ </div>
109
+
110
+ {/* Body */}
111
+ <div className="px-5 py-4 space-y-4 overflow-y-auto max-h-[60vh]">
112
+ {/* Section header */}
113
+ <h3 className="text-[11px] font-semibold uppercase tracking-widest text-teal-600">
114
+ Text-to-Speech
115
+ </h3>
116
+
117
+ {/* API Key */}
118
+ <div>
119
+ <label className="block text-xs font-medium text-zinc-700 mb-1">
120
+ ElevenLabs API Key
121
+ </label>
122
+ <div className="relative">
123
+ <input
124
+ type={showKey ? "text" : "password"}
125
+ value={apiKey}
126
+ onChange={(e) => setApiKey(e.target.value)}
127
+ placeholder="sk_..."
128
+ className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 pr-10"
129
+ />
130
+ <button
131
+ type="button"
132
+ onClick={() => setShowKey((v) => !v)}
133
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
134
+ title={showKey ? "Hide key" : "Show key"}
135
+ >
136
+ {showKey ? (
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="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
148
+ />
149
+ </svg>
150
+ ) : (
151
+ <svg
152
+ className="w-4 h-4"
153
+ fill="none"
154
+ viewBox="0 0 24 24"
155
+ strokeWidth={1.5}
156
+ stroke="currentColor"
157
+ >
158
+ <path
159
+ strokeLinecap="round"
160
+ strokeLinejoin="round"
161
+ d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
162
+ />
163
+ <path
164
+ strokeLinecap="round"
165
+ strokeLinejoin="round"
166
+ d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
167
+ />
168
+ </svg>
169
+ )}
170
+ </button>
171
+ </div>
172
+ <p className="mt-1 text-[11px] text-zinc-400">
173
+ Get your key at{" "}
174
+ <a
175
+ href="https://elevenlabs.io/app/settings/api-keys"
176
+ target="_blank"
177
+ rel="noopener noreferrer"
178
+ className="underline underline-offset-2 text-teal-500 hover:text-teal-600"
179
+ >
180
+ elevenlabs.io/app/settings
181
+ </a>
182
+ </p>
183
+ </div>
184
+
185
+ {/* Voice ID */}
186
+ <div>
187
+ <label className="block text-xs font-medium text-zinc-700 mb-1">
188
+ Voice ID
189
+ </label>
190
+ <input
191
+ type="text"
192
+ value={voiceId}
193
+ onChange={(e) => setVoiceId(e.target.value)}
194
+ placeholder="UgBBYS2sOqTuMpoF3BR0"
195
+ className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500"
196
+ />
197
+ <p className="mt-1 text-[11px] text-zinc-400">
198
+ Default: Mark - Natural Conversations. Find voice IDs in the{" "}
199
+ <a
200
+ href="https://elevenlabs.io/app/voice-library"
201
+ target="_blank"
202
+ rel="noopener noreferrer"
203
+ className="underline underline-offset-2 text-teal-500 hover:text-teal-600"
204
+ >
205
+ ElevenLabs Voice Library
206
+ </a>
207
+ .
208
+ </p>
209
+ </div>
210
+
211
+ {/* Model */}
212
+ <div>
213
+ <label className="block text-xs font-medium text-zinc-700 mb-1">
214
+ Model
215
+ </label>
216
+ <select
217
+ value={model}
218
+ onChange={(e) => setModel(e.target.value)}
219
+ className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-900 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500"
220
+ >
221
+ <option value="eleven_flash_v2_5">
222
+ Flash v2.5 (default, fastest &amp; cheapest)
223
+ </option>
224
+ <option value="eleven_turbo_v2_5">
225
+ Turbo v2.5 (fast, good quality)
226
+ </option>
227
+ <option value="eleven_multilingual_v2">
228
+ Multilingual v2 (highest quality)
229
+ </option>
230
+ </select>
231
+ </div>
232
+
233
+ {/* Divider */}
234
+ <div className="border-t border-zinc-100 pt-4">
235
+ <h3 className="text-[11px] font-semibold uppercase tracking-widest text-teal-600 mb-2">
236
+ Public API
237
+ </h3>
238
+ <p className="text-xs text-zinc-500 leading-relaxed">
239
+ Heartbeads exposes a read-only REST API for AI agents, CI/CD
240
+ bots, and integrations.{" "}
241
+ {isGateProtected
242
+ ? "API requests require the dashboard password as a Bearer token. "
243
+ : "No authentication required. "}
244
+ <a
245
+ href="/api/docs"
246
+ target="_blank"
247
+ rel="noopener noreferrer"
248
+ className="underline underline-offset-2 text-teal-500 hover:text-teal-600"
249
+ >
250
+ View API documentation
251
+ </a>
252
+ </p>
253
+ </div>
254
+
255
+ {/* Dashboard Access — only shown when password-protected */}
256
+ {isGateProtected && (
257
+ <div className="border-t border-zinc-100 pt-4">
258
+ <h3 className="text-[11px] font-semibold uppercase tracking-widest text-teal-600 mb-2">
259
+ Dashboard Access
260
+ </h3>
261
+ <p className="text-xs text-zinc-500 leading-relaxed mb-3">
262
+ This instance is password-protected. The{" "}
263
+ <span className="font-medium text-zinc-600">Sign In</span>{" "}
264
+ button in the navbar is for{" "}
265
+ <span className="font-medium text-zinc-600">
266
+ ATProto/Bluesky
267
+ </span>{" "}
268
+ &mdash; used for posting comments, likes, and claiming
269
+ tasks. The dashboard password controls who can view the
270
+ graph and access the API.
271
+ </p>
272
+ <button
273
+ onClick={handleLockDashboard}
274
+ type="button"
275
+ className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-zinc-200 text-xs font-medium text-zinc-600 hover:bg-zinc-50 hover:border-zinc-300 transition-colors"
276
+ >
277
+ <svg
278
+ className="w-3.5 h-3.5"
279
+ fill="none"
280
+ viewBox="0 0 24 24"
281
+ strokeWidth={1.5}
282
+ stroke="currentColor"
283
+ >
284
+ <path
285
+ strokeLinecap="round"
286
+ strokeLinejoin="round"
287
+ d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
288
+ />
289
+ </svg>
290
+ Lock dashboard
291
+ </button>
292
+ </div>
293
+ )}
294
+ </div>
295
+
296
+ {/* Footer */}
297
+ <div className="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-zinc-100">
298
+ <button
299
+ onClick={onClose}
300
+ className="px-4 py-2 text-sm font-medium text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-50 transition-colors"
301
+ >
302
+ Cancel
303
+ </button>
304
+ <button
305
+ onClick={handleSave}
306
+ className="px-4 py-2 text-sm font-medium text-white rounded-lg bg-emerald-500 hover:bg-emerald-600 transition-colors"
307
+ >
308
+ Save
309
+ </button>
310
+ </div>
311
+ </div>
312
+ </div>,
313
+ document.body
314
+ );
315
+ }
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { STATUS_COLORS, STATUS_LABELS, PREFIX_COLORS, PREFIX_LABELS } from "@/lib/types";
4
+
5
+ interface StatusLegendProps {
6
+ prefixes: string[];
7
+ }
8
+
9
+ export default function StatusLegend({ prefixes }: StatusLegendProps) {
10
+ const statuses = ["open", "in_progress", "blocked", "closed"];
11
+
12
+ return (
13
+ <div className="space-y-4">
14
+ {/* Status legend */}
15
+ <div>
16
+ <h4 className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider mb-2">
17
+ Status
18
+ </h4>
19
+ <div className="flex flex-wrap gap-x-4 gap-y-1.5">
20
+ {statuses.map((status) => (
21
+ <div key={status} className="flex items-center gap-1.5">
22
+ <span
23
+ className="w-2.5 h-2.5 rounded-full"
24
+ style={{ backgroundColor: STATUS_COLORS[status] }}
25
+ />
26
+ <span className="text-xs text-zinc-500">
27
+ {STATUS_LABELS[status]}
28
+ </span>
29
+ </div>
30
+ ))}
31
+ </div>
32
+ </div>
33
+
34
+ {/* Project prefix legend */}
35
+ {prefixes.length > 1 && (
36
+ <div>
37
+ <h4 className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider mb-2">
38
+ Projects (outer ring)
39
+ </h4>
40
+ <div className="flex flex-wrap gap-x-4 gap-y-1.5">
41
+ {prefixes.map((prefix) => (
42
+ <div key={prefix} className="flex items-center gap-1.5">
43
+ <span
44
+ className="w-2.5 h-2.5 rounded-full"
45
+ style={{
46
+ backgroundColor: "transparent",
47
+ boxShadow: `0 0 0 2px ${PREFIX_COLORS[prefix] || "#a1a1aa"}`,
48
+ }}
49
+ />
50
+ <span className="text-xs text-zinc-500">
51
+ {PREFIX_LABELS[prefix] || prefix}
52
+ </span>
53
+ </div>
54
+ ))}
55
+ </div>
56
+ </div>
57
+ )}
58
+
59
+ {/* Interaction hints */}
60
+ <div>
61
+ <h4 className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider mb-2">
62
+ Interactions
63
+ </h4>
64
+ <div className="space-y-1 text-xs text-zinc-400">
65
+ <div className="flex items-center gap-2">
66
+ <kbd className="px-1.5 py-0.5 bg-zinc-100 rounded text-[10px] font-mono border border-zinc-200">
67
+ Click
68
+ </kbd>
69
+ <span>Select node</span>
70
+ </div>
71
+ <div className="flex items-center gap-2">
72
+ <kbd className="px-1.5 py-0.5 bg-zinc-100 rounded text-[10px] font-mono border border-zinc-200">
73
+ Hover
74
+ </kbd>
75
+ <span>Highlight connections</span>
76
+ </div>
77
+ <div className="flex items-center gap-2">
78
+ <kbd className="px-1.5 py-0.5 bg-zinc-100 rounded text-[10px] font-mono border border-zinc-200">
79
+ Scroll
80
+ </kbd>
81
+ <span>Zoom in/out</span>
82
+ </div>
83
+ <div className="flex items-center gap-2">
84
+ <kbd className="px-1.5 py-0.5 bg-zinc-100 rounded text-[10px] font-mono border border-zinc-200">
85
+ Drag
86
+ </kbd>
87
+ <span>Pan / Move nodes</span>
88
+ </div>
89
+ <div className="flex items-center gap-2">
90
+ <kbd className="px-1.5 py-0.5 bg-zinc-100 rounded text-[10px] font-mono border border-zinc-200">
91
+ Pinch
92
+ </kbd>
93
+ <span>Zoom (touch)</span>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import React, { useCallback } from "react";
4
+
5
+ interface TimelineBarProps {
6
+ totalSteps: number;
7
+ currentStep: number;
8
+ currentTime: number; // unix ms of current event (for date display)
9
+ isPlaying: boolean;
10
+ speed: number;
11
+ onStepChange: (step: number) => void;
12
+ onPlayPause: () => void;
13
+ onSpeedChange: (speed: number) => void;
14
+ }
15
+
16
+ function formatTimelineDate(ms: number): string {
17
+ const d = new Date(ms);
18
+ return d.toLocaleDateString("en-US", {
19
+ month: "short",
20
+ day: "numeric",
21
+ year: "numeric",
22
+ });
23
+ }
24
+
25
+ export default function TimelineBar({
26
+ totalSteps,
27
+ currentStep,
28
+ currentTime,
29
+ isPlaying,
30
+ speed,
31
+ onStepChange,
32
+ onPlayPause,
33
+ onSpeedChange,
34
+ }: TimelineBarProps) {
35
+ const handleSliderChange = useCallback(
36
+ (e: React.ChangeEvent<HTMLInputElement>) => {
37
+ onStepChange(Number(e.target.value));
38
+ },
39
+ [onStepChange]
40
+ );
41
+
42
+ const handleSpeedCycle = useCallback(() => {
43
+ const next = speed === 1 ? 2 : speed === 2 ? 4 : 1;
44
+ onSpeedChange(next);
45
+ }, [speed, onSpeedChange]);
46
+
47
+ const hasRange = totalSteps > 1;
48
+
49
+ return (
50
+ <div className="bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm px-3 py-2 min-w-[280px] max-w-[480px]">
51
+ <div className="flex items-center gap-2">
52
+ {/* Play/Pause button */}
53
+ <button
54
+ onClick={onPlayPause}
55
+ disabled={!hasRange}
56
+ className={`shrink-0 w-6 h-6 flex items-center justify-center rounded transition-colors ${
57
+ isPlaying
58
+ ? "text-emerald-500 hover:text-emerald-600"
59
+ : "text-zinc-500 hover:text-zinc-700"
60
+ } disabled:opacity-40 disabled:cursor-not-allowed`}
61
+ >
62
+ {isPlaying ? (
63
+ <svg
64
+ className="w-4 h-4"
65
+ viewBox="0 0 16 16"
66
+ fill="currentColor"
67
+ >
68
+ <rect x="3" y="2" width="3.5" height="12" rx="1" />
69
+ <rect x="9.5" y="2" width="3.5" height="12" rx="1" />
70
+ </svg>
71
+ ) : (
72
+ <svg
73
+ className="w-4 h-4"
74
+ viewBox="0 0 16 16"
75
+ fill="currentColor"
76
+ >
77
+ <path d="M4 2l10 6-10 6V2z" />
78
+ </svg>
79
+ )}
80
+ </button>
81
+
82
+ {/* Scrubber slider */}
83
+ <input
84
+ type="range"
85
+ min={0}
86
+ max={Math.max(totalSteps - 1, 0)}
87
+ value={currentStep}
88
+ onChange={handleSliderChange}
89
+ disabled={!hasRange}
90
+ className="timeline-slider flex-1 h-4 disabled:opacity-40"
91
+ />
92
+
93
+ {/* Step counter + date */}
94
+ <span className="shrink-0 text-xs text-zinc-500 font-medium text-right whitespace-nowrap">
95
+ <span className="text-zinc-400">
96
+ {currentStep + 1}/{totalSteps}
97
+ </span>
98
+ {" "}
99
+ {formatTimelineDate(currentTime)}
100
+ </span>
101
+
102
+ {/* Speed toggle */}
103
+ <button
104
+ onClick={handleSpeedCycle}
105
+ className={`shrink-0 px-1.5 py-0.5 text-[10px] font-semibold rounded transition-colors ${
106
+ speed > 1
107
+ ? "bg-emerald-500 text-white"
108
+ : "text-zinc-500 border border-zinc-200 hover:text-zinc-700"
109
+ }`}
110
+ >
111
+ {speed}x
112
+ </button>
113
+ </div>
114
+ </div>
115
+ );
116
+ }