@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,424 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { ChevronDown, ChevronRight, FileText, RotateCcw, Search, X } from 'lucide-react';
4
+
5
+ import { fetchFacets, searchDocuments, type SearchFacet, type SearchResult, type SearchMetadata } from '@/lib/api';
6
+
7
+ type DatePreset = '24h' | '7d' | '30d' | 'custom' | null;
8
+
9
+ const DATE_PRESETS: { label: string; value: DatePreset }[] = [
10
+ { label: '24h', value: '24h' },
11
+ { label: '7 days', value: '7d' },
12
+ { label: '30 days', value: '30d' },
13
+ { label: 'Custom', value: 'custom' },
14
+ ];
15
+
16
+ function getPresetDate(preset: DatePreset): Date | null {
17
+ if (!preset || preset === 'custom') return null;
18
+ const now = new Date();
19
+ if (preset === '24h') return new Date(now.getTime() - 24 * 60 * 60 * 1000);
20
+ if (preset === '7d') return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
21
+ if (preset === '30d') return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
22
+ return null;
23
+ }
24
+
25
+ interface SearchModalProps {
26
+ open: boolean;
27
+ onClose: () => void;
28
+ }
29
+
30
+ function FilterChips({
31
+ label,
32
+ values,
33
+ selected,
34
+ onToggle,
35
+ }: {
36
+ label: string;
37
+ values: string[];
38
+ selected: Set<string>;
39
+ onToggle: (value: string) => void;
40
+ }) {
41
+ if (values.length === 0) return null;
42
+ return (
43
+ <div className="flex items-center gap-1.5 flex-wrap">
44
+ <span className="text-xs text-muted-foreground font-medium">{label}:</span>
45
+ {values.map((v) => (
46
+ <button
47
+ key={v}
48
+ onClick={() => onToggle(v)}
49
+ className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
50
+ selected.has(v)
51
+ ? 'bg-primary text-primary-foreground border-primary'
52
+ : 'bg-muted text-muted-foreground border-border hover:bg-accent'
53
+ }`}
54
+ >
55
+ {v}
56
+ </button>
57
+ ))}
58
+ </div>
59
+ );
60
+ }
61
+
62
+ function ResultRow({ result, onNavigate }: { result: SearchResult; onNavigate: (path: string) => void }) {
63
+ const [expanded, setExpanded] = useState(false);
64
+ const preview = result.chunks[0]?.text ?? '';
65
+ const truncatedPreview = preview.length > 150 ? preview.slice(0, 150) + '…' : preview;
66
+
67
+ return (
68
+ <div className="border-b border-border last:border-0 px-4 py-2">
69
+ <div className="flex items-start gap-2">
70
+ <FileText className="h-4 w-4 text-zinc-400 shrink-0 mt-1" />
71
+ <div className="min-w-0 flex-1">
72
+ {/* Header row: file name, domain, score, expand toggle */}
73
+ <div className="flex items-center gap-2">
74
+ <button
75
+ onClick={() => onNavigate(`/browse/${result.browsePath}`)}
76
+ className="text-blue-500 hover:underline text-sm font-medium truncate"
77
+ >
78
+ {result.fileName}
79
+ </button>
80
+ {result.domains && result.domains.length > 0 && (
81
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border border-border">
82
+ {result.domains.join(', ')}
83
+ </span>
84
+ )}
85
+ <span className="text-[10px] text-muted-foreground shrink-0">
86
+ {(result.bestScore * 100).toFixed(0)}%
87
+ </span>
88
+ <span className="text-[10px] text-muted-foreground shrink-0">
89
+ {result.chunks.length} chunk{result.chunks.length !== 1 ? 's' : ''}
90
+ </span>
91
+ {result.mtime && (
92
+ <span className="text-[10px] text-muted-foreground shrink-0">
93
+ {new Date(result.mtime).toLocaleDateString()}
94
+ </span>
95
+ )}
96
+ <button
97
+ onClick={() => setExpanded(!expanded)}
98
+ className="ml-auto text-muted-foreground hover:text-foreground shrink-0"
99
+ title={expanded ? 'Collapse chunks' : 'Expand chunks'}
100
+ >
101
+ {expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
102
+ </button>
103
+ </div>
104
+ <div className="text-xs text-muted-foreground mt-0.5 break-words leading-relaxed">
105
+ {result.browsePath}
106
+ </div>
107
+ {/* Collapsed: one-line preview */}
108
+ {!expanded && (
109
+ <div className="text-sm text-foreground/70 mt-1 truncate">
110
+ {truncatedPreview}
111
+ </div>
112
+ )}
113
+ {/* Expanded: scrollable chunk accordion */}
114
+ {expanded && (
115
+ <div className="mt-2 max-h-48 overflow-y-auto border border-border rounded bg-muted/30 divide-y divide-border">
116
+ {result.chunks.map((chunk, i) => (
117
+ <div key={i} className="px-3 py-2 text-sm text-foreground/80 leading-relaxed">
118
+ <span className="text-[10px] text-muted-foreground mr-2">#{chunk.index}</span>
119
+ {chunk.text}
120
+ </div>
121
+ ))}
122
+ </div>
123
+ )}
124
+ </div>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ export function SearchModal({ open, onClose }: SearchModalProps) {
131
+ const [query, setQuery] = useState('');
132
+ const [results, setResults] = useState<SearchResult[]>([]);
133
+ const [metadata, setMetadata] = useState<SearchMetadata>({ domains: [], authors: [], participants: [] });
134
+ const [loading, setLoading] = useState(false);
135
+ const [error, setError] = useState<string | null>(null);
136
+ const [domainFilter, setDomainFilter] = useState<Set<string>>(new Set());
137
+ const [authorFilter, setAuthorFilter] = useState<Set<string>>(new Set());
138
+ const [extFilter, setExtFilter] = useState<Set<string>>(new Set());
139
+ const [datePreset, setDatePreset] = useState<DatePreset>(null);
140
+ const [dateFrom, setDateFrom] = useState('');
141
+ const [dateTo, setDateTo] = useState('');
142
+ const [facets, setFacets] = useState<SearchFacet[]>([]);
143
+ const [facetSelections, setFacetSelections] = useState<Record<string, Set<string>>>({});
144
+ const inputRef = useRef<HTMLInputElement>(null);
145
+ const navigate = useNavigate();
146
+ const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
147
+
148
+ useEffect(() => {
149
+ if (open) {
150
+ setTimeout(() => inputRef.current?.focus(), 50);
151
+ // Fetch facets on modal open
152
+ void fetchFacets()
153
+ .then((res) => setFacets(res.facets))
154
+ .catch(() => setFacets([]));
155
+ }
156
+ }, [open]);
157
+
158
+ const resetSearch = useCallback(() => {
159
+ setQuery('');
160
+ setResults([]);
161
+ setMetadata({ domains: [], authors: [], participants: [] });
162
+ setError(null);
163
+ setDomainFilter(new Set());
164
+ setAuthorFilter(new Set());
165
+ setExtFilter(new Set());
166
+ setDatePreset(null);
167
+ setDateFrom('');
168
+ setDateTo('');
169
+ setFacetSelections({});
170
+ inputRef.current?.focus();
171
+ }, []);
172
+
173
+ const doSearch = useCallback(async (q: string) => {
174
+ if (!q.trim()) {
175
+ setResults([]);
176
+ setMetadata({ domains: [], authors: [], participants: [] });
177
+ return;
178
+ }
179
+ setLoading(true);
180
+ setError(null);
181
+ try {
182
+ // Build Qdrant filter from facet selections
183
+ const mustClauses: Record<string, unknown>[] = [];
184
+ for (const [field, selected] of Object.entries(facetSelections)) {
185
+ if (selected.size > 0) {
186
+ mustClauses.push({
187
+ key: field,
188
+ match: { any: [...selected] },
189
+ });
190
+ }
191
+ }
192
+ const filter = mustClauses.length > 0
193
+ ? { must: mustClauses }
194
+ : undefined;
195
+ const res = await searchDocuments(q, 30, filter);
196
+ setResults(res.results);
197
+ setMetadata(res.metadata);
198
+ } catch (err) {
199
+ setError(String(err));
200
+ } finally {
201
+ setLoading(false);
202
+ }
203
+ }, [facetSelections]);
204
+
205
+ const handleInputChange = useCallback(
206
+ (value: string) => {
207
+ setQuery(value);
208
+ if (debounceRef.current) clearTimeout(debounceRef.current);
209
+ debounceRef.current = setTimeout(() => void doSearch(value), 400);
210
+ },
211
+ [doSearch],
212
+ );
213
+
214
+ const handleNavigate = useCallback(
215
+ (path: string) => {
216
+ onClose();
217
+ navigate(path);
218
+ },
219
+ [navigate, onClose],
220
+ );
221
+
222
+ const toggleFilter = useCallback(
223
+ (set: Set<string>, setFn: React.Dispatch<React.SetStateAction<Set<string>>>, value: string) => {
224
+ const next = new Set(set);
225
+ if (next.has(value)) next.delete(value);
226
+ else next.add(value);
227
+ setFn(next);
228
+ },
229
+ [],
230
+ );
231
+
232
+ const toggleFacet = useCallback(
233
+ (field: string, value: string) => {
234
+ setFacetSelections((prev) => {
235
+ const current = prev[field] ?? new Set<string>();
236
+ const next = new Set(current);
237
+ if (next.has(value)) next.delete(value);
238
+ else next.add(value);
239
+ return { ...prev, [field]: next };
240
+ });
241
+ },
242
+ [],
243
+ );
244
+
245
+ // Re-search when facet selections change
246
+ useEffect(() => {
247
+ if (query.trim()) {
248
+ void doSearch(query);
249
+ }
250
+ // eslint-disable-next-line react-hooks/exhaustive-deps
251
+ }, [facetSelections]);
252
+
253
+ // Extract extensions from results
254
+ const extensions = [...new Set(results.map((r) => {
255
+ const dot = r.fileName.lastIndexOf('.');
256
+ return dot > 0 ? r.fileName.slice(dot).toLowerCase() : '(none)';
257
+ }))].sort();
258
+
259
+ // Compute effective date range
260
+ const effectiveDateFrom = datePreset && datePreset !== 'custom'
261
+ ? getPresetDate(datePreset)
262
+ : dateFrom ? new Date(dateFrom) : null;
263
+ const effectiveDateTo = datePreset === 'custom' && dateTo
264
+ ? new Date(dateTo + 'T23:59:59.999Z') : null;
265
+
266
+ // Apply client-side filters
267
+ const filtered = results.filter((r) => {
268
+ if (domainFilter.size > 0 && (!r.domains || !r.domains.some(d => domainFilter.has(d)))) return false;
269
+ if (authorFilter.size > 0 && (!r.author || !authorFilter.has(r.author))) return false;
270
+ if (extFilter.size > 0) {
271
+ const dot = r.fileName.lastIndexOf('.');
272
+ const ext = dot > 0 ? r.fileName.slice(dot).toLowerCase() : '(none)';
273
+ if (!extFilter.has(ext)) return false;
274
+ }
275
+ if (effectiveDateFrom && r.mtime) {
276
+ if (new Date(r.mtime) < effectiveDateFrom) return false;
277
+ }
278
+ if (effectiveDateTo && r.mtime) {
279
+ if (new Date(r.mtime) > effectiveDateTo) return false;
280
+ }
281
+ return true;
282
+ });
283
+
284
+ if (!open) return null;
285
+
286
+ return (
287
+ <div className="fixed inset-0 z-[100] flex items-start justify-center pt-[5vh]" onClick={onClose}>
288
+ <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
289
+ <div
290
+ className="relative bg-background border border-border rounded-lg shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col"
291
+ onClick={(e) => e.stopPropagation()}
292
+ >
293
+ {/* Search input */}
294
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-border">
295
+ <Search className="h-5 w-5 text-muted-foreground shrink-0" />
296
+ <input
297
+ ref={inputRef}
298
+ type="text"
299
+ value={query}
300
+ onChange={(e) => handleInputChange(e.target.value)}
301
+ onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}
302
+ placeholder="Search documents..."
303
+ className="flex-1 bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
304
+ />
305
+ {loading && <div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />}
306
+ {(query || results.length > 0) && (
307
+ <button onClick={resetSearch} className="text-muted-foreground hover:text-foreground" title="Reset search">
308
+ <RotateCcw className="h-4 w-4" />
309
+ </button>
310
+ )}
311
+ <button onClick={onClose} className="text-muted-foreground hover:text-foreground" title="Close (Esc)">
312
+ <X className="h-5 w-5" />
313
+ </button>
314
+ </div>
315
+
316
+ {/* Schema-driven facet filters */}
317
+ {facets.length > 0 && (
318
+ <div className="px-4 py-2 border-b border-border flex flex-col gap-1.5">
319
+ {facets
320
+ .filter((f) => f.values.length > 0 && f.uiHint !== 'hidden')
321
+ .map((f) => (
322
+ <FilterChips
323
+ key={f.field}
324
+ label={f.field.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
325
+ values={f.values}
326
+ selected={facetSelections[f.field] ?? new Set()}
327
+ onToggle={(v) => toggleFacet(f.field, v)}
328
+ />
329
+ ))}
330
+ </div>
331
+ )}
332
+
333
+ {/* Post-hoc filter chips — Type always shown when results exist */}
334
+ {results.length > 0 && (
335
+ <div className="px-4 py-2 border-b border-border flex flex-col gap-1.5">
336
+ <FilterChips
337
+ label="Type"
338
+ values={extensions}
339
+ selected={extFilter}
340
+ onToggle={(v) => toggleFilter(extFilter, setExtFilter, v)}
341
+ />
342
+ <FilterChips
343
+ label="Domain"
344
+ values={metadata.domains}
345
+ selected={domainFilter}
346
+ onToggle={(v) => toggleFilter(domainFilter, setDomainFilter, v)}
347
+ />
348
+ <FilterChips
349
+ label="Author"
350
+ values={metadata.authors}
351
+ selected={authorFilter}
352
+ onToggle={(v) => toggleFilter(authorFilter, setAuthorFilter, v)}
353
+ />
354
+ {/* Date range filter */}
355
+ <div className="flex items-center gap-1.5 flex-wrap">
356
+ <span className="text-xs text-muted-foreground font-medium">Date:</span>
357
+ {DATE_PRESETS.map((p) => (
358
+ <button
359
+ key={p.value}
360
+ onClick={() => setDatePreset(datePreset === p.value ? null : p.value)}
361
+ className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
362
+ datePreset === p.value
363
+ ? 'bg-primary text-primary-foreground border-primary'
364
+ : 'bg-muted text-muted-foreground border-border hover:bg-accent'
365
+ }`}
366
+ >
367
+ {p.label}
368
+ </button>
369
+ ))}
370
+ {datePreset === 'custom' && (
371
+ <>
372
+ <input
373
+ type="month"
374
+ value={dateFrom}
375
+ onChange={(e) => setDateFrom(e.target.value ? e.target.value + '-01' : '')}
376
+ className="text-xs px-1.5 py-0.5 rounded border border-border bg-muted text-foreground w-28"
377
+ placeholder="From"
378
+ />
379
+ <span className="text-xs text-muted-foreground">to</span>
380
+ <input
381
+ type="month"
382
+ value={dateTo}
383
+ onChange={(e) => setDateTo(e.target.value ? e.target.value + '-28' : '')}
384
+ className="text-xs px-1.5 py-0.5 rounded border border-border bg-muted text-foreground w-28"
385
+ placeholder="To"
386
+ />
387
+ </>
388
+ )}
389
+ </div>
390
+ </div>
391
+ )}
392
+
393
+ {/* Results */}
394
+ <div className="overflow-y-auto flex-1">
395
+ {error && (
396
+ <div className="px-4 py-3 text-sm text-red-500">{error}</div>
397
+ )}
398
+ {!error && filtered.length === 0 && query.trim() && !loading && (
399
+ <div className="px-4 py-8 text-center text-muted-foreground text-sm">
400
+ No results found
401
+ </div>
402
+ )}
403
+ {!error && !query.trim() && !loading && (
404
+ <div className="px-4 py-8 text-center text-muted-foreground text-sm">
405
+ Type a query to search across all documents
406
+ </div>
407
+ )}
408
+ {filtered.map((r) => (
409
+ <ResultRow key={r.browsePath} result={r} onNavigate={handleNavigate} />
410
+ ))}
411
+ </div>
412
+
413
+ {/* Footer */}
414
+ {filtered.length > 0 && (
415
+ <div className="px-4 py-2 border-t border-border text-xs text-muted-foreground">
416
+ {filtered.length} result{filtered.length !== 1 ? 's' : ''}
417
+ {filtered.length < results.length && ` (${results.length} total, ${results.length - filtered.length} filtered)`}
418
+ </div>
419
+ )}
420
+ </div>
421
+ </div>
422
+ );
423
+ }
424
+
@@ -0,0 +1,107 @@
1
+ import Panzoom from '@panzoom/panzoom';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { normalizeSvg } from '@/lib/svg';
4
+
5
+ interface SvgViewerProps {
6
+ content: string;
7
+ }
8
+
9
+ /**
10
+ * Pre-process SVG for the full-page viewer.
11
+ * Uses shared normalization then adds viewer-specific overrides.
12
+ */
13
+ function prepareSvgContent(raw: string): string {
14
+ // Use shared normalization (viewBox, preserveAspectRatio, strip sizing)
15
+ const normalized = normalizeSvg(raw);
16
+ const parser = new DOMParser();
17
+ const doc = parser.parseFromString(normalized, 'image/svg+xml');
18
+ const svg = doc.querySelector('svg');
19
+ if (!svg) return normalized;
20
+
21
+ // Viewer-specific: fill container in both dimensions
22
+ svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
23
+ svg.setAttribute('height', '100%');
24
+ svg.style.display = 'block';
25
+
26
+ return new XMLSerializer().serializeToString(doc);
27
+ }
28
+
29
+ export function SvgViewer({ content }: SvgViewerProps) {
30
+ const containerRef = useRef<HTMLDivElement>(null);
31
+ const innerRef = useRef<HTMLDivElement>(null);
32
+ const panzoomRef = useRef<ReturnType<typeof Panzoom> | null>(null);
33
+ const wheelCleanupRef = useRef<(() => void) | null>(null);
34
+ const [ready, setReady] = useState(false);
35
+
36
+ const html = useMemo(() => prepareSvgContent(content), [content]);
37
+
38
+ const initPanzoom = useCallback(() => {
39
+ // Clean up previous instance
40
+ wheelCleanupRef.current?.();
41
+ wheelCleanupRef.current = null;
42
+ if (panzoomRef.current) {
43
+ panzoomRef.current.destroy();
44
+ panzoomRef.current = null;
45
+ }
46
+ if (!innerRef.current || !containerRef.current) return;
47
+
48
+ const container = containerRef.current;
49
+ const inner = innerRef.current;
50
+ const cw = container.clientWidth;
51
+ const ch = container.clientHeight;
52
+
53
+ if (cw === 0 || ch === 0) return;
54
+
55
+ // Inner div fills the container; the SVG inside zoom-to-fits via
56
+ // viewBox + preserveAspectRatio. Panzoom then handles user zoom/pan
57
+ // starting from this natural fit.
58
+ inner.style.width = `${cw}px`;
59
+ inner.style.height = `${ch}px`;
60
+
61
+ const pz = Panzoom(inner, {
62
+ maxScale: 20,
63
+ minScale: 0.1,
64
+ startScale: 1,
65
+ });
66
+ panzoomRef.current = pz;
67
+
68
+ const wheelHandler = (e: WheelEvent) => {
69
+ pz.zoomWithWheel(e);
70
+ };
71
+ container.addEventListener('wheel', wheelHandler, { passive: false });
72
+ wheelCleanupRef.current = () => container.removeEventListener('wheel', wheelHandler);
73
+
74
+ setReady(true);
75
+ }, []);
76
+
77
+ useEffect(() => {
78
+ const container = containerRef.current;
79
+ if (!container) return;
80
+
81
+ const observer = new ResizeObserver(() => {
82
+ initPanzoom();
83
+ });
84
+ observer.observe(container);
85
+
86
+ return () => {
87
+ observer.disconnect();
88
+ wheelCleanupRef.current?.();
89
+ panzoomRef.current?.destroy();
90
+ };
91
+ }, [html, initPanzoom]);
92
+
93
+ return (
94
+ <div className="relative bg-white rounded-lg border border-border overflow-hidden">
95
+ <div
96
+ ref={containerRef}
97
+ className="overflow-hidden cursor-grab active:cursor-grabbing w-full h-[calc(100vh-8rem)]"
98
+ >
99
+ <div
100
+ ref={innerRef}
101
+ className={ready ? '' : 'invisible'}
102
+ dangerouslySetInnerHTML={{ __html: html }}
103
+ />
104
+ </div>
105
+ </div>
106
+ );
107
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Tab bar for file views — Rendered/Raw tabs, prose width toggle, and edit button.
3
+ */
4
+ import { Menu, Minus, Minimize2, Maximize2, Pencil, X } from 'lucide-react';
5
+
6
+ import type { FileContent } from '@/lib/api';
7
+ import { isRenderable, isRenderableExt } from './renderableUtils';
8
+
9
+ interface TabBarProps {
10
+ reqPath: string;
11
+ file: FileContent | null;
12
+ fileRendered: FileContent | null;
13
+ viewTab: 'rendered' | 'raw';
14
+ setViewTab: (tab: 'rendered' | 'raw') => void;
15
+ proseWidth: 'narrow' | 'medium' | 'wide';
16
+ toggleProseWidth: (w: 'narrow' | 'medium' | 'wide') => void;
17
+ isInsider: boolean;
18
+ editing: boolean;
19
+ setEditing: (editing: boolean) => void;
20
+ mobileTocOpen: boolean;
21
+ setMobileTocOpen: (open: boolean) => void;
22
+ loading: boolean;
23
+ }
24
+
25
+ export function TabBar({
26
+ reqPath, file, fileRendered, viewTab, setViewTab,
27
+ proseWidth, toggleProseWidth,
28
+ isInsider, editing, setEditing,
29
+ mobileTocOpen, setMobileTocOpen, loading,
30
+ }: TabBarProps) {
31
+ if (!file && !loading) return null;
32
+
33
+ const renderable = file ? isRenderable(file) : isRenderableExt(reqPath);
34
+ const activeTab = renderable ? viewTab : 'raw';
35
+
36
+ return (
37
+ <div className="flex items-center gap-1 border-b border-border bg-background px-4 md:px-6">
38
+ {fileRendered?.headings && fileRendered.headings.length > 2 && (
39
+ <button
40
+ onClick={() => setMobileTocOpen(!mobileTocOpen)}
41
+ className="lg:hidden p-1.5 mr-1 text-muted-foreground hover:text-foreground transition-colors"
42
+ title="Table of contents"
43
+ >
44
+ {mobileTocOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
45
+ </button>
46
+ )}
47
+ {renderable && (
48
+ <button
49
+ onClick={() => setViewTab('rendered')}
50
+ className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
51
+ activeTab === 'rendered'
52
+ ? 'border-blue-500 text-blue-500'
53
+ : 'border-transparent text-muted-foreground hover:text-foreground'
54
+ }`}
55
+ >
56
+ Rendered
57
+ </button>
58
+ )}
59
+ <button
60
+ onClick={() => setViewTab('raw')}
61
+ className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
62
+ activeTab === 'raw'
63
+ ? 'border-blue-500 text-blue-500'
64
+ : 'border-transparent text-muted-foreground hover:text-foreground'
65
+ }`}
66
+ >
67
+ Raw
68
+ </button>
69
+ {file?.type === 'markdown' && activeTab === 'rendered' && (
70
+ <div className="hidden md:flex items-center ml-2 border border-border rounded-md overflow-hidden">
71
+ {(['narrow', 'medium', 'wide'] as const).map((w) => (
72
+ <button
73
+ key={w}
74
+ onClick={() => toggleProseWidth(w)}
75
+ className={`p-1.5 transition-colors ${
76
+ proseWidth === w ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground'
77
+ }`}
78
+ title={`${w.charAt(0).toUpperCase() + w.slice(1)} width`}
79
+ >
80
+ {w === 'narrow' ? <Minimize2 className="h-3.5 w-3.5" /> : w === 'medium' ? <Minus className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
81
+ </button>
82
+ ))}
83
+ </div>
84
+ )}
85
+ {isInsider && activeTab === 'raw' && file?.content != null && !editing && (
86
+ <button
87
+ onClick={() => setEditing(true)}
88
+ className="ml-2 flex items-center gap-1 px-2 py-1 text-sm text-muted-foreground hover:text-foreground border border-border rounded transition-colors"
89
+ title="Edit file"
90
+ >
91
+ <Pencil className="h-3.5 w-3.5" /> Edit
92
+ </button>
93
+ )}
94
+ </div>
95
+ );
96
+ }