documentation-hub 5.7.2

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 (271) hide show
  1. package/.eslintrc.json +43 -0
  2. package/.github/workflows/build.yml +64 -0
  3. package/.github/workflows/ci.yml +39 -0
  4. package/.vscode/extensions.json +3 -0
  5. package/Current.md +97 -0
  6. package/DocHub_Image.png +0 -0
  7. package/README.md +666 -0
  8. package/USER_GUIDE.md +1173 -0
  9. package/Updater.md +311 -0
  10. package/build/256x256.png +0 -0
  11. package/build/512x512.png +0 -0
  12. package/build/app-update.yml +4 -0
  13. package/build/create-icon.js +208 -0
  14. package/build/icon.ico +0 -0
  15. package/build/icon.png +0 -0
  16. package/build/icon_1024x1024.png +0 -0
  17. package/dist/assets/Analytics-BpsG9895.js +1 -0
  18. package/dist/assets/Card-IAZin8kp.js +1 -0
  19. package/dist/assets/CurrentSession-B-rFkHvf.js +12 -0
  20. package/dist/assets/Dashboard-C_5gMb0q.js +1 -0
  21. package/dist/assets/Documents-CqZ25axS.js +1 -0
  22. package/dist/assets/Input-l89xwXBi.js +1 -0
  23. package/dist/assets/Reporting-DqdHJY_a.js +1 -0
  24. package/dist/assets/Search-XNbu5z_3.js +1 -0
  25. package/dist/assets/SessionManager-lH9hZfzH.js +1 -0
  26. package/dist/assets/Sessions-ClZOPYNc.js +1 -0
  27. package/dist/assets/Settings-DUEHGURa.js +11 -0
  28. package/dist/assets/index-8xUe8ptc.js +24 -0
  29. package/dist/assets/index-RYyJqF7O.css +1 -0
  30. package/dist/assets/path-BkOl0AGO.js +1 -0
  31. package/dist/assets/promises-ID_B9S-h.js +1 -0
  32. package/dist/assets/urlHelpers-TvgahX0r.js +1 -0
  33. package/dist/assets/useToast-yRSO1dkm.js +1 -0
  34. package/dist/assets/vendor-charts-RkGK5ROP.js +36 -0
  35. package/dist/assets/vendor-db-l0sNRNKZ.js +1 -0
  36. package/dist/assets/vendor-react-BVZ_anCF.js +4 -0
  37. package/dist/assets/vendor-search-Dw8P0qyA.js +1 -0
  38. package/dist/assets/vendor-ui-BU7NfluV.js +53 -0
  39. package/dist/electron/PowerAutomateApiService-LfW09ZGr.js +147 -0
  40. package/dist/electron/main-CXkNtyv-.js +19789 -0
  41. package/dist/electron/main.js +5 -0
  42. package/dist/electron/preload.js +1 -0
  43. package/dist/icon.png +0 -0
  44. package/dist/index.html +27 -0
  45. package/docs/CODEBASE_ANALYSIS_REPORT.md +309 -0
  46. package/docs/DEBUG_LOGGING_GUIDE.md +244 -0
  47. package/docs/README.md +115 -0
  48. package/docs/TOC_WIRING_GUIDE.md +344 -0
  49. package/docs/analysis/Bullet_Symbol_Bug_Analysis.md +136 -0
  50. package/docs/analysis/DOCXMLATER_ANALYSIS_SUMMARY.txt +169 -0
  51. package/docs/analysis/Document_Processing_Issues_Analysis.md +704 -0
  52. package/docs/analysis/FIELD_PRESERVATION_ANALYSIS.md +1200 -0
  53. package/docs/analysis/INDENTATION_PRESERVE_ANALYSIS.md +181 -0
  54. package/docs/analysis/INDENTATION_PRESERVE_IMPLEMENTATION.md +207 -0
  55. package/docs/analysis/List_Implementation.md +206 -0
  56. package/docs/analysis/List_Implementation_Accuracy_Report.md +366 -0
  57. package/docs/analysis/PROCESSING_OPTIONS_UI_UPDATES.md +220 -0
  58. package/docs/analysis/RefactorStyles.md +852 -0
  59. package/docs/analysis/STYLE_PARAMETER_ENHANCEMENT.md +143 -0
  60. package/docs/analysis/docxmlater-comparison-todo-2025-11-13.md +636 -0
  61. package/docs/analysis/docxmlater-implementation-analysis-2025-11-13.md +340 -0
  62. package/docs/analysis/docxmlater-template_ui-integration-analysis.md +263 -0
  63. package/docs/analysis/github-issues-to-create.md +237 -0
  64. package/docs/api/API_README.md +538 -0
  65. package/docs/api/API_REFERENCE.md +751 -0
  66. package/docs/api/TYPE_DEFINITIONS.md +869 -0
  67. package/docs/architecture/FONT_EMBEDDING_GUIDE.md +318 -0
  68. package/docs/architecture/docxmlater-functions-and-structure.md +726 -0
  69. package/docs/docxmlater-readme.md +1341 -0
  70. package/docs/fixes/EXECUTION_LOG_TEST_BASE.md +573 -0
  71. package/docs/fixes/HYPERLINK_TEXT_SANITIZATION.md +253 -0
  72. package/docs/fixes/README.md +37 -0
  73. package/docs/github-issues/issue-1-body.md +125 -0
  74. package/docs/github-issues/issue-10-body.md +850 -0
  75. package/docs/github-issues/issue-2-body.md +200 -0
  76. package/docs/github-issues/issue-3-body.md +270 -0
  77. package/docs/github-issues/issue-4-body.md +169 -0
  78. package/docs/github-issues/issue-5-body.md +173 -0
  79. package/docs/github-issues/issue-6-body.md +158 -0
  80. package/docs/github-issues/issue-7-body.md +171 -0
  81. package/docs/github-issues/issue-8-body.md +407 -0
  82. package/docs/github-issues/issue-9-body.md +515 -0
  83. package/docs/github-issues/issue-tracker.md +274 -0
  84. package/docs/github-issues/predictive-analysis-2025-10-18.md +2131 -0
  85. package/docs/implementation/List_Framework_Refactor_Plan.md +336 -0
  86. package/docs/implementation/PRIMARY_TEXT_COLOR_FEATURE.md +217 -0
  87. package/docs/implementation/RELEASE_PLAN_v2.1.0.md +362 -0
  88. package/docs/implementation/RefactorStyles.md +588 -0
  89. package/docs/implementation/implement-plan.md +489 -0
  90. package/docs/implementation/missing-helpers-implementation.md +391 -0
  91. package/docs/implementation/refactor-plan.md +520 -0
  92. package/docs/implementation/session-implementation-complete.md +233 -0
  93. package/docs/implementation/session-management-plan.md +250 -0
  94. package/docs/setup-checklist.md +77 -0
  95. package/docs/versions/changelog.md +345 -0
  96. package/electron/customUpdater.ts +656 -0
  97. package/electron/main.ts +2441 -0
  98. package/electron/memoryConfig.ts +187 -0
  99. package/electron/preload.ts +394 -0
  100. package/electron/proxyConfig.ts +340 -0
  101. package/electron/services/BackupService.ts +452 -0
  102. package/electron/services/DictionaryService.ts +402 -0
  103. package/electron/services/LocalDictionaryLookupService.ts +147 -0
  104. package/electron/services/PowerAutomateApiService.ts +231 -0
  105. package/electron/services/SharePointSyncService.ts +474 -0
  106. package/electron/windowsCertStore.ts +427 -0
  107. package/electron/zscalerConfig.ts +381 -0
  108. package/eslint.config.js +92 -0
  109. package/jest.config.js +52 -0
  110. package/package.json +214 -0
  111. package/postcss.config.mjs +6 -0
  112. package/public/icon.png +0 -0
  113. package/publish-release.ps1 +5 -0
  114. package/renovate.json +30 -0
  115. package/src/App.tsx +216 -0
  116. package/src/__mocks__/p-limit.js +12 -0
  117. package/src/__mocks__/styleMock.js +1 -0
  118. package/src/components/common/BugReportButton.tsx +44 -0
  119. package/src/components/common/BugReportDialog.tsx +193 -0
  120. package/src/components/common/Button.tsx +153 -0
  121. package/src/components/common/Card.tsx +86 -0
  122. package/src/components/common/ColorPickerDialog.tsx +177 -0
  123. package/src/components/common/ConfirmDialog.tsx +96 -0
  124. package/src/components/common/DebugConsole.tsx +275 -0
  125. package/src/components/common/EmptyState.tsx +183 -0
  126. package/src/components/common/ErrorBoundary.tsx +98 -0
  127. package/src/components/common/ErrorDetailsDialog.tsx +153 -0
  128. package/src/components/common/ErrorFallback.tsx +218 -0
  129. package/src/components/common/Input.tsx +109 -0
  130. package/src/components/common/Skeleton.tsx +184 -0
  131. package/src/components/common/SplashScreen.tsx +81 -0
  132. package/src/components/common/Toast.tsx +155 -0
  133. package/src/components/common/Tooltip.tsx +79 -0
  134. package/src/components/common/UpdateNotification.tsx +320 -0
  135. package/src/components/comparison/ComparisonWindow.tsx +374 -0
  136. package/src/components/comparison/SideBySideDiff.tsx +486 -0
  137. package/src/components/comparison/index.ts +8 -0
  138. package/src/components/document/DocumentUploader.tsx +288 -0
  139. package/src/components/document/HyperlinkPreview.tsx +430 -0
  140. package/src/components/document/HyperlinkService.md +1484 -0
  141. package/src/components/document/Hyperlink_Technical_Documentation.md +496 -0
  142. package/src/components/document/InlineChangesView.tsx +707 -0
  143. package/src/components/document/ProcessingProgress.tsx +303 -0
  144. package/src/components/document/ProcessingResults.tsx +256 -0
  145. package/src/components/document/TrackedChangesDetail.tsx +530 -0
  146. package/src/components/document/TrackedChangesPanel.tsx +546 -0
  147. package/src/components/document/VirtualDocumentList.tsx +240 -0
  148. package/src/components/editor/DocumentEditor.tsx +723 -0
  149. package/src/components/editor/DocumentEditorModal.tsx +640 -0
  150. package/src/components/editor/EditorQuickActions.tsx +502 -0
  151. package/src/components/editor/EditorToolbar.tsx +312 -0
  152. package/src/components/editor/TableEditor.tsx +926 -0
  153. package/src/components/editor/index.ts +18 -0
  154. package/src/components/layout/Header.tsx +190 -0
  155. package/src/components/layout/Sidebar.tsx +313 -0
  156. package/src/components/layout/TitleBar.tsx +190 -0
  157. package/src/components/navigation/CommandPalette.tsx +233 -0
  158. package/src/components/navigation/KeyboardShortcutsModal.tsx +173 -0
  159. package/src/components/sessions/ChangeItem.tsx +408 -0
  160. package/src/components/sessions/ChangeViewer.tsx +1155 -0
  161. package/src/components/sessions/DocumentComparisonModal.tsx +314 -0
  162. package/src/components/sessions/ProcessingOptions.tsx +297 -0
  163. package/src/components/sessions/ReplacementsTab.tsx +438 -0
  164. package/src/components/sessions/RevisionHandlingOptions.tsx +87 -0
  165. package/src/components/sessions/SessionManager.tsx +188 -0
  166. package/src/components/sessions/StylesEditor.tsx +1335 -0
  167. package/src/components/sessions/TabContainer.tsx +151 -0
  168. package/src/components/sessions/VirtualSessionList.tsx +157 -0
  169. package/src/components/sessions/sessionToProcessorManager.tsx +420 -0
  170. package/src/components/settings/CertificateManager.tsx +410 -0
  171. package/src/components/settings/SegmentedControl.tsx +88 -0
  172. package/src/components/settings/SettingRow.tsx +52 -0
  173. package/src/contexts/GlobalStatsContext.tsx +396 -0
  174. package/src/contexts/SessionContext.tsx +2129 -0
  175. package/src/contexts/ThemeContext.tsx +428 -0
  176. package/src/contexts/UserSettingsContext.tsx +290 -0
  177. package/src/contexts/__tests__/GlobalStatsContext.test.tsx +390 -0
  178. package/src/global.d.ts +273 -0
  179. package/src/hooks/useDocumentQueue.tsx +210 -0
  180. package/src/hooks/useToast.tsx +55 -0
  181. package/src/main.tsx +10 -0
  182. package/src/pages/Analytics.tsx +386 -0
  183. package/src/pages/CurrentSession.tsx +1174 -0
  184. package/src/pages/Dashboard.tsx +319 -0
  185. package/src/pages/Documents.tsx +317 -0
  186. package/src/pages/Projects.tsx +250 -0
  187. package/src/pages/Reporting.tsx +386 -0
  188. package/src/pages/Search.tsx +349 -0
  189. package/src/pages/Sessions.tsx +285 -0
  190. package/src/pages/Settings.tsx +2662 -0
  191. package/src/services/HyperlinkService.ts +1085 -0
  192. package/src/services/document/DocXMLaterProcessor.ts +617 -0
  193. package/src/services/document/DocumentProcessingComparison.ts +856 -0
  194. package/src/services/document/DocumentSnapshotService.ts +575 -0
  195. package/src/services/document/WordDocumentProcessor.ts +10509 -0
  196. package/src/services/document/__tests__/DocXMLaterProcessor.hyperlinks.test.md +311 -0
  197. package/src/services/document/__tests__/WordDocumentProcessor.integration.test.ts +515 -0
  198. package/src/services/document/__tests__/WordDocumentProcessor.test.ts +812 -0
  199. package/src/services/document/blanklines/BlankLineManager.ts +658 -0
  200. package/src/services/document/blanklines/__tests__/paragraphChecks.test.ts +281 -0
  201. package/src/services/document/blanklines/helpers/blankLineInsertion.ts +87 -0
  202. package/src/services/document/blanklines/helpers/blankLineSnapshot.ts +251 -0
  203. package/src/services/document/blanklines/helpers/clearCustom.ts +121 -0
  204. package/src/services/document/blanklines/helpers/contextChecks.ts +117 -0
  205. package/src/services/document/blanklines/helpers/imageChecks.ts +51 -0
  206. package/src/services/document/blanklines/helpers/paragraphChecks.ts +236 -0
  207. package/src/services/document/blanklines/helpers/removeBlanksBetweenListItems.ts +91 -0
  208. package/src/services/document/blanklines/helpers/removeTrailingBlanks.ts +35 -0
  209. package/src/services/document/blanklines/helpers/tableGuards.ts +21 -0
  210. package/src/services/document/blanklines/index.ts +67 -0
  211. package/src/services/document/blanklines/rules/additionRules.ts +337 -0
  212. package/src/services/document/blanklines/rules/indentationRules.ts +317 -0
  213. package/src/services/document/blanklines/rules/removalRules.ts +362 -0
  214. package/src/services/document/blanklines/rules/ruleTypes.ts +92 -0
  215. package/src/services/document/blanklines/types.ts +29 -0
  216. package/src/services/document/helpers/ImageBorderCropper.ts +377 -0
  217. package/src/services/document/helpers/__tests__/whitespace.test.ts +272 -0
  218. package/src/services/document/helpers/whitespace.ts +117 -0
  219. package/src/services/document/list/ListNormalizer.ts +947 -0
  220. package/src/services/document/list/index.ts +45 -0
  221. package/src/services/document/list/list-detection.ts +275 -0
  222. package/src/services/document/list/list-types.ts +162 -0
  223. package/src/services/document/processors/HyperlinkProcessor.ts +370 -0
  224. package/src/services/document/processors/ListProcessor.ts +257 -0
  225. package/src/services/document/processors/StructureProcessor.ts +176 -0
  226. package/src/services/document/processors/StyleProcessor.ts +389 -0
  227. package/src/services/document/processors/TableProcessor.ts +2238 -0
  228. package/src/services/document/processors/__tests__/HyperlinkProcessor.test.ts +314 -0
  229. package/src/services/document/processors/__tests__/ListProcessor.test.ts +291 -0
  230. package/src/services/document/processors/__tests__/StructureProcessor.test.ts +257 -0
  231. package/src/services/document/processors/__tests__/TableProcessor.hlp-tips-bullets.test.ts +459 -0
  232. package/src/services/document/processors/__tests__/TableProcessor.test.ts +1604 -0
  233. package/src/services/document/processors/index.ts +28 -0
  234. package/src/services/document/types/docx-processing.ts +310 -0
  235. package/src/services/editor/EditorActionHandlers.ts +901 -0
  236. package/src/services/editor/index.ts +13 -0
  237. package/src/setupTests.ts +47 -0
  238. package/src/styles/global.css +782 -0
  239. package/src/types/backup.ts +132 -0
  240. package/src/types/dictionary.ts +125 -0
  241. package/src/types/document-processing.ts +331 -0
  242. package/src/types/docxmlater-augments.d.ts +142 -0
  243. package/src/types/editor.ts +280 -0
  244. package/src/types/electron.ts +340 -0
  245. package/src/types/globalStats.ts +155 -0
  246. package/src/types/hyperlink.ts +471 -0
  247. package/src/types/operations.ts +354 -0
  248. package/src/types/session.ts +427 -0
  249. package/src/types/settings.ts +112 -0
  250. package/src/utils/MemoryMonitor.ts +248 -0
  251. package/src/utils/cn.ts +6 -0
  252. package/src/utils/colorConvert.ts +306 -0
  253. package/src/utils/diffUtils.ts +347 -0
  254. package/src/utils/documentUtils.ts +202 -0
  255. package/src/utils/electronGuard.ts +62 -0
  256. package/src/utils/indexedDB.ts +915 -0
  257. package/src/utils/logger.ts +717 -0
  258. package/src/utils/pathSecurity.ts +232 -0
  259. package/src/utils/pathValidator.ts +236 -0
  260. package/src/utils/processingTimeEstimator.ts +153 -0
  261. package/src/utils/safeJsonParse.ts +62 -0
  262. package/src/utils/textSanitizer.ts +162 -0
  263. package/src/utils/urlHelpers.ts +304 -0
  264. package/src/utils/urlPatterns.ts +198 -0
  265. package/src/utils/urlSanitizer.ts +152 -0
  266. package/src/vite-env.d.ts +11 -0
  267. package/tsconfig.electron.json +19 -0
  268. package/tsconfig.json +36 -0
  269. package/tsconfig.node.json +12 -0
  270. package/typedoc.json +45 -0
  271. package/vite.config.ts +152 -0
