@knotpad/app 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 (342) hide show
  1. package/README.md +167 -0
  2. package/app/(app)/calendar/page.tsx +57 -0
  3. package/app/(app)/error.tsx +35 -0
  4. package/app/(app)/graph/page.tsx +32 -0
  5. package/app/(app)/guide/page.tsx +21 -0
  6. package/app/(app)/kanban/loading.tsx +24 -0
  7. package/app/(app)/kanban/page.tsx +59 -0
  8. package/app/(app)/layout.tsx +122 -0
  9. package/app/(app)/list/loading.tsx +21 -0
  10. package/app/(app)/list/page.tsx +137 -0
  11. package/app/(app)/loading.tsx +18 -0
  12. package/app/(app)/notes/[noteId]/page.tsx +84 -0
  13. package/app/(app)/notes/layout.tsx +30 -0
  14. package/app/(app)/notes/page.tsx +39 -0
  15. package/app/(app)/page.tsx +5 -0
  16. package/app/(app)/settings/agent-token/page.tsx +59 -0
  17. package/app/(app)/settings/backup/page.tsx +49 -0
  18. package/app/(app)/settings/billing/page.tsx +53 -0
  19. package/app/(app)/settings/calendar/page.tsx +41 -0
  20. package/app/(app)/settings/layout.test.tsx +39 -0
  21. package/app/(app)/settings/layout.tsx +71 -0
  22. package/app/(app)/settings/page.tsx +4 -0
  23. package/app/(app)/settings/security/page.tsx +43 -0
  24. package/app/(app)/settings/team/page.tsx +74 -0
  25. package/app/(app)/settings/workspace/page.tsx +27 -0
  26. package/app/(app)/tasks/[taskId]/page.tsx +79 -0
  27. package/app/(auth)/forgot-password/page.tsx +106 -0
  28. package/app/(auth)/guest/page.tsx +56 -0
  29. package/app/(auth)/layout.tsx +13 -0
  30. package/app/(auth)/login/page.tsx +14 -0
  31. package/app/(auth)/register/page.tsx +193 -0
  32. package/app/(auth)/reset-password/page.tsx +138 -0
  33. package/app/api/account/claim/route.tsx +135 -0
  34. package/app/api/admin/backfill-encryption/route.tsx +43 -0
  35. package/app/api/admin/license/route.tsx +42 -0
  36. package/app/api/auth/2fa/route.tsx +148 -0
  37. package/app/api/auth/[...nextauth]/route.tsx +3 -0
  38. package/app/api/auth/change-password/route.tsx +61 -0
  39. package/app/api/auth/check-2fa/route.tsx +19 -0
  40. package/app/api/auth/forgot-password/route.tsx +65 -0
  41. package/app/api/auth/reset-password/route.tsx +52 -0
  42. package/app/api/auth/verify-2fa/route.tsx +88 -0
  43. package/app/api/backup/download/db/route.ts +29 -0
  44. package/app/api/backup/download/notes/route.ts +25 -0
  45. package/app/api/backup/settings/route.ts +92 -0
  46. package/app/api/billing/checkout/route.tsx +81 -0
  47. package/app/api/billing/migrate/route.tsx +163 -0
  48. package/app/api/billing/portal/route.tsx +24 -0
  49. package/app/api/billing/setup-intent/route.tsx +55 -0
  50. package/app/api/billing/status/route.tsx +36 -0
  51. package/app/api/billing/subscribe/route.tsx +85 -0
  52. package/app/api/billing/webhook/route.tsx +199 -0
  53. package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
  54. package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
  55. package/app/api/calendar-feeds/events/route.tsx +82 -0
  56. package/app/api/calendar-feeds/route.tsx +52 -0
  57. package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
  58. package/app/api/cron/calendar-feeds/route.tsx +31 -0
  59. package/app/api/cron/stale-tasks/route.tsx +51 -0
  60. package/app/api/cron/sync/route.tsx +34 -0
  61. package/app/api/devices/[deviceId]/route.tsx +25 -0
  62. package/app/api/devices/route.tsx +41 -0
  63. package/app/api/export/route.tsx +40 -0
  64. package/app/api/feedback/route.tsx +54 -0
  65. package/app/api/folders/[folderId]/route.tsx +51 -0
  66. package/app/api/folders/route.tsx +37 -0
  67. package/app/api/graph/route.tsx +242 -0
  68. package/app/api/guest/route.tsx +58 -0
  69. package/app/api/health/route.tsx +10 -0
  70. package/app/api/holidays/countries/route.tsx +14 -0
  71. package/app/api/holidays/route.tsx +49 -0
  72. package/app/api/holidays/states/route.tsx +21 -0
  73. package/app/api/invites/[token]/route.tsx +131 -0
  74. package/app/api/invites/route.tsx +74 -0
  75. package/app/api/mcp/generate-token/route.tsx +55 -0
  76. package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
  77. package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
  78. package/app/api/notes/[noteId]/export/route.tsx +45 -0
  79. package/app/api/notes/[noteId]/route.tsx +360 -0
  80. package/app/api/notes/route.tsx +112 -0
  81. package/app/api/notifications/route.tsx +44 -0
  82. package/app/api/register/route.tsx +67 -0
  83. package/app/api/restore/route.tsx +148 -0
  84. package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
  85. package/app/api/sync/conflicts/route.tsx +48 -0
  86. package/app/api/sync/status/route.tsx +49 -0
  87. package/app/api/sync/trigger/route.tsx +15 -0
  88. package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
  89. package/app/api/tasks/[taskId]/route.tsx +259 -0
  90. package/app/api/tasks/bulk/route.tsx +133 -0
  91. package/app/api/tasks/route.tsx +36 -0
  92. package/app/api/workspace/active/route.tsx +39 -0
  93. package/app/api/workspace/create-team/route.tsx +42 -0
  94. package/app/api/workspace/kanban-statuses/route.tsx +71 -0
  95. package/app/api/workspace/members/[memberId]/route.tsx +69 -0
  96. package/app/api/workspace/route.tsx +24 -0
  97. package/app/download/page.tsx +170 -0
  98. package/app/favicon.ico +0 -0
  99. package/app/generated/prisma/client.d.ts +1 -0
  100. package/app/generated/prisma/client.js +5 -0
  101. package/app/generated/prisma/default.d.ts +1 -0
  102. package/app/generated/prisma/default.js +5 -0
  103. package/app/generated/prisma/edge.d.ts +1 -0
  104. package/app/generated/prisma/edge.js +497 -0
  105. package/app/generated/prisma/index-browser.js +523 -0
  106. package/app/generated/prisma/index.d.ts +46376 -0
  107. package/app/generated/prisma/index.js +497 -0
  108. package/app/generated/prisma/package.json +144 -0
  109. package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
  110. package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
  111. package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
  112. package/app/generated/prisma/runtime/client.d.ts +3386 -0
  113. package/app/generated/prisma/runtime/client.js +86 -0
  114. package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
  115. package/app/generated/prisma/runtime/index-browser.js +6 -0
  116. package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
  117. package/app/generated/prisma/schema.prisma +456 -0
  118. package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
  119. package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
  120. package/app/globals.css +54 -0
  121. package/app/invite/[token]/page.tsx +52 -0
  122. package/app/layout.tsx +90 -0
  123. package/app/mcp/route.tsx +430 -0
  124. package/app/opengraph-image.tsx +120 -0
  125. package/app/page.tsx +398 -0
  126. package/app/privacy/page.tsx +69 -0
  127. package/app/robots.tsx +25 -0
  128. package/app/sitemap.tsx +36 -0
  129. package/app/terms/page.tsx +69 -0
  130. package/app/upgrade/page.tsx +75 -0
  131. package/auth.config.ts +33 -0
  132. package/auth.ts +79 -0
  133. package/bin/brief.js +224 -0
  134. package/components/auth/login-form.tsx +302 -0
  135. package/components/auth/password-checklist.tsx +31 -0
  136. package/components/auth/password-input.tsx +36 -0
  137. package/components/auth/switch-account-button.test.tsx +22 -0
  138. package/components/auth/switch-account-button.tsx +19 -0
  139. package/components/auth/two-factor-input.tsx +116 -0
  140. package/components/billing/billing-dashboard.tsx +265 -0
  141. package/components/billing/card-form.tsx +210 -0
  142. package/components/billing/claim-account-form.tsx +99 -0
  143. package/components/branding/app-logo.test.tsx +20 -0
  144. package/components/branding/app-logo.tsx +25 -0
  145. package/components/calendar/calendar-agenda.tsx +150 -0
  146. package/components/calendar/calendar-drag.test.tsx +177 -0
  147. package/components/calendar/calendar-grid.tsx +357 -0
  148. package/components/calendar/calendar-hooks.test.tsx +27 -0
  149. package/components/calendar/calendar-hooks.ts +351 -0
  150. package/components/calendar/calendar-toolbar.test.tsx +68 -0
  151. package/components/calendar/calendar-toolbar.tsx +291 -0
  152. package/components/calendar/calendar-types.ts +148 -0
  153. package/components/calendar/calendar-view.test.tsx +295 -0
  154. package/components/calendar/calendar-view.tsx +307 -0
  155. package/components/calendar/day-detail-popover.tsx +174 -0
  156. package/components/calendar/task-chip.tsx +86 -0
  157. package/components/command/command-palette.test.tsx +33 -0
  158. package/components/command/command-palette.tsx +310 -0
  159. package/components/download-cta.tsx +87 -0
  160. package/components/feedback/feedback-popup.tsx +207 -0
  161. package/components/graph/graph-draw.ts +337 -0
  162. package/components/graph/graph-overlays.tsx +160 -0
  163. package/components/graph/graph-page.test.tsx +131 -0
  164. package/components/graph/graph-page.tsx +263 -0
  165. package/components/graph/graph-types.ts +47 -0
  166. package/components/graph/graph-view.tsx +322 -0
  167. package/components/guide/guide-view.tsx +522 -0
  168. package/components/kanban/kanban-board.test.tsx +128 -0
  169. package/components/kanban/kanban-board.tsx +361 -0
  170. package/components/kanban/kanban-card-menu.tsx +102 -0
  171. package/components/kanban/kanban-card.tsx +227 -0
  172. package/components/kanban/kanban-column.tsx +49 -0
  173. package/components/kanban/kanban-status-context.tsx +28 -0
  174. package/components/landing/calendar-sandbox.test.tsx +15 -0
  175. package/components/landing/calendar-sandbox.tsx +107 -0
  176. package/components/landing/graph-sandbox.test.tsx +27 -0
  177. package/components/landing/graph-sandbox.tsx +80 -0
  178. package/components/landing/kanban-sandbox.test.tsx +24 -0
  179. package/components/landing/kanban-sandbox.tsx +101 -0
  180. package/components/landing/landing-showcase.test.tsx +21 -0
  181. package/components/landing/landing-showcase.tsx +54 -0
  182. package/components/landing/list-sandbox.tsx +86 -0
  183. package/components/landing/mock-workspace.ts +168 -0
  184. package/components/landing/notes-sandbox.test.tsx +14 -0
  185. package/components/landing/notes-sandbox.tsx +88 -0
  186. package/components/layout/app-shell.tsx +83 -0
  187. package/components/layout/backup-scheduler.tsx +122 -0
  188. package/components/layout/bottom-nav.tsx +43 -0
  189. package/components/layout/icon-bar.test.tsx +29 -0
  190. package/components/layout/icon-bar.tsx +118 -0
  191. package/components/layout/mobile-top-bar.tsx +68 -0
  192. package/components/layout/notes-panel-folder.tsx +127 -0
  193. package/components/layout/notes-panel-note-item.tsx +140 -0
  194. package/components/layout/notes-panel-task-tab.tsx +63 -0
  195. package/components/layout/notes-panel-types.ts +44 -0
  196. package/components/layout/notes-panel.tsx +476 -0
  197. package/components/layout/notification-bell.tsx +251 -0
  198. package/components/layout/paywall-screen.tsx +41 -0
  199. package/components/layout/pro-banner.tsx +76 -0
  200. package/components/layout/sw-register.tsx +27 -0
  201. package/components/layout/workspace-switcher.tsx +90 -0
  202. package/components/notes/mobile-bottom-sheet.tsx +99 -0
  203. package/components/notes/note-editor-context-menu.tsx +47 -0
  204. package/components/notes/note-editor-dom.ts +33 -0
  205. package/components/notes/note-editor-dropdowns.tsx +484 -0
  206. package/components/notes/note-editor-hooks.ts +692 -0
  207. package/components/notes/note-editor-keyboard.ts +305 -0
  208. package/components/notes/note-editor-overlay.tsx +90 -0
  209. package/components/notes/note-editor.test.tsx +372 -0
  210. package/components/notes/note-editor.tsx +662 -0
  211. package/components/notes/note-preview-pane.tsx +156 -0
  212. package/components/notes/note-tabs.tsx +120 -0
  213. package/components/notes/note-types.tsx +157 -0
  214. package/components/settings/accept-invite.tsx +108 -0
  215. package/components/settings/agent-token-settings.tsx +369 -0
  216. package/components/settings/backup-restore-settings.test.tsx +25 -0
  217. package/components/settings/backup-restore-settings.tsx +327 -0
  218. package/components/settings/calendar-feeds-settings.tsx +489 -0
  219. package/components/settings/calendar-general-settings.tsx +174 -0
  220. package/components/settings/confirm-danger-action.test.tsx +215 -0
  221. package/components/settings/confirm-danger-action.tsx +65 -0
  222. package/components/settings/security-settings.tsx +252 -0
  223. package/components/settings/settings-guidance.test.tsx +98 -0
  224. package/components/settings/team-settings.tsx +319 -0
  225. package/components/settings/two-factor-auth.tsx +296 -0
  226. package/components/settings/workspace-settings-client.tsx +363 -0
  227. package/components/settings/workspace-settings-form.tsx +73 -0
  228. package/components/sync/conflict-viewer.tsx +247 -0
  229. package/components/sync/sync-indicator.tsx +171 -0
  230. package/components/tasks/snippet-thread.tsx +119 -0
  231. package/components/tasks/status-dot.tsx +47 -0
  232. package/components/tasks/task-badge.tsx +43 -0
  233. package/components/tasks/task-detail.test.tsx +187 -0
  234. package/components/tasks/task-detail.tsx +458 -0
  235. package/components/tasks/task-list-filters.test.tsx +75 -0
  236. package/components/tasks/task-list-filters.tsx +163 -0
  237. package/components/tasks/task-list-types.ts +20 -0
  238. package/components/tasks/task-list.test.tsx +175 -0
  239. package/components/tasks/task-list.tsx +481 -0
  240. package/components/tasks/task-row.tsx +85 -0
  241. package/components/tasks/task-table-row.tsx +259 -0
  242. package/components/ui/skeleton.tsx +3 -0
  243. package/components/ui/toast.test.tsx +42 -0
  244. package/components/ui/toast.tsx +70 -0
  245. package/electron/main.ts +251 -0
  246. package/electron/preload.ts +56 -0
  247. package/instrumentation.tsx +23 -0
  248. package/lib/api-error.ts +50 -0
  249. package/lib/backup/backup-runner.test.ts +32 -0
  250. package/lib/backup/backup-runner.ts +19 -0
  251. package/lib/backup/backup-schedule.test.ts +23 -0
  252. package/lib/backup/backup-schedule.ts +55 -0
  253. package/lib/backup/backup-settings.test.ts +30 -0
  254. package/lib/backup/backup-settings.ts +27 -0
  255. package/lib/backup/export-notes-zip.test.ts +26 -0
  256. package/lib/backup/export-notes-zip.ts +82 -0
  257. package/lib/backup/export-workspace-backup.test.ts +17 -0
  258. package/lib/backup/export-workspace-backup.ts +77 -0
  259. package/lib/backup/restore-workspace-from-export.test.ts +18 -0
  260. package/lib/backup/restore-workspace-from-export.ts +183 -0
  261. package/lib/backup/types.ts +14 -0
  262. package/lib/brand-icons.ts +1 -0
  263. package/lib/calendar-feed-crypto.ts +38 -0
  264. package/lib/calendar-feed.ts +239 -0
  265. package/lib/client/online-status.ts +47 -0
  266. package/lib/conflict-resolver.test.ts +57 -0
  267. package/lib/conflict-resolver.ts +240 -0
  268. package/lib/db-init.ts +79 -0
  269. package/lib/email.ts +159 -0
  270. package/lib/encryption.test.ts +41 -0
  271. package/lib/encryption.ts +98 -0
  272. package/lib/extract-snippet.test.ts +123 -0
  273. package/lib/extract-snippet.ts +69 -0
  274. package/lib/kanban-status.ts +55 -0
  275. package/lib/license.ts +21 -0
  276. package/lib/limits.ts +31 -0
  277. package/lib/mcp-auth.test.ts +58 -0
  278. package/lib/mcp-auth.ts +65 -0
  279. package/lib/mcp-contract.test.ts +25 -0
  280. package/lib/mcp-contract.ts +210 -0
  281. package/lib/mcp-handler.ts +31 -0
  282. package/lib/mcp-url.test.ts +12 -0
  283. package/lib/mcp-url.ts +7 -0
  284. package/lib/mentions.test.ts +45 -0
  285. package/lib/mentions.ts +73 -0
  286. package/lib/note-crypto.ts +108 -0
  287. package/lib/note-sync.ts +201 -0
  288. package/lib/note-title.ts +93 -0
  289. package/lib/prisma.ts +193 -0
  290. package/lib/pro-flush.ts +292 -0
  291. package/lib/rate-limit.ts +57 -0
  292. package/lib/stripe.ts +38 -0
  293. package/lib/sync-worker.ts +388 -0
  294. package/lib/task-parser.test.ts +91 -0
  295. package/lib/task-parser.ts +81 -0
  296. package/lib/task-utils.ts +52 -0
  297. package/lib/use-is-electron.ts +19 -0
  298. package/lib/use-is-mobile.ts +22 -0
  299. package/lib/validation/calendar-feed.ts +31 -0
  300. package/lib/validation/note.ts +27 -0
  301. package/lib/validation/task.ts +26 -0
  302. package/lib/view-preferences.test.ts +54 -0
  303. package/lib/view-preferences.ts +28 -0
  304. package/lib/workspace.ts +66 -0
  305. package/next.config.ts +21 -0
  306. package/package.json +99 -0
  307. package/postcss.config.mjs +7 -0
  308. package/prisma/migrations/20260519021916_init/migration.sql +388 -0
  309. package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
  310. package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
  311. package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
  312. package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
  313. package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
  314. package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
  315. package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
  316. package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
  317. package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
  318. package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
  319. package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
  320. package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
  321. package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
  322. package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
  323. package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
  324. package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
  325. package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
  326. package/prisma/migrations/migration_lock.toml +3 -0
  327. package/prisma/schema.prisma +457 -0
  328. package/public/Logo_icon.svg +1 -0
  329. package/public/file.svg +1 -0
  330. package/public/globe.svg +1 -0
  331. package/public/icon-192.png +0 -0
  332. package/public/icon-512.png +0 -0
  333. package/public/icon.svg +4 -0
  334. package/public/icon_dark.svg +1 -0
  335. package/public/knotpad_icon.svg +1 -0
  336. package/public/knotpad_logo_full.svg +1 -0
  337. package/public/manifest.json +14 -0
  338. package/public/next.svg +1 -0
  339. package/public/sw.js +137 -0
  340. package/public/vercel.svg +1 -0
  341. package/public/window.svg +1 -0
  342. package/tsconfig.json +35 -0
