import React, { useState, useEffect, useMemo } from 'react'; import { useEditor, TLShape, TLShapeId } from 'tldraw'; import { computeShapeDiff, getDeletedShapes, saveLastSeenState, formatRelativeTime, formatTimestamp, DeletedShape, VersionSnapshot, } from '../lib/versionHistory'; import { WORKER_URL } from '../constants/workerUrl'; import { usePermissions } from '../hooks/usePermissions'; interface VersionHistoryPanelProps { boardId: string; userId: string; onClose: () => void; } type TabType = 'changes' | 'versions' | 'deleted'; /** * Version History Panel - shows recent changes, version snapshots, and deleted shapes */ export const VersionHistoryPanel: React.FC = ({ boardId, userId, onClose, }) => { const editor = useEditor(); const { canRevert, canRestoreDeleted, canMarkAsSeen, role } = usePermissions(boardId); const [activeTab, setActiveTab] = useState('changes'); const [versions, setVersions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [showNewShapeGlow, setShowNewShapeGlow] = useState(true); const [showDeletedShapes, setShowDeletedShapes] = useState(true); // Compute current diff const diff = useMemo(() => { if (!editor) return { newShapes: [], deletedShapes: [], modifiedShapes: [] }; const shapes = editor.getCurrentPageShapes(); return computeShapeDiff(userId, boardId, shapes); }, [editor, userId, boardId]); // Get deleted shapes const deletedShapes = useMemo(() => { return getDeletedShapes(userId, boardId); }, [userId, boardId]); // Fetch R2 version snapshots useEffect(() => { const fetchVersions = async () => { setIsLoading(true); try { const response = await fetch(`${WORKER_URL}/api/versions/${boardId}`); if (response.ok) { const data = await response.json() as { versions?: VersionSnapshot[] }; setVersions(data.versions || []); } } catch (error) { console.error('Error fetching versions:', error); } finally { setIsLoading(false); } }; if (activeTab === 'versions') { fetchVersions(); } }, [boardId, activeTab]); // Toggle new shape highlighting const handleToggleNewShapeGlow = () => { setShowNewShapeGlow(!showNewShapeGlow); // Dispatch event to toggle visual effects window.dispatchEvent(new CustomEvent('toggle-new-shape-glow', { detail: !showNewShapeGlow })); }; // Toggle deleted shape visibility const handleToggleDeletedShapes = () => { setShowDeletedShapes(!showDeletedShapes); window.dispatchEvent(new CustomEvent('toggle-deleted-shapes', { detail: !showDeletedShapes })); }; // Mark current state as "seen" const handleMarkAsSeen = () => { if (!editor) return; const shapes = editor.getCurrentPageShapes(); saveLastSeenState(userId, boardId, shapes); // Force re-render by closing and reopening onClose(); }; // Restore a deleted shape const handleRestoreShape = (deletedShape: DeletedShape) => { if (!editor) return; try { // Create a new shape with the deleted shape's properties editor.createShape({ id: deletedShape.id, type: deletedShape.type, x: deletedShape.x, y: deletedShape.y, props: deletedShape.props, } as any); // Remove from deleted list window.dispatchEvent(new CustomEvent('shape-restored', { detail: deletedShape.id })); } catch (error) { console.error('Error restoring shape:', error); } }; // Navigate to a shape const handleNavigateToShape = (shapeId: TLShapeId) => { if (!editor) return; const shape = editor.getShape(shapeId); if (!shape) return; // Center viewport on the shape const bounds = editor.getShapePageBounds(shape); if (bounds) { editor.zoomToBounds(bounds, { animation: { duration: 300 }, inset: 100, }); } // Select the shape editor.setSelectedShapes([shapeId]); }; // Revert to a version const handleRevertToVersion = async (version: VersionSnapshot) => { if (!editor || !version) return; const confirmRevert = window.confirm( `Revert to version from ${formatTimestamp(version.timestamp)}?\n\nThis will replace the current board state with the selected version.` ); if (!confirmRevert) return; try { setIsLoading(true); const response = await fetch(`${WORKER_URL}/api/versions/${boardId}/${version.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'revert' }), }); if (response.ok) { // Reload the page to get the reverted state window.location.reload(); } else { console.error('Failed to revert version'); } } catch (error) { console.error('Error reverting to version:', error); } finally { setIsLoading(false); } }; return (
{/* Header */}

Version History

{role}
{/* Tabs */}
{(['changes', 'versions', 'deleted'] as TabType[]).map((tab) => ( ))}
{/* Tab Content */}
{/* Changes Tab */} {activeTab === 'changes' && (
{/* Controls */}
{/* New Shapes */} {diff.newShapes.length > 0 ? (

New Shapes ({diff.newShapes.length})

{diff.newShapes.map((shapeId) => { const shape = editor?.getShape(shapeId); return ( ); })}
) : (

No new shapes since your last visit

)} {/* Mark as Seen Button */} {canMarkAsSeen && ( )}
)} {/* Versions Tab */} {activeTab === 'versions' && (
{isLoading ? (

Loading versions...

) : versions.length > 0 ? (
{versions.map((version) => (

{formatRelativeTime(version.timestamp)}

{formatTimestamp(version.timestamp)} • {version.shapeCount} shapes

{canRevert && ( )}
))}
) : (

No saved versions available

)}
)} {/* Deleted Tab */} {activeTab === 'deleted' && (
{deletedShapes.length > 0 ? (
{deletedShapes.map((deleted) => (
{deleted.type === 'draw' ? '✏️' : deleted.type === 'text' ? '📝' : deleted.type === 'image' ? '🖼️' : deleted.type === 'embed' ? '🔗' : '📦'}

{deleted.type}

Deleted {formatRelativeTime(deleted.deletedAt)}

{canRestoreDeleted && ( )}
))}
) : (

No recently deleted shapes

)}
)}
); };