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.
- package/.eslintrc.json +43 -0
- package/.github/workflows/build.yml +64 -0
- package/.github/workflows/ci.yml +39 -0
- package/.vscode/extensions.json +3 -0
- package/Current.md +97 -0
- package/DocHub_Image.png +0 -0
- package/README.md +666 -0
- package/USER_GUIDE.md +1173 -0
- package/Updater.md +311 -0
- package/build/256x256.png +0 -0
- package/build/512x512.png +0 -0
- package/build/app-update.yml +4 -0
- package/build/create-icon.js +208 -0
- package/build/icon.ico +0 -0
- package/build/icon.png +0 -0
- package/build/icon_1024x1024.png +0 -0
- package/dist/assets/Analytics-BpsG9895.js +1 -0
- package/dist/assets/Card-IAZin8kp.js +1 -0
- package/dist/assets/CurrentSession-B-rFkHvf.js +12 -0
- package/dist/assets/Dashboard-C_5gMb0q.js +1 -0
- package/dist/assets/Documents-CqZ25axS.js +1 -0
- package/dist/assets/Input-l89xwXBi.js +1 -0
- package/dist/assets/Reporting-DqdHJY_a.js +1 -0
- package/dist/assets/Search-XNbu5z_3.js +1 -0
- package/dist/assets/SessionManager-lH9hZfzH.js +1 -0
- package/dist/assets/Sessions-ClZOPYNc.js +1 -0
- package/dist/assets/Settings-DUEHGURa.js +11 -0
- package/dist/assets/index-8xUe8ptc.js +24 -0
- package/dist/assets/index-RYyJqF7O.css +1 -0
- package/dist/assets/path-BkOl0AGO.js +1 -0
- package/dist/assets/promises-ID_B9S-h.js +1 -0
- package/dist/assets/urlHelpers-TvgahX0r.js +1 -0
- package/dist/assets/useToast-yRSO1dkm.js +1 -0
- package/dist/assets/vendor-charts-RkGK5ROP.js +36 -0
- package/dist/assets/vendor-db-l0sNRNKZ.js +1 -0
- package/dist/assets/vendor-react-BVZ_anCF.js +4 -0
- package/dist/assets/vendor-search-Dw8P0qyA.js +1 -0
- package/dist/assets/vendor-ui-BU7NfluV.js +53 -0
- package/dist/electron/PowerAutomateApiService-LfW09ZGr.js +147 -0
- package/dist/electron/main-CXkNtyv-.js +19789 -0
- package/dist/electron/main.js +5 -0
- package/dist/electron/preload.js +1 -0
- package/dist/icon.png +0 -0
- package/dist/index.html +27 -0
- package/docs/CODEBASE_ANALYSIS_REPORT.md +309 -0
- package/docs/DEBUG_LOGGING_GUIDE.md +244 -0
- package/docs/README.md +115 -0
- package/docs/TOC_WIRING_GUIDE.md +344 -0
- package/docs/analysis/Bullet_Symbol_Bug_Analysis.md +136 -0
- package/docs/analysis/DOCXMLATER_ANALYSIS_SUMMARY.txt +169 -0
- package/docs/analysis/Document_Processing_Issues_Analysis.md +704 -0
- package/docs/analysis/FIELD_PRESERVATION_ANALYSIS.md +1200 -0
- package/docs/analysis/INDENTATION_PRESERVE_ANALYSIS.md +181 -0
- package/docs/analysis/INDENTATION_PRESERVE_IMPLEMENTATION.md +207 -0
- package/docs/analysis/List_Implementation.md +206 -0
- package/docs/analysis/List_Implementation_Accuracy_Report.md +366 -0
- package/docs/analysis/PROCESSING_OPTIONS_UI_UPDATES.md +220 -0
- package/docs/analysis/RefactorStyles.md +852 -0
- package/docs/analysis/STYLE_PARAMETER_ENHANCEMENT.md +143 -0
- package/docs/analysis/docxmlater-comparison-todo-2025-11-13.md +636 -0
- package/docs/analysis/docxmlater-implementation-analysis-2025-11-13.md +340 -0
- package/docs/analysis/docxmlater-template_ui-integration-analysis.md +263 -0
- package/docs/analysis/github-issues-to-create.md +237 -0
- package/docs/api/API_README.md +538 -0
- package/docs/api/API_REFERENCE.md +751 -0
- package/docs/api/TYPE_DEFINITIONS.md +869 -0
- package/docs/architecture/FONT_EMBEDDING_GUIDE.md +318 -0
- package/docs/architecture/docxmlater-functions-and-structure.md +726 -0
- package/docs/docxmlater-readme.md +1341 -0
- package/docs/fixes/EXECUTION_LOG_TEST_BASE.md +573 -0
- package/docs/fixes/HYPERLINK_TEXT_SANITIZATION.md +253 -0
- package/docs/fixes/README.md +37 -0
- package/docs/github-issues/issue-1-body.md +125 -0
- package/docs/github-issues/issue-10-body.md +850 -0
- package/docs/github-issues/issue-2-body.md +200 -0
- package/docs/github-issues/issue-3-body.md +270 -0
- package/docs/github-issues/issue-4-body.md +169 -0
- package/docs/github-issues/issue-5-body.md +173 -0
- package/docs/github-issues/issue-6-body.md +158 -0
- package/docs/github-issues/issue-7-body.md +171 -0
- package/docs/github-issues/issue-8-body.md +407 -0
- package/docs/github-issues/issue-9-body.md +515 -0
- package/docs/github-issues/issue-tracker.md +274 -0
- package/docs/github-issues/predictive-analysis-2025-10-18.md +2131 -0
- package/docs/implementation/List_Framework_Refactor_Plan.md +336 -0
- package/docs/implementation/PRIMARY_TEXT_COLOR_FEATURE.md +217 -0
- package/docs/implementation/RELEASE_PLAN_v2.1.0.md +362 -0
- package/docs/implementation/RefactorStyles.md +588 -0
- package/docs/implementation/implement-plan.md +489 -0
- package/docs/implementation/missing-helpers-implementation.md +391 -0
- package/docs/implementation/refactor-plan.md +520 -0
- package/docs/implementation/session-implementation-complete.md +233 -0
- package/docs/implementation/session-management-plan.md +250 -0
- package/docs/setup-checklist.md +77 -0
- package/docs/versions/changelog.md +345 -0
- package/electron/customUpdater.ts +656 -0
- package/electron/main.ts +2441 -0
- package/electron/memoryConfig.ts +187 -0
- package/electron/preload.ts +394 -0
- package/electron/proxyConfig.ts +340 -0
- package/electron/services/BackupService.ts +452 -0
- package/electron/services/DictionaryService.ts +402 -0
- package/electron/services/LocalDictionaryLookupService.ts +147 -0
- package/electron/services/PowerAutomateApiService.ts +231 -0
- package/electron/services/SharePointSyncService.ts +474 -0
- package/electron/windowsCertStore.ts +427 -0
- package/electron/zscalerConfig.ts +381 -0
- package/eslint.config.js +92 -0
- package/jest.config.js +52 -0
- package/package.json +214 -0
- package/postcss.config.mjs +6 -0
- package/public/icon.png +0 -0
- package/publish-release.ps1 +5 -0
- package/renovate.json +30 -0
- package/src/App.tsx +216 -0
- package/src/__mocks__/p-limit.js +12 -0
- package/src/__mocks__/styleMock.js +1 -0
- package/src/components/common/BugReportButton.tsx +44 -0
- package/src/components/common/BugReportDialog.tsx +193 -0
- package/src/components/common/Button.tsx +153 -0
- package/src/components/common/Card.tsx +86 -0
- package/src/components/common/ColorPickerDialog.tsx +177 -0
- package/src/components/common/ConfirmDialog.tsx +96 -0
- package/src/components/common/DebugConsole.tsx +275 -0
- package/src/components/common/EmptyState.tsx +183 -0
- package/src/components/common/ErrorBoundary.tsx +98 -0
- package/src/components/common/ErrorDetailsDialog.tsx +153 -0
- package/src/components/common/ErrorFallback.tsx +218 -0
- package/src/components/common/Input.tsx +109 -0
- package/src/components/common/Skeleton.tsx +184 -0
- package/src/components/common/SplashScreen.tsx +81 -0
- package/src/components/common/Toast.tsx +155 -0
- package/src/components/common/Tooltip.tsx +79 -0
- package/src/components/common/UpdateNotification.tsx +320 -0
- package/src/components/comparison/ComparisonWindow.tsx +374 -0
- package/src/components/comparison/SideBySideDiff.tsx +486 -0
- package/src/components/comparison/index.ts +8 -0
- package/src/components/document/DocumentUploader.tsx +288 -0
- package/src/components/document/HyperlinkPreview.tsx +430 -0
- package/src/components/document/HyperlinkService.md +1484 -0
- package/src/components/document/Hyperlink_Technical_Documentation.md +496 -0
- package/src/components/document/InlineChangesView.tsx +707 -0
- package/src/components/document/ProcessingProgress.tsx +303 -0
- package/src/components/document/ProcessingResults.tsx +256 -0
- package/src/components/document/TrackedChangesDetail.tsx +530 -0
- package/src/components/document/TrackedChangesPanel.tsx +546 -0
- package/src/components/document/VirtualDocumentList.tsx +240 -0
- package/src/components/editor/DocumentEditor.tsx +723 -0
- package/src/components/editor/DocumentEditorModal.tsx +640 -0
- package/src/components/editor/EditorQuickActions.tsx +502 -0
- package/src/components/editor/EditorToolbar.tsx +312 -0
- package/src/components/editor/TableEditor.tsx +926 -0
- package/src/components/editor/index.ts +18 -0
- package/src/components/layout/Header.tsx +190 -0
- package/src/components/layout/Sidebar.tsx +313 -0
- package/src/components/layout/TitleBar.tsx +190 -0
- package/src/components/navigation/CommandPalette.tsx +233 -0
- package/src/components/navigation/KeyboardShortcutsModal.tsx +173 -0
- package/src/components/sessions/ChangeItem.tsx +408 -0
- package/src/components/sessions/ChangeViewer.tsx +1155 -0
- package/src/components/sessions/DocumentComparisonModal.tsx +314 -0
- package/src/components/sessions/ProcessingOptions.tsx +297 -0
- package/src/components/sessions/ReplacementsTab.tsx +438 -0
- package/src/components/sessions/RevisionHandlingOptions.tsx +87 -0
- package/src/components/sessions/SessionManager.tsx +188 -0
- package/src/components/sessions/StylesEditor.tsx +1335 -0
- package/src/components/sessions/TabContainer.tsx +151 -0
- package/src/components/sessions/VirtualSessionList.tsx +157 -0
- package/src/components/sessions/sessionToProcessorManager.tsx +420 -0
- package/src/components/settings/CertificateManager.tsx +410 -0
- package/src/components/settings/SegmentedControl.tsx +88 -0
- package/src/components/settings/SettingRow.tsx +52 -0
- package/src/contexts/GlobalStatsContext.tsx +396 -0
- package/src/contexts/SessionContext.tsx +2129 -0
- package/src/contexts/ThemeContext.tsx +428 -0
- package/src/contexts/UserSettingsContext.tsx +290 -0
- package/src/contexts/__tests__/GlobalStatsContext.test.tsx +390 -0
- package/src/global.d.ts +273 -0
- package/src/hooks/useDocumentQueue.tsx +210 -0
- package/src/hooks/useToast.tsx +55 -0
- package/src/main.tsx +10 -0
- package/src/pages/Analytics.tsx +386 -0
- package/src/pages/CurrentSession.tsx +1174 -0
- package/src/pages/Dashboard.tsx +319 -0
- package/src/pages/Documents.tsx +317 -0
- package/src/pages/Projects.tsx +250 -0
- package/src/pages/Reporting.tsx +386 -0
- package/src/pages/Search.tsx +349 -0
- package/src/pages/Sessions.tsx +285 -0
- package/src/pages/Settings.tsx +2662 -0
- package/src/services/HyperlinkService.ts +1085 -0
- package/src/services/document/DocXMLaterProcessor.ts +617 -0
- package/src/services/document/DocumentProcessingComparison.ts +856 -0
- package/src/services/document/DocumentSnapshotService.ts +575 -0
- package/src/services/document/WordDocumentProcessor.ts +10509 -0
- package/src/services/document/__tests__/DocXMLaterProcessor.hyperlinks.test.md +311 -0
- package/src/services/document/__tests__/WordDocumentProcessor.integration.test.ts +515 -0
- package/src/services/document/__tests__/WordDocumentProcessor.test.ts +812 -0
- package/src/services/document/blanklines/BlankLineManager.ts +658 -0
- package/src/services/document/blanklines/__tests__/paragraphChecks.test.ts +281 -0
- package/src/services/document/blanklines/helpers/blankLineInsertion.ts +87 -0
- package/src/services/document/blanklines/helpers/blankLineSnapshot.ts +251 -0
- package/src/services/document/blanklines/helpers/clearCustom.ts +121 -0
- package/src/services/document/blanklines/helpers/contextChecks.ts +117 -0
- package/src/services/document/blanklines/helpers/imageChecks.ts +51 -0
- package/src/services/document/blanklines/helpers/paragraphChecks.ts +236 -0
- package/src/services/document/blanklines/helpers/removeBlanksBetweenListItems.ts +91 -0
- package/src/services/document/blanklines/helpers/removeTrailingBlanks.ts +35 -0
- package/src/services/document/blanklines/helpers/tableGuards.ts +21 -0
- package/src/services/document/blanklines/index.ts +67 -0
- package/src/services/document/blanklines/rules/additionRules.ts +337 -0
- package/src/services/document/blanklines/rules/indentationRules.ts +317 -0
- package/src/services/document/blanklines/rules/removalRules.ts +362 -0
- package/src/services/document/blanklines/rules/ruleTypes.ts +92 -0
- package/src/services/document/blanklines/types.ts +29 -0
- package/src/services/document/helpers/ImageBorderCropper.ts +377 -0
- package/src/services/document/helpers/__tests__/whitespace.test.ts +272 -0
- package/src/services/document/helpers/whitespace.ts +117 -0
- package/src/services/document/list/ListNormalizer.ts +947 -0
- package/src/services/document/list/index.ts +45 -0
- package/src/services/document/list/list-detection.ts +275 -0
- package/src/services/document/list/list-types.ts +162 -0
- package/src/services/document/processors/HyperlinkProcessor.ts +370 -0
- package/src/services/document/processors/ListProcessor.ts +257 -0
- package/src/services/document/processors/StructureProcessor.ts +176 -0
- package/src/services/document/processors/StyleProcessor.ts +389 -0
- package/src/services/document/processors/TableProcessor.ts +2238 -0
- package/src/services/document/processors/__tests__/HyperlinkProcessor.test.ts +314 -0
- package/src/services/document/processors/__tests__/ListProcessor.test.ts +291 -0
- package/src/services/document/processors/__tests__/StructureProcessor.test.ts +257 -0
- package/src/services/document/processors/__tests__/TableProcessor.hlp-tips-bullets.test.ts +459 -0
- package/src/services/document/processors/__tests__/TableProcessor.test.ts +1604 -0
- package/src/services/document/processors/index.ts +28 -0
- package/src/services/document/types/docx-processing.ts +310 -0
- package/src/services/editor/EditorActionHandlers.ts +901 -0
- package/src/services/editor/index.ts +13 -0
- package/src/setupTests.ts +47 -0
- package/src/styles/global.css +782 -0
- package/src/types/backup.ts +132 -0
- package/src/types/dictionary.ts +125 -0
- package/src/types/document-processing.ts +331 -0
- package/src/types/docxmlater-augments.d.ts +142 -0
- package/src/types/editor.ts +280 -0
- package/src/types/electron.ts +340 -0
- package/src/types/globalStats.ts +155 -0
- package/src/types/hyperlink.ts +471 -0
- package/src/types/operations.ts +354 -0
- package/src/types/session.ts +427 -0
- package/src/types/settings.ts +112 -0
- package/src/utils/MemoryMonitor.ts +248 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/colorConvert.ts +306 -0
- package/src/utils/diffUtils.ts +347 -0
- package/src/utils/documentUtils.ts +202 -0
- package/src/utils/electronGuard.ts +62 -0
- package/src/utils/indexedDB.ts +915 -0
- package/src/utils/logger.ts +717 -0
- package/src/utils/pathSecurity.ts +232 -0
- package/src/utils/pathValidator.ts +236 -0
- package/src/utils/processingTimeEstimator.ts +153 -0
- package/src/utils/safeJsonParse.ts +62 -0
- package/src/utils/textSanitizer.ts +162 -0
- package/src/utils/urlHelpers.ts +304 -0
- package/src/utils/urlPatterns.ts +198 -0
- package/src/utils/urlSanitizer.ts +152 -0
- package/src/vite-env.d.ts +11 -0
- package/tsconfig.electron.json +19 -0
- package/tsconfig.json +36 -0
- package/tsconfig.node.json +12 -0
- package/typedoc.json +45 -0
- 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
|
+
}
|