@@ -0,0 +1,207 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { MessageSquare, Bug, Lightbulb, X, Send, Loader2 } from "lucide-react";
5
+ import { useToast } from "@/components/ui/toast";
6
+
7
+ type FeedbackType = "feedback" | "bug" | "feature_request";
8
+
9
+ const typeConfig: Record<FeedbackType, { icon: typeof MessageSquare; label: string; placeholder: string }> = {
10
+ feedback: {
11
+ icon: MessageSquare,
12
+ label: "General Feedback",
13
+ placeholder: "Tell us what you think about the app...",
14
+ },
15
+ bug: {
16
+ icon: Bug,
17
+ label: "Report a Bug",
18
+ placeholder: "Describe the issue you encountered...",
19
+ },
20
+ feature_request: {
21
+ icon: Lightbulb,
22
+ label: "Feature Request",
23
+ placeholder: "What feature would you like to see?",
24
+ },
25
+ };
26
+
27
+ export function FeedbackPopup() {
28
+ const [open, setOpen] = useState(false);
29
+ const [type, setType] = useState<FeedbackType>("feedback");
30
+ const [subject, setSubject] = useState("");
31
+ const [message, setMessage] = useState("");
32
+ const [submitting, setSubmitting] = useState(false);
33
+ const [submitted, setSubmitted] = useState(false);
34
+ const ref = useRef<HTMLDivElement>(null);
35
+ const toast = useToast();
36
+
37
+ useEffect(() => {
38
+ function handleClickOutside(e: MouseEvent | TouchEvent) {
39
+ if (ref.current && !ref.current.contains(e.target as Node)) {
40
+ setOpen(false);
41
+ }
42
+ }
43
+ document.addEventListener("mousedown", handleClickOutside);
44
+ document.addEventListener("touchstart", handleClickOutside);
45
+ return () => {
46
+ document.removeEventListener("mousedown", handleClickOutside);
47
+ document.removeEventListener("touchstart", handleClickOutside);
48
+ };
49
+ }, []);
50
+
51
+ useEffect(() => {
52
+ if (!open) return;
53
+ function onKey(e: KeyboardEvent) {
54
+ if (e.key === "Escape") setOpen(false);
55
+ }
56
+ document.addEventListener("keydown", onKey);
57
+ return () => document.removeEventListener("keydown", onKey);
58
+ }, [open]);
59
+
60
+ async function handleSubmit(e: React.FormEvent) {
61
+ e.preventDefault();
62
+ if (!subject.trim() || !message.trim()) return;
63
+
64
+ setSubmitting(true);
65
+ try {
66
+ const res = await fetch("/api/feedback", {
67
+ method: "POST",
68
+ headers: { "Content-Type": "application/json" },
69
+ body: JSON.stringify({ type, subject: subject.trim(), message: message.trim() }),
70
+ });
71
+
72
+ if (!res.ok) throw new Error("Failed to submit");
73
+
74
+ setSubmitted(true);
75
+ toast("Thank you for your feedback!", "success");
76
+ setTimeout(() => {
77
+ setOpen(false);
78
+ setSubmitted(false);
79
+ setSubject("");
80
+ setMessage("");
81
+ setType("feedback");
82
+ }, 2000);
83
+ } catch {
84
+ toast("Failed to submit feedback. Please try again.", "error");
85
+ } finally {
86
+ setSubmitting(false);
87
+ }
88
+ }
89
+
90
+ const currentType = typeConfig[type];
91
+ const TypeIcon = currentType.icon;
92
+
93
+ return (
94
+ <div ref={ref} className="relative">
95
+ <button
96
+ onClick={() => setOpen((o) => !o)}
97
+ title="Feedback"
98
+ aria-label="Send feedback"
99
+ aria-expanded={open}
100
+ className="relative flex h-10 w-10 items-center justify-center rounded-md text-zinc-500 transition-colors ring-focus hover:bg-zinc-800 hover:text-zinc-300"
101
+ >
102
+ <MessageSquare size={18} />
103
+ </button>
104
+
105
+ {open && (
106
+ <div className="absolute bottom-full left-0 mb-2 w-80 rounded-lg border border-zinc-700 bg-zinc-900 shadow-xl overflow-hidden z-50">
107
+ {/* Header */}
108
+ <div className="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
109
+ <div className="flex items-center gap-2">
110
+ <TypeIcon size={14} className="text-zinc-400" />
111
+ <span className="text-xs font-semibold text-zinc-400">Send Feedback</span>
112
+ </div>
113
+ <button
114
+ onClick={() => setOpen(false)}
115
+ className="text-zinc-500 hover:text-zinc-300"
116
+ aria-label="Close"
117
+ >
118
+ <X size={14} />
119
+ </button>
120
+ </div>
121
+
122
+ {/* Type Selector */}
123
+ {!submitted && (
124
+ <div className="flex border-b border-zinc-800">
125
+ {(Object.keys(typeConfig) as FeedbackType[]).map((t) => {
126
+ const Icon = typeConfig[t].icon;
127
+ return (
128
+ <button
129
+ key={t}
130
+ onClick={() => setType(t)}
131
+ className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-[10px] font-medium transition-colors ${
132
+ type === t
133
+ ? "bg-zinc-800 text-zinc-200"
134
+ : "text-zinc-500 hover:bg-zinc-800/50 hover:text-zinc-300"
135
+ }`}
136
+ title={typeConfig[t].label}
137
+ >
138
+ <Icon size={12} />
139
+ <span className="hidden sm:inline">{t === "feature_request" ? "Feature" : typeConfig[t].label.split(" ")[0]}</span>
140
+ </button>
141
+ );
142
+ })}
143
+ </div>
144
+ )}
145
+
146
+ {/* Form */}
147
+ <div className="p-3">
148
+ {submitted ? (
149
+ <div className="flex flex-col items-center justify-center py-6 text-center">
150
+ <div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-500/10">
151
+ <Send size={18} className="text-emerald-400" />
152
+ </div>
153
+ <p className="text-sm font-medium text-zinc-200">Thank you!</p>
154
+ <p className="text-xs text-zinc-500 mt-1">Your feedback has been received.</p>
155
+ </div>
156
+ ) : (
157
+ <form onSubmit={handleSubmit} className="space-y-3">
158
+ <div>
159
+ <input
160
+ type="text"
161
+ value={subject}
162
+ onChange={(e) => setSubject(e.target.value)}
163
+ placeholder="Subject"
164
+ maxLength={200}
165
+ className="w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
166
+ required
167
+ />
168
+ </div>
169
+ <div>
170
+ <textarea
171
+ value={message}
172
+ onChange={(e) => setMessage(e.target.value)}
173
+ placeholder={currentType.placeholder}
174
+ maxLength={5000}
175
+ rows={4}
176
+ className="w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none resize-none"
177
+ required
178
+ />
179
+ <div className="mt-1 flex justify-end">
180
+ <span className="text-[10px] text-zinc-600">{message.length}/5000</span>
181
+ </div>
182
+ </div>
183
+ <button
184
+ type="submit"
185
+ disabled={submitting || !subject.trim() || !message.trim()}
186
+ className="w-full flex items-center justify-center gap-2 rounded-md bg-zinc-100 px-3 py-2 text-xs font-medium text-zinc-900 transition-colors hover:bg-white disabled:opacity-50 disabled:cursor-not-allowed"
187
+ >
188
+ {submitting ? (
189
+ <>
190
+ <Loader2 size={14} className="animate-spin" />
191
+ Sending...
192
+ </>
193
+ ) : (
194
+ <>
195
+ <Send size={14} />
196
+ Send Feedback
197
+ </>
198
+ )}
199
+ </button>
200
+ </form>
201
+ )}
202
+ </div>
203
+ </div>
204
+ )}
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,337 @@
1
+ import * as d3 from "d3";
2
+ import type { SimNode, SimLink, GraphNode, GraphEdge } from "./graph-types";
3
+ import { NODE_COLORS, NODE_RADIUS, LEGEND_LABELS } from "./graph-types";
4
+
5
+ type DrawParams = {
6
+ svgRef: React.RefObject<SVGSVGElement | null>;
7
+ gRef: React.MutableRefObject<SVGGElement | null>;
8
+ zoomRef: React.MutableRefObject<d3.ZoomBehavior<SVGSVGElement, unknown> | null>;
9
+ transformRef: React.MutableRefObject<d3.ZoomTransform>;
10
+ positionsRef: React.MutableRefObject<Map<string, { x: number; y: number }>>;
11
+ nodeSelRef: React.MutableRefObject<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>;
12
+ linkSelRef: React.MutableRefObject<d3.Selection<SVGLineElement, SimLink, SVGGElement, unknown> | null>;
13
+ dimmedRef: React.MutableRefObject<boolean>;
14
+ highlightNodeIdsRef: React.MutableRefObject<Set<string>>;
15
+ highlightEdgePairsRef: React.MutableRefObject<Set<string>>;
16
+ selectedNodeIdRef: React.MutableRefObject<string | null>;
17
+ collapsedNodeIds: Set<string>;
18
+ visibleNodes: GraphNode[];
19
+ visibleEdges: GraphEdge[];
20
+ setSelectedNodeId: (v: string | null) => void;
21
+ setContextMenu: (v: { x: number; y: number; nodeId: string } | null) => void;
22
+ setTooltip: (v: { x: number; y: number; label: string; type: string } | null | ((prev: { x: number; y: number; label: string; type: string } | null) => { x: number; y: number; label: string; type: string } | null)) => void;
23
+ onNoteSelect: (noteId: string | null) => void;
24
+ };
25
+
26
+ export function createGraphDraw(params: DrawParams) {
27
+ const {
28
+ svgRef, gRef, zoomRef, transformRef, positionsRef,
29
+ nodeSelRef, linkSelRef,
30
+ dimmedRef, highlightNodeIdsRef, highlightEdgePairsRef, selectedNodeIdRef,
31
+ collapsedNodeIds, visibleNodes, visibleEdges,
32
+ setSelectedNodeId, setContextMenu, setTooltip, onNoteSelect,
33
+ } = params;
34
+
35
+ return () => {
36
+ const svg = svgRef.current;
37
+ if (!svg || visibleNodes.length === 0) return;
38
+
39
+ d3.select(svg).selectAll("*").remove();
40
+ nodeSelRef.current = null;
41
+ linkSelRef.current = null;
42
+ gRef.current = null;
43
+
44
+ const width = svg.clientWidth || 900;
45
+ const height = svg.clientHeight || 600;
46
+
47
+ // Restore positions from cache so nodes don't jump after topology-triggered redraws
48
+ const simNodes: SimNode[] = visibleNodes.map((n) => {
49
+ const cached = positionsRef.current.get(n.id);
50
+ return cached ? { ...n, x: cached.x, y: cached.y } : { ...n };
51
+ });
52
+ const nodeById = new Map(simNodes.map((n) => [n.id, n]));
53
+
54
+ const simLinks: SimLink[] = visibleEdges
55
+ .map((e) => ({
56
+ source: nodeById.get(e.source)!,
57
+ target: nodeById.get(e.target)!,
58
+ type: e.type,
59
+ }))
60
+ .filter((e) => e.source && e.target);
61
+
62
+ const containedNodeIds = new Set(
63
+ visibleEdges
64
+ .filter((edge) => edge.type === "contains")
65
+ .map((edge) => edge.target)
66
+ );
67
+ const uncachedNodes = simNodes.filter((node) => !positionsRef.current.has(node.id));
68
+ const coreFolders = uncachedNodes.filter((node) => node.type === "folder");
69
+ const coreNotes = uncachedNodes.filter(
70
+ (node) => node.type === "note" && containedNodeIds.has(node.id)
71
+ );
72
+ const orphanNotes = uncachedNodes.filter(
73
+ (node) => node.type === "note" && !containedNodeIds.has(node.id)
74
+ );
75
+ const supportingNodes = uncachedNodes.filter(
76
+ (node) => node.type !== "folder" && node.type !== "note"
77
+ );
78
+ const orphanDirections = new Map<string, number>();
79
+
80
+ function seedEllipse(nodes: SimNode[], radiusX: number, radiusY: number, offsetY = 0) {
81
+ nodes.forEach((node, index) => {
82
+ const angle = (index / Math.max(1, nodes.length)) * Math.PI * 2;
83
+ node.x ??= width / 2 + Math.cos(angle) * radiusX;
84
+ node.y ??= height / 2 + offsetY + Math.sin(angle) * radiusY;
85
+ });
86
+ }
87
+
88
+ seedEllipse(coreFolders, 90, 60, -18);
89
+ seedEllipse(coreNotes, 180, 120, 0);
90
+ seedEllipse(supportingNodes, 255, 160, 18);
91
+
92
+ orphanNotes.forEach((node, index) => {
93
+ const direction = index % 2 === 0 ? -1 : 1;
94
+ const row = Math.floor(index / 2);
95
+ const rowOffset = row - Math.floor(orphanNotes.length / 4);
96
+ orphanDirections.set(node.id, direction);
97
+ node.x ??= width / 2 + direction * 280;
98
+ node.y ??= height / 2 + rowOffset * 68;
99
+ });
100
+
101
+ // Use a low alpha if most nodes already have cached positions (avoids jiggle on collapse/expand)
102
+ const cachedCount = simNodes.filter((n) => n.x !== undefined).length;
103
+ const initialAlpha = cachedCount > simNodes.length * 0.8 ? 0.05 : 1;
104
+
105
+ const simulation = d3
106
+ .forceSimulation<SimNode>(simNodes)
107
+ .alpha(initialAlpha)
108
+ .force("link", d3.forceLink<SimNode, SimLink>(simLinks).id((d) => d.id).distance((l) => {
109
+ const srcType = (l.source as SimNode).type;
110
+ const tgtType = (l.target as SimNode).type;
111
+ if (srcType === "folder" || tgtType === "folder") return 96;
112
+ if ((l as SimLink).type === "references") return 88;
113
+ return 72;
114
+ }))
115
+ .force("charge", d3.forceManyBody().strength(-185))
116
+ .force("center", d3.forceCenter(width / 2, height / 2))
117
+ .force("x", d3.forceX<SimNode>((node) => {
118
+ if (orphanDirections.has(node.id)) {
119
+ return width / 2 + orphanDirections.get(node.id)! * 250;
120
+ }
121
+ if (node.type === "folder") return width / 2;
122
+ return width / 2;
123
+ }).strength((node) => (orphanDirections.has(node.id) ? 0.14 : node.type === "folder" ? 0.08 : 0.035)))
124
+ .force("y", d3.forceY<SimNode>((node) => {
125
+ if (node.type === "folder") return height / 2 - 12;
126
+ return height / 2;
127
+ }).strength((node) => (orphanDirections.has(node.id) ? 0.08 : node.type === "folder" ? 0.08 : 0.03)))
128
+ .force("collision", d3.forceCollide<SimNode>((d) => NODE_RADIUS[d.type] + 10));
129
+
130
+ const root = d3.select(svg).append("g");
131
+ gRef.current = root.node();
132
+
133
+ const zoom = d3
134
+ .zoom<SVGSVGElement, unknown>()
135
+ .scaleExtent([0.15, 4])
136
+ .on("zoom", (event) => {
137
+ transformRef.current = event.transform;
138
+ root.attr("transform", event.transform.toString());
139
+ });
140
+ zoomRef.current = zoom;
141
+ d3.select(svg).call(zoom);
142
+
143
+ // Restore previous zoom/pan transform
144
+ if (transformRef.current !== d3.zoomIdentity) {
145
+ d3.select(svg).call(zoom.transform as any, transformRef.current);
146
+ }
147
+
148
+ // Click background to clear selection
149
+ d3.select(svg).on("click", (event) => {
150
+ if (event.target === svg) {
151
+ setSelectedNodeId(null);
152
+ onNoteSelect(null);
153
+ setContextMenu(null);
154
+ }
155
+ });
156
+
157
+ // Arrow markers
158
+ const defs = d3.select(svg).append("defs");
159
+ const arrowTypes = [
160
+ { id: "arrow-contains", color: "#52525b" },
161
+ { id: "arrow-assigned", color: "#52525b" },
162
+ { id: "arrow-references", color: "#78716c" },
163
+ ];
164
+ for (const { id, color } of arrowTypes) {
165
+ defs.append("marker")
166
+ .attr("id", id)
167
+ .attr("viewBox", "0 -5 10 10")
168
+ .attr("refX", 22)
169
+ .attr("markerWidth", 5)
170
+ .attr("markerHeight", 5)
171
+ .attr("orient", "auto")
172
+ .append("path")
173
+ .attr("d", "M0,-5L10,0L0,5")
174
+ .attr("fill", color);
175
+ }
176
+
177
+ // Read visual state from refs (not from closure, so draw deps stay minimal)
178
+ const _dimmed = dimmedRef.current;
179
+ const _highlightNodeIds = highlightNodeIdsRef.current;
180
+ const _highlightEdgePairs = highlightEdgePairsRef.current;
181
+ const _selectedNodeId = selectedNodeIdRef.current;
182
+
183
+ // Edges
184
+ const link = root
185
+ .append("g")
186
+ .selectAll<SVGLineElement, SimLink>("line")
187
+ .data(simLinks)
188
+ .join("line")
189
+ .attr("stroke", (d) => {
190
+ const src = (d.source as SimNode).id;
191
+ const tgt = (d.target as SimNode).id;
192
+ const key = `${src}→${tgt}`;
193
+ if (d.type === "references") {
194
+ return _dimmed ? (_highlightEdgePairs.has(key) ? "#78716c" : "#27272a") : "#57534e";
195
+ }
196
+ if (_dimmed) return _highlightEdgePairs.has(key) ? "#52525b" : "#27272a";
197
+ return "#3f3f46";
198
+ })
199
+ .attr("stroke-width", (d) => {
200
+ const src = (d.source as SimNode).id;
201
+ const tgt = (d.target as SimNode).id;
202
+ return _highlightEdgePairs.has(`${src}→${tgt}`) ? 2.5 : 1;
203
+ })
204
+ .attr("stroke-opacity", (d) => {
205
+ const src = (d.source as SimNode).id;
206
+ const tgt = (d.target as SimNode).id;
207
+ if (_dimmed && !_highlightEdgePairs.has(`${src}→${tgt}`)) return 0.15;
208
+ return d.type === "references" ? 0.5 : 0.7;
209
+ })
210
+ .attr("stroke-dasharray", (d) => d.type === "references" ? "4 3" : null)
211
+ .attr("marker-end", (d) => `url(#arrow-${d.type})`);
212
+
213
+ linkSelRef.current = link as unknown as d3.Selection<SVGLineElement, SimLink, SVGGElement, unknown>;
214
+
215
+ // Node groups
216
+ const node = root
217
+ .append("g")
218
+ .selectAll<SVGGElement, SimNode>("g")
219
+ .data(simNodes)
220
+ .join("g")
221
+ .attr("cursor", "pointer")
222
+ .on("click", (event, d) => {
223
+ event.stopPropagation();
224
+ setContextMenu(null);
225
+ // Read from ref — not from stale closure
226
+ const currentSelected = selectedNodeIdRef.current;
227
+ if (currentSelected === d.id) {
228
+ setSelectedNodeId(null);
229
+ onNoteSelect(null);
230
+ } else {
231
+ setSelectedNodeId(d.id);
232
+ if (d.type === "note" && d.id.startsWith("note-")) {
233
+ onNoteSelect(d.id.slice(5));
234
+ } else {
235
+ onNoteSelect(null);
236
+ }
237
+ }
238
+ })
239
+ .on("contextmenu", (event, d) => {
240
+ event.preventDefault();
241
+ event.stopPropagation();
242
+ const rect = svg.getBoundingClientRect();
243
+ setContextMenu({ x: event.clientX - rect.left, y: event.clientY - rect.top, nodeId: d.id });
244
+ })
245
+ .on("mouseenter", (event, d) => {
246
+ const rect = svg.getBoundingClientRect();
247
+ setTooltip({
248
+ x: event.clientX - rect.left,
249
+ y: event.clientY - rect.top,
250
+ label: d.label,
251
+ type: LEGEND_LABELS[d.type] ?? d.type,
252
+ });
253
+ })
254
+ .on("mousemove", (event) => {
255
+ const rect = svg.getBoundingClientRect();
256
+ setTooltip((prev) => prev ? { ...prev, x: event.clientX - rect.left, y: event.clientY - rect.top } : null);
257
+ })
258
+ .on("mouseleave", () => setTooltip(null));
259
+
260
+ // Drag: only reheat simulation on actual movement, not on plain clicks.
261
+ let simulationHeated = false;
262
+ node.call(
263
+ d3.drag<SVGGElement, SimNode>()
264
+ .on("start", (event, d) => {
265
+ simulationHeated = false;
266
+ d.fx = d.x; d.fy = d.y;
267
+ })
268
+ .on("drag", (event, d) => {
269
+ if (!simulationHeated) {
270
+ simulationHeated = true;
271
+ simulation.alphaTarget(0.3).restart();
272
+ }
273
+ d.fx = event.x; d.fy = event.y;
274
+ })
275
+ .on("end", (event, d) => {
276
+ simulationHeated = false;
277
+ if (!event.active) simulation.alphaTarget(0);
278
+ d.fx = null; d.fy = null;
279
+ })
280
+ );
281
+
282
+ nodeSelRef.current = node as unknown as d3.Selection<SVGGElement, SimNode, SVGGElement, unknown>;
283
+
284
+ // Circle
285
+ node
286
+ .append("circle")
287
+ .attr("r", (d) => NODE_RADIUS[d.type] ?? 10)
288
+ .attr("fill", (d) => NODE_COLORS[d.type] ?? "#52525b")
289
+ .attr("fill-opacity", (d) => (_dimmed && !_highlightNodeIds.has(d.id) ? 0.12 : 0.9))
290
+ .attr("stroke", (d) => {
291
+ if (_selectedNodeId === d.id) return "#e4e4e7";
292
+ if (!_dimmed || !_highlightNodeIds.has(d.id)) return "transparent";
293
+ return NODE_COLORS[d.type] ?? "#52525b";
294
+ })
295
+ .attr("stroke-width", (d) => _selectedNodeId === d.id ? 3 : 2)
296
+ .attr("stroke-opacity", (d) => _selectedNodeId === d.id ? 1 : 0.6);
297
+
298
+ // Label
299
+ node
300
+ .append("text")
301
+ .text((d) => (d.label.length > 22 ? d.label.slice(0, 20) + "…" : d.label))
302
+ .attr("x", (d) => (NODE_RADIUS[d.type] ?? 10) + 5)
303
+ .attr("y", 4)
304
+ .attr("font-size", 11)
305
+ .attr("fill", (d) => (_dimmed && !_highlightNodeIds.has(d.id) ? "#3f3f46" : "#a1a1aa"))
306
+ .attr("font-family", "var(--font-geist-sans), system-ui, sans-serif")
307
+ .attr("pointer-events", "none");
308
+
309
+ // Collapse indicator dot
310
+ node
311
+ .filter((d) => collapsedNodeIds.has(d.id))
312
+ .append("circle")
313
+ .attr("r", 4)
314
+ .attr("cx", (d) => -(NODE_RADIUS[d.type] ?? 10) - 2)
315
+ .attr("cy", 0)
316
+ .attr("fill", "#d4d4d8");
317
+
318
+ simulation.on("tick", () => {
319
+ // Update position cache on every tick
320
+ simNodes.forEach((n) => {
321
+ if (n.x !== undefined && n.y !== undefined) {
322
+ positionsRef.current.set(n.id, { x: n.x, y: n.y });
323
+ }
324
+ });
325
+
326
+ link
327
+ .attr("x1", (d) => (d.source as SimNode).x ?? 0)
328
+ .attr("y1", (d) => (d.source as SimNode).y ?? 0)
329
+ .attr("x2", (d) => (d.target as SimNode).x ?? 0)
330
+ .attr("y2", (d) => (d.target as SimNode).y ?? 0);
331
+
332
+ node.attr("transform", (d) => `translate(${d.x ?? 0},${d.y ?? 0})`);
333
+ });
334
+
335
+ return () => { simulation.stop(); };
336
+ };
337
+ }