1
0
mirror of https://github.com/coleam00/Archon.git synced 2026-01-11 17:16:57 -05:00
Files
archon/archon-ui-main/src/components/project-tasks/VersionHistoryModal.tsx
sean-eskerium 1b5196d70f - Fix the threading service to properly handle rate limiting.
- Fix the clipboard functionality to work on non local hosts and https
- Improvements in sockets on front-end and backend. Storing session in local browser storage for reconnect. Logic to prevent socket echos coausing rerender and performance issues.
- Fixes and udpates to re-ordering logic in adding a new task, reordering items on the task table.
- Allowing assignee to not be hardcoded enum.
- Fix to Document Version Control (Improvements still needed in the Milkdown editor conversion to store in the docs.
- Adding types to remove [any] typescript issues.
2025-08-20 02:28:02 -04:00

661 lines
24 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { X, Clock, RotateCcw, Eye, Calendar, User, FileText, Diff, GitBranch, Layers, Plus, Minus, AlertTriangle } from 'lucide-react';
import { projectService } from '../../services/projectService';
import { Button } from '../ui/Button';
import { useToast } from '../../contexts/ToastContext';
interface Version {
id: string;
version_number: number;
change_summary: string;
change_type: string;
created_by: string;
created_at: string;
content: any;
document_id?: string;
}
interface VersionHistoryModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
documentId?: string;
fieldName?: string;
onRestore?: () => void;
}
interface DiffLine {
type: 'added' | 'removed' | 'unchanged';
content: string;
lineNumber?: number;
}
interface RestoreConfirmModalProps {
isOpen: boolean;
versionNumber: number;
onConfirm: () => void;
onCancel: () => void;
}
const RestoreConfirmModal: React.FC<RestoreConfirmModalProps> = ({
isOpen,
versionNumber,
onConfirm,
onCancel
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60]">
<div className="relative p-6 rounded-md backdrop-blur-md w-full max-w-md
bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30
border border-gray-200 dark:border-zinc-800/50
shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)] dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)]
before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-[2px]
before:rounded-t-[4px] before:bg-orange-500
before:shadow-[0_0_10px_2px_rgba(249,115,22,0.4)] dark:before:shadow-[0_0_20px_5px_rgba(249,115,22,0.7)]">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Restore Version
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
This will create a new version
</p>
</div>
</div>
<p className="text-gray-700 dark:text-gray-300 mb-6">
Are you sure you want to restore to <span className="font-medium text-orange-600 dark:text-orange-400">version {versionNumber}</span>?
This will create a new version with the restored content.
</p>
<div className="flex justify-end gap-3">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button
variant="primary"
accentColor="orange"
onClick={onConfirm}
icon={<RotateCcw className="w-4 h-4" />}
>
Restore Version
</Button>
</div>
</div>
</div>
</div>
);
};
export const VersionHistoryModal: React.FC<VersionHistoryModalProps> = ({
isOpen,
onClose,
projectId,
documentId,
fieldName = 'docs',
onRestore
}) => {
const [versions, setVersions] = useState<Version[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [previewContent, setPreviewContent] = useState<any>(null);
const [previewVersion, setPreviewVersion] = useState<number | null>(null);
const [restoring, setRestoring] = useState<number | null>(null);
const [currentContent, setCurrentContent] = useState<any>(null);
const [viewMode, setViewMode] = useState<'diff' | 'rendered'>('diff');
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
const [versionToRestore, setVersionToRestore] = useState<number | null>(null);
const { showToast } = useToast();
useEffect(() => {
if (isOpen && projectId) {
loadVersionHistory();
loadCurrentContent();
}
}, [isOpen, projectId, fieldName, documentId]);
const loadVersionHistory = async () => {
setLoading(true);
setError(null);
try {
const versionData = await projectService.getDocumentVersionHistory(projectId, fieldName);
// Filter versions by document if documentId is provided
let filteredVersions = versionData || [];
if (documentId) {
filteredVersions = versionData.filter((version: Version) => {
// Check if this version contains changes to the specific document
if (version.document_id === documentId) {
return true;
}
// Also check if the content contains the document
if (Array.isArray(version.content)) {
return version.content.some((doc: any) => doc.id === documentId);
}
return false;
});
}
setVersions(filteredVersions);
} catch (error) {
console.error('Error loading version history:', error);
setError('Failed to load version history');
showToast('Failed to load version history', 'error');
} finally {
setLoading(false);
}
};
const loadCurrentContent = async () => {
try {
const currentProject = await projectService.getProject(projectId);
setCurrentContent((currentProject as any)[fieldName] || []);
} catch (error) {
console.error('Error loading current content:', error);
showToast('Failed to load current content', 'error');
}
};
const handlePreview = async (versionNumber: number) => {
try {
setPreviewVersion(versionNumber);
const contentData = await projectService.getVersionContent(projectId, versionNumber, fieldName);
setPreviewContent(contentData.content);
} catch (error) {
console.error('Error loading version content:', error);
setError('Failed to load version content');
showToast('Failed to load version content', 'error');
}
};
const handleRestoreClick = (versionNumber: number) => {
setVersionToRestore(versionNumber);
setShowRestoreConfirm(true);
};
const handleRestoreConfirm = async () => {
if (!versionToRestore) return;
setRestoring(versionToRestore);
setError(null);
setShowRestoreConfirm(false);
try {
await projectService.restoreDocumentVersion(projectId, versionToRestore, fieldName);
await loadVersionHistory();
await loadCurrentContent();
if (onRestore) {
onRestore();
}
showToast(`Successfully restored to version ${versionToRestore}`, 'success');
} catch (error) {
console.error('Error restoring version:', error);
setError('Failed to restore version');
showToast('Failed to restore version', 'error');
} finally {
setRestoring(null);
setVersionToRestore(null);
}
};
const handleRestoreCancel = () => {
setShowRestoreConfirm(false);
setVersionToRestore(null);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const getChangeTypeIcon = (changeType: string) => {
switch (changeType) {
case 'create':
return <FileText className="w-4 h-4 text-emerald-400" />;
case 'update':
return <Clock className="w-4 h-4 text-blue-400" />;
case 'delete':
return <X className="w-4 h-4 text-red-400" />;
case 'restore':
return <RotateCcw className="w-4 h-4 text-purple-400" />;
default:
return <Clock className="w-4 h-4 text-gray-400" />;
}
};
const extractTextContent = (content: any, docId?: string): string => {
if (!content) return '';
// If content is an array of documents
if (Array.isArray(content)) {
// If we have a documentId, filter to just that document
if (docId) {
const doc = content.find(d => d.id === docId);
if (doc) {
// If it has markdown content, return that
if (doc.content?.markdown) {
return doc.content.markdown;
}
// Otherwise try to extract text content
return extractDocumentText(doc);
}
return 'Document not found in this version';
}
// Otherwise show all documents
return content.map(doc => {
if (doc.content?.markdown) {
return `=== ${doc.title || 'Document'} ===\n${doc.content.markdown}`;
}
return `=== ${doc.title || 'Document'} ===\n${extractDocumentText(doc)}`;
}).join('\n\n');
}
// If content is an object with markdown
if (typeof content === 'object' && content.markdown) {
return content.markdown;
}
if (typeof content === 'object') {
return JSON.stringify(content, null, 2);
}
return String(content);
};
const extractDocumentText = (doc: any): string => {
let text = '';
if (doc.blocks) {
text = doc.blocks.map((block: any) => {
if (block.content) {
return block.content;
}
return '';
}).filter(Boolean).join('\n');
} else if (doc.content && typeof doc.content === 'string') {
text = doc.content;
} else if (doc.content && typeof doc.content === 'object') {
text = JSON.stringify(doc.content, null, 2);
}
return text;
};
const generateDiff = (oldContent: any, newContent: any): DiffLine[] => {
const oldText = extractTextContent(oldContent, documentId);
const newText = extractTextContent(newContent, documentId);
const oldLines = oldText.split('\n');
const newLines = newText.split('\n');
const diff: DiffLine[] = [];
// Simple line-by-line diff (in a real app you'd use a proper diff library)
const maxLines = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLines; i++) {
const oldLine = oldLines[i] || '';
const newLine = newLines[i] || '';
if (oldLine === newLine) {
if (oldLine) {
diff.push({ type: 'unchanged', content: oldLine, lineNumber: i + 1 });
}
} else {
if (oldLine && !newLines.includes(oldLine)) {
diff.push({ type: 'removed', content: oldLine, lineNumber: i + 1 });
}
if (newLine && !oldLines.includes(newLine)) {
diff.push({ type: 'added', content: newLine, lineNumber: i + 1 });
}
}
}
return diff;
};
const renderInlineDiff = () => {
if (!previewContent || !currentContent) {
return (
<div className="text-center py-12">
<Diff className="w-16 h-16 text-gray-600 mx-auto mb-4 opacity-50" />
<p className="text-gray-500 text-lg">Select a version to see changes</p>
</div>
);
}
const diffLines = generateDiff(previewContent, currentContent);
// If filtering by document but no changes found
if (documentId && diffLines.length === 0) {
return (
<div className="text-center py-12">
<FileText className="w-16 h-16 text-gray-600 mx-auto mb-4 opacity-50" />
<p className="text-gray-500 text-lg">No changes found for this document in the selected version</p>
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-gray-400 mb-4">
<Diff className="w-4 h-4" />
Comparing Version {previewVersion} Current
</div>
<div className="bg-gray-900/50 rounded-lg border border-gray-700/50 overflow-hidden">
<div className="bg-gray-800/50 px-4 py-2 border-b border-gray-700/50">
<span className="text-gray-300 text-sm font-mono">Changes</span>
</div>
<div className="max-h-96 overflow-y-auto font-mono text-sm">
{diffLines.map((line, index) => (
<div
key={index}
className={`flex items-start px-4 py-1 ${
line.type === 'added'
? 'bg-green-500/10 border-l-2 border-green-500'
: line.type === 'removed'
? 'bg-red-500/10 border-l-2 border-red-500'
: 'hover:bg-gray-800/30'
}`}
>
<span className="text-gray-500 w-8 text-right mr-4 select-none">
{line.lineNumber}
</span>
<span className="mr-3 w-4 flex-shrink-0">
{line.type === 'added' && <Plus className="w-3 h-3 text-green-400" />}
{line.type === 'removed' && <Minus className="w-3 h-3 text-red-400" />}
</span>
<span className={`flex-1 ${
line.type === 'added'
? 'text-green-300'
: line.type === 'removed'
? 'text-red-300'
: 'text-gray-300'
}`}>
{line.content || ' '}
</span>
</div>
))}
</div>
</div>
</div>
);
};
const renderDocumentContent = (content: any) => {
if (!content) return <div className="text-gray-500 text-center py-8">No content available</div>;
// Extract the markdown content for the specific document
const markdownContent = extractTextContent(content, documentId);
if (markdownContent === 'Document not found in this version') {
return (
<div className="text-center py-12">
<FileText className="w-16 h-16 text-gray-600 mx-auto mb-4 opacity-50" />
<p className="text-gray-500 text-lg">Document not found in this version</p>
</div>
);
}
// Render the markdown content
return (
<div className="prose prose-invert max-w-none">
<pre className="whitespace-pre-wrap bg-gray-900/50 p-6 rounded-lg border border-gray-700/50 text-gray-300 font-mono text-sm overflow-auto">
{markdownContent}
</pre>
</div>
);
// Old array handling code - keeping for reference but not using
if (Array.isArray(content) && false) {
return (
<div className="space-y-6">
{content.map((doc, index) => (
<div key={index} className="border border-gray-700/50 rounded-lg p-4 bg-gray-900/30">
<div className="flex items-center gap-3 mb-4">
<FileText className="w-5 h-5 text-blue-400" />
<h3 className="text-lg font-semibold text-white">{doc.title || `Document ${index + 1}`}</h3>
</div>
{doc.blocks && (
<div className="prose prose-invert max-w-none">
{doc.blocks.map((block: any, blockIndex: number) => (
<div key={blockIndex} className="mb-4">
{block.type === 'heading_1' && (
<h1 className="text-2xl font-bold text-white mb-2">{block.content}</h1>
)}
{block.type === 'heading_2' && (
<h2 className="text-xl font-semibold text-white mb-2">{block.content}</h2>
)}
{block.type === 'heading_3' && (
<h3 className="text-lg font-medium text-white mb-2">{block.content}</h3>
)}
{block.type === 'paragraph' && (
<p className="text-gray-300 leading-relaxed mb-2">{block.content}</p>
)}
{block.type === 'bulletListItem' && (
<ul className="list-disc list-inside text-gray-300 mb-2">
<li>{block.content}</li>
</ul>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
);
}
if (typeof content === 'object') {
return (
<div className="border border-gray-700/50 rounded-lg p-4 bg-gray-900/30">
<pre className="text-sm text-gray-300 whitespace-pre-wrap overflow-x-auto">
{JSON.stringify(content, null, 2)}
</pre>
</div>
);
}
return <div className="text-gray-500">Unsupported content type</div>;
};
if (!isOpen) return null;
return (
<>
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="relative w-full max-w-6xl h-5/6 flex flex-col bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border border-purple-500/30 rounded-lg overflow-hidden shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)] backdrop-blur-md">
{/* Neon top edge */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-purple-500 shadow-[0_0_20px_5px_rgba(168,85,247,0.7)]"></div>
{/* Header */}
<div className="relative z-10 flex items-center justify-between p-6 border-b border-purple-500/30">
<h2 className="text-xl font-semibold text-white flex items-center gap-3">
<Clock className="w-6 h-6 text-purple-400" />
Version History
<span className="text-purple-400">- {fieldName}{documentId ? ' (Document Filtered)' : ''}</span>
</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-white/10 transition-all duration-300"
>
<X className="w-6 h-6 text-gray-400 hover:text-red-400" />
</button>
</div>
{/* Content */}
<div className="flex-1 flex overflow-hidden relative z-10">
{/* Version List */}
<div className="w-1/3 border-r border-purple-500/30 overflow-y-auto">
<div className="p-6">
<h3 className="font-medium text-white mb-4 flex items-center gap-2">
<GitBranch className="w-5 h-5 text-purple-400" />
Versions
</h3>
{loading && (
<div className="text-center py-8">
<div className="w-8 h-8 border-2 border-purple-500/30 border-t-purple-500 rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-400">Loading versions...</p>
</div>
)}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 mb-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{!loading && versions.length === 0 && (
<div className="text-center py-8">
<Clock className="w-12 h-12 text-gray-600 mx-auto mb-4 opacity-50" />
<p className="text-gray-500">No versions found</p>
</div>
)}
<div className="space-y-3">
{versions.map((version) => (
<div
key={version.id}
className={`relative p-4 rounded-lg cursor-pointer transition-all duration-300 border ${
previewVersion === version.version_number
? 'bg-blue-500/20 border-blue-500/50'
: 'bg-white/5 border-gray-500/30 hover:bg-white/10 hover:border-gray-400/50'
}`}
onClick={() => handlePreview(version.version_number)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
{getChangeTypeIcon(version.change_type)}
<span className="font-medium text-white">
Version {version.version_number}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
accentColor="green"
onClick={(e) => {
e.stopPropagation();
handleRestoreClick(version.version_number);
}}
disabled={restoring === version.version_number}
icon={restoring === version.version_number ?
<div className="w-4 h-4 border-2 border-emerald-500/30 border-t-emerald-500 rounded-full animate-spin" /> :
<RotateCcw className="w-4 h-4" />
}
>
Restore
</Button>
</div>
</div>
<p className="text-sm text-gray-300 mb-3">
{version.change_summary}
{version.document_id && documentId && version.document_id === documentId && (
<span className="ml-2 text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded">
This document
</span>
)}
</p>
<div className="flex items-center gap-4 text-xs text-gray-400">
<div className="flex items-center gap-1.5">
<User className="w-3 h-3" />
{version.created_by}
</div>
<div className="flex items-center gap-1.5">
<Calendar className="w-3 h-3" />
{formatDate(version.created_at)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Preview Panel */}
<div className="flex-1 overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="font-medium text-white flex items-center gap-3">
<Eye className="w-5 h-5 text-blue-400" />
{viewMode === 'diff' ? 'Changes' : 'Content'}
</h3>
{previewVersion !== null && (
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'diff' ? 'primary' : 'ghost'}
size="sm"
accentColor="purple"
onClick={() => setViewMode('diff')}
icon={<Diff className="w-4 h-4" />}
>
Diff View
</Button>
<Button
variant={viewMode === 'rendered' ? 'primary' : 'ghost'}
size="sm"
accentColor="blue"
onClick={() => setViewMode('rendered')}
icon={<Layers className="w-4 h-4" />}
>
Rendered
</Button>
</div>
)}
</div>
{previewVersion === null ? (
<div className="text-center py-12">
<Eye className="w-16 h-16 text-gray-600 mx-auto mb-6 opacity-50" />
<p className="text-gray-500 text-lg">Select a version to preview</p>
</div>
) : (
<div>
{viewMode === 'diff' ? renderInlineDiff() : renderDocumentContent(previewContent)}
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="relative z-10 border-t border-purple-500/30 p-6">
<div className="flex justify-end">
<Button
variant="ghost"
accentColor="purple"
onClick={onClose}
>
Close
</Button>
</div>
</div>
</div>
</div>
{/* Restore Confirmation Modal */}
<RestoreConfirmModal
isOpen={showRestoreConfirm}
versionNumber={versionToRestore || 0}
onConfirm={handleRestoreConfirm}
onCancel={handleRestoreCancel}
/>
</>
);
};