@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,102 @@
1
+ /**
2
+ * Composition root for the file browser — combines focused hooks.
3
+ */
4
+ import { useEffect, useState } from 'react';
5
+ import { useParams, useSearchParams } from 'react-router-dom';
6
+
7
+ import type { BreadcrumbItem } from '@/lib/api';
8
+ import { useAuth } from '@/lib/AuthContext';
9
+ import { useTheme } from '@/lib/theme';
10
+ import { useFileData } from './useFileData';
11
+ import { useShareSettings } from './useShareSettings';
12
+ import { useTopBar } from './useTopBar';
13
+
14
+ function formatRelativeTime(isoDate: string): string {
15
+ const ms = Date.now() - new Date(isoDate).getTime();
16
+ const mins = Math.floor(ms / 60_000);
17
+ if (mins < 60) return `${String(mins)}m ago`;
18
+ const hours = Math.floor(mins / 60);
19
+ if (hours < 24) return `${String(hours)}h ago`;
20
+ const days = Math.floor(hours / 24);
21
+ return `${String(days)}d ago`;
22
+ }
23
+
24
+ export function useFileBrowser() {
25
+ const params = useParams<{ '*': string }>();
26
+ const reqPath = params['*'] ?? '';
27
+ const [searchParams, setSearchParams] = useSearchParams();
28
+ const [theme, toggleTheme] = useTheme();
29
+
30
+ // Browser tab title
31
+ useEffect(() => {
32
+ const siteTitle = 'Jeeves Server';
33
+ const segments = reqPath.split('/').filter(Boolean);
34
+ const last = segments.length ? segments[segments.length - 1] : '';
35
+ const decoded = last ? decodeURIComponent(last) : '';
36
+ document.title = decoded ? `${decoded} - ${siteTitle}` : siteTitle;
37
+ }, [reqPath]);
38
+
39
+ // Data
40
+ const {
41
+ drives, directory, fileRaw, fileRendered, file,
42
+ loading, error, editing, setEditing,
43
+ viewTab, setViewTab: setViewTabInternal,
44
+ handleSave,
45
+ } = useFileData(reqPath, searchParams);
46
+
47
+ // Sync tab to URL
48
+ const setViewTab = (tab: 'rendered' | 'raw') => {
49
+ setViewTabInternal(tab);
50
+ setSearchParams((prev) => {
51
+ const next = new URLSearchParams(prev);
52
+ if (tab === 'rendered') next.delete('tab');
53
+ else next.set('tab', tab);
54
+ return next;
55
+ }, { replace: true });
56
+ };
57
+
58
+ // Sharing
59
+ const { shareSettings, setShareSettings } = useShareSettings();
60
+
61
+ // UI state
62
+ const [mobileTocOpen, setMobileTocOpen] = useState(false);
63
+ const [proseWidth, setProseWidth] = useState<'narrow' | 'medium' | 'wide'>(
64
+ () => (localStorage.getItem('jeeves-prose-width') as 'narrow' | 'medium' | 'wide') ?? 'medium',
65
+ );
66
+ const toggleProseWidth = (w: 'narrow' | 'medium' | 'wide') => {
67
+ setProseWidth(w);
68
+ localStorage.setItem('jeeves-prose-width', w);
69
+ };
70
+
71
+ // Auth
72
+ const { isInsider: authInsider, searchEnabled, keyCreatedAt, rotateKey } = useAuth();
73
+ const breadcrumbs: BreadcrumbItem[] = directory?.breadcrumbs ?? file?.breadcrumbs ?? [];
74
+ const isInsider = directory?.isInsider ?? file?.isInsider ?? authInsider;
75
+ const keyAge = keyCreatedAt ? formatRelativeTime(keyCreatedAt) : null;
76
+
77
+ // Key rotation dialog
78
+ const [rotateKeyDialogOpen, setRotateKeyDialogOpen] = useState(false);
79
+ const handleRotateKey = () => setRotateKeyDialogOpen(true);
80
+ const confirmRotateKey = async () => {
81
+ setRotateKeyDialogOpen(false);
82
+ await rotateKey();
83
+ };
84
+
85
+ // Layout
86
+ const { topBarRef, mainRef, topBarHeight } = useTopBar(JSON.stringify([file, directory, drives]));
87
+
88
+ return {
89
+ reqPath, theme, toggleTheme,
90
+ shareSettings, setShareSettings,
91
+ mobileTocOpen, setMobileTocOpen,
92
+ proseWidth, toggleProseWidth,
93
+ drives, directory, fileRaw, fileRendered, file,
94
+ loading, error, editing, setEditing,
95
+ viewTab, setViewTab,
96
+ breadcrumbs, isInsider, searchEnabled, keyAge,
97
+ rotateKeyDialogOpen, setRotateKeyDialogOpen,
98
+ handleRotateKey, confirmRotateKey,
99
+ topBarRef, mainRef, topBarHeight,
100
+ handleSave,
101
+ };
102
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Data fetching for file browser — drives, directories, and file content.
3
+ */
4
+ import { useCallback, useEffect, useState } from 'react';
5
+
6
+ import type { DirectoryListing, DriveEntry, FileContent } from '@/lib/api';
7
+ import { getDrives, getDirectory, getFile, getFileRaw, saveFile } from '@/lib/api';
8
+
9
+ export function useFileData(reqPath: string, searchParams: URLSearchParams) {
10
+ const [drives, setDrives] = useState<DriveEntry[] | null>(null);
11
+ const [directory, setDirectory] = useState<DirectoryListing | null>(null);
12
+ const [fileRaw, setFileRaw] = useState<FileContent | null>(null);
13
+ const [fileRendered, setFileRendered] = useState<FileContent | null>(null);
14
+ const [loading, setLoading] = useState(true);
15
+ const [error, setError] = useState<string | null>(null);
16
+ const [editing, setEditing] = useState(false);
17
+
18
+ const initialTab = searchParams.get('tab') === 'raw' ? 'raw' : 'rendered';
19
+ const [viewTab, setViewTabInternal] = useState<'rendered' | 'raw'>(initialTab);
20
+
21
+ const file = fileRendered ?? fileRaw;
22
+
23
+ const loadData = useCallback(async (path: string, params: URLSearchParams) => {
24
+ setLoading(true);
25
+ setError(null);
26
+ setDrives(null);
27
+ setDirectory(null);
28
+ setFileRaw(null);
29
+ setFileRendered(null);
30
+ setEditing(false);
31
+ setViewTabInternal(params.get('tab') === 'raw' ? 'raw' : 'rendered');
32
+
33
+ if (!path) {
34
+ try {
35
+ const data = await getDrives();
36
+ setDrives(data);
37
+ } catch (e: unknown) {
38
+ setError((e as Error).message);
39
+ } finally {
40
+ setLoading(false);
41
+ }
42
+ } else {
43
+ try {
44
+ const data = await getDirectory(path);
45
+ if ('entries' in data) {
46
+ setDirectory(data);
47
+ setLoading(false);
48
+ } else {
49
+ getFileRaw(path).then((raw) => { setFileRaw(raw); setLoading(false); }).catch(() => {});
50
+ getFile(path).then(setFileRendered).catch(() => {});
51
+ }
52
+ } catch {
53
+ getFileRaw(path).then((raw) => { setFileRaw(raw); setLoading(false); }).catch((e: Error) => { setError(e.message); setLoading(false); });
54
+ getFile(path).then(setFileRendered).catch(() => {});
55
+ }
56
+ }
57
+ }, []);
58
+
59
+ useEffect(() => {
60
+ void loadData(reqPath, searchParams);
61
+ }, [loadData, reqPath, searchParams]);
62
+
63
+ const handleSave = async (content: string) => {
64
+ await saveFile(reqPath, content);
65
+ const refreshed = await getFileRaw(reqPath);
66
+ setFileRaw(refreshed);
67
+ try { const r = await getFile(reqPath); setFileRendered(r); } catch { /* ignore */ }
68
+ setEditing(false);
69
+ };
70
+
71
+ return {
72
+ drives, directory, fileRaw, fileRendered, file,
73
+ loading, error,
74
+ editing, setEditing,
75
+ viewTab, setViewTab: setViewTabInternal,
76
+ handleSave,
77
+ };
78
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Scroll anchoring hook.
3
+ *
4
+ * Observes a scrollable container for content height changes (e.g. lazy diagram
5
+ * renders replacing placeholders with SVGs). When content above the current
6
+ * viewport grows, compensates the scroll position so the user's reading position
7
+ * remains stable.
8
+ *
9
+ * Uses ResizeObserver on the content element inside the scroll container.
10
+ */
11
+ import { useEffect, useRef } from 'react';
12
+
13
+ /**
14
+ * Attach scroll anchoring to a scrollable container.
15
+ *
16
+ * @param scrollRef - Ref to the scrollable container (the element with overflow-y).
17
+ * @param contentRef - Ref to the content element inside the scroller whose height may change.
18
+ * @param active - Whether anchoring is active (disable during programmatic scrolls).
19
+ */
20
+ export function useScrollAnchor(
21
+ scrollRef: React.RefObject<HTMLElement | null>,
22
+ contentRef: React.RefObject<HTMLElement | null>,
23
+ active = true,
24
+ ): void {
25
+ const prevHeight = useRef<number>(0);
26
+ const prevScrollTop = useRef<number>(0);
27
+
28
+ useEffect(() => {
29
+ const scroller = scrollRef.current;
30
+ const content = contentRef.current;
31
+ if (!scroller || !content || !active) return;
32
+
33
+ // Snapshot initial state
34
+ prevHeight.current = content.scrollHeight;
35
+ prevScrollTop.current = scroller.scrollTop;
36
+
37
+ // Track scroll position
38
+ const onScroll = () => {
39
+ prevScrollTop.current = scroller.scrollTop;
40
+ };
41
+ scroller.addEventListener('scroll', onScroll, { passive: true });
42
+
43
+ // Observe content size changes
44
+ const observer = new ResizeObserver(() => {
45
+ const newHeight = content.scrollHeight;
46
+ const oldHeight = prevHeight.current;
47
+ const delta = newHeight - oldHeight;
48
+
49
+ if (Math.abs(delta) > 0 && prevScrollTop.current > 0) {
50
+ // Only compensate if content grew above the current scroll position.
51
+ // If the scroller's scrollTop hasn't changed but scrollHeight grew,
52
+ // the browser didn't auto-adjust, meaning growth was above viewport.
53
+ const currentScrollTop = scroller.scrollTop;
54
+ if (currentScrollTop === prevScrollTop.current && delta > 0) {
55
+ scroller.scrollTop = currentScrollTop + delta;
56
+ }
57
+ prevScrollTop.current = scroller.scrollTop;
58
+ }
59
+
60
+ prevHeight.current = newHeight;
61
+ });
62
+
63
+ observer.observe(content);
64
+
65
+ return () => {
66
+ scroller.removeEventListener('scroll', onScroll);
67
+ observer.disconnect();
68
+ };
69
+ }, [scrollRef, contentRef, active]);
70
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Share settings persistence (localStorage).
3
+ */
4
+ import { useEffect, useState } from 'react';
5
+
6
+ import type { ShareSettings } from '@/lib/api';
7
+
8
+ function loadShareSettings(): ShareSettings {
9
+ const saved = localStorage.getItem('jeeves-share-settings');
10
+ if (saved) try { return JSON.parse(saved) as ShareSettings; } catch { /* ignore */ }
11
+ return { expiry: localStorage.getItem('jeeves-share-expiry') ?? '', depth: 0, dirs: false };
12
+ }
13
+
14
+ export function useShareSettings() {
15
+ const [shareSettings, setShareSettings] = useState<ShareSettings>(loadShareSettings);
16
+
17
+ useEffect(() => {
18
+ localStorage.setItem('jeeves-share-settings', JSON.stringify(shareSettings));
19
+ }, [shareSettings]);
20
+
21
+ return { shareSettings, setShareSettings };
22
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Top bar height measurement for sticky layout.
3
+ *
4
+ * @param depsKey - Serialized dependency key (e.g. JSON.stringify([val1, val2])).
5
+ * When this value changes, the top bar height is re-measured.
6
+ */
7
+ import { useCallback, useEffect, useRef, useState } from 'react';
8
+
9
+ export function useTopBar(depsKey = '') {
10
+ const topBarRef = useRef<HTMLDivElement>(null);
11
+ const mainRef = useRef<HTMLElement>(null);
12
+ const [topBarHeight, setTopBarHeight] = useState(96);
13
+
14
+ const measureTopBar = useCallback(() => {
15
+ if (topBarRef.current) setTopBarHeight(topBarRef.current.offsetHeight);
16
+ }, []);
17
+
18
+ useEffect(() => {
19
+ measureTopBar();
20
+ window.addEventListener('resize', measureTopBar);
21
+ return () => window.removeEventListener('resize', measureTopBar);
22
+ }, [measureTopBar]);
23
+
24
+ useEffect(() => { measureTopBar(); }, [measureTopBar, depsKey]);
25
+
26
+ return { topBarRef, mainRef, topBarHeight };
27
+ }
@@ -0,0 +1,281 @@
1
+ @import 'tailwindcss';
2
+
3
+ @custom-variant dark (&:where(.dark, .dark *));
4
+
5
+ html, body, #root {
6
+ height: 100%;
7
+ overflow: hidden;
8
+ }
9
+ @plugin '@tailwindcss/typography';
10
+
11
+ @theme inline {
12
+ --color-background: var(--background);
13
+ --color-foreground: var(--foreground);
14
+ --color-muted: var(--muted);
15
+ --color-muted-foreground: var(--muted-foreground);
16
+ --color-border: var(--border);
17
+ --color-primary: var(--primary);
18
+ --color-primary-foreground: var(--primary-foreground);
19
+ --color-accent: var(--accent);
20
+ --color-accent-foreground: var(--accent-foreground);
21
+ --color-popover: var(--popover);
22
+ --color-popover-foreground: var(--popover-foreground);
23
+ --color-destructive: var(--destructive);
24
+ --color-ring: var(--ring);
25
+ --radius: 0.5rem;
26
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
27
+ }
28
+
29
+ @layer base {
30
+ :root {
31
+ color-scheme: light;
32
+ --background: #ffffff;
33
+ --foreground: #0a0a0a;
34
+ --muted: #f5f5f5;
35
+ --muted-foreground: #737373;
36
+ --border: #e5e5e5;
37
+ --primary: #171717;
38
+ --primary-foreground: #fafafa;
39
+ --accent: #f5f5f5;
40
+ --accent-foreground: #171717;
41
+ --popover: #ffffff;
42
+ --popover-foreground: #0a0a0a;
43
+ --destructive: #ef4444;
44
+ --ring: #171717;
45
+ }
46
+
47
+ .dark {
48
+ color-scheme: dark;
49
+ --background: #0a0a0a;
50
+ --foreground: #fafafa;
51
+ --muted: #262626;
52
+ --muted-foreground: #a3a3a3;
53
+ --border: #262626;
54
+ --primary: #fafafa;
55
+ --primary-foreground: #0a0a0a;
56
+ --accent: #3a3a3a;
57
+ --accent-foreground: #fafafa;
58
+ --popover: #1c1c1c;
59
+ --popover-foreground: #fafafa;
60
+ --destructive: #f87171;
61
+ --ring: #d4d4d4;
62
+ }
63
+ }
64
+
65
+ body {
66
+ font-family: var(--font-sans);
67
+ background-color: var(--color-background);
68
+ color: var(--color-foreground);
69
+ }
70
+
71
+ /* Markdown anchor links */
72
+ .prose .anchor {
73
+ text-decoration: none;
74
+ color: var(--color-muted-foreground);
75
+ opacity: 0;
76
+ transition: opacity 0.15s;
77
+ margin-left: 0.4em;
78
+ }
79
+
80
+ .prose h1:hover .anchor,
81
+ .prose h2:hover .anchor,
82
+ .prose h3:hover .anchor,
83
+ .prose h4:hover .anchor,
84
+ .prose h5:hover .anchor,
85
+ .prose h6:hover .anchor {
86
+ opacity: 1;
87
+ }
88
+
89
+ .prose h1, .prose h2, .prose h3,
90
+ .prose h4, .prose h5, .prose h6 {
91
+ position: relative;
92
+ }
93
+
94
+ /* Frontmatter block */
95
+ .prose .frontmatter-block {
96
+ margin-bottom: 2em;
97
+ }
98
+
99
+ /* Code blocks */
100
+ .prose pre {
101
+ background-color: var(--color-muted);
102
+ color: var(--color-foreground);
103
+ border: 1px solid var(--color-border);
104
+ border-radius: 0.5rem;
105
+ position: relative;
106
+ }
107
+
108
+ .prose pre code {
109
+ color: inherit;
110
+ }
111
+
112
+ /* Copy button on code blocks (injected via JS) */
113
+ .code-copy-btn {
114
+ position: absolute;
115
+ top: 0.5rem;
116
+ right: 0.5rem;
117
+ padding: 0.375rem;
118
+ border-radius: 0.25rem;
119
+ background: rgba(63, 63, 70, 0.8);
120
+ color: #a1a1aa;
121
+ border: none;
122
+ cursor: pointer;
123
+ opacity: 0;
124
+ transition: opacity 0.15s, color 0.15s, background 0.15s;
125
+ display: flex;
126
+ align-items: center;
127
+ justify-content: center;
128
+ }
129
+
130
+ pre:hover .code-copy-btn {
131
+ opacity: 1;
132
+ }
133
+
134
+ .code-copy-btn:hover {
135
+ background: rgba(82, 82, 91, 0.9);
136
+ color: #fff;
137
+ }
138
+
139
+ .prose code {
140
+ font-size: 0.875em;
141
+ }
142
+
143
+ .prose :not(pre) > code {
144
+ background-color: var(--color-muted);
145
+ padding: 0.125em 0.375em;
146
+ border-radius: 0.25rem;
147
+ font-weight: 500;
148
+ }
149
+
150
+ .prose :not(pre) > code::before,
151
+ .prose :not(pre) > code::after {
152
+ content: none;
153
+ }
154
+
155
+ /* Thin scrollbar for breadcrumb nav */
156
+ .scrollbar-thin {
157
+ scrollbar-width: thin;
158
+ scrollbar-color: rgba(255,255,255,0.2) transparent;
159
+ }
160
+ .scrollbar-thin::-webkit-scrollbar {
161
+ height: 3px;
162
+ }
163
+ .scrollbar-thin::-webkit-scrollbar-thumb {
164
+ background: rgba(255,255,255,0.2);
165
+ border-radius: 2px;
166
+ }
167
+ .scrollbar-thin::-webkit-scrollbar-track {
168
+ background: transparent;
169
+ }
170
+
171
+ /* TOC sidebar */
172
+ .toc-sidebar {
173
+ position: sticky;
174
+ top: 1rem;
175
+ overflow-y: auto;
176
+ }
177
+
178
+ .toc-sidebar a {
179
+ display: block;
180
+ padding: 0.25rem 0;
181
+ color: var(--color-muted-foreground);
182
+ text-decoration: none;
183
+ font-size: 0.8125rem;
184
+ line-height: 1.4;
185
+ transition: color 0.15s;
186
+ }
187
+
188
+ .toc-sidebar a:hover {
189
+ color: var(--color-foreground);
190
+ }
191
+
192
+ .toc-sidebar a.active {
193
+ color: var(--color-foreground);
194
+ font-weight: 600;
195
+ }
196
+
197
+ /* Embedded diagram containers in markdown */
198
+ /* Lazy diagram loading placeholder */
199
+ .embedded-diagram-lazy,
200
+ .embedded-diagram-loading {
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: center;
204
+ gap: 0.5rem;
205
+ padding: 2rem 1rem;
206
+ margin: 1rem 0;
207
+ border: 1px dashed var(--border);
208
+ border-radius: 0.5rem;
209
+ color: var(--muted-foreground);
210
+ font-size: 0.875rem;
211
+ }
212
+
213
+ .diagram-spinner {
214
+ width: 1rem;
215
+ height: 1rem;
216
+ border: 2px solid var(--border);
217
+ border-top-color: var(--foreground);
218
+ border-radius: 50%;
219
+ animation: diagram-spin 0.8s linear infinite;
220
+ }
221
+
222
+ @keyframes diagram-spin {
223
+ to { transform: rotate(360deg); }
224
+ }
225
+
226
+ .embedded-diagram-rendered {
227
+ background: white;
228
+ border: 1px solid var(--border);
229
+ border-radius: 0.5rem;
230
+ padding: 1rem;
231
+ margin: 1rem 0;
232
+ overflow-x: auto;
233
+ text-align: center;
234
+ }
235
+
236
+ .embedded-diagram-rendered svg {
237
+ max-width: 100%;
238
+ height: auto;
239
+ display: inline-block;
240
+ }
241
+
242
+ .embedded-diagram-error {
243
+ background: color-mix(in srgb, var(--color-destructive) 10%, transparent);
244
+ border: 1px solid var(--color-destructive);
245
+ border-radius: 0.5rem;
246
+ padding: 1rem;
247
+ margin: 1rem 0;
248
+ }
249
+
250
+ .diagram-error-label {
251
+ color: var(--color-destructive);
252
+ font-size: 0.875rem;
253
+ font-weight: 500;
254
+ margin-bottom: 0.5rem;
255
+ }
256
+
257
+ .diagram-reload-hint {
258
+ color: var(--color-muted-foreground);
259
+ font-size: 0.8125rem;
260
+ font-style: italic;
261
+ margin-bottom: 0.5rem;
262
+ }
263
+
264
+ .diagram-retry-btn {
265
+ display: inline-flex;
266
+ align-items: center;
267
+ gap: 0.375rem;
268
+ padding: 0.375rem 0.75rem;
269
+ font-size: 0.8125rem;
270
+ font-weight: 500;
271
+ color: var(--color-foreground);
272
+ background: var(--color-muted);
273
+ border: 1px solid var(--color-border);
274
+ border-radius: 0.375rem;
275
+ cursor: pointer;
276
+ transition: background 150ms;
277
+ }
278
+
279
+ .diagram-retry-btn:hover {
280
+ background: var(--color-accent);
281
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Auth context and hook.
3
+ */
4
+ import { createContext, useContext } from 'react';
5
+
6
+ export interface AuthState {
7
+ loading: boolean;
8
+ authenticated: boolean;
9
+ email?: string;
10
+ picture?: string;
11
+ isInsider: boolean;
12
+ searchEnabled: boolean;
13
+ keyCreatedAt?: string | null;
14
+ rotateKey: () => Promise<void>;
15
+ }
16
+
17
+ export const AuthContext = createContext<AuthState>({
18
+ loading: true,
19
+ authenticated: false,
20
+ isInsider: false,
21
+ searchEnabled: false,
22
+ rotateKey: async () => {},
23
+ });
24
+
25
+ export function useAuth(): AuthState {
26
+ return useContext(AuthContext);
27
+ }