@karmaniverous/jeeves-server 3.0.0-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 (260) hide show
  1. package/.env.local +13 -0
  2. package/.env.local.template +13 -0
  3. package/.tsbuildinfo +1 -0
  4. package/CHANGELOG.md +450 -0
  5. package/about.md +82 -0
  6. package/client/README.md +73 -0
  7. package/client/eslint.config.js +23 -0
  8. package/client/index.html +14 -0
  9. package/client/package-lock.json +5181 -0
  10. package/client/package.json +60 -0
  11. package/client/public/vite.svg +1 -0
  12. package/client/src/App.tsx +22 -0
  13. package/client/src/components/AccountMenu.tsx +167 -0
  14. package/client/src/components/ActionDropdown.tsx +120 -0
  15. package/client/src/components/CodeEditor.tsx +143 -0
  16. package/client/src/components/CodeViewer.tsx +113 -0
  17. package/client/src/components/ConfirmDialog.tsx +32 -0
  18. package/client/src/components/DirectoryRow.tsx +62 -0
  19. package/client/src/components/DirectoryTable.tsx +42 -0
  20. package/client/src/components/DownloadDropdown.tsx +116 -0
  21. package/client/src/components/DriveList.tsx +54 -0
  22. package/client/src/components/EmbeddedDiagramPanzoom.ts +28 -0
  23. package/client/src/components/FileContentView.tsx +155 -0
  24. package/client/src/components/InlineSvgPanzoom.ts +60 -0
  25. package/client/src/components/LazyDiagram.ts +93 -0
  26. package/client/src/components/LinkDropdown.tsx +134 -0
  27. package/client/src/components/MarkdownView.tsx +115 -0
  28. package/client/src/components/MermaidViewer.tsx +21 -0
  29. package/client/src/components/PlantUmlViewer.tsx +21 -0
  30. package/client/src/components/SearchModal.tsx +424 -0
  31. package/client/src/components/SvgViewer.tsx +107 -0
  32. package/client/src/components/TabBar.tsx +96 -0
  33. package/client/src/components/layout/Header.tsx +270 -0
  34. package/client/src/components/panzoom.ts +203 -0
  35. package/client/src/components/renderableUtils.ts +15 -0
  36. package/client/src/components/runner/JobTable.tsx +153 -0
  37. package/client/src/components/runner/RunHistory.tsx +140 -0
  38. package/client/src/components/runner/StatsBar.tsx +43 -0
  39. package/client/src/components/runner/StatusPill.tsx +27 -0
  40. package/client/src/components/runner/jobTableUtils.ts +65 -0
  41. package/client/src/components/scrollUtils.ts +39 -0
  42. package/client/src/components/ui/alert-dialog.tsx +107 -0
  43. package/client/src/components/ui/button.tsx +40 -0
  44. package/client/src/components/ui/dropdown-menu.tsx +79 -0
  45. package/client/src/components/ui/input.tsx +26 -0
  46. package/client/src/components/useActionState.ts +43 -0
  47. package/client/src/hooks/useFileBrowser.ts +102 -0
  48. package/client/src/hooks/useFileData.ts +78 -0
  49. package/client/src/hooks/useScrollAnchor.ts +70 -0
  50. package/client/src/hooks/useShareSettings.ts +22 -0
  51. package/client/src/hooks/useTopBar.ts +27 -0
  52. package/client/src/index.css +281 -0
  53. package/client/src/lib/AuthContext.ts +27 -0
  54. package/client/src/lib/api.ts +239 -0
  55. package/client/src/lib/auth.tsx +50 -0
  56. package/client/src/lib/codeBlockCm6.ts +129 -0
  57. package/client/src/lib/codeBlockCopy.ts +43 -0
  58. package/client/src/lib/codemirror.ts +77 -0
  59. package/client/src/lib/runner-api.ts +172 -0
  60. package/client/src/lib/svg.ts +50 -0
  61. package/client/src/lib/theme.ts +34 -0
  62. package/client/src/lib/utils.ts +6 -0
  63. package/client/src/main.tsx +11 -0
  64. package/client/src/pages/FileBrowser.tsx +135 -0
  65. package/client/src/pages/Home.tsx +46 -0
  66. package/client/src/pages/Runner.tsx +151 -0
  67. package/client/src/pages/RunnerJob.tsx +170 -0
  68. package/client/tsconfig.app.json +32 -0
  69. package/client/tsconfig.json +7 -0
  70. package/client/tsconfig.node.json +26 -0
  71. package/client/vite.config.ts +35 -0
  72. package/content/privacy.md +61 -0
  73. package/content/terms.md +41 -0
  74. package/dist/client/assets/CodeEditor-0XHVI8Nu.js +1 -0
  75. package/dist/client/assets/CodeViewer-CykMVsfX.js +1 -0
  76. package/dist/client/assets/index--MBieNJA.js +1 -0
  77. package/dist/client/assets/index-BENeXQI_.js +1 -0
  78. package/dist/client/assets/index-BbBpoOxz.js +1 -0
  79. package/dist/client/assets/index-BdV9g5AM.js +6 -0
  80. package/dist/client/assets/index-BjAilRri.js +2 -0
  81. package/dist/client/assets/index-BqbhWo2I.js +3 -0
  82. package/dist/client/assets/index-CVbycZ0H.js +1 -0
  83. package/dist/client/assets/index-Cs5oz2oJ.js +5 -0
  84. package/dist/client/assets/index-D8KZVveX.js +1 -0
  85. package/dist/client/assets/index-DC4HMHxY.js +13 -0
  86. package/dist/client/assets/index-DbMebkkd.css +1 -0
  87. package/dist/client/assets/index-DcY2RXqX.js +1 -0
  88. package/dist/client/assets/index-Duy-tZYV.js +1 -0
  89. package/dist/client/assets/index-Dw7rDFmE.js +7 -0
  90. package/dist/client/assets/index-FlCUvrjv.js +2 -0
  91. package/dist/client/assets/index-K6OVmfhg.js +1 -0
  92. package/dist/client/assets/index-LjwgzZ7F.js +62 -0
  93. package/dist/client/assets/index-MLwyFRN0.js +1 -0
  94. package/dist/client/assets/index-OpqBpSjn.js +1 -0
  95. package/dist/client/assets/index-SsHei0HE.js +1 -0
  96. package/dist/client/assets/index-uQa2yckk.js +1 -0
  97. package/dist/client/assets/index-udkXoIER.js +1 -0
  98. package/dist/client/index.html +15 -0
  99. package/dist/client/vite.svg +1 -0
  100. package/dist/src/auth/google.js +57 -0
  101. package/dist/src/auth/keys.js +185 -0
  102. package/dist/src/auth/resolve.js +102 -0
  103. package/dist/src/auth/session.js +57 -0
  104. package/dist/src/cli/commands/config.js +100 -0
  105. package/dist/src/cli/commands/config.test.js +84 -0
  106. package/dist/src/cli/commands/service.js +93 -0
  107. package/dist/src/cli/commands/start.js +24 -0
  108. package/dist/src/cli/index.js +20 -0
  109. package/dist/src/config/index.js +90 -0
  110. package/dist/src/config/loadConfig.test.js +127 -0
  111. package/dist/src/config/resolve.js +134 -0
  112. package/dist/src/config/resolve.test.js +148 -0
  113. package/dist/src/config/schema.js +159 -0
  114. package/dist/src/config/substituteEnvVars.js +45 -0
  115. package/dist/src/config/substituteEnvVars.test.js +51 -0
  116. package/dist/src/config/types.js +5 -0
  117. package/dist/src/routes/api/auth-status.js +56 -0
  118. package/dist/src/routes/api/diagrams.js +35 -0
  119. package/dist/src/routes/api/directory.js +93 -0
  120. package/dist/src/routes/api/drives.js +15 -0
  121. package/dist/src/routes/api/export.js +218 -0
  122. package/dist/src/routes/api/fileContent.js +286 -0
  123. package/dist/src/routes/api/index.js +33 -0
  124. package/dist/src/routes/api/linkInfo.js +71 -0
  125. package/dist/src/routes/api/linkInfo.test.js +104 -0
  126. package/dist/src/routes/api/middleware.js +117 -0
  127. package/dist/src/routes/api/raw.js +38 -0
  128. package/dist/src/routes/api/runner.js +59 -0
  129. package/dist/src/routes/api/search.js +236 -0
  130. package/dist/src/routes/api/sharing.js +203 -0
  131. package/dist/src/routes/api/status.js +68 -0
  132. package/dist/src/routes/api/status.test.js +62 -0
  133. package/dist/src/routes/auth.js +99 -0
  134. package/dist/src/routes/event.js +77 -0
  135. package/dist/src/routes/event.test.js +206 -0
  136. package/dist/src/routes/health.js +10 -0
  137. package/dist/src/routes/keys.js +129 -0
  138. package/dist/src/routes/path/index.js +17 -0
  139. package/dist/src/routes/static.js +30 -0
  140. package/dist/src/server.js +90 -0
  141. package/dist/src/services/deepShareLinks.js +163 -0
  142. package/dist/src/services/diagramCache.js +104 -0
  143. package/dist/src/services/embeddedDiagrams.js +136 -0
  144. package/dist/src/services/eventLog.js +55 -0
  145. package/dist/src/services/eventLog.test.js +113 -0
  146. package/dist/src/services/eventQueue.js +154 -0
  147. package/dist/src/services/eventQueue.test.js +104 -0
  148. package/dist/src/services/export.js +220 -0
  149. package/dist/src/services/exportCache.js +196 -0
  150. package/dist/src/services/markdown.js +147 -0
  151. package/dist/src/services/mermaid.js +97 -0
  152. package/dist/src/services/plantuml.js +145 -0
  153. package/dist/src/services/puppeteer.js +156 -0
  154. package/dist/src/util/breadcrumbs.js +22 -0
  155. package/dist/src/util/crypto.js +56 -0
  156. package/dist/src/util/crypto.test.js +99 -0
  157. package/dist/src/util/fileDetection.js +66 -0
  158. package/dist/src/util/fileDetection.test.js +89 -0
  159. package/dist/src/util/formatters.js +43 -0
  160. package/dist/src/util/formatters.test.js +83 -0
  161. package/dist/src/util/packageVersion.js +25 -0
  162. package/dist/src/util/platform.js +148 -0
  163. package/dist/src/util/state.js +46 -0
  164. package/dist/vitest.config.js +12 -0
  165. package/favicon.svg +3 -0
  166. package/guides/access-decision-flow.mmd +24 -0
  167. package/guides/access-decision-flow.svg +1 -0
  168. package/guides/api-integration.md +236 -0
  169. package/guides/deployment.md +287 -0
  170. package/guides/event-gateway.md +204 -0
  171. package/guides/event-gateway.mmd +17 -0
  172. package/guides/event-gateway.svg +1 -0
  173. package/guides/exports.md +239 -0
  174. package/guides/setup.md +313 -0
  175. package/guides/sharing.md +204 -0
  176. package/jeeves-server.config.template.json +25 -0
  177. package/package.json +124 -0
  178. package/scripts/download-plantuml.js +70 -0
  179. package/src/auth/google.ts +93 -0
  180. package/src/auth/keys.ts +252 -0
  181. package/src/auth/resolve.ts +157 -0
  182. package/src/auth/session.ts +77 -0
  183. package/src/cli/commands/config.test.ts +107 -0
  184. package/src/cli/commands/config.ts +113 -0
  185. package/src/cli/commands/service.ts +129 -0
  186. package/src/cli/commands/start.ts +27 -0
  187. package/src/cli/index.ts +25 -0
  188. package/src/config/index.ts +113 -0
  189. package/src/config/loadConfig.test.ts +155 -0
  190. package/src/config/resolve.test.ts +192 -0
  191. package/src/config/resolve.ts +173 -0
  192. package/src/config/schema.ts +179 -0
  193. package/src/config/substituteEnvVars.test.ts +64 -0
  194. package/src/config/substituteEnvVars.ts +52 -0
  195. package/src/config/types.ts +129 -0
  196. package/src/routes/api/auth-status.ts +85 -0
  197. package/src/routes/api/diagrams.ts +53 -0
  198. package/src/routes/api/directory.ts +123 -0
  199. package/src/routes/api/drives.ts +23 -0
  200. package/src/routes/api/export.ts +314 -0
  201. package/src/routes/api/fileContent.ts +414 -0
  202. package/src/routes/api/index.ts +37 -0
  203. package/src/routes/api/linkInfo.test.ts +132 -0
  204. package/src/routes/api/linkInfo.ts +83 -0
  205. package/src/routes/api/middleware.ts +156 -0
  206. package/src/routes/api/raw.ts +54 -0
  207. package/src/routes/api/runner.ts +107 -0
  208. package/src/routes/api/search.ts +321 -0
  209. package/src/routes/api/sharing.ts +259 -0
  210. package/src/routes/api/status.test.ts +72 -0
  211. package/src/routes/api/status.ts +82 -0
  212. package/src/routes/auth.ts +143 -0
  213. package/src/routes/event.test.ts +248 -0
  214. package/src/routes/event.ts +109 -0
  215. package/src/routes/health.ts +13 -0
  216. package/src/routes/keys.ts +192 -0
  217. package/src/routes/path/index.ts +24 -0
  218. package/src/routes/static.ts +54 -0
  219. package/src/server.ts +104 -0
  220. package/src/services/deepShareLinks.ts +203 -0
  221. package/src/services/diagramCache.ts +128 -0
  222. package/src/services/embeddedDiagrams.ts +168 -0
  223. package/src/services/eventLog.test.ts +144 -0
  224. package/src/services/eventLog.ts +68 -0
  225. package/src/services/eventQueue.test.ts +127 -0
  226. package/src/services/eventQueue.ts +196 -0
  227. package/src/services/export.ts +267 -0
  228. package/src/services/exportCache.ts +216 -0
  229. package/src/services/markdown.ts +189 -0
  230. package/src/services/mermaid.ts +113 -0
  231. package/src/services/plantuml.ts +172 -0
  232. package/src/services/puppeteer.ts +188 -0
  233. package/src/types/fastify.d.ts +13 -0
  234. package/src/types/jsonmap.d.ts +10 -0
  235. package/src/types/plantuml-encoder.d.ts +4 -0
  236. package/src/util/breadcrumbs.ts +33 -0
  237. package/src/util/crypto.test.ts +132 -0
  238. package/src/util/crypto.ts +79 -0
  239. package/src/util/fileDetection.test.ts +115 -0
  240. package/src/util/fileDetection.ts +70 -0
  241. package/src/util/formatters.test.ts +105 -0
  242. package/src/util/formatters.ts +44 -0
  243. package/src/util/packageVersion.ts +30 -0
  244. package/src/util/platform.ts +178 -0
  245. package/src/util/state.ts +55 -0
  246. package/test-docs/diagram-retry-test.md +18 -0
  247. package/test-docs/embedded-diagrams.md +52 -0
  248. package/test-docs/lazy-diagrams-test.md +333 -0
  249. package/test-docs/page-a.md +7 -0
  250. package/test-docs/page-b.md +7 -0
  251. package/test-docs/page-c.md +7 -0
  252. package/test-docs/sub/page-d.md +7 -0
  253. package/test-docs/test-diagram.puml +13 -0
  254. package/test-docs/validate-deep-share.js +318 -0
  255. package/tsconfig.json +37 -0
  256. package/tsdoc.json +13 -0
  257. package/vendor/.plantuml-version +1 -0
  258. package/vendor/plantuml.jar +0 -0
  259. package/vitest.config.js +12 -0
  260. package/vitest.config.ts +13 -0
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "client",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview",
11
+ "lint:fix": "eslint --fix .",
12
+ "typecheck": "tsc -b --noEmit"
13
+ },
14
+ "dependencies": {
15
+ "@codemirror/lang-cpp": "^6.0.3",
16
+ "@codemirror/lang-css": "^6.3.1",
17
+ "@codemirror/lang-html": "^6.4.11",
18
+ "@codemirror/lang-java": "^6.0.2",
19
+ "@codemirror/lang-javascript": "^6.2.4",
20
+ "@codemirror/lang-json": "^6.0.2",
21
+ "@codemirror/lang-markdown": "^6.5.0",
22
+ "@codemirror/lang-php": "^6.0.2",
23
+ "@codemirror/lang-python": "^6.2.1",
24
+ "@codemirror/lang-rust": "^6.0.2",
25
+ "@codemirror/lang-sql": "^6.10.0",
26
+ "@codemirror/lang-xml": "^6.1.0",
27
+ "@codemirror/lang-yaml": "^6.1.2",
28
+ "@codemirror/state": "^6.5.2",
29
+ "@codemirror/theme-one-dark": "^6.1.3",
30
+ "@codemirror/view": "^6.36.5",
31
+ "@panzoom/panzoom": "^4.5.1",
32
+ "@radix-ui/react-alert-dialog": "^1.1.15",
33
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
34
+ "@tailwindcss/typography": "^0.5.19",
35
+ "@tailwindcss/vite": "^4.1.18",
36
+ "clsx": "^2.1.1",
37
+ "codemirror": "^6.0.2",
38
+ "lucide": "^0.575.0",
39
+ "lucide-react": "^0.564.0",
40
+ "react": "^19.2.0",
41
+ "react-dom": "^19.2.0",
42
+ "react-router-dom": "^7.13.0",
43
+ "tailwind-merge": "^3.4.1",
44
+ "tailwindcss": "^4.1.18"
45
+ },
46
+ "devDependencies": {
47
+ "@eslint/js": "^9.39.1",
48
+ "@types/node": "^24.10.1",
49
+ "@types/react": "^19.2.7",
50
+ "@types/react-dom": "^19.2.3",
51
+ "@vitejs/plugin-react": "^5.1.1",
52
+ "eslint": "^9.39.1",
53
+ "eslint-plugin-react-hooks": "^7.0.1",
54
+ "eslint-plugin-react-refresh": "^0.4.24",
55
+ "globals": "^16.5.0",
56
+ "typescript": "~5.9.3",
57
+ "typescript-eslint": "^8.48.0",
58
+ "vite": "^7.3.1"
59
+ }
60
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
@@ -0,0 +1,22 @@
1
+ import { BrowserRouter, Routes, Route } from 'react-router-dom';
2
+
3
+ import { AuthProvider } from '@/lib/auth';
4
+ import { FileBrowser } from '@/pages/FileBrowser';
5
+ import { Home } from '@/pages/Home';
6
+ import { Runner } from '@/pages/Runner';
7
+ import { RunnerJob } from '@/pages/RunnerJob';
8
+
9
+ export default function App() {
10
+ return (
11
+ <AuthProvider>
12
+ <BrowserRouter>
13
+ <Routes>
14
+ <Route path="/runner/:jobId" element={<RunnerJob />} />
15
+ <Route path="/runner" element={<Runner />} />
16
+ <Route path="/browse/*" element={<FileBrowser />} />
17
+ <Route path="/" element={<Home />} />
18
+ </Routes>
19
+ </BrowserRouter>
20
+ </AuthProvider>
21
+ );
22
+ }
@@ -0,0 +1,167 @@
1
+ import { ExternalLink, LogOut, User } from 'lucide-react';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+
4
+ import { useAuth } from '@/lib/AuthContext';
5
+
6
+ export interface CollapsedItem {
7
+ node: React.ReactNode | ((onDismiss: () => void) => React.ReactNode);
8
+ /** Breakpoint at which this item is hidden from the header bar (and thus shown in the menu) */
9
+ breakpoint: 'bp-400' | 'bp-480' | 'sm' | 'md' | 'lg';
10
+ /** If true, this item contains a nested dropdown that should prevent account menu auto-close */
11
+ hasNestedDropdown?: boolean;
12
+ }
13
+
14
+ interface AccountMenuProps {
15
+ theme?: 'light' | 'dark';
16
+ onToggleTheme?: () => void;
17
+ /** Items that collapse into this menu at various breakpoints, in display order */
18
+ collapsedItems?: CollapsedItem[];
19
+ }
20
+
21
+ /**
22
+ * Maps breakpoint to Tailwind class that shows the item only BELOW that breakpoint.
23
+ * e.g. breakpoint 'sm' → item is in menu when < sm → "sm:hidden" (visible below sm, hidden at sm+)
24
+ */
25
+ /**
26
+ * Maps breakpoint key to Tailwind class that hides the item AT OR ABOVE that width.
27
+ * Items appear in the menu only below their breakpoint.
28
+ * Using arbitrary min-width values for tighter control over when items fold.
29
+ */
30
+ const BREAKPOINT_CLASS: Record<string, string> = {
31
+ 'bp-400': 'min-[400px]:hidden',
32
+ 'bp-480': 'min-[480px]:hidden',
33
+ sm: 'sm:hidden', // 640px
34
+ md: 'md:hidden', // 768px
35
+ lg: 'lg:hidden', // 1024px
36
+ };
37
+
38
+ export function AccountMenu({ collapsedItems = [] }: AccountMenuProps) {
39
+ const { authenticated, email, picture } = useAuth();
40
+ const [open, setOpen] = useState(false);
41
+ const menuRef = useRef<HTMLDivElement>(null);
42
+ const [privacyUrl, setPrivacyUrl] = useState<string | null>(null);
43
+ const [termsUrl, setTermsUrl] = useState<string | null>(null);
44
+
45
+ // Fetch content share links on mount
46
+ useEffect(() => {
47
+ fetch('/api/content-link/privacy')
48
+ .then((r) => r.json())
49
+ .then((data: { url?: string }) => { if (data.url) setPrivacyUrl(data.url); })
50
+ .catch(() => {});
51
+ fetch('/api/content-link/terms')
52
+ .then((r) => r.json())
53
+ .then((data: { url?: string }) => { if (data.url) setTermsUrl(data.url); })
54
+ .catch(() => {});
55
+ }, []);
56
+
57
+ // Track whether a nested Radix dropdown is currently open
58
+ const nestedDropdownOpen = useCallback(() => {
59
+ return !!document.querySelector('[data-radix-popper-content-wrapper]');
60
+ }, []);
61
+
62
+ useEffect(() => {
63
+ if (!open) return;
64
+ function handleClickOutside(e: Event) {
65
+ const target = e.target as HTMLElement;
66
+ // Don't close if click is inside the account menu
67
+ if (menuRef.current?.contains(target)) return;
68
+ // Don't close if a nested Radix dropdown is open anywhere
69
+ if (nestedDropdownOpen()) return;
70
+ // Don't close if click is inside any Radix portal
71
+ if (target.closest?.('[data-radix-popper-content-wrapper]')) return;
72
+ if (target.closest?.('[role="menu"]')) return;
73
+ setOpen(false);
74
+ }
75
+ document.addEventListener('pointerdown', handleClickOutside, true);
76
+ document.addEventListener('mousedown', handleClickOutside, true);
77
+ return () => {
78
+ document.removeEventListener('pointerdown', handleClickOutside, true);
79
+ document.removeEventListener('mousedown', handleClickOutside, true);
80
+ };
81
+ }, [open, nestedDropdownOpen]);
82
+
83
+ if (!authenticated) return null;
84
+
85
+ const initial = email ? email[0].toUpperCase() : '?';
86
+
87
+ return (
88
+ <div className="relative" ref={menuRef}>
89
+ <button
90
+ onClick={() => setOpen(!open)}
91
+ className="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-zinc-700 transition-colors"
92
+ title={email ?? 'Account'}
93
+ >
94
+ {picture ? (
95
+ <img src={picture} alt="" className="h-7 w-7 rounded-full" referrerPolicy="no-referrer" />
96
+ ) : (
97
+ <div className="h-7 w-7 rounded-full bg-blue-600 flex items-center justify-center text-white text-xs font-semibold">
98
+ {initial}
99
+ </div>
100
+ )}
101
+ </button>
102
+
103
+ {open && (
104
+ <div className="absolute right-0 top-full mt-1 w-56 bg-popover border border-border rounded-lg shadow-lg z-50 py-1">
105
+ {/* User info — links to Google account */}
106
+ <a
107
+ href="https://myaccount.google.com/"
108
+ target="_blank"
109
+ rel="noopener noreferrer"
110
+ className="flex items-center gap-2 px-3 py-2 border-b border-border hover:bg-accent transition-colors"
111
+ >
112
+ <User className="h-4 w-4 text-foreground" />
113
+ <span className="text-sm text-foreground truncate">{email}</span>
114
+ </a>
115
+
116
+ {/* Collapsed items — each visible in menu only below its breakpoint */}
117
+ {collapsedItems.map((item, i) => (
118
+ <div key={i} className={BREAKPOINT_CLASS[item.breakpoint]}>
119
+ {typeof item.node === 'function' ? item.node(() => setOpen(false)) : item.node}
120
+ </div>
121
+ ))}
122
+
123
+ {/* Separator before sign out if there are collapsed items */}
124
+ {collapsedItems.length > 0 && (
125
+ <div className="border-b border-border" />
126
+ )}
127
+
128
+ <a
129
+ href="/auth/logout"
130
+ className="flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-accent transition-colors"
131
+ >
132
+ <LogOut className="h-4 w-4" />
133
+ Sign out
134
+ </a>
135
+
136
+ {/* Legal links — share links to content/*.md */}
137
+ {(privacyUrl ?? termsUrl) && (
138
+ <div className="border-t border-border mt-1 pt-1">
139
+ {privacyUrl && (
140
+ <a
141
+ href={privacyUrl}
142
+ target="_blank"
143
+ rel="noopener noreferrer"
144
+ className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent transition-colors"
145
+ >
146
+ <ExternalLink className="h-3 w-3" />
147
+ Privacy
148
+ </a>
149
+ )}
150
+ {termsUrl && (
151
+ <a
152
+ href={termsUrl}
153
+ target="_blank"
154
+ rel="noopener noreferrer"
155
+ className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent transition-colors"
156
+ >
157
+ <ExternalLink className="h-3 w-3" />
158
+ Terms
159
+ </a>
160
+ )}
161
+ </div>
162
+ )}
163
+ </div>
164
+ )}
165
+ </div>
166
+ );
167
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Generic action dropdown with state machine (idle/loading/done/error),
3
+ * icon swapping, and variant rendering (header/default/menuItem).
4
+ *
5
+ * Used by LinkDropdown and DownloadDropdown to eliminate structural duplication.
6
+ */
7
+ import { Check, Loader2, X } from 'lucide-react';
8
+ import type { LucideIcon } from 'lucide-react';
9
+
10
+ import { Button } from '@/components/ui/button';
11
+ import {
12
+ DropdownMenu,
13
+ DropdownMenuContent,
14
+ DropdownMenuSeparator,
15
+ DropdownMenuTrigger,
16
+ } from '@/components/ui/dropdown-menu';
17
+
18
+ export type ActionState = 'idle' | 'loading' | 'done' | 'error';
19
+
20
+ export interface ActionDropdownProps {
21
+ /** Icon shown in idle state */
22
+ icon: LucideIcon;
23
+ /** Label for the trigger (menuItem variant) */
24
+ label: string;
25
+ /** Tooltip for the trigger button */
26
+ title: string;
27
+ /** Render variant */
28
+ variant?: 'header' | 'default' | 'menuItem';
29
+ /** Small variant for directory rows */
30
+ compact?: boolean;
31
+ /** Error callback */
32
+ onError?: (error: string) => void;
33
+ /** State change callback */
34
+ onStateChange?: (state: ActionState) => void;
35
+ /** Children rendered inside the dropdown content */
36
+ children: React.ReactNode;
37
+ /** Extra content before children (e.g., error display) — rendered by parent */
38
+ errorSlot?: React.ReactNode;
39
+ /** Dropdown content alignment */
40
+ align?: 'start' | 'center' | 'end';
41
+ /** Dropdown content width class */
42
+ contentClass?: string;
43
+ /** Disabled state */
44
+ disabled?: boolean;
45
+ /** External state override (parent controls state) */
46
+ state?: ActionState;
47
+ /** Called when dropdown opens/closes */
48
+ onOpenChange?: (open: boolean) => void;
49
+ }
50
+
51
+ export function ActionDropdown({
52
+ icon: IdleIcon,
53
+ label,
54
+ title,
55
+ variant = 'default',
56
+ compact,
57
+ state: externalState,
58
+ disabled,
59
+ children,
60
+ errorSlot,
61
+ align = 'end',
62
+ contentClass,
63
+ onOpenChange,
64
+ }: ActionDropdownProps) {
65
+ const state = externalState ?? 'idle';
66
+ const isMenuItem = variant === 'menuItem';
67
+ const iconSize = compact ? 'h-3.5 w-3.5' : 'h-4 w-4';
68
+ const btnSize = compact ? 'h-7 w-7' : 'h-8 w-8';
69
+
70
+ const Icon = state === 'done' ? Check : state === 'error' ? X : state === 'loading' ? Loader2 : IdleIcon;
71
+ const iconColor = state === 'done' ? 'text-green-500' : state === 'error' ? 'text-red-500' : '';
72
+
73
+ const trigger = isMenuItem ? (
74
+ <button
75
+ className="flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-accent transition-colors w-full text-left"
76
+ disabled={disabled || state === 'loading'}
77
+ >
78
+ <Icon className={`h-4 w-4 shrink-0 ${iconColor} ${state === 'loading' ? 'animate-spin' : ''}`} />
79
+ {label}
80
+ </button>
81
+ ) : (
82
+ <Button
83
+ variant="ghost"
84
+ size="icon"
85
+ className={`${btnSize} ${iconColor || (variant === 'header' ? 'text-zinc-300 hover:text-white hover:bg-white/10' : 'text-muted-foreground hover:text-foreground')}`}
86
+ disabled={disabled || state === 'loading'}
87
+ title={title}
88
+ >
89
+ <Icon className={`${iconSize} ${state === 'loading' ? 'animate-spin' : ''}`} />
90
+ </Button>
91
+ );
92
+
93
+ return (
94
+ <DropdownMenu onOpenChange={onOpenChange}>
95
+ <DropdownMenuTrigger asChild>
96
+ {trigger}
97
+ </DropdownMenuTrigger>
98
+ <DropdownMenuContent align={align} className={contentClass}>
99
+ {errorSlot && (
100
+ <>
101
+ {errorSlot}
102
+ <DropdownMenuSeparator />
103
+ </>
104
+ )}
105
+ {children}
106
+ </DropdownMenuContent>
107
+ </DropdownMenu>
108
+ );
109
+ }
110
+
111
+ /** Reusable error message banner for dropdowns. */
112
+ export function DropdownErrorBanner({ message }: { message: string | null }) {
113
+ if (!message) return null;
114
+ return (
115
+ <div className="px-2 py-1.5 text-xs text-red-500 bg-red-500/10 rounded mx-1 mb-1">
116
+ {message}
117
+ </div>
118
+ );
119
+ }
120
+
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Lazy-loaded CodeMirror editor for in-browser text editing.
3
+ * Only imported when the user clicks Edit on a text file's Raw tab.
4
+ */
5
+ import { useCallback, useEffect, useRef, useState } from 'react';
6
+
7
+ import { getLanguageExtension, loadCodeMirror } from '@/lib/codemirror';
8
+ import { useTheme } from '@/lib/theme';
9
+
10
+ interface CodeEditorProps {
11
+ content: string;
12
+ fileName: string;
13
+ onSave: (content: string) => Promise<void>;
14
+ onCancel: () => void;
15
+ }
16
+
17
+ export function CodeEditor({ content, fileName, onSave, onCancel }: CodeEditorProps) {
18
+ const containerRef = useRef<HTMLDivElement>(null);
19
+ const viewRef = useRef<import('@codemirror/view').EditorView | null>(null);
20
+ const [dirty, setDirty] = useState(false);
21
+ const [saving, setSaving] = useState(false);
22
+ const [loading, setLoading] = useState(true);
23
+ const [theme] = useTheme();
24
+ const savedContentRef = useRef(content);
25
+
26
+ const handleSave = useCallback(async () => {
27
+ if (!viewRef.current) return;
28
+ const newContent = viewRef.current.state.doc.toString();
29
+ setSaving(true);
30
+ try {
31
+ await onSave(newContent);
32
+ savedContentRef.current = newContent;
33
+ setDirty(false);
34
+ } finally {
35
+ setSaving(false);
36
+ }
37
+ }, [onSave]);
38
+
39
+ useEffect(() => {
40
+ if (!containerRef.current) return;
41
+ let destroyed = false;
42
+
43
+ (async () => {
44
+ const { EditorView, EditorState, basicSetup, keymap, oneDark } = await loadCodeMirror();
45
+ if (destroyed) return;
46
+
47
+ const ext = fileName.split('.').pop() ?? '';
48
+ const langExt = await getLanguageExtension(ext);
49
+ if (destroyed) return;
50
+
51
+ const extensions = [
52
+ basicSetup,
53
+ keymap.of([{
54
+ key: 'Mod-s',
55
+ run: () => {
56
+ handleSave();
57
+ return true;
58
+ },
59
+ }]),
60
+ EditorView.updateListener.of((update) => {
61
+ if (update.docChanged) {
62
+ const current = update.state.doc.toString();
63
+ setDirty(current !== savedContentRef.current);
64
+ }
65
+ }),
66
+ EditorView.theme({
67
+ '&': { fontSize: '14px', height: '100%' },
68
+ '.cm-scroller': { overflow: 'auto' },
69
+ '.cm-content': { fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace" },
70
+ '.cm-gutters': { fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace" },
71
+ }),
72
+ ];
73
+
74
+ if (theme === 'dark') {
75
+ extensions.push(oneDark);
76
+ }
77
+
78
+ if (langExt) {
79
+ extensions.push(langExt);
80
+ }
81
+
82
+ const state = EditorState.create({
83
+ doc: content,
84
+ extensions,
85
+ });
86
+
87
+ const view = new EditorView({
88
+ state,
89
+ parent: containerRef.current!,
90
+ });
91
+
92
+ viewRef.current = view;
93
+ setLoading(false);
94
+ })();
95
+
96
+ return () => {
97
+ destroyed = true;
98
+ if (viewRef.current) {
99
+ viewRef.current.destroy();
100
+ viewRef.current = null;
101
+ }
102
+ };
103
+ // eslint-disable-next-line react-hooks/exhaustive-deps
104
+ }, []);
105
+
106
+ return (
107
+ <div className="flex flex-col h-full">
108
+ {/* Toolbar */}
109
+ <div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-muted/50">
110
+ <span className="text-sm font-medium text-foreground">Editing</span>
111
+ {dirty && (
112
+ <span className="text-xs px-1.5 py-0.5 bg-amber-500/20 text-amber-600 dark:text-amber-400 rounded">
113
+ unsaved
114
+ </span>
115
+ )}
116
+ <div className="flex-1" />
117
+ <button
118
+ onClick={onCancel}
119
+ className="px-3 py-1 text-sm rounded border border-border text-muted-foreground hover:bg-accent transition-colors"
120
+ >
121
+ Cancel
122
+ </button>
123
+ <button
124
+ onClick={handleSave}
125
+ disabled={saving || !dirty}
126
+ className="px-3 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
127
+ >
128
+ {saving ? 'Saving…' : 'Save'}
129
+ </button>
130
+ <span className="text-xs text-muted-foreground hidden sm:inline">Ctrl+S</span>
131
+ </div>
132
+
133
+ {/* Editor */}
134
+ <div ref={containerRef} className="flex-1 overflow-hidden">
135
+ {loading && (
136
+ <div className="flex items-center justify-center h-32 text-muted-foreground">
137
+ Loading editor…
138
+ </div>
139
+ )}
140
+ </div>
141
+ </div>
142
+ );
143
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Read-only CodeMirror 6 viewer with syntax highlighting and code folding.
3
+ * Replaces highlight.js-based CodeBlock for the raw file view.
4
+ */
5
+ import { Check, Copy } from 'lucide-react';
6
+ import { useEffect, useRef, useState } from 'react';
7
+
8
+ import { getLanguageExtension, loadCodeMirror } from '@/lib/codemirror';
9
+ import { useTheme } from '@/lib/theme';
10
+
11
+ interface CodeViewerProps {
12
+ content: string;
13
+ fileName: string;
14
+ }
15
+
16
+ export function CodeViewer({ content, fileName }: CodeViewerProps) {
17
+ const containerRef = useRef<HTMLDivElement>(null);
18
+ const viewRef = useRef<import('@codemirror/view').EditorView | null>(null);
19
+ const [loading, setLoading] = useState(true);
20
+ const [copied, setCopied] = useState(false);
21
+ const [theme] = useTheme();
22
+
23
+ const handleCopy = async () => {
24
+ await navigator.clipboard.writeText(content);
25
+ setCopied(true);
26
+ setTimeout(() => setCopied(false), 1500);
27
+ };
28
+
29
+ useEffect(() => {
30
+ if (!containerRef.current) return;
31
+ let destroyed = false;
32
+
33
+ (async () => {
34
+ const { EditorView, EditorState, basicSetup, oneDark } = await loadCodeMirror();
35
+ if (destroyed) return;
36
+
37
+ const ext = fileName.split('.').pop() ?? '';
38
+ const langExt = await getLanguageExtension(ext);
39
+ if (destroyed) return;
40
+
41
+ const extensions = [
42
+ basicSetup,
43
+ EditorState.readOnly.of(true),
44
+ EditorView.editable.of(false),
45
+ EditorView.theme({
46
+ '&': { fontSize: '14px' },
47
+ '.cm-scroller': { overflow: 'auto' },
48
+ '.cm-content': {
49
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
50
+ },
51
+ '.cm-gutters': {
52
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
53
+ },
54
+ // Style the cursor as invisible in read-only mode
55
+ '.cm-cursor': { display: 'none' },
56
+ }),
57
+ ];
58
+
59
+ if (theme === 'dark') {
60
+ extensions.push(oneDark);
61
+ }
62
+
63
+ if (langExt) {
64
+ extensions.push(langExt);
65
+ }
66
+
67
+ const state = EditorState.create({
68
+ doc: content,
69
+ extensions,
70
+ });
71
+
72
+ const view = new EditorView({
73
+ state,
74
+ parent: containerRef.current!,
75
+ });
76
+
77
+ viewRef.current = view;
78
+ setLoading(false);
79
+ })();
80
+
81
+ return () => {
82
+ destroyed = true;
83
+ if (viewRef.current) {
84
+ viewRef.current.destroy();
85
+ viewRef.current = null;
86
+ }
87
+ };
88
+ }, [content, fileName, theme]);
89
+
90
+ return (
91
+ <div className="relative group rounded-lg border border-border overflow-hidden">
92
+ {/* Copy button */}
93
+ <div className="absolute top-2 right-2 z-10">
94
+ <button
95
+ onClick={() => void handleCopy()}
96
+ className="p-1.5 rounded bg-accent hover:bg-accent/80 text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-all"
97
+ title="Copy to clipboard"
98
+ >
99
+ {copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
100
+ </button>
101
+ </div>
102
+
103
+ {/* CodeMirror container */}
104
+ <div ref={containerRef}>
105
+ {loading && (
106
+ <pre className="p-4 text-sm text-muted-foreground bg-muted">
107
+ <code>{content.slice(0, 200)}…</code>
108
+ </pre>
109
+ )}
110
+ </div>
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,32 @@
1
+ import {
2
+ AlertDialog,
3
+ AlertDialogAction,
4
+ AlertDialogCancel,
5
+ AlertDialogContent,
6
+ AlertDialogDescription,
7
+ AlertDialogTitle,
8
+ } from '@/components/ui/alert-dialog';
9
+
10
+ interface ConfirmDialogProps {
11
+ open: boolean;
12
+ onOpenChange: (open: boolean) => void;
13
+ title: string;
14
+ description: string;
15
+ confirmLabel?: string;
16
+ onConfirm: () => void;
17
+ }
18
+
19
+ export function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel = 'Continue', onConfirm }: ConfirmDialogProps) {
20
+ return (
21
+ <AlertDialog open={open} onOpenChange={onOpenChange}>
22
+ <AlertDialogContent>
23
+ <AlertDialogTitle>{title}</AlertDialogTitle>
24
+ <AlertDialogDescription>{description}</AlertDialogDescription>
25
+ <div className="flex justify-end gap-2 mt-2">
26
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
27
+ <AlertDialogAction onClick={onConfirm}>{confirmLabel}</AlertDialogAction>
28
+ </div>
29
+ </AlertDialogContent>
30
+ </AlertDialog>
31
+ );
32
+ }