@knotpad/app 0.1.5 → 0.1.6

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 (340) hide show
  1. package/app/(app)/calendar/page.tsx +57 -0
  2. package/app/(app)/error.tsx +35 -0
  3. package/app/(app)/graph/page.tsx +32 -0
  4. package/app/(app)/guide/page.tsx +21 -0
  5. package/app/(app)/kanban/loading.tsx +24 -0
  6. package/app/(app)/kanban/page.tsx +59 -0
  7. package/app/(app)/layout.tsx +122 -0
  8. package/app/(app)/list/loading.tsx +21 -0
  9. package/app/(app)/list/page.tsx +137 -0
  10. package/app/(app)/loading.tsx +18 -0
  11. package/app/(app)/notes/[noteId]/page.tsx +84 -0
  12. package/app/(app)/notes/layout.tsx +30 -0
  13. package/app/(app)/notes/page.tsx +39 -0
  14. package/app/(app)/page.tsx +5 -0
  15. package/app/(app)/settings/agent-token/page.tsx +59 -0
  16. package/app/(app)/settings/backup/page.tsx +49 -0
  17. package/app/(app)/settings/billing/page.tsx +53 -0
  18. package/app/(app)/settings/calendar/page.tsx +41 -0
  19. package/app/(app)/settings/layout.test.tsx +39 -0
  20. package/app/(app)/settings/layout.tsx +71 -0
  21. package/app/(app)/settings/page.tsx +4 -0
  22. package/app/(app)/settings/security/page.tsx +43 -0
  23. package/app/(app)/settings/team/page.tsx +74 -0
  24. package/app/(app)/settings/workspace/page.tsx +27 -0
  25. package/app/(app)/tasks/[taskId]/page.tsx +79 -0
  26. package/app/(auth)/forgot-password/page.tsx +106 -0
  27. package/app/(auth)/guest/page.tsx +56 -0
  28. package/app/(auth)/layout.tsx +13 -0
  29. package/app/(auth)/login/page.tsx +14 -0
  30. package/app/(auth)/register/page.tsx +193 -0
  31. package/app/(auth)/reset-password/page.tsx +138 -0
  32. package/app/api/account/claim/route.tsx +135 -0
  33. package/app/api/admin/backfill-encryption/route.tsx +43 -0
  34. package/app/api/admin/license/route.tsx +42 -0
  35. package/app/api/auth/2fa/route.tsx +148 -0
  36. package/app/api/auth/[...nextauth]/route.tsx +3 -0
  37. package/app/api/auth/change-password/route.tsx +61 -0
  38. package/app/api/auth/check-2fa/route.tsx +19 -0
  39. package/app/api/auth/forgot-password/route.tsx +65 -0
  40. package/app/api/auth/reset-password/route.tsx +52 -0
  41. package/app/api/auth/verify-2fa/route.tsx +88 -0
  42. package/app/api/backup/download/db/route.ts +29 -0
  43. package/app/api/backup/download/notes/route.ts +25 -0
  44. package/app/api/backup/settings/route.ts +92 -0
  45. package/app/api/billing/checkout/route.tsx +81 -0
  46. package/app/api/billing/migrate/route.tsx +163 -0
  47. package/app/api/billing/portal/route.tsx +24 -0
  48. package/app/api/billing/setup-intent/route.tsx +55 -0
  49. package/app/api/billing/status/route.tsx +36 -0
  50. package/app/api/billing/subscribe/route.tsx +85 -0
  51. package/app/api/billing/webhook/route.tsx +199 -0
  52. package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
  53. package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
  54. package/app/api/calendar-feeds/events/route.tsx +82 -0
  55. package/app/api/calendar-feeds/route.tsx +52 -0
  56. package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
  57. package/app/api/cron/calendar-feeds/route.tsx +31 -0
  58. package/app/api/cron/stale-tasks/route.tsx +51 -0
  59. package/app/api/cron/sync/route.tsx +34 -0
  60. package/app/api/devices/[deviceId]/route.tsx +25 -0
  61. package/app/api/devices/route.tsx +41 -0
  62. package/app/api/export/route.tsx +40 -0
  63. package/app/api/feedback/route.tsx +54 -0
  64. package/app/api/folders/[folderId]/route.tsx +51 -0
  65. package/app/api/folders/route.tsx +37 -0
  66. package/app/api/graph/route.tsx +242 -0
  67. package/app/api/guest/route.tsx +58 -0
  68. package/app/api/health/route.tsx +10 -0
  69. package/app/api/holidays/countries/route.tsx +14 -0
  70. package/app/api/holidays/route.tsx +49 -0
  71. package/app/api/holidays/states/route.tsx +21 -0
  72. package/app/api/invites/[token]/route.tsx +131 -0
  73. package/app/api/invites/route.tsx +74 -0
  74. package/app/api/mcp/generate-token/route.tsx +55 -0
  75. package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
  76. package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
  77. package/app/api/notes/[noteId]/export/route.tsx +45 -0
  78. package/app/api/notes/[noteId]/route.tsx +360 -0
  79. package/app/api/notes/route.tsx +112 -0
  80. package/app/api/notifications/route.tsx +44 -0
  81. package/app/api/register/route.tsx +67 -0
  82. package/app/api/restore/route.tsx +148 -0
  83. package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
  84. package/app/api/sync/conflicts/route.tsx +48 -0
  85. package/app/api/sync/status/route.tsx +49 -0
  86. package/app/api/sync/trigger/route.tsx +15 -0
  87. package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
  88. package/app/api/tasks/[taskId]/route.tsx +259 -0
  89. package/app/api/tasks/bulk/route.tsx +133 -0
  90. package/app/api/tasks/route.tsx +36 -0
  91. package/app/api/workspace/active/route.tsx +39 -0
  92. package/app/api/workspace/create-team/route.tsx +42 -0
  93. package/app/api/workspace/kanban-statuses/route.tsx +71 -0
  94. package/app/api/workspace/members/[memberId]/route.tsx +69 -0
  95. package/app/api/workspace/route.tsx +24 -0
  96. package/app/download/page.tsx +170 -0
  97. package/app/favicon.ico +0 -0
  98. package/app/generated/prisma/client.d.ts +1 -0
  99. package/app/generated/prisma/client.js +5 -0
  100. package/app/generated/prisma/default.d.ts +1 -0
  101. package/app/generated/prisma/default.js +5 -0
  102. package/app/generated/prisma/edge.d.ts +1 -0
  103. package/app/generated/prisma/edge.js +497 -0
  104. package/app/generated/prisma/index-browser.js +523 -0
  105. package/app/generated/prisma/index.d.ts +46376 -0
  106. package/app/generated/prisma/index.js +497 -0
  107. package/app/generated/prisma/package.json +144 -0
  108. package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
  109. package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
  110. package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
  111. package/app/generated/prisma/runtime/client.d.ts +3386 -0
  112. package/app/generated/prisma/runtime/client.js +86 -0
  113. package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
  114. package/app/generated/prisma/runtime/index-browser.js +6 -0
  115. package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
  116. package/app/generated/prisma/schema.prisma +456 -0
  117. package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
  118. package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
  119. package/app/globals.css +54 -0
  120. package/app/invite/[token]/page.tsx +52 -0
  121. package/app/layout.tsx +90 -0
  122. package/app/mcp/route.tsx +430 -0
  123. package/app/opengraph-image.tsx +120 -0
  124. package/app/page.tsx +398 -0
  125. package/app/privacy/page.tsx +69 -0
  126. package/app/robots.tsx +25 -0
  127. package/app/sitemap.tsx +36 -0
  128. package/app/terms/page.tsx +69 -0
  129. package/app/upgrade/page.tsx +75 -0
  130. package/auth.config.ts +33 -0
  131. package/auth.ts +79 -0
  132. package/bin/brief.js +224 -0
  133. package/components/auth/login-form.tsx +302 -0
  134. package/components/auth/password-checklist.tsx +31 -0
  135. package/components/auth/password-input.tsx +36 -0
  136. package/components/auth/switch-account-button.test.tsx +22 -0
  137. package/components/auth/switch-account-button.tsx +19 -0
  138. package/components/auth/two-factor-input.tsx +116 -0
  139. package/components/billing/billing-dashboard.tsx +265 -0
  140. package/components/billing/card-form.tsx +210 -0
  141. package/components/billing/claim-account-form.tsx +99 -0
  142. package/components/branding/app-logo.test.tsx +20 -0
  143. package/components/branding/app-logo.tsx +25 -0
  144. package/components/calendar/calendar-agenda.tsx +150 -0
  145. package/components/calendar/calendar-drag.test.tsx +177 -0
  146. package/components/calendar/calendar-grid.tsx +357 -0
  147. package/components/calendar/calendar-hooks.test.tsx +27 -0
  148. package/components/calendar/calendar-hooks.ts +351 -0
  149. package/components/calendar/calendar-toolbar.test.tsx +68 -0
  150. package/components/calendar/calendar-toolbar.tsx +291 -0
  151. package/components/calendar/calendar-types.ts +148 -0
  152. package/components/calendar/calendar-view.test.tsx +295 -0
  153. package/components/calendar/calendar-view.tsx +307 -0
  154. package/components/calendar/day-detail-popover.tsx +174 -0
  155. package/components/calendar/task-chip.tsx +86 -0
  156. package/components/command/command-palette.test.tsx +33 -0
  157. package/components/command/command-palette.tsx +310 -0
  158. package/components/download-cta.tsx +87 -0
  159. package/components/feedback/feedback-popup.tsx +207 -0
  160. package/components/graph/graph-draw.ts +337 -0
  161. package/components/graph/graph-overlays.tsx +160 -0
  162. package/components/graph/graph-page.test.tsx +131 -0
  163. package/components/graph/graph-page.tsx +263 -0
  164. package/components/graph/graph-types.ts +47 -0
  165. package/components/graph/graph-view.tsx +322 -0
  166. package/components/guide/guide-view.tsx +522 -0
  167. package/components/kanban/kanban-board.test.tsx +128 -0
  168. package/components/kanban/kanban-board.tsx +361 -0
  169. package/components/kanban/kanban-card-menu.tsx +102 -0
  170. package/components/kanban/kanban-card.tsx +227 -0
  171. package/components/kanban/kanban-column.tsx +49 -0
  172. package/components/kanban/kanban-status-context.tsx +28 -0
  173. package/components/landing/calendar-sandbox.test.tsx +15 -0
  174. package/components/landing/calendar-sandbox.tsx +107 -0
  175. package/components/landing/graph-sandbox.test.tsx +27 -0
  176. package/components/landing/graph-sandbox.tsx +80 -0
  177. package/components/landing/kanban-sandbox.test.tsx +24 -0
  178. package/components/landing/kanban-sandbox.tsx +101 -0
  179. package/components/landing/landing-showcase.test.tsx +21 -0
  180. package/components/landing/landing-showcase.tsx +54 -0
  181. package/components/landing/list-sandbox.tsx +86 -0
  182. package/components/landing/mock-workspace.ts +168 -0
  183. package/components/landing/notes-sandbox.test.tsx +14 -0
  184. package/components/landing/notes-sandbox.tsx +88 -0
  185. package/components/layout/app-shell.tsx +83 -0
  186. package/components/layout/backup-scheduler.tsx +122 -0
  187. package/components/layout/bottom-nav.tsx +43 -0
  188. package/components/layout/icon-bar.test.tsx +29 -0
  189. package/components/layout/icon-bar.tsx +118 -0
  190. package/components/layout/mobile-top-bar.tsx +68 -0
  191. package/components/layout/notes-panel-folder.tsx +127 -0
  192. package/components/layout/notes-panel-note-item.tsx +140 -0
  193. package/components/layout/notes-panel-task-tab.tsx +63 -0
  194. package/components/layout/notes-panel-types.ts +44 -0
  195. package/components/layout/notes-panel.tsx +476 -0
  196. package/components/layout/notification-bell.tsx +251 -0
  197. package/components/layout/paywall-screen.tsx +41 -0
  198. package/components/layout/pro-banner.tsx +76 -0
  199. package/components/layout/sw-register.tsx +27 -0
  200. package/components/layout/workspace-switcher.tsx +90 -0
  201. package/components/notes/mobile-bottom-sheet.tsx +99 -0
  202. package/components/notes/note-editor-context-menu.tsx +47 -0
  203. package/components/notes/note-editor-dom.ts +33 -0
  204. package/components/notes/note-editor-dropdowns.tsx +484 -0
  205. package/components/notes/note-editor-hooks.ts +692 -0
  206. package/components/notes/note-editor-keyboard.ts +305 -0
  207. package/components/notes/note-editor-overlay.tsx +90 -0
  208. package/components/notes/note-editor.test.tsx +372 -0
  209. package/components/notes/note-editor.tsx +662 -0
  210. package/components/notes/note-preview-pane.tsx +156 -0
  211. package/components/notes/note-tabs.tsx +120 -0
  212. package/components/notes/note-types.tsx +157 -0
  213. package/components/settings/accept-invite.tsx +108 -0
  214. package/components/settings/agent-token-settings.tsx +369 -0
  215. package/components/settings/backup-restore-settings.test.tsx +25 -0
  216. package/components/settings/backup-restore-settings.tsx +327 -0
  217. package/components/settings/calendar-feeds-settings.tsx +489 -0
  218. package/components/settings/calendar-general-settings.tsx +174 -0
  219. package/components/settings/confirm-danger-action.test.tsx +215 -0
  220. package/components/settings/confirm-danger-action.tsx +65 -0
  221. package/components/settings/security-settings.tsx +252 -0
  222. package/components/settings/settings-guidance.test.tsx +98 -0
  223. package/components/settings/team-settings.tsx +319 -0
  224. package/components/settings/two-factor-auth.tsx +296 -0
  225. package/components/settings/workspace-settings-client.tsx +363 -0
  226. package/components/settings/workspace-settings-form.tsx +73 -0
  227. package/components/sync/conflict-viewer.tsx +247 -0
  228. package/components/sync/sync-indicator.tsx +171 -0
  229. package/components/tasks/snippet-thread.tsx +119 -0
  230. package/components/tasks/status-dot.tsx +47 -0
  231. package/components/tasks/task-badge.tsx +43 -0
  232. package/components/tasks/task-detail.test.tsx +187 -0
  233. package/components/tasks/task-detail.tsx +458 -0
  234. package/components/tasks/task-list-filters.test.tsx +75 -0
  235. package/components/tasks/task-list-filters.tsx +163 -0
  236. package/components/tasks/task-list-types.ts +20 -0
  237. package/components/tasks/task-list.test.tsx +175 -0
  238. package/components/tasks/task-list.tsx +481 -0
  239. package/components/tasks/task-row.tsx +85 -0
  240. package/components/tasks/task-table-row.tsx +259 -0
  241. package/components/ui/skeleton.tsx +3 -0
  242. package/components/ui/toast.test.tsx +42 -0
  243. package/components/ui/toast.tsx +70 -0
  244. package/instrumentation.tsx +23 -0
  245. package/lib/api-error.ts +50 -0
  246. package/lib/backup/backup-runner.test.ts +32 -0
  247. package/lib/backup/backup-runner.ts +19 -0
  248. package/lib/backup/backup-schedule.test.ts +23 -0
  249. package/lib/backup/backup-schedule.ts +55 -0
  250. package/lib/backup/backup-settings.test.ts +30 -0
  251. package/lib/backup/backup-settings.ts +27 -0
  252. package/lib/backup/export-notes-zip.test.ts +26 -0
  253. package/lib/backup/export-notes-zip.ts +82 -0
  254. package/lib/backup/export-workspace-backup.test.ts +17 -0
  255. package/lib/backup/export-workspace-backup.ts +77 -0
  256. package/lib/backup/restore-workspace-from-export.test.ts +18 -0
  257. package/lib/backup/restore-workspace-from-export.ts +183 -0
  258. package/lib/backup/types.ts +14 -0
  259. package/lib/brand-icons.ts +1 -0
  260. package/lib/calendar-feed-crypto.ts +38 -0
  261. package/lib/calendar-feed.ts +239 -0
  262. package/lib/client/online-status.ts +47 -0
  263. package/lib/conflict-resolver.test.ts +57 -0
  264. package/lib/conflict-resolver.ts +240 -0
  265. package/lib/db-init.ts +79 -0
  266. package/lib/email.ts +159 -0
  267. package/lib/encryption.test.ts +41 -0
  268. package/lib/encryption.ts +98 -0
  269. package/lib/extract-snippet.test.ts +123 -0
  270. package/lib/extract-snippet.ts +69 -0
  271. package/lib/kanban-status.ts +55 -0
  272. package/lib/license.ts +21 -0
  273. package/lib/limits.ts +31 -0
  274. package/lib/mcp-auth.test.ts +58 -0
  275. package/lib/mcp-auth.ts +65 -0
  276. package/lib/mcp-contract.test.ts +25 -0
  277. package/lib/mcp-contract.ts +210 -0
  278. package/lib/mcp-handler.ts +31 -0
  279. package/lib/mcp-url.test.ts +12 -0
  280. package/lib/mcp-url.ts +7 -0
  281. package/lib/mentions.test.ts +45 -0
  282. package/lib/mentions.ts +73 -0
  283. package/lib/note-crypto.ts +108 -0
  284. package/lib/note-sync.ts +201 -0
  285. package/lib/note-title.ts +93 -0
  286. package/lib/prisma.ts +193 -0
  287. package/lib/pro-flush.ts +292 -0
  288. package/lib/rate-limit.ts +57 -0
  289. package/lib/stripe.ts +38 -0
  290. package/lib/sync-worker.ts +388 -0
  291. package/lib/task-parser.test.ts +91 -0
  292. package/lib/task-parser.ts +81 -0
  293. package/lib/task-utils.ts +52 -0
  294. package/lib/use-is-electron.ts +19 -0
  295. package/lib/use-is-mobile.ts +22 -0
  296. package/lib/validation/calendar-feed.ts +31 -0
  297. package/lib/validation/note.ts +27 -0
  298. package/lib/validation/task.ts +26 -0
  299. package/lib/view-preferences.test.ts +54 -0
  300. package/lib/view-preferences.ts +28 -0
  301. package/lib/workspace.ts +66 -0
  302. package/next.config.ts +21 -0
  303. package/package.json +49 -3
  304. package/postcss.config.mjs +7 -0
  305. package/prisma/migrations/20260519021916_init/migration.sql +388 -0
  306. package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
  307. package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
  308. package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
  309. package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
  310. package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
  311. package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
  312. package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
  313. package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
  314. package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
  315. package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
  316. package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
  317. package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
  318. package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
  319. package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
  320. package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
  321. package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
  322. package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
  323. package/prisma/migrations/migration_lock.toml +3 -0
  324. package/prisma/schema.prisma +457 -0
  325. package/public/Logo_icon.svg +1 -0
  326. package/public/file.svg +1 -0
  327. package/public/globe.svg +1 -0
  328. package/public/icon-192.png +0 -0
  329. package/public/icon-512.png +0 -0
  330. package/public/icon.svg +4 -0
  331. package/public/icon_dark.svg +1 -0
  332. package/public/knotpad_icon.svg +1 -0
  333. package/public/knotpad_logo_full.svg +1 -0
  334. package/public/manifest.json +14 -0
  335. package/public/next.svg +1 -0
  336. package/public/sw.js +137 -0
  337. package/public/vercel.svg +1 -0
  338. package/public/window.svg +1 -0
  339. package/tsconfig.json +35 -0
  340. package/brief.js +0 -311
