@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,62 @@
1
+ import { FileText, FolderOpen } from 'lucide-react';
2
+ import { Link } from 'react-router-dom';
3
+
4
+ import { DownloadDropdown } from '@/components/DownloadDropdown';
5
+ import { LinkDropdown } from '@/components/LinkDropdown';
6
+ import type { DirectoryEntry, ShareSettings } from '@/lib/api';
7
+
8
+ function formatSize(bytes: number | null): string {
9
+ if (bytes === null) return '-';
10
+ if (bytes === 0) return '0 B';
11
+ const units = ['B', 'KB', 'MB', 'GB'];
12
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
13
+ const val = bytes / Math.pow(1024, i);
14
+ return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
15
+ }
16
+
17
+ interface DirectoryRowProps {
18
+ entry: DirectoryEntry;
19
+ basePath: string;
20
+ isInsider: boolean;
21
+ shareSettings: ShareSettings;
22
+ onShareSettingsChange: (settings: ShareSettings) => void;
23
+ }
24
+
25
+ export function DirectoryRow({ entry, basePath, isInsider, shareSettings, onShareSettingsChange }: DirectoryRowProps) {
26
+ const entryPath = basePath ? `${basePath}/${entry.name}` : entry.name;
27
+ const isDir = entry.type === 'directory';
28
+ const hasRaw = !isDir;
29
+ const urlPath = `/${entryPath}`;
30
+ const typeLabel = isDir ? 'Directory' : entry.ext ? entry.ext.slice(1).toUpperCase() : 'File';
31
+
32
+ return (
33
+ <tr className="border-b border-border last:border-0 hover:bg-accent/50 transition-colors">
34
+ <td className="px-4 py-2.5">
35
+ <div className="flex items-center gap-2">
36
+ <Link
37
+ to={`/browse/${entryPath}`}
38
+ className="text-blue-500 hover:underline flex items-center gap-2 min-w-0"
39
+ >
40
+ {isDir ? <FolderOpen className="h-4 w-4 text-yellow-500 shrink-0" /> : <FileText className="h-4 w-4 text-zinc-400 shrink-0" />}
41
+ <span className="truncate">{entry.name}</span>
42
+ </Link>
43
+ {isInsider && (
44
+ <div className="ml-auto flex items-center gap-0.5 shrink-0">
45
+ <LinkDropdown path={urlPath} shareSettings={shareSettings} onShareSettingsChange={onShareSettingsChange} showRaw={hasRaw} compact isDirectory={isDir} />
46
+ <DownloadDropdown
47
+ reqPath={entryPath}
48
+ file={isDir ? null : { fileName: entry.name, type: entry.ext }}
49
+ isDirectory={isDir}
50
+ isInsider={isInsider}
51
+ compact
52
+ />
53
+ </div>
54
+ )}
55
+ </div>
56
+ </td>
57
+ <td className="px-4 py-2.5 text-muted-foreground text-sm">{typeLabel}</td>
58
+ <td className="px-4 py-2.5 text-muted-foreground text-sm">{formatSize(entry.size)}</td>
59
+ <td className="px-4 py-2.5 text-muted-foreground text-sm">{entry.mtime ?? '-'}</td>
60
+ </tr>
61
+ );
62
+ }
@@ -0,0 +1,42 @@
1
+ import { DirectoryRow } from '@/components/DirectoryRow';
2
+ import type { DirectoryEntry, ShareSettings } from '@/lib/api';
3
+
4
+ interface DirectoryTableProps {
5
+ entries: DirectoryEntry[];
6
+ basePath: string;
7
+ isInsider: boolean;
8
+ shareSettings: ShareSettings;
9
+ onShareSettingsChange: (settings: ShareSettings) => void;
10
+ }
11
+
12
+ export function DirectoryTable({ entries, basePath, isInsider, shareSettings, onShareSettingsChange }: DirectoryTableProps) {
13
+ return (
14
+ <div>
15
+ <p className="text-muted-foreground text-sm mb-4">{entries.length} items</p>
16
+ <div className="bg-muted/50 rounded-lg overflow-hidden border border-border">
17
+ <table className="w-full">
18
+ <thead>
19
+ <tr className="border-b border-border">
20
+ <th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Name</th>
21
+ <th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Type</th>
22
+ <th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Size</th>
23
+ <th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Modified</th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ {entries.map((entry) => (
28
+ <DirectoryRow
29
+ key={entry.name}
30
+ entry={entry}
31
+ basePath={basePath}
32
+ isInsider={isInsider}
33
+ shareSettings={shareSettings}
34
+ onShareSettingsChange={onShareSettingsChange}
35
+ />
36
+ ))}
37
+ </tbody>
38
+ </table>
39
+ </div>
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,116 @@
1
+ import { CloudDownload } from 'lucide-react';
2
+
3
+ import { DropdownMenuSeparator, DropdownMenuItem } from '@/components/ui/dropdown-menu';
4
+ import { ActionDropdown, DropdownErrorBanner, type ActionState } from '@/components/ActionDropdown';
5
+ import { useActionState } from '@/components/useActionState';
6
+ import { clearCache, withKey } from '@/lib/api';
7
+
8
+ interface DownloadDropdownProps {
9
+ reqPath: string;
10
+ file: { fileName: string; type: string } | null;
11
+ isDirectory?: boolean;
12
+ isInsider?: boolean;
13
+ compact?: boolean;
14
+ variant?: 'header' | 'default' | 'menuItem';
15
+ onError?: (error: string) => void;
16
+ onStateChange?: (state: ActionState) => void;
17
+ }
18
+
19
+ interface DownloadItem {
20
+ label: string;
21
+ href: string;
22
+ filename: string;
23
+ }
24
+
25
+ function getDownloadItems(reqPath: string, file: { fileName: string; type: string } | null, isDirectory?: boolean): DownloadItem[] {
26
+ const items: DownloadItem[] = [];
27
+
28
+ if (isDirectory || !file) {
29
+ items.push({ label: 'ZIP', href: `/api/export/${reqPath}?format=zip`, filename: `${reqPath.split('/').pop() ?? 'archive'}.zip` });
30
+ return items;
31
+ }
32
+
33
+ const baseName = file.fileName.replace(/\.[^.]+$/, '');
34
+ items.push({ label: 'Raw', href: `/api/raw/${reqPath}`, filename: file.fileName });
35
+
36
+ if (file.type === 'markdown' || file.type === '.md') {
37
+ items.push({ label: 'PDF', href: `/api/export/${reqPath}?format=pdf`, filename: `${baseName}.pdf` });
38
+ items.push({ label: 'DOCX', href: `/api/export/${reqPath}?format=docx`, filename: `${baseName}.docx` });
39
+ }
40
+
41
+ if (file.type === 'mermaid' || file.type === '.mmd') {
42
+ items.push({ label: 'SVG', href: `/api/mermaid-export/${reqPath}?format=svg`, filename: `${baseName}.svg` });
43
+ items.push({ label: 'PNG', href: `/api/mermaid-export/${reqPath}?format=png`, filename: `${baseName}.png` });
44
+ items.push({ label: 'PDF', href: `/api/mermaid-export/${reqPath}?format=pdf`, filename: `${baseName}.pdf` });
45
+ }
46
+
47
+ if (file.type === 'plantuml' || ['.puml', '.plantuml', '.pu'].includes(file.type)) {
48
+ items.push({ label: 'SVG', href: `/api/plantuml-export/${reqPath}?format=svg`, filename: `${baseName}.svg` });
49
+ items.push({ label: 'PNG', href: `/api/plantuml-export/${reqPath}?format=png`, filename: `${baseName}.png` });
50
+ items.push({ label: 'PDF', href: `/api/plantuml-export/${reqPath}?format=pdf`, filename: `${baseName}.pdf` });
51
+ items.push({ label: 'EPS', href: `/api/plantuml-export/${reqPath}?format=eps`, filename: `${baseName}.eps` });
52
+ }
53
+
54
+ return items;
55
+ }
56
+
57
+ async function downloadBlob(href: string, filename: string) {
58
+ const res = await fetch(withKey(href), { credentials: 'include' });
59
+ if (!res.ok) {
60
+ const body = await res.text().catch(() => '');
61
+ throw new Error(body || `Download failed (${String(res.status)})`);
62
+ }
63
+ const blob = await res.blob();
64
+ const url = URL.createObjectURL(blob);
65
+ const a = document.createElement('a');
66
+ a.href = url;
67
+ a.download = filename;
68
+ document.body.appendChild(a);
69
+ a.click();
70
+ document.body.removeChild(a);
71
+ URL.revokeObjectURL(url);
72
+ }
73
+
74
+ export function DownloadDropdown({ reqPath, file, isDirectory, isInsider, compact, variant = 'default', onError, onStateChange }: DownloadDropdownProps) {
75
+ const { state, errorMsg, handleAction, resetOnClose } = useActionState(
76
+ (msg) => { onError?.(msg); alert(`Download failed: ${msg}`); },
77
+ onStateChange,
78
+ );
79
+ const items = getDownloadItems(reqPath, file, isDirectory);
80
+
81
+ if (items.length === 0) return null;
82
+
83
+ return (
84
+ <ActionDropdown
85
+ icon={CloudDownload}
86
+ label="Download"
87
+ title="Download"
88
+ variant={variant}
89
+ compact={compact}
90
+ state={state}
91
+ onOpenChange={resetOnClose}
92
+ errorSlot={<DropdownErrorBanner message={errorMsg} />}
93
+ >
94
+ {items.map((item) => (
95
+ <DropdownMenuItem
96
+ key={item.label}
97
+ onSelect={() => void handleAction(() => downloadBlob(item.href, item.filename))}
98
+ className="cursor-pointer"
99
+ >
100
+ {item.label}
101
+ </DropdownMenuItem>
102
+ ))}
103
+ {isInsider && !isDirectory && (
104
+ <>
105
+ <DropdownMenuSeparator />
106
+ <DropdownMenuItem
107
+ onSelect={() => void handleAction(async () => { await clearCache(reqPath); })}
108
+ className="cursor-pointer text-muted-foreground"
109
+ >
110
+ Clear Cache
111
+ </DropdownMenuItem>
112
+ </>
113
+ )}
114
+ </ActionDropdown>
115
+ );
116
+ }
@@ -0,0 +1,54 @@
1
+ import { Link } from 'react-router-dom';
2
+
3
+ import { LinkDropdown } from '@/components/LinkDropdown';
4
+ import type { DriveEntry, ShareSettings } from '@/lib/api';
5
+
6
+ interface DriveListProps {
7
+ drives: DriveEntry[];
8
+ isInsider: boolean;
9
+ shareSettings: ShareSettings;
10
+ onShareSettingsChange: (settings: ShareSettings) => void;
11
+ }
12
+
13
+ export function DriveList({ drives, isInsider, shareSettings, onShareSettingsChange }: DriveListProps) {
14
+ return (
15
+ <div>
16
+ <p className="text-muted-foreground text-sm mb-4">{drives.length} drives</p>
17
+ <div className="bg-muted/50 rounded-lg overflow-hidden border border-border">
18
+ <table className="w-full">
19
+ <thead>
20
+ <tr className="border-b border-border">
21
+ <th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Name</th>
22
+ <th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Type</th>
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ {drives.map((drive) => {
27
+ const drivePath = `/${drive.letter.toLowerCase()}`;
28
+ return (
29
+ <tr key={drive.letter} className="border-b border-border last:border-0 hover:bg-accent/50 transition-colors">
30
+ <td className="px-4 py-2.5">
31
+ <div className="flex items-center gap-2">
32
+ <Link
33
+ to={`/browse/${drive.letter.toLowerCase()}`}
34
+ className="text-blue-500 hover:underline flex items-center gap-2 min-w-0"
35
+ >
36
+ 💾 {drive.letter}:\{drive.label ? ` (${drive.label})` : ''}
37
+ </Link>
38
+ {isInsider && (
39
+ <div className="ml-auto flex items-center gap-0.5 shrink-0">
40
+ <LinkDropdown path={drivePath} shareSettings={shareSettings} onShareSettingsChange={onShareSettingsChange} compact isDirectory />
41
+ </div>
42
+ )}
43
+ </div>
44
+ </td>
45
+ <td className="px-4 py-2.5 text-muted-foreground text-sm">Drive</td>
46
+ </tr>
47
+ );
48
+ })}
49
+ </tbody>
50
+ </table>
51
+ </div>
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Add Panzoom to embedded diagram SVGs rendered server-side in markdown.
3
+ *
4
+ * Finds .embedded-diagram-rendered containers with inline <svg> elements
5
+ * and wraps them with Panzoom for wheel-zoom and drag-pan.
6
+ */
7
+ import { createPanzoomWrapper } from './panzoom';
8
+
9
+ export function initEmbeddedDiagramPanzoom(article: HTMLElement): () => void {
10
+ const cleanups: (() => void)[] = [];
11
+
12
+ const containers = article.querySelectorAll<HTMLDivElement>('.embedded-diagram-rendered');
13
+
14
+ for (const container of containers) {
15
+ const svg = container.querySelector('svg');
16
+ if (!svg) continue;
17
+
18
+ const { wrapper, initPanzoom, cleanup } = createPanzoomWrapper(svg, container, {
19
+ wrapperExtraClass: 'embedded-diagram-panzoom',
20
+ });
21
+
22
+ container.replaceWith(wrapper);
23
+ initPanzoom();
24
+ cleanups.push(cleanup);
25
+ }
26
+
27
+ return () => { for (const cleanup of cleanups) cleanup(); };
28
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Dispatches to the correct viewer based on file type.
3
+ */
4
+ import { Loader2 } from 'lucide-react';
5
+ import { useRef } from 'react';
6
+ import { lazy, Suspense, useEffect } from 'react';
7
+
8
+ const CodeEditor = lazy(() => import('@/components/CodeEditor').then(m => ({ default: m.CodeEditor })));
9
+ const CodeViewer = lazy(() => import('@/components/CodeViewer').then(m => ({ default: m.CodeViewer })));
10
+ import { MermaidViewer } from '@/components/MermaidViewer';
11
+ import { PlantUmlViewer } from '@/components/PlantUmlViewer';
12
+ import { SvgViewer } from '@/components/SvgViewer';
13
+ import { MarkdownView } from '@/components/MarkdownView';
14
+ import { scrollToIdInContainer } from '@/components/scrollUtils';
15
+ import { isRenderable } from '@/components/renderableUtils';
16
+ import { useScrollAnchor } from '@/hooks/useScrollAnchor';
17
+ import type { FileContent } from '@/lib/api';
18
+
19
+ interface FileContentViewProps {
20
+ reqPath: string;
21
+ file: FileContent | null;
22
+ fileRaw: FileContent | null;
23
+ fileRendered: FileContent | null;
24
+ viewTab: 'rendered' | 'raw';
25
+ editing: boolean;
26
+ setEditing: (editing: boolean) => void;
27
+ proseWidth: 'narrow' | 'medium' | 'wide';
28
+ topBarHeight: number;
29
+ mainRef: React.RefObject<HTMLElement | null>;
30
+ mobileTocOpen: boolean;
31
+ setMobileTocOpen: (open: boolean) => void;
32
+ onSave: (content: string) => Promise<void>;
33
+ loading: boolean;
34
+ }
35
+
36
+ export function FileContentView({
37
+ reqPath, file, fileRaw, fileRendered, viewTab,
38
+ editing, setEditing,
39
+ proseWidth, topBarHeight, mainRef,
40
+ mobileTocOpen, setMobileTocOpen,
41
+ onSave,
42
+ }: FileContentViewProps) {
43
+ const contentRef = useRef<HTMLDivElement>(null);
44
+ useScrollAnchor(mainRef, contentRef);
45
+ const renderable = file ? isRenderable(file) : false;
46
+ const activeTab = renderable ? viewTab : 'raw';
47
+ const fileLoading = !file;
48
+
49
+ // Scroll to hash on file load
50
+ useEffect(() => {
51
+ const hash = window.location.hash.slice(1);
52
+ if (hash && file) {
53
+ const timer = setTimeout(() => scrollToIdInContainer(mainRef.current, hash), 100);
54
+ return () => clearTimeout(timer);
55
+ }
56
+ }, [file, mainRef]);
57
+
58
+ return (
59
+ <div ref={contentRef} className="px-4 md:px-6 pt-4">
60
+ {/* Loading state */}
61
+ {fileLoading && (
62
+ <div className="flex items-center gap-2 text-muted-foreground text-sm py-8 justify-center">
63
+ <Loader2 className="h-4 w-4 animate-spin" /> Loading...
64
+ </div>
65
+ )}
66
+
67
+ {/* Raw view */}
68
+ {(fileRaw ?? file)?.content && activeTab === 'raw' && !editing && (
69
+ <Suspense fallback={
70
+ <pre className="rounded-lg overflow-x-auto text-sm border border-border p-4 bg-muted text-foreground">
71
+ <code>{(fileRaw ?? file)!.content!.slice(0, 500)}…</code>
72
+ </pre>
73
+ }>
74
+ <CodeViewer
75
+ content={(fileRaw ?? file)!.content!}
76
+ fileName={(fileRaw ?? file)!.fileName}
77
+ />
78
+ </Suspense>
79
+ )}
80
+
81
+ {/* Editor */}
82
+ {editing && (fileRaw ?? file)?.content != null && activeTab === 'raw' && (
83
+ <Suspense fallback={
84
+ <div className="flex items-center gap-2 text-muted-foreground text-sm py-8 justify-center">
85
+ <Loader2 className="h-4 w-4 animate-spin" /> Loading editor…
86
+ </div>
87
+ }>
88
+ <div style={{ height: `calc(100vh - ${topBarHeight + 16}px)` }}>
89
+ <CodeEditor
90
+ content={(fileRaw ?? file)!.content!}
91
+ fileName={(fileRaw ?? file)!.fileName}
92
+ onSave={onSave}
93
+ onCancel={() => setEditing(false)}
94
+ />
95
+ </div>
96
+ </Suspense>
97
+ )}
98
+
99
+ {/* Rendering spinner */}
100
+ {!fileRendered && renderable && activeTab === 'rendered' && !fileLoading && (
101
+ <div className="flex items-center gap-2 text-muted-foreground text-sm py-8 justify-center">
102
+ <Loader2 className="h-4 w-4 animate-spin" /> Rendering...
103
+ </div>
104
+ )}
105
+
106
+ {/* Markdown */}
107
+ {fileRendered?.type === 'markdown' && fileRendered.html && activeTab === 'rendered' && (
108
+ <MarkdownView
109
+ fileRendered={fileRendered}
110
+ proseWidth={proseWidth}
111
+ topBarHeight={topBarHeight}
112
+ mainRef={mainRef}
113
+ mobileTocOpen={mobileTocOpen}
114
+ setMobileTocOpen={setMobileTocOpen}
115
+ />
116
+ )}
117
+
118
+ {/* SVG */}
119
+ {fileRendered?.type === 'svg' && fileRendered.content && activeTab === 'rendered' && (
120
+ <SvgViewer content={fileRendered.content} />
121
+ )}
122
+
123
+ {/* Mermaid */}
124
+ {fileRendered?.type === 'mermaid' && activeTab === 'rendered' && (
125
+ <MermaidViewer html={fileRendered.html ?? null} content={fileRendered.content ?? ''} />
126
+ )}
127
+
128
+ {/* PlantUML */}
129
+ {fileRendered?.type === 'plantuml' && activeTab === 'rendered' && (
130
+ <PlantUmlViewer html={fileRendered.html ?? null} content={fileRendered.content ?? ''} />
131
+ )}
132
+
133
+ {/* Image */}
134
+ {file?.type === 'image' && (
135
+ <div className="flex justify-center p-4">
136
+ <img src={`/api/raw/${reqPath}`} alt={file.fileName} className="max-w-full rounded-lg shadow-md" />
137
+ </div>
138
+ )}
139
+
140
+ {/* Binary download */}
141
+ {file?.type === 'binary' && (
142
+ <div className="text-center p-8">
143
+ <p className="text-muted-foreground mb-4">{file.fileName}</p>
144
+ <a
145
+ href={`/api/raw/${reqPath}`}
146
+ download={file.fileName}
147
+ className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
148
+ >
149
+ Download
150
+ </a>
151
+ </div>
152
+ )}
153
+ </div>
154
+ );
155
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Post-processes a rendered markdown article to add Panzoom to embedded SVG images.
3
+ *
4
+ * Finds <img> elements with .svg sources, fetches the SVG content,
5
+ * and replaces them with interactive Panzoom containers with fullscreen support.
6
+ */
7
+ import { withKey } from '@/lib/api';
8
+ import { createPanzoomWrapper } from './panzoom';
9
+
10
+ export function initInlineSvgPanzoom(article: HTMLElement): () => void {
11
+ const cleanups: (() => void)[] = [];
12
+ const imgs = Array.from(article.querySelectorAll<HTMLImageElement>('img')).filter(
13
+ (img) => /\.svg(\?|&|$)/i.test(img.getAttribute('src') ?? ''),
14
+ );
15
+
16
+ for (const img of imgs) {
17
+ const src = img.getAttribute('src');
18
+ if (!src) continue;
19
+
20
+ const rawSrc = src.includes('raw=1') ? src : (src.includes('?') ? `${src}&raw=1` : `${src}?raw=1`);
21
+
22
+ // Create a temporary placeholder
23
+ const placeholder = document.createElement('div');
24
+ placeholder.className = 'inline-svg-panzoom relative bg-white rounded-lg border border-border overflow-hidden my-4';
25
+ placeholder.style.cursor = 'grab';
26
+ placeholder.innerHTML = '<div class="overflow-hidden w-full min-h-[200px]"><div class="flex items-center justify-center p-4">Loading SVG…</div></div>';
27
+
28
+ img.parentElement?.replaceChild(placeholder, img);
29
+
30
+ // Fetch SVG content and init Panzoom
31
+ fetch(withKey(rawSrc))
32
+ .then((r) => {
33
+ if (!r.ok) throw new Error(`Failed to fetch SVG: ${String(r.status)}`);
34
+ return r.text();
35
+ })
36
+ .then((svgContent) => {
37
+ const tempDiv = document.createElement('div');
38
+ tempDiv.innerHTML = svgContent;
39
+ const svg = tempDiv.querySelector('svg');
40
+ if (!svg) throw new Error('No SVG element found');
41
+
42
+ const { wrapper, initPanzoom, cleanup } = createPanzoomWrapper(svg, placeholder, {
43
+ wrapperExtraClass: 'inline-svg-panzoom',
44
+ viewportMinHeight: '200px',
45
+ });
46
+
47
+ placeholder.replaceWith(wrapper);
48
+ initPanzoom();
49
+ cleanups.push(cleanup);
50
+ })
51
+ .catch(() => {
52
+ // Fallback: put the img back
53
+ placeholder.replaceWith(img);
54
+ });
55
+ }
56
+
57
+ return () => {
58
+ for (const cleanup of cleanups) cleanup();
59
+ };
60
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Lazy-load embedded diagrams in markdown.
3
+ *
4
+ * Finds .embedded-diagram-lazy placeholders and fetches their rendered SVGs
5
+ * via /api/diagram/:type/:hash.svg when they scroll into view.
6
+ * After loading, initializes panzoom on the diagram.
7
+ */
8
+ import { withKey } from '@/lib/api';
9
+ import { normalizeSvg } from '@/lib/svg';
10
+ import { createPanzoomWrapper } from './panzoom';
11
+
12
+ /**
13
+ * Initialize lazy diagram loading for all placeholders in the given article.
14
+ * Returns a cleanup function.
15
+ */
16
+ export function initLazyDiagrams(article: HTMLElement): () => void {
17
+ const cleanups: (() => void)[] = [];
18
+ const placeholders = article.querySelectorAll<HTMLDivElement>('.embedded-diagram-lazy');
19
+
20
+ if (placeholders.length === 0) return () => {};
21
+
22
+ for (const placeholder of placeholders) {
23
+ const type = placeholder.dataset.diagramType ?? 'diagram';
24
+ placeholder.innerHTML = `<div class="embedded-diagram-loading"><div class="diagram-spinner"></div><span>Rendering ${type} diagram…</span></div>`;
25
+ void loadDiagram(placeholder, cleanups);
26
+ }
27
+
28
+ return () => { for (const cleanup of cleanups) cleanup(); };
29
+ }
30
+
31
+ async function loadDiagram(
32
+ placeholder: HTMLDivElement,
33
+ cleanups: (() => void)[],
34
+ ): Promise<void> {
35
+ const type = placeholder.dataset.diagramType;
36
+ const hash = placeholder.dataset.diagramHash;
37
+ if (!type || !hash) return;
38
+
39
+ try {
40
+ const url = withKey(`/api/diagram/${type}/${hash}.svg`);
41
+ const resp = await fetch(url);
42
+ if (!resp.ok) {
43
+ const err = await resp.json().catch(() => ({ error: 'Render failed' })) as { error?: string };
44
+ throw new Error(err.error ?? `HTTP ${String(resp.status)}`);
45
+ }
46
+ const svgText = await resp.text();
47
+
48
+ // Normalize SVG for responsive display
49
+ const normalizedSvg = normalizeSvg(svgText);
50
+
51
+ const container = document.createElement('div');
52
+ container.className = 'embedded-diagram-rendered';
53
+ container.dataset.type = type;
54
+ container.innerHTML = normalizedSvg;
55
+
56
+ const svg = container.querySelector('svg');
57
+ if (!svg) {
58
+ placeholder.replaceWith(container);
59
+ return;
60
+ }
61
+
62
+ // Use shared panzoom wrapper
63
+ const { wrapper, initPanzoom, cleanup } = createPanzoomWrapper(svg, container, {
64
+ wrapperExtraClass: 'embedded-diagram-panzoom',
65
+ });
66
+
67
+ placeholder.replaceWith(wrapper);
68
+ initPanzoom();
69
+ cleanups.push(cleanup);
70
+ } catch (err) {
71
+ const message = err instanceof Error ? err.message : 'Unknown error';
72
+ const label = type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Diagram';
73
+ const retries = Number(placeholder.dataset.retryCount ?? '0');
74
+ const reloadHint = retries > 0
75
+ ? '<div class="diagram-reload-hint">Still failing? Try reloading the page — the diagram source may have changed.</div>'
76
+ : '';
77
+ placeholder.innerHTML = `<div class="embedded-diagram-error" data-type="${type}"><div class="diagram-error-label">${label} render failed: ${escapeHtml(message)}</div>${reloadHint}<button class="diagram-retry-btn">Retry</button></div>`;
78
+
79
+ const retryBtn = placeholder.querySelector('.diagram-retry-btn');
80
+ retryBtn?.addEventListener('click', () => {
81
+ placeholder.dataset.retryCount = String(retries + 1);
82
+ placeholder.innerHTML = `<div class="embedded-diagram-loading"><div class="diagram-spinner"></div><span>Rendering ${type} diagram…</span></div>`;
83
+ void loadDiagram(placeholder, cleanups);
84
+ });
85
+ }
86
+ }
87
+
88
+ function escapeHtml(str: string): string {
89
+ return str
90
+ .replace(/&/g, '&amp;')
91
+ .replace(/</g, '&lt;')
92
+ .replace(/>/g, '&gt;');
93
+ }