@@ -0,0 +1,1155 @@
1
+ /**
2
+ * ChangeViewer - Unified viewer for Word revisions and DocHub processing changes
3
+ *
4
+ * Displays both Word tracked changes (from docxmlater) and DocHub processing changes
5
+ * in a unified, filterable interface with export capabilities.
6
+ */
7
+
8
+ import { Button } from '@/components/common/Button';
9
+ import { useSession } from '@/contexts/SessionContext';
10
+ import type {
11
+ ChangeCategory,
12
+ ChangeEntry,
13
+ Document,
14
+ PreviousRevisionState,
15
+ UnifiedChange,
16
+ } from '@/types/session';
17
+ import { cn } from '@/utils/cn';
18
+ import { AnimatePresence, motion } from 'framer-motion';
19
+ import {
20
+ Bookmark,
21
+ Box,
22
+ ChevronDown,
23
+ ChevronRight,
24
+ ClipboardCopy,
25
+ Columns,
26
+ FileText,
27
+ Filter,
28
+ Hash,
29
+ History,
30
+ Image,
31
+ Link,
32
+ MessageCircle,
33
+ Minus,
34
+ Paintbrush,
35
+ Plus,
36
+ Settings,
37
+ Table,
38
+ User,
39
+ X,
40
+ } from 'lucide-react';
41
+ import { useCallback, useMemo, useState, useRef, useEffect } from 'react';
42
+ import { ChangeItem } from './ChangeItem';
43
+ // DEFERRED: Side-by-side document comparison feature for future implementation
44
+ // import { DocumentComparisonModal } from './DocumentComparisonModal';
45
+
46
+ interface ChangeViewerProps {
47
+ sessionId: string;
48
+ expandDocumentId?: string | null;
49
+ onExpandHandled?: () => void;
50
+ }
51
+
52
+ type SourceFilter = 'all' | 'word' | 'processing';
53
+ type CategoryFilter = 'all' | ChangeCategory;
54
+
55
+ /**
56
+ * Converts Word revision entries to unified format
57
+ * All changes (both original Word changes and DocHub changes) come from wordRevisions.entries
58
+ * DocHub changes are identified by matching the processingAuthor from the document
59
+ */
60
+ function getUnifiedChanges(document: Document): UnifiedChange[] {
61
+ if (!document.wordRevisions?.entries) {
62
+ return [];
63
+ }
64
+
65
+ // Get the processing author used for this document's DocHub changes
66
+ // This allows proper source detection regardless of the actual author name used
67
+ const processingAuthor = document.wordRevisions.processingAuthor;
68
+
69
+ const rawChanges = document.wordRevisions.entries.map((entry: ChangeEntry) => ({
70
+ id: entry.id,
71
+ // Distinguish source by comparing to the actual processing author, not a hardcoded string
72
+ source: processingAuthor && entry.author === processingAuthor
73
+ ? ('processing' as const)
74
+ : ('word' as const),
75
+ category: entry.category,
76
+ description: entry.description,
77
+ author: entry.author,
78
+ date: entry.date,
79
+ location: entry.location
80
+ ? {
81
+ paragraphIndex: entry.location.paragraphIndex,
82
+ nearestHeading: entry.location.nearestHeading,
83
+ }
84
+ : undefined,
85
+ before: entry.content?.before,
86
+ after: entry.content?.after,
87
+ // Extract affected text: prefer explicit affectedText, fallback to before/after content
88
+ affectedText: entry.content?.affectedText || entry.content?.before || entry.content?.after,
89
+ hyperlinkChange: entry.content?.hyperlinkChange,
90
+ propertyChange: entry.propertyChange,
91
+ }));
92
+
93
+ // Apply processing pipeline:
94
+ // 1. Filter out unhelpful changes (empty spaces, etc.)
95
+ // 2. Combine delete+insert pairs into "Updated" changes
96
+ // 3. Group formatting changes that affect the same text
97
+ const filtered = rawChanges.filter(isHelpfulChange);
98
+ const combined = combineDeleteInsertPairs(filtered);
99
+ return groupPropertyChanges(combined);
100
+ }
101
+
102
+ /**
103
+ * Gets pre-existing tracked changes that were in the document BEFORE DocHub processing
104
+ * These are stored separately in document.previousRevisions
105
+ */
106
+ function getPreviousChanges(document: Document): UnifiedChange[] {
107
+ if (!document.previousRevisions?.entries || document.previousRevisions.entries.length === 0) {
108
+ return [];
109
+ }
110
+
111
+ const rawChanges = document.previousRevisions.entries.map((entry: ChangeEntry) => ({
112
+ id: `previous-${entry.id}`,
113
+ // All previous changes are marked as 'word' source since they came from Word before DocHub
114
+ source: 'word' as const,
115
+ category: entry.category,
116
+ description: entry.description,
117
+ author: entry.author,
118
+ date: entry.date,
119
+ location: entry.location
120
+ ? {
121
+ paragraphIndex: entry.location.paragraphIndex,
122
+ nearestHeading: entry.location.nearestHeading,
123
+ }
124
+ : undefined,
125
+ before: entry.content?.before,
126
+ after: entry.content?.after,
127
+ affectedText: entry.content?.affectedText || entry.content?.before || entry.content?.after,
128
+ hyperlinkChange: entry.content?.hyperlinkChange,
129
+ propertyChange: entry.propertyChange,
130
+ }));
131
+
132
+ // Apply processing pipeline:
133
+ // 1. Filter out unhelpful changes (empty spaces, etc.)
134
+ // 2. Combine delete+insert pairs into "Updated" changes
135
+ // 3. Group formatting changes that affect the same text
136
+ const filtered = rawChanges.filter(isHelpfulChange);
137
+ const combined = combineDeleteInsertPairs(filtered);
138
+ return groupPropertyChanges(combined);
139
+ }
140
+
141
+ /**
142
+ * Filters out unhelpful/trivial changes that don't provide meaningful context
143
+ * Examples: empty spaces, whitespace-only insertions/deletions
144
+ */
145
+ function isHelpfulChange(change: UnifiedChange): boolean {
146
+ // Always keep hyperlink changes - they're meaningful
147
+ if (change.hyperlinkChange) {
148
+ return true;
149
+ }
150
+
151
+ // Always keep property/formatting changes
152
+ if (change.propertyChange) {
153
+ return true;
154
+ }
155
+
156
+ // Check if content is meaningful
157
+ const beforeTrimmed = change.before?.trim() || '';
158
+ const afterTrimmed = change.after?.trim() || '';
159
+ const affectedTrimmed = change.affectedText?.trim() || '';
160
+
161
+ // Skip if all content fields are empty/whitespace
162
+ if (!beforeTrimmed && !afterTrimmed && !affectedTrimmed) {
163
+ return false;
164
+ }
165
+
166
+ // Skip changes that are just single spaces or very short whitespace
167
+ if (
168
+ (beforeTrimmed.length === 0 && afterTrimmed.length <= 1) ||
169
+ (afterTrimmed.length === 0 && beforeTrimmed.length <= 1)
170
+ ) {
171
+ // Allow if it's part of a larger description that's meaningful
172
+ const desc = change.description?.toLowerCase() || '';
173
+ if (desc.includes('inserted " "') || desc.includes('deleted " "') ||
174
+ desc.includes("inserted ' '") || desc.includes("deleted ' '")) {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ return true;
180
+ }
181
+
182
+ /**
183
+ * Combines deletion + insertion pairs at the same location into "Updated" changes
184
+ * This provides cleaner display when text is replaced rather than showing separate delete/insert
185
+ */
186
+ function combineDeleteInsertPairs(changes: UnifiedChange[]): UnifiedChange[] {
187
+ const result: UnifiedChange[] = [];
188
+ const processed = new Set<string>();
189
+
190
+ // Collect all deletions and insertions for potential pairing
191
+ const deletions: UnifiedChange[] = [];
192
+ const insertions: UnifiedChange[] = [];
193
+
194
+ for (const change of changes) {
195
+ // Only consider content changes for pairing
196
+ if (change.category === 'content') {
197
+ const isDeletion = change.before && !change.after;
198
+ const isInsertion = change.after && !change.before;
199
+
200
+ if (isDeletion) {
201
+ deletions.push(change);
202
+ continue;
203
+ }
204
+ if (isInsertion) {
205
+ insertions.push(change);
206
+ continue;
207
+ }
208
+ }
209
+
210
+ // Non-pairable changes go directly to result
211
+ result.push(change);
212
+ }
213
+
214
+ // Try to pair deletions with insertions using multiple matching strategies
215
+ for (const deletion of deletions) {
216
+ if (processed.has(deletion.id)) continue;
217
+
218
+ let matchingInsertion: UnifiedChange | null = null;
219
+ let bestScore = 0;
220
+
221
+ for (const insertion of insertions) {
222
+ if (processed.has(insertion.id)) continue;
223
+
224
+ // Must have same source and author to be paired
225
+ if (deletion.source !== insertion.source) continue;
226
+ if (deletion.author !== insertion.author) continue;
227
+
228
+ let score = 0;
229
+
230
+ // Strategy 1: Same paragraph index (strongest signal)
231
+ const delPara = deletion.location?.paragraphIndex ?? -1;
232
+ const insPara = insertion.location?.paragraphIndex ?? -1;
233
+ if (delPara >= 0 && insPara >= 0) {
234
+ if (delPara === insPara) {
235
+ score += 10;
236
+ } else if (Math.abs(delPara - insPara) <= 1) {
237
+ score += 5;
238
+ } else if (Math.abs(delPara - insPara) <= 3) {
239
+ score += 2;
240
+ }
241
+ }
242
+
243
+ // Strategy 2: Same nearest heading (strong signal)
244
+ const delHeading = deletion.location?.nearestHeading;
245
+ const insHeading = insertion.location?.nearestHeading;
246
+ if (delHeading && insHeading && delHeading === insHeading) {
247
+ score += 8;
248
+ }
249
+
250
+ // Strategy 3: Same or similar text content (very strong signal)
251
+ const delText = deletion.before?.trim().toLowerCase() || '';
252
+ const insText = insertion.after?.trim().toLowerCase() || '';
253
+ if (delText && insText) {
254
+ if (delText === insText) {
255
+ // Exact same text deleted and inserted - definitely a pair
256
+ score += 15;
257
+ } else if (delText.includes(insText) || insText.includes(delText)) {
258
+ // One contains the other - likely a pair
259
+ score += 7;
260
+ }
261
+ }
262
+
263
+ // Require minimum score to consider it a match
264
+ if (score > bestScore && score >= 5) {
265
+ bestScore = score;
266
+ matchingInsertion = insertion;
267
+ }
268
+ }
269
+
270
+ if (matchingInsertion) {
271
+ // Combine into "Updated" change
272
+ processed.add(deletion.id);
273
+ processed.add(matchingInsertion.id);
274
+
275
+ // Create context from before/after content
276
+ const beforeText = deletion.before || '';
277
+ const afterText = matchingInsertion.after || '';
278
+ const contextWords = getContextSnippet(beforeText, afterText);
279
+
280
+ result.push({
281
+ id: `updated-${deletion.id}`,
282
+ source: deletion.source,
283
+ category: 'content',
284
+ description: `Updated${contextWords ? `: "${contextWords}"` : ''}`,
285
+ author: deletion.author,
286
+ date: deletion.date || matchingInsertion.date,
287
+ location: deletion.location,
288
+ before: beforeText,
289
+ after: afterText,
290
+ affectedText: beforeText || afterText,
291
+ });
292
+ } else {
293
+ // No matching insertion - keep as deletion
294
+ result.push(deletion);
295
+ processed.add(deletion.id);
296
+ }
297
+ }
298
+
299
+ // Add remaining unpaired insertions
300
+ for (const insertion of insertions) {
301
+ if (!processed.has(insertion.id)) {
302
+ result.push(insertion);
303
+ processed.add(insertion.id);
304
+ }
305
+ }
306
+
307
+ return result;
308
+ }
309
+
310
+ /**
311
+ * Extracts a context snippet (5-8 words) from text for display
312
+ */
313
+ function getContextSnippet(before: string, after: string): string {
314
+ const text = after || before || '';
315
+ if (!text) return '';
316
+
317
+ // Clean up the text
318
+ const cleaned = text.replace(/\s+/g, ' ').trim();
319
+ if (!cleaned) return '';
320
+
321
+ // Split into words and take up to 8 words
322
+ const words = cleaned.split(' ').filter(w => w.length > 0);
323
+ if (words.length === 0) return '';
324
+
325
+ if (words.length <= 8) {
326
+ return cleaned;
327
+ }
328
+
329
+ // Take first 5-8 words and add ellipsis
330
+ return words.slice(0, 6).join(' ') + '...';
331
+ }
332
+
333
+ /**
334
+ * Groups multiple property changes on the same text into a single change entry
335
+ * This consolidates entries like: "Changed alignment", "Changed spacing.before", "Changed size"
336
+ * for the same "Part 1" text into one entry with groupedProperties
337
+ *
338
+ * Also consolidates duplicate entries (same description, author, affected text)
339
+ */
340
+ function groupPropertyChanges(changes: UnifiedChange[]): UnifiedChange[] {
341
+ const result: UnifiedChange[] = [];
342
+ const formattingByKey = new Map<string, UnifiedChange[]>();
343
+ const duplicatesByKey = new Map<string, UnifiedChange[]>();
344
+
345
+ for (const change of changes) {
346
+ // Group formatting changes with propertyChange and affectedText
347
+ if (
348
+ change.category === 'formatting' &&
349
+ change.propertyChange?.property &&
350
+ change.affectedText
351
+ ) {
352
+ // Create a grouping key based on text, source, author, AND location
353
+ // This ensures changes at different paragraphs are NOT incorrectly consolidated
354
+ const locationKey = change.location?.paragraphIndex ?? 'unknown';
355
+ const key = `${change.affectedText}|${change.source}|${change.author || ''}|${locationKey}`;
356
+
357
+ if (!formattingByKey.has(key)) {
358
+ formattingByKey.set(key, []);
359
+ }
360
+ formattingByKey.get(key)!.push(change);
361
+ }
362
+ // Also consolidate duplicate entries (same description, author, affected text, AND location)
363
+ else if (change.affectedText && change.description) {
364
+ const locationKey = change.location?.paragraphIndex ?? 'unknown';
365
+ const key = `${change.description}|${change.affectedText}|${change.source}|${change.author || ''}|${locationKey}`;
366
+
367
+ if (!duplicatesByKey.has(key)) {
368
+ duplicatesByKey.set(key, []);
369
+ }
370
+ duplicatesByKey.get(key)!.push(change);
371
+ } else {
372
+ // Non-groupable changes go directly to result
373
+ result.push(change);
374
+ }
375
+ }
376
+
377
+ // Process grouped formatting changes (with propertyChange)
378
+ for (const [, groupedChanges] of formattingByKey) {
379
+ if (groupedChanges.length === 1) {
380
+ // Single change - no grouping needed
381
+ result.push(groupedChanges[0]);
382
+ } else {
383
+ // Multiple changes - create a grouped entry
384
+ const first = groupedChanges[0];
385
+ const groupedProperties = groupedChanges.map((c) => ({
386
+ property: c.propertyChange!.property,
387
+ oldValue: c.propertyChange!.oldValue,
388
+ newValue: c.propertyChange!.newValue,
389
+ }));
390
+
391
+ result.push({
392
+ id: first.id,
393
+ source: first.source,
394
+ category: first.category,
395
+ description: `Changed ${groupedChanges.length} formatting properties`,
396
+ author: first.author,
397
+ date: first.date,
398
+ location: first.location,
399
+ affectedText: first.affectedText,
400
+ count: groupedChanges.length,
401
+ groupedProperties,
402
+ });
403
+ }
404
+ }
405
+
406
+ // Process duplicate entries (same description/affectedText but no propertyChange)
407
+ for (const [, groupedChanges] of duplicatesByKey) {
408
+ if (groupedChanges.length === 1) {
409
+ result.push(groupedChanges[0]);
410
+ } else {
411
+ // Consolidate duplicates - show count
412
+ const first = groupedChanges[0];
413
+ result.push({
414
+ ...first,
415
+ count: groupedChanges.length,
416
+ description: first.description,
417
+ });
418
+ }
419
+ }
420
+
421
+ return result;
422
+ }
423
+
424
+ /**
425
+ * Category display configuration
426
+ */
427
+ const categoryConfig: Record<
428
+ ChangeCategory,
429
+ { label: string; icon: typeof Plus; color: string }
430
+ > = {
431
+ content: { label: 'Content', icon: FileText, color: 'text-blue-500' },
432
+ formatting: { label: 'Formatting', icon: Paintbrush, color: 'text-purple-500' },
433
+ structural: { label: 'Structural', icon: Settings, color: 'text-orange-500' },
434
+ table: { label: 'Table', icon: Table, color: 'text-green-500' },
435
+ hyperlink: { label: 'Hyperlinks', icon: Link, color: 'text-cyan-500' },
436
+ image: { label: 'Images', icon: Image, color: 'text-pink-500' },
437
+ field: { label: 'Fields', icon: Hash, color: 'text-yellow-500' },
438
+ comment: { label: 'Comments', icon: MessageCircle, color: 'text-indigo-500' },
439
+ bookmark: { label: 'Bookmarks', icon: Bookmark, color: 'text-red-500' },
440
+ contentControl: { label: 'Content Controls', icon: Box, color: 'text-teal-500' },
441
+ };
442
+
443
+ export function ChangeViewer({ sessionId, expandDocumentId, onExpandHandled }: ChangeViewerProps) {
444
+ const { sessions } = useSession();
445
+ const [expandedDocs, setExpandedDocs] = useState<Set<string>>(new Set());
446
+ const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all');
447
+ const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
448
+ const [authorFilter, setAuthorFilter] = useState<string>('all');
449
+ const [showAuthorDropdown, setShowAuthorDropdown] = useState(false);
450
+ const [searchQuery, setSearchQuery] = useState('');
451
+ const [copiedToClipboard, setCopiedToClipboard] = useState(false);
452
+ const authorDropdownRef = useRef<HTMLDivElement>(null);
453
+ // DEFERRED: Compare Documents modal state
454
+ // const [showComparisonModal, setShowComparisonModal] = useState(false);
455
+
456
+ // Handle external expand request (from double-click on document)
457
+ useEffect(() => {
458
+ if (expandDocumentId) {
459
+ // Expand only the requested document, collapse others
460
+ setExpandedDocs(new Set([expandDocumentId]));
461
+ onExpandHandled?.();
462
+ }
463
+ }, [expandDocumentId, onExpandHandled]);
464
+
465
+ // Get the current session
466
+ const session = sessions.find((s) => s.id === sessionId);
467
+
468
+ // Get all changes from all documents (DocHub processing changes)
469
+ const documentChanges = useMemo(() => {
470
+ if (!session) return [];
471
+
472
+ return session.documents
473
+ .filter((doc) => doc.status === 'completed')
474
+ .map((doc) => ({
475
+ document: doc,
476
+ changes: getUnifiedChanges(doc),
477
+ }))
478
+ .filter((item) => item.changes.length > 0);
479
+ }, [session]);
480
+
481
+ // Get previous tracked changes (pre-existing changes from before DocHub processing)
482
+ const previousChanges = useMemo(() => {
483
+ if (!session) return [];
484
+
485
+ return session.documents
486
+ .filter((doc) => doc.status === 'completed')
487
+ .map((doc) => ({
488
+ document: doc,
489
+ changes: getPreviousChanges(doc),
490
+ }))
491
+ .filter((item) => item.changes.length > 0);
492
+ }, [session]);
493
+
494
+ // Check if there are any previous changes to display
495
+ const hasPreviousChanges = previousChanges.length > 0;
496
+ const totalPreviousChanges = previousChanges.reduce((acc, item) => acc + item.changes.length, 0);
497
+
498
+ // Calculate summary statistics
499
+ const stats = useMemo(() => {
500
+ const allChanges = documentChanges.flatMap((d) => d.changes);
501
+ return {
502
+ total: allChanges.length,
503
+ insertions: allChanges.filter(
504
+ (c) => c.category === 'content' && c.after && !c.before
505
+ ).length,
506
+ deletions: allChanges.filter(
507
+ (c) => c.category === 'content' && c.before && !c.after
508
+ ).length,
509
+ formatting: allChanges.filter((c) => c.category === 'formatting').length,
510
+ structural: allChanges.filter((c) => c.category === 'structural').length,
511
+ table: allChanges.filter((c) => c.category === 'table').length,
512
+ hyperlink: allChanges.filter((c) => c.category === 'hyperlink').length,
513
+ wordRevisions: allChanges.filter((c) => c.source === 'word').length,
514
+ processingChanges: allChanges.filter((c) => c.source === 'processing').length,
515
+ };
516
+ }, [documentChanges]);
517
+
518
+ // Extract unique authors from all changes
519
+ const uniqueAuthors = useMemo(() => {
520
+ const allChanges = documentChanges.flatMap((d) => d.changes);
521
+ const authors = new Set<string>();
522
+ allChanges.forEach((change) => {
523
+ if (change.author) {
524
+ authors.add(change.author);
525
+ }
526
+ });
527
+ return Array.from(authors).sort();
528
+ }, [documentChanges]);
529
+
530
+ // Close author dropdown when clicking outside
531
+ useEffect(() => {
532
+ const handleClickOutside = (event: MouseEvent) => {
533
+ if (authorDropdownRef.current && !authorDropdownRef.current.contains(event.target as Node)) {
534
+ setShowAuthorDropdown(false);
535
+ }
536
+ };
537
+
538
+ document.addEventListener('mousedown', handleClickOutside);
539
+ return () => document.removeEventListener('mousedown', handleClickOutside);
540
+ }, []);
541
+
542
+ // Filter changes based on current filters
543
+ const filteredDocumentChanges = useMemo(() => {
544
+ return documentChanges.map((item) => ({
545
+ ...item,
546
+ changes: item.changes.filter((change) => {
547
+ // Source filter
548
+ if (sourceFilter !== 'all' && change.source !== sourceFilter) return false;
549
+ // Category filter
550
+ if (categoryFilter !== 'all' && change.category !== categoryFilter) return false;
551
+ // Author filter
552
+ if (authorFilter !== 'all' && change.author !== authorFilter) return false;
553
+ // Search filter
554
+ if (searchQuery) {
555
+ const query = searchQuery.toLowerCase();
556
+ const matchesDescription = change.description?.toLowerCase().includes(query);
557
+ const matchesBefore = change.before?.toLowerCase().includes(query);
558
+ const matchesAfter = change.after?.toLowerCase().includes(query);
559
+ if (!matchesDescription && !matchesBefore && !matchesAfter) return false;
560
+ }
561
+ return true;
562
+ }),
563
+ })).filter((item) => item.changes.length > 0);
564
+ }, [documentChanges, sourceFilter, categoryFilter, authorFilter, searchQuery]);
565
+
566
+ // Group changes by category within each document
567
+ const groupedDocumentChanges = useMemo(() => {
568
+ return filteredDocumentChanges.map((item) => {
569
+ const grouped: Record<ChangeCategory, UnifiedChange[]> = {
570
+ content: [],
571
+ formatting: [],
572
+ structural: [],
573
+ table: [],
574
+ hyperlink: [],
575
+ image: [],
576
+ field: [],
577
+ comment: [],
578
+ bookmark: [],
579
+ contentControl: [],
580
+ };
581
+ item.changes.forEach((change) => {
582
+ grouped[change.category].push(change);
583
+ });
584
+ return {
585
+ document: item.document,
586
+ grouped,
587
+ total: item.changes.length,
588
+ };
589
+ });
590
+ }, [filteredDocumentChanges]);
591
+
592
+ // Toggle document expansion
593
+ const toggleDocument = useCallback((docId: string) => {
594
+ setExpandedDocs((prev) => {
595
+ const next = new Set(prev);
596
+ if (next.has(docId)) {
597
+ next.delete(docId);
598
+ } else {
599
+ next.add(docId);
600
+ }
601
+ return next;
602
+ });
603
+ }, []);
604
+
605
+ // Export changes as markdown
606
+ const exportAsMarkdown = useCallback(() => {
607
+ let markdown = '# Document Changes\n\n';
608
+
609
+ filteredDocumentChanges.forEach((item) => {
610
+ markdown += `## ${item.document.name}\n\n`;
611
+
612
+ // Group by category
613
+ const grouped: Record<ChangeCategory, UnifiedChange[]> = {
614
+ content: [],
615
+ formatting: [],
616
+ structural: [],
617
+ table: [],
618
+ hyperlink: [],
619
+ image: [],
620
+ field: [],
621
+ comment: [],
622
+ bookmark: [],
623
+ contentControl: [],
624
+ };
625
+ item.changes.forEach((change) => {
626
+ grouped[change.category].push(change);
627
+ });
628
+
629
+ (Object.keys(grouped) as ChangeCategory[]).forEach((category) => {
630
+ const changes = grouped[category];
631
+ if (changes.length === 0) return;
632
+
633
+ markdown += `### ${categoryConfig[category].label} Changes\n\n`;
634
+ changes.forEach((change) => {
635
+ markdown += `- ${change.description}`;
636
+ if (change.source === 'word' && change.author) {
637
+ markdown += ` (by ${change.author})`;
638
+ }
639
+ // Include the affected text for context
640
+ if (change.affectedText) {
641
+ markdown += `\n - Text: "${change.affectedText}"`;
642
+ }
643
+ if (change.before && change.after) {
644
+ markdown += `\n - Before: \`${change.before}\`\n - After: \`${change.after}\``;
645
+ }
646
+ markdown += '\n';
647
+ });
648
+ markdown += '\n';
649
+ });
650
+ });
651
+
652
+ // Copy to clipboard
653
+ navigator.clipboard.writeText(markdown).then(() => {
654
+ setCopiedToClipboard(true);
655
+ setTimeout(() => setCopiedToClipboard(false), 2000);
656
+ });
657
+ }, [filteredDocumentChanges]);
658
+
659
+ // Empty state - only show if there are no DocHub changes AND no previous changes
660
+ if (documentChanges.length === 0 && !hasPreviousChanges) {
661
+ return (
662
+ <div className="flex flex-col items-center justify-center py-12 text-center">
663
+ <FileText className="w-12 h-12 text-muted-foreground mb-4" />
664
+ <h3 className="text-lg font-medium mb-2">No Changes to Display</h3>
665
+ <p className="text-sm text-muted-foreground max-w-md">
666
+ Process documents to see tracked changes here. Both Word tracked changes
667
+ and DocHub processing changes will be displayed.
668
+ </p>
669
+ </div>
670
+ );
671
+ }
672
+
673
+ return (
674
+ <div className="space-y-4">
675
+ {/* Header with Summary Stats - only show if there are DocHub changes */}
676
+ {documentChanges.length > 0 && (
677
+ <>
678
+ <div className="flex flex-wrap items-center justify-between gap-4">
679
+ <div>
680
+ <h3 className="text-lg font-semibold">Document Changes</h3>
681
+ <p className="text-sm text-muted-foreground">
682
+ {stats.total} total changes across {documentChanges.length} document(s)
683
+ </p>
684
+ </div>
685
+
686
+ <div className="flex items-center gap-2">
687
+ {/* DEFERRED: Compare Documents feature - Side-by-side comparison for future implementation
688
+ <Button
689
+ variant="outline"
690
+ size="sm"
691
+ onClick={() => setShowComparisonModal(true)}
692
+ className="gap-2"
693
+ disabled={documentChanges.length === 0}
694
+ title="Compare original vs processed documents"
695
+ >
696
+ <Columns className="w-4 h-4" />
697
+ Compare Documents
698
+ </Button>
699
+ */}
700
+ <Button
701
+ variant="outline"
702
+ size="sm"
703
+ onClick={exportAsMarkdown}
704
+ className="gap-2"
705
+ >
706
+ <ClipboardCopy className="w-4 h-4" />
707
+ {copiedToClipboard ? 'Copied!' : 'Copy Markdown'}
708
+ </Button>
709
+ </div>
710
+ </div>
711
+
712
+ {/* Summary Stats */}
713
+ <div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-2">
714
+ <StatBadge
715
+ icon={Plus}
716
+ label="Insertions"
717
+ count={stats.insertions}
718
+ color="text-green-500"
719
+ />
720
+ <StatBadge
721
+ icon={Minus}
722
+ label="Deletions"
723
+ count={stats.deletions}
724
+ color="text-red-500"
725
+ />
726
+ <StatBadge
727
+ icon={Paintbrush}
728
+ label="Formatting"
729
+ count={stats.formatting}
730
+ color="text-purple-500"
731
+ />
732
+ <StatBadge
733
+ icon={Settings}
734
+ label="Structural"
735
+ count={stats.structural}
736
+ color="text-orange-500"
737
+ />
738
+ <StatBadge
739
+ icon={FileText}
740
+ label="Word"
741
+ count={stats.wordRevisions}
742
+ color="text-blue-500"
743
+ />
744
+ <StatBadge
745
+ icon={Settings}
746
+ label="Processing"
747
+ count={stats.processingChanges}
748
+ color="text-gray-500"
749
+ />
750
+ </div>
751
+ </>
752
+ )}
753
+
754
+ {/* Filter Controls - only show if there are DocHub changes */}
755
+ {documentChanges.length > 0 && (
756
+ <div className="flex flex-wrap items-center gap-3 p-3 bg-muted/50 rounded-lg">
757
+ <Filter className="w-4 h-4 text-muted-foreground" />
758
+
759
+ {/* Source Filter */}
760
+ <div className="flex items-center gap-1">
761
+ <span className="text-sm text-muted-foreground mr-1">Source:</span>
762
+ <FilterButton
763
+ active={sourceFilter === 'all'}
764
+ onClick={() => setSourceFilter('all')}
765
+ >
766
+ All
767
+ </FilterButton>
768
+ <FilterButton
769
+ active={sourceFilter === 'word'}
770
+ onClick={() => setSourceFilter('word')}
771
+ >
772
+ Word
773
+ </FilterButton>
774
+ <FilterButton
775
+ active={sourceFilter === 'processing'}
776
+ onClick={() => setSourceFilter('processing')}
777
+ >
778
+ DocHub
779
+ </FilterButton>
780
+ </div>
781
+
782
+ <div className="w-px h-6 bg-border" />
783
+
784
+ {/* Category Filter */}
785
+ <div className="flex items-center gap-1">
786
+ <span className="text-sm text-muted-foreground mr-1">Category:</span>
787
+ <FilterButton
788
+ active={categoryFilter === 'all'}
789
+ onClick={() => setCategoryFilter('all')}
790
+ >
791
+ All
792
+ </FilterButton>
793
+ {(Object.keys(categoryConfig) as ChangeCategory[]).map((category) => (
794
+ <FilterButton
795
+ key={category}
796
+ active={categoryFilter === category}
797
+ onClick={() => setCategoryFilter(category)}
798
+ >
799
+ {categoryConfig[category].label}
800
+ </FilterButton>
801
+ ))}
802
+ </div>
803
+
804
+ <div className="w-px h-6 bg-border" />
805
+
806
+ {/* Author Filter Dropdown */}
807
+ {uniqueAuthors.length > 0 && (
808
+ <>
809
+ <div className="relative" ref={authorDropdownRef}>
810
+ <button
811
+ onClick={() => setShowAuthorDropdown(!showAuthorDropdown)}
812
+ className={cn(
813
+ 'flex items-center gap-2 px-2 py-1 text-xs rounded-md transition-colors',
814
+ authorFilter !== 'all'
815
+ ? 'bg-primary text-primary-foreground'
816
+ : 'bg-background hover:bg-muted border border-border'
817
+ )}
818
+ >
819
+ <User className="w-3 h-3" />
820
+ <span>{authorFilter === 'all' ? 'All Authors' : authorFilter}</span>
821
+ <ChevronDown className="w-3 h-3" />
822
+ </button>
823
+
824
+ {/* Dropdown menu */}
825
+ {showAuthorDropdown && (
826
+ <div className="absolute top-full left-0 mt-1 z-50 min-w-[180px] bg-popover border border-border rounded-md shadow-lg py-1">
827
+ <button
828
+ onClick={() => {
829
+ setAuthorFilter('all');
830
+ setShowAuthorDropdown(false);
831
+ }}
832
+ className={cn(
833
+ 'w-full px-3 py-1.5 text-left text-sm hover:bg-muted transition-colors flex items-center gap-2',
834
+ authorFilter === 'all' && 'bg-muted'
835
+ )}
836
+ >
837
+ <span className="w-4" />
838
+ All Authors
839
+ </button>
840
+ <div className="h-px bg-border my-1" />
841
+ {uniqueAuthors.map((author) => (
842
+ <button
843
+ key={author}
844
+ onClick={() => {
845
+ setAuthorFilter(author);
846
+ setShowAuthorDropdown(false);
847
+ }}
848
+ className={cn(
849
+ 'w-full px-3 py-1.5 text-left text-sm hover:bg-muted transition-colors flex items-center gap-2',
850
+ authorFilter === author && 'bg-muted'
851
+ )}
852
+ >
853
+ <User className="w-3 h-3 text-muted-foreground" />
854
+ {author}
855
+ </button>
856
+ ))}
857
+ </div>
858
+ )}
859
+ </div>
860
+
861
+ {/* Clear author filter button */}
862
+ {authorFilter !== 'all' && (
863
+ <button
864
+ onClick={() => setAuthorFilter('all')}
865
+ className="p-1 rounded hover:bg-muted transition-colors"
866
+ title="Clear author filter"
867
+ >
868
+ <X className="w-3 h-3" />
869
+ </button>
870
+ )}
871
+
872
+ <div className="w-px h-6 bg-border" />
873
+ </>
874
+ )}
875
+
876
+ {/* Search */}
877
+ <input
878
+ type="text"
879
+ placeholder="Search changes..."
880
+ value={searchQuery}
881
+ onChange={(e) => setSearchQuery(e.target.value)}
882
+ className="px-3 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50"
883
+ />
884
+ </div>
885
+ )}
886
+
887
+ {/* Document List - only show if there are DocHub changes */}
888
+ {documentChanges.length > 0 && (
889
+ <div className="space-y-3">
890
+ {groupedDocumentChanges.map((item) => (
891
+ <div
892
+ key={item.document.id}
893
+ className="border border-border rounded-lg overflow-hidden"
894
+ >
895
+ {/* Document Header */}
896
+ <button
897
+ onClick={() => toggleDocument(item.document.id)}
898
+ className="w-full px-4 py-3 flex items-center justify-between hover:bg-muted/50 transition-colors"
899
+ >
900
+ <div className="flex items-center gap-3">
901
+ {expandedDocs.has(item.document.id) ? (
902
+ <ChevronDown className="w-4 h-4" />
903
+ ) : (
904
+ <ChevronRight className="w-4 h-4" />
905
+ )}
906
+ <FileText className="w-4 h-4 text-muted-foreground" />
907
+ <span className="font-medium">{item.document.name}</span>
908
+ </div>
909
+ <span className="text-sm text-muted-foreground">
910
+ {item.total} change{item.total !== 1 ? 's' : ''}
911
+ </span>
912
+ </button>
913
+
914
+ {/* Document Changes */}
915
+ <AnimatePresence>
916
+ {expandedDocs.has(item.document.id) && (
917
+ <motion.div
918
+ initial={{ height: 0, opacity: 0 }}
919
+ animate={{ height: 'auto', opacity: 1 }}
920
+ exit={{ height: 0, opacity: 0 }}
921
+ transition={{ duration: 0.2 }}
922
+ className="overflow-hidden"
923
+ >
924
+ <div className="px-4 pb-4 space-y-4">
925
+ {(Object.keys(categoryConfig) as ChangeCategory[]).map((category) => {
926
+ const changes = item.grouped[category];
927
+ if (changes.length === 0) return null;
928
+
929
+ const config = categoryConfig[category];
930
+ const Icon = config.icon;
931
+
932
+ return (
933
+ <div key={category} className="space-y-2">
934
+ <div className="flex items-center gap-2 text-sm font-medium">
935
+ <Icon className={cn('w-4 h-4', config.color)} />
936
+ <span>{config.label}</span>
937
+ <span className="text-muted-foreground">
938
+ ({changes.length})
939
+ </span>
940
+ </div>
941
+ <div className="pl-6 space-y-2">
942
+ {changes.map((change) => (
943
+ <ChangeItem key={change.id} change={change} />
944
+ ))}
945
+ </div>
946
+ </div>
947
+ );
948
+ })}
949
+ </div>
950
+ </motion.div>
951
+ )}
952
+ </AnimatePresence>
953
+ </div>
954
+ ))}
955
+ </div>
956
+ )}
957
+
958
+ {/* Empty filtered state */}
959
+ {filteredDocumentChanges.length === 0 && documentChanges.length > 0 && (
960
+ <div className="flex flex-col items-center justify-center py-8 text-center">
961
+ <Filter className="w-8 h-8 text-muted-foreground mb-3" />
962
+ <p className="text-sm text-muted-foreground">
963
+ No changes match the current filters
964
+ </p>
965
+ <Button
966
+ variant="ghost"
967
+ size="sm"
968
+ className="mt-2"
969
+ onClick={() => {
970
+ setSourceFilter('all');
971
+ setCategoryFilter('all');
972
+ setAuthorFilter('all');
973
+ setSearchQuery('');
974
+ }}
975
+ >
976
+ Clear Filters
977
+ </Button>
978
+ </div>
979
+ )}
980
+
981
+ {/* Previous Tracked Changes Section */}
982
+ {hasPreviousChanges && (
983
+ <div className="mt-8 pt-6 border-t border-border">
984
+ <div className="flex items-center gap-3 mb-4">
985
+ <History className="w-5 h-5 text-amber-500" />
986
+ <div>
987
+ <h3 className="text-lg font-semibold">Previous Tracked Changes</h3>
988
+ <p className="text-sm text-muted-foreground">
989
+ {totalPreviousChanges} change{totalPreviousChanges !== 1 ? 's' : ''} that existed in the document before DocHub processing
990
+ </p>
991
+ </div>
992
+ </div>
993
+
994
+ <div className="space-y-3">
995
+ {previousChanges.map((item) => (
996
+ <PreviousChangesSection
997
+ key={`previous-${item.document.id}`}
998
+ document={item.document}
999
+ changes={item.changes}
1000
+ />
1001
+ ))}
1002
+ </div>
1003
+ </div>
1004
+ )}
1005
+
1006
+ {/* DEFERRED: Document Comparison Modal - Side-by-side comparison for future implementation
1007
+ <DocumentComparisonModal
1008
+ isOpen={showComparisonModal}
1009
+ onClose={() => setShowComparisonModal(false)}
1010
+ sessionId={sessionId}
1011
+ documents={documentChanges.map((item) => item.document)}
1012
+ />
1013
+ */}
1014
+ </div>
1015
+ );
1016
+ }
1017
+
1018
+ // Helper Components
1019
+
1020
+ interface StatBadgeProps {
1021
+ icon: typeof Plus;
1022
+ label: string;
1023
+ count: number;
1024
+ color: string;
1025
+ }
1026
+
1027
+ function StatBadge({ icon: Icon, label, count, color }: StatBadgeProps) {
1028
+ return (
1029
+ <div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md">
1030
+ <Icon className={cn('w-4 h-4', color)} />
1031
+ <div className="flex flex-col">
1032
+ <span className="text-xs text-muted-foreground">{label}</span>
1033
+ <span className="text-sm font-medium">{count}</span>
1034
+ </div>
1035
+ </div>
1036
+ );
1037
+ }
1038
+
1039
+ interface FilterButtonProps {
1040
+ active: boolean;
1041
+ onClick: () => void;
1042
+ children: React.ReactNode;
1043
+ }
1044
+
1045
+ function FilterButton({ active, onClick, children }: FilterButtonProps) {
1046
+ return (
1047
+ <button
1048
+ onClick={onClick}
1049
+ className={cn(
1050
+ 'px-2 py-1 text-xs rounded-md transition-colors',
1051
+ active
1052
+ ? 'bg-primary text-primary-foreground'
1053
+ : 'bg-background hover:bg-muted border border-border'
1054
+ )}
1055
+ >
1056
+ {children}
1057
+ </button>
1058
+ );
1059
+ }
1060
+
1061
+ /**
1062
+ * Component for displaying previous tracked changes for a single document
1063
+ */
1064
+ interface PreviousChangesSectionProps {
1065
+ document: Document;
1066
+ changes: UnifiedChange[];
1067
+ }
1068
+
1069
+ function PreviousChangesSection({ document, changes }: PreviousChangesSectionProps) {
1070
+ const [isExpanded, setIsExpanded] = useState(false);
1071
+
1072
+ // Group changes by category
1073
+ const grouped = useMemo(() => {
1074
+ const result: Record<ChangeCategory, UnifiedChange[]> = {
1075
+ content: [],
1076
+ formatting: [],
1077
+ structural: [],
1078
+ table: [],
1079
+ hyperlink: [],
1080
+ image: [],
1081
+ field: [],
1082
+ comment: [],
1083
+ bookmark: [],
1084
+ contentControl: [],
1085
+ };
1086
+ changes.forEach((change) => {
1087
+ result[change.category].push(change);
1088
+ });
1089
+ return result;
1090
+ }, [changes]);
1091
+
1092
+ return (
1093
+ <div className="border border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-950/20 rounded-lg overflow-hidden">
1094
+ {/* Document Header */}
1095
+ <button
1096
+ type="button"
1097
+ onClick={() => setIsExpanded(!isExpanded)}
1098
+ className="w-full px-4 py-3 flex items-center justify-between hover:bg-amber-100/50 dark:hover:bg-amber-900/20 transition-colors"
1099
+ >
1100
+ <div className="flex items-center gap-3">
1101
+ {isExpanded ? (
1102
+ <ChevronDown className="w-4 h-4 text-amber-600" />
1103
+ ) : (
1104
+ <ChevronRight className="w-4 h-4 text-amber-600" />
1105
+ )}
1106
+ <FileText className="w-4 h-4 text-amber-600" />
1107
+ <span className="font-medium">{document.name}</span>
1108
+ </div>
1109
+ <span className="text-sm text-amber-700 dark:text-amber-400">
1110
+ {changes.length} previous change{changes.length !== 1 ? 's' : ''}
1111
+ </span>
1112
+ </button>
1113
+
1114
+ {/* Previous Changes */}
1115
+ <AnimatePresence>
1116
+ {isExpanded && (
1117
+ <motion.div
1118
+ initial={{ height: 0, opacity: 0 }}
1119
+ animate={{ height: 'auto', opacity: 1 }}
1120
+ exit={{ height: 0, opacity: 0 }}
1121
+ transition={{ duration: 0.2 }}
1122
+ className="overflow-hidden"
1123
+ >
1124
+ <div className="px-4 pb-4 space-y-4">
1125
+ {(Object.keys(categoryConfig) as ChangeCategory[]).map((category) => {
1126
+ const categoryChanges = grouped[category];
1127
+ if (categoryChanges.length === 0) return null;
1128
+
1129
+ const config = categoryConfig[category];
1130
+ const Icon = config.icon;
1131
+
1132
+ return (
1133
+ <div key={category} className="space-y-2">
1134
+ <div className="flex items-center gap-2 text-sm font-medium">
1135
+ <Icon className={cn('w-4 h-4', config.color)} />
1136
+ <span>{config.label}</span>
1137
+ <span className="text-muted-foreground">
1138
+ ({categoryChanges.length})
1139
+ </span>
1140
+ </div>
1141
+ <div className="pl-6 space-y-2">
1142
+ {categoryChanges.map((change) => (
1143
+ <ChangeItem key={change.id} change={change} />
1144
+ ))}
1145
+ </div>
1146
+ </div>
1147
+ );
1148
+ })}
1149
+ </div>
1150
+ </motion.div>
1151
+ )}
1152
+ </AnimatePresence>
1153
+ </div>
1154
+ );
1155
+ }