@@ -0,0 +1,476 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname, useSearchParams, useRouter } from "next/navigation";
5
+ import { FilePlus, FolderPlus } from "lucide-react";
6
+ import { useState, useCallback, useEffect, useRef } from "react";
7
+ import { SyncIndicator } from "@/components/sync/sync-indicator";
8
+ import { useIsElectron } from "@/lib/use-is-electron";
9
+ import { ConflictViewer } from "@/components/sync/conflict-viewer";
10
+ import { WorkspaceSwitcher } from "@/components/layout/workspace-switcher";
11
+ import { useKanbanStatuses } from "@/components/kanban/kanban-status-context";
12
+ import type { NoteItem, FolderItem, TaskItem, NotesPanelProps, UserWorkspace } from "./notes-panel-types";
13
+ import { statusDot, MULTI_SELECT_VIEWS } from "./notes-panel-types";
14
+ import { TaskTabContent } from "./notes-panel-task-tab";
15
+ import { NoteItemRow } from "./notes-panel-note-item";
16
+ import { FolderTreeItem } from "./notes-panel-folder";
17
+
18
+ export type { NotesPanelProps, NoteItem, FolderItem, TaskItem, UserWorkspace };
19
+
20
+ export function NotesPanel({ notes, folders, tasks, workspaces, activeWorkspaceId, activeNoteId: activeNoteIdProp }: NotesPanelProps) {
21
+ const router = useRouter();
22
+ const pathname = usePathname();
23
+ const searchParams = useSearchParams();
24
+ const kanbanStatuses = useKanbanStatuses();
25
+ const isElectron = useIsElectron();
26
+ const taskStatusOrder = kanbanStatuses.filter((s) => s.isVisible).map((s) => s.key);
27
+ const taskStatusLabels = Object.fromEntries(kanbanStatuses.map((s) => [s.key, s.label]));
28
+ const activeStatusKeys = kanbanStatuses.filter((s) => s.isVisible && s.key !== "OPEN" && s.key !== "DONE").map((s) => s.key);
29
+
30
+ // The layout doesn't pass an explicit active note, so derive it from the URL
31
+ // (/notes/<id>). Drives sidebar highlighting and "deleted the open note" nav.
32
+ const activeNoteId =
33
+ activeNoteIdProp ?? (pathname.startsWith("/notes/") ? pathname.split("/")[2] : undefined);
34
+ const [panelTab, setPanelTab] = useState<"notes" | "tasks">(() => {
35
+ if (typeof window !== "undefined") {
36
+ return (localStorage.getItem("brief_panel_tab") as "notes" | "tasks") ?? "notes";
37
+ }
38
+ return "notes";
39
+ });
40
+ const [creating, setCreating] = useState(false);
41
+ const [showConflicts, setShowConflicts] = useState(false);
42
+ const [syncRefreshSignal, setSyncRefreshSignal] = useState(0);
43
+ const [deleteError, setDeleteError] = useState<string | null>(null);
44
+ const [menuOpen, setMenuOpen] = useState<string | null>(null);
45
+ const [renamingId, setRenamingId] = useState<string | null>(null);
46
+ const [renameValue, setRenameValue] = useState("");
47
+ const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
48
+ const menuRef = useRef<HTMLDivElement>(null);
49
+
50
+ // ── Folder state ──────────────────────────────────────────────────────────
51
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
52
+ const [creatingFolder, setCreatingFolder] = useState(false);
53
+ const [newFolderName, setNewFolderName] = useState("");
54
+ const [folderMenuOpen, setFolderMenuOpen] = useState<string | null>(null);
55
+ const [renamingFolderId, setRenamingFolderId] = useState<string | null>(null);
56
+ const [folderRenameValue, setFolderRenameValue] = useState("");
57
+ const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
58
+ const folderMenuRef = useRef<HTMLDivElement>(null);
59
+
60
+ useEffect(() => {
61
+ if (!menuOpen) return;
62
+ function onDown(e: MouseEvent) {
63
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
64
+ setMenuOpen(null);
65
+ setConfirmDelete(null);
66
+ }
67
+ }
68
+ document.addEventListener("mousedown", onDown);
69
+ return () => document.removeEventListener("mousedown", onDown);
70
+ }, [menuOpen]);
71
+
72
+ useEffect(() => {
73
+ if (!folderMenuOpen) return;
74
+ function onDown(e: MouseEvent) {
75
+ if (folderMenuRef.current && !folderMenuRef.current.contains(e.target as Node)) setFolderMenuOpen(null);
76
+ }
77
+ document.addEventListener("mousedown", onDown);
78
+ return () => document.removeEventListener("mousedown", onDown);
79
+ }, [folderMenuOpen]);
80
+
81
+ const isMultiSelectView = MULTI_SELECT_VIEWS.some((v) => pathname.startsWith(v));
82
+ const isGraphView = pathname.startsWith("/graph");
83
+ const isKanbanOrList = pathname.startsWith("/kanban") || pathname.startsWith("/list");
84
+ const isCalendarView = pathname.startsWith("/calendar");
85
+ const filterNoteId = searchParams.get("note") ?? undefined;
86
+ const filterFolderId = searchParams.get("folder") ?? undefined;
87
+ const graphNotes = searchParams.get("notes")?.split(",").filter(Boolean) ?? [];
88
+
89
+ async function handleNewNote() {
90
+ setCreating(true);
91
+ try {
92
+ const activeNote = notes.find((n) => n.id === activeNoteId);
93
+ const body: Record<string, unknown> = { title: "Untitled", content: "" };
94
+ if (activeNote?.folderId) body.folderId = activeNote.folderId;
95
+ const res = await fetch("/api/notes", {
96
+ method: "POST",
97
+ headers: { "Content-Type": "application/json" },
98
+ body: JSON.stringify(body),
99
+ });
100
+ if (res.ok) {
101
+ const note = await res.json();
102
+ router.push(`/notes/${note.id}`);
103
+ router.refresh();
104
+ }
105
+ } finally {
106
+ setCreating(false);
107
+ }
108
+ }
109
+
110
+ async function handleDelete(noteId: string) {
111
+ setDeleteError(null);
112
+ try {
113
+ const res = await fetch(`/api/notes/${noteId}`, { method: "DELETE" });
114
+ if (!res.ok) throw new Error(`${res.status}`);
115
+ setMenuOpen(null);
116
+ setConfirmDelete(null);
117
+ if (activeNoteId === noteId) router.push("/notes");
118
+ router.refresh();
119
+ } catch {
120
+ setDeleteError("Couldn't delete note — please try again.");
121
+ setConfirmDelete(null);
122
+ }
123
+ }
124
+
125
+ function startRename(note: NoteItem) {
126
+ setRenamingId(note.id);
127
+ setRenameValue(note.title);
128
+ setMenuOpen(null);
129
+ setConfirmDelete(null);
130
+ }
131
+
132
+ async function submitRename(noteId: string) {
133
+ const trimmed = renameValue.trim();
134
+ if (!trimmed) { setRenamingId(null); return; }
135
+ await fetch(`/api/notes/${noteId}`, {
136
+ method: "PATCH",
137
+ headers: { "Content-Type": "application/json" },
138
+ body: JSON.stringify({ title: trimmed }),
139
+ });
140
+ setRenamingId(null);
141
+ router.refresh();
142
+ }
143
+
144
+ // ── Folder actions ──────────────────────────────────────────────────────────
145
+ async function submitNewFolder() {
146
+ const name = newFolderName.trim();
147
+ setCreatingFolder(false);
148
+ setNewFolderName("");
149
+ if (!name) return;
150
+ await fetch("/api/folders", {
151
+ method: "POST",
152
+ headers: { "Content-Type": "application/json" },
153
+ body: JSON.stringify({ name }),
154
+ });
155
+ router.refresh();
156
+ }
157
+
158
+ async function submitFolderRename(folderId: string) {
159
+ const name = folderRenameValue.trim();
160
+ setRenamingFolderId(null);
161
+ if (!name) return;
162
+ await fetch(`/api/folders/${folderId}`, {
163
+ method: "PATCH",
164
+ headers: { "Content-Type": "application/json" },
165
+ body: JSON.stringify({ name }),
166
+ });
167
+ router.refresh();
168
+ }
169
+
170
+ async function deleteFolder(folderId: string) {
171
+ setFolderMenuOpen(null);
172
+ await fetch(`/api/folders/${folderId}`, { method: "DELETE" });
173
+ router.refresh();
174
+ }
175
+
176
+ async function moveNoteToFolder(noteId: string, folderId: string | null) {
177
+ await fetch(`/api/notes/${noteId}`, {
178
+ method: "PATCH",
179
+ headers: { "Content-Type": "application/json" },
180
+ body: JSON.stringify({ folderId }),
181
+ });
182
+ router.refresh();
183
+ }
184
+
185
+ function toggleCollapse(folderId: string) {
186
+ setCollapsed((prev) => {
187
+ const next = new Set(prev);
188
+ if (next.has(folderId)) next.delete(folderId);
189
+ else next.add(folderId);
190
+ return next;
191
+ });
192
+ }
193
+
194
+ const handleNoteClick = useCallback(
195
+ (e: React.MouseEvent, noteId: string) => {
196
+ if (!isMultiSelectView || pathname.startsWith("/notes") || pathname === "/") return;
197
+ e.preventDefault();
198
+ if (isGraphView) {
199
+ const params = new URLSearchParams(searchParams.toString());
200
+ if (e.ctrlKey || e.metaKey) {
201
+ const next = graphNotes.includes(noteId)
202
+ ? graphNotes.filter((id) => id !== noteId)
203
+ : [...graphNotes, noteId];
204
+ if (next.length === 0) params.delete("notes");
205
+ else params.set("notes", next.join(","));
206
+ } else {
207
+ if (graphNotes.length === 1 && graphNotes[0] === noteId) params.delete("notes");
208
+ else params.set("notes", noteId);
209
+ }
210
+ router.push(`${pathname}?${params.toString()}`);
211
+ return;
212
+ }
213
+ const params = new URLSearchParams(searchParams.toString());
214
+ params.delete("folder"); // mutually exclusive with note filter
215
+ if (filterNoteId === noteId) params.delete("note");
216
+ else params.set("note", noteId);
217
+ router.push(`${pathname}?${params.toString()}`);
218
+ },
219
+ [pathname, searchParams, isMultiSelectView, isGraphView, filterNoteId, graphNotes, router]
220
+ );
221
+
222
+ function handleFolderClick(folderId: string) {
223
+ // Graph view: select all notes in the folder
224
+ if (isGraphView) {
225
+ const folderNoteIds = notesByFolder(folderId).map((n) => n.id).sort();
226
+ const currentNoteIds = graphNotes.slice().sort();
227
+ const isSame =
228
+ folderNoteIds.length === currentNoteIds.length &&
229
+ folderNoteIds.every((id, i) => id === currentNoteIds[i]);
230
+ const params = new URLSearchParams(searchParams.toString());
231
+ if (isSame) {
232
+ params.delete("notes");
233
+ } else {
234
+ params.set("notes", folderNoteIds.join(","));
235
+ }
236
+ router.push(`${pathname}?${params.toString()}`);
237
+ return;
238
+ }
239
+
240
+ // Calendar / Kanban / List: use folder param filter
241
+ if (isKanbanOrList || isCalendarView) {
242
+ const params = new URLSearchParams(searchParams.toString());
243
+ params.delete("note"); // mutually exclusive with folder filter
244
+ if (filterFolderId === folderId) params.delete("folder");
245
+ else params.set("folder", folderId);
246
+ router.push(`${pathname}?${params.toString()}`);
247
+ return;
248
+ }
249
+
250
+ // Default: just toggle collapse
251
+ toggleCollapse(folderId);
252
+ }
253
+
254
+ function noteHref(noteId: string) {
255
+ if (!isMultiSelectView || pathname.startsWith("/notes") || pathname === "/") {
256
+ return `/notes/${noteId}`;
257
+ }
258
+ if (isGraphView) return "#";
259
+ const params = new URLSearchParams(searchParams.toString());
260
+ params.delete("folder"); // mutually exclusive with note filter
261
+ if (filterNoteId === noteId) params.delete("note");
262
+ else params.set("note", noteId);
263
+ return `${pathname}?${params.toString()}`;
264
+ }
265
+
266
+ function isNoteActive(noteId: string) {
267
+ if (activeNoteId) return activeNoteId === noteId;
268
+ if (isGraphView) return graphNotes.includes(noteId);
269
+ return filterNoteId === noteId;
270
+ }
271
+
272
+ function isFolderActive(folderId: string) {
273
+ if (isGraphView) {
274
+ const folderNoteIds = notesByFolder(folderId).map((n) => n.id).sort();
275
+ const currentNoteIds = graphNotes.slice().sort();
276
+ return (
277
+ folderNoteIds.length === currentNoteIds.length &&
278
+ folderNoteIds.every((id, i) => id === currentNoteIds[i])
279
+ );
280
+ }
281
+ return filterFolderId === folderId;
282
+ }
283
+
284
+ function renderNote(note: NoteItem) {
285
+ return (
286
+ <NoteItemRow
287
+ key={note.id}
288
+ note={note}
289
+ isRenaming={renamingId === note.id}
290
+ renameValue={renameValue}
291
+ onRenameChange={setRenameValue}
292
+ onRenameSubmit={submitRename}
293
+ onRenameCancel={() => setRenamingId(null)}
294
+ isMenuOpen={menuOpen === note.id}
295
+ onMenuToggle={() => { setMenuOpen(menuOpen === note.id ? null : note.id); setConfirmDelete(null); }}
296
+ isConfirmDelete={confirmDelete === note.id}
297
+ onConfirmDelete={() => setConfirmDelete(note.id)}
298
+ onCancelDelete={() => setConfirmDelete(null)}
299
+ onDelete={handleDelete}
300
+ onStartRename={() => startRename(note)}
301
+ onMoveToFolder={(noteId, folderId) => { setMenuOpen(null); moveNoteToFolder(noteId, folderId); }}
302
+ isActive={isNoteActive(note.id)}
303
+ href={noteHref(note.id)}
304
+ onClick={handleNoteClick}
305
+ onDragStart={(e, n) => {
306
+ e.dataTransfer.setData("application/brief-note-title", n.title);
307
+ e.dataTransfer.setData("application/brief-note-id", n.id);
308
+ // "copyMove" so both the editor (copy → insert link) and folder drop
309
+ // (move → file note) targets accept it. A "copy"-only source would
310
+ // make the folder's dropEffect="move" reject the drop.
311
+ e.dataTransfer.effectAllowed = "copyMove";
312
+ }}
313
+ menuRef={menuRef}
314
+ />
315
+ );
316
+ }
317
+
318
+ const ungrouped = notes.filter((n) => !n.folderId);
319
+ const notesByFolder = (folderId: string) => notes.filter((n) => n.folderId === folderId);
320
+
321
+ function handleFolderDragOver(e: React.DragEvent, folderId: string) {
322
+ if (e.dataTransfer.types.includes("application/brief-note-id")) {
323
+ e.preventDefault();
324
+ e.dataTransfer.dropEffect = "move";
325
+ setDragOverFolder(folderId);
326
+ }
327
+ }
328
+
329
+ function handleFolderDragLeave(folderId: string) {
330
+ setDragOverFolder((f) => (f === folderId ? null : f));
331
+ }
332
+
333
+ function handleFolderDrop(e: React.DragEvent, folderId: string) {
334
+ const noteId = e.dataTransfer.getData("application/brief-note-id");
335
+ setDragOverFolder(null);
336
+ if (noteId) { e.preventDefault(); moveNoteToFolder(noteId, folderId); }
337
+ }
338
+
339
+ return (
340
+ <aside className="flex w-full flex-col border-r border-zinc-800 bg-zinc-950 overflow-hidden">
341
+ <WorkspaceSwitcher workspaces={workspaces} activeId={activeWorkspaceId} />
342
+
343
+ <div className="flex items-center justify-between border-b border-zinc-800 px-3">
344
+ <div className="flex items-center gap-3">
345
+ <button
346
+ onClick={() => { setPanelTab("notes"); localStorage.setItem("brief_panel_tab", "notes"); }}
347
+ className={`py-2.5 text-xs font-semibold uppercase tracking-wider transition-colors ${panelTab === "notes" ? "border-b-2 border-zinc-400 text-zinc-100" : "text-zinc-500 hover:text-zinc-300"}`}
348
+ >
349
+ Notes
350
+ </button>
351
+ <button
352
+ onClick={() => { setPanelTab("tasks"); localStorage.setItem("brief_panel_tab", "tasks"); }}
353
+ className={`py-2.5 text-xs font-semibold uppercase tracking-wider transition-colors ${panelTab === "tasks" ? "border-b-2 border-zinc-400 text-zinc-100" : "text-zinc-500 hover:text-zinc-300"}`}
354
+ >
355
+ Tasks
356
+ </button>
357
+ </div>
358
+ {panelTab === "notes" && (
359
+ <div className="flex items-center gap-1">
360
+ <button onClick={handleNewNote} disabled={creating} title="New note" className="flex h-6 w-6 items-center justify-center rounded text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-50">
361
+ <FilePlus size={14} />
362
+ </button>
363
+ <button onClick={() => { setCreatingFolder(true); setNewFolderName(""); }} title="New folder" className="flex h-6 w-6 items-center justify-center rounded text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300">
364
+ <FolderPlus size={14} />
365
+ </button>
366
+ </div>
367
+ )}
368
+ </div>
369
+
370
+ {panelTab === "notes" && isGraphView && (
371
+ <p className="px-3 py-1.5 text-[10px] text-zinc-700 border-b border-zinc-800/60">
372
+ Click to filter · Ctrl+click to multi-select
373
+ </p>
374
+ )}
375
+
376
+ {panelTab === "tasks" && (
377
+ <TaskTabContent
378
+ tasks={tasks}
379
+ taskStatusOrder={taskStatusOrder}
380
+ taskStatusLabels={taskStatusLabels}
381
+ activeStatusKeys={activeStatusKeys}
382
+ pathname={pathname}
383
+ searchParams={searchParams}
384
+ />
385
+ )}
386
+
387
+ {panelTab === "notes" && <nav className="flex-1 overflow-y-auto py-1">
388
+ {creatingFolder && (
389
+ <div className="px-3 py-1.5">
390
+ <input
391
+ autoFocus
392
+ placeholder="Folder name"
393
+ value={newFolderName}
394
+ onChange={(e) => setNewFolderName(e.target.value)}
395
+ onKeyDown={(e) => { if (e.key === "Enter") submitNewFolder(); if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); } }}
396
+ onBlur={submitNewFolder}
397
+ className="w-full rounded bg-zinc-800 px-1.5 py-0.5 text-sm text-zinc-100 outline-none ring-1 ring-zinc-600"
398
+ />
399
+ </div>
400
+ )}
401
+
402
+ {/* Folders */}
403
+ {folders.map((folder) => (
404
+ <FolderTreeItem
405
+ key={folder.id}
406
+ folder={folder}
407
+ folderNotes={notesByFolder(folder.id)}
408
+ isCollapsed={collapsed.has(folder.id)}
409
+ isFolderActive={isFolderActive(folder.id)}
410
+ isRenaming={renamingFolderId === folder.id}
411
+ renameValue={folderRenameValue}
412
+ onRenameChange={setFolderRenameValue}
413
+ onRenameSubmit={() => submitFolderRename(folder.id)}
414
+ onRenameCancel={() => setRenamingFolderId(null)}
415
+ onStartRename={() => { setRenamingFolderId(folder.id); setFolderRenameValue(folder.name); setFolderMenuOpen(null); }}
416
+ onToggleCollapse={() => toggleCollapse(folder.id)}
417
+ onClick={() => handleFolderClick(folder.id)}
418
+ onDelete={() => deleteFolder(folder.id)}
419
+ isMenuOpen={folderMenuOpen === folder.id}
420
+ onMenuToggle={() => setFolderMenuOpen(folderMenuOpen === folder.id ? null : folder.id)}
421
+ menuRef={folderMenuRef}
422
+ dragOverFolder={dragOverFolder}
423
+ onDragOver={handleFolderDragOver}
424
+ onDragLeave={handleFolderDragLeave}
425
+ onDrop={handleFolderDrop}
426
+ renderNote={renderNote}
427
+ />
428
+ ))}
429
+
430
+ {/* Ungrouped notes — also a drop target to remove from a folder */}
431
+ <div
432
+ onDragOver={(e) => {
433
+ if (e.dataTransfer.types.includes("application/brief-note-id")) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOverFolder("__none__"); }
434
+ }}
435
+ onDragLeave={() => setDragOverFolder((f) => (f === "__none__" ? null : f))}
436
+ onDrop={(e) => {
437
+ const noteId = e.dataTransfer.getData("application/brief-note-id");
438
+ setDragOverFolder(null);
439
+ if (noteId) { e.preventDefault(); moveNoteToFolder(noteId, null); }
440
+ }}
441
+ className={dragOverFolder === "__none__" ? "bg-blue-900/20" : ""}
442
+ >
443
+ {folders.length > 0 && ungrouped.length > 0 && (
444
+ <p className="px-3 pt-2 pb-0.5 text-[10px] uppercase tracking-wider text-zinc-700">No folder</p>
445
+ )}
446
+ {notes.length === 0 && !creatingFolder && (
447
+ <p className="px-3 py-4 text-xs text-zinc-600">No notes yet. Click + to create one.</p>
448
+ )}
449
+ {ungrouped.map(renderNote)}
450
+ </div>
451
+ </nav>}
452
+
453
+ {deleteError && (
454
+ <div className="flex items-center justify-between gap-1 border-t border-red-800/40 bg-red-950/40 px-3 py-2">
455
+ <span className="text-[11px] text-red-300 leading-tight">{deleteError}</span>
456
+ <button onClick={() => setDeleteError(null)} className="shrink-0 text-zinc-600 hover:text-zinc-400 text-xs">✕</button>
457
+ </div>
458
+ )}
459
+ {isElectron && (
460
+ <SyncIndicator
461
+ onConflictClick={() => setShowConflicts(true)}
462
+ refreshSignal={syncRefreshSignal}
463
+ />
464
+ )}
465
+ {showConflicts && (
466
+ <ConflictViewer
467
+ onClose={() => setShowConflicts(false)}
468
+ onResolved={() => {
469
+ setShowConflicts(false);
470
+ setSyncRefreshSignal((s) => s + 1);
471
+ }}
472
+ />
473
+ )}
474
+ </aside>
475
+ );
476
+ }