feat: add version history and change tracking system
- Add time-rewind button next to star dashboard button - Create VersionHistoryPanel with Changes, Versions, and Deleted tabs - Implement localStorage tracking of user's last-seen board state - Add visual diff highlighting: yellow glow for new shapes, dim grey for deleted - Create DeletedShapesOverlay with floating indicator and restore options - Integrate with R2 backups for version snapshots via /api/versions API - Add permission system (admin, editor, viewer roles) - Admins can revert to versions, editors can restore shapes - Viewers can see history but cannot modify 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ebb3ab661b
commit
03894d2146
|
|
@ -0,0 +1,300 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useEditor, TLShapeId } from 'tldraw';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
getDeletedShapes,
|
||||||
|
removeDeletedShape,
|
||||||
|
DeletedShape,
|
||||||
|
formatRelativeTime,
|
||||||
|
} from '../lib/versionHistory';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
|
interface DeletedShapesOverlayProps {
|
||||||
|
show?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeletedShapesOverlay - Shows ghost representations of deleted shapes
|
||||||
|
* and provides a floating indicator with restore options
|
||||||
|
*/
|
||||||
|
export const DeletedShapesOverlay: React.FC<DeletedShapesOverlayProps> = ({
|
||||||
|
show = true,
|
||||||
|
}) => {
|
||||||
|
const editor = useEditor();
|
||||||
|
const { session } = useAuth();
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const { canRestoreDeleted } = usePermissions(slug || '');
|
||||||
|
const [deletedShapes, setDeletedShapes] = useState<DeletedShape[]>([]);
|
||||||
|
const [showPanel, setShowPanel] = useState(false);
|
||||||
|
const [visible, setVisible] = useState(show);
|
||||||
|
|
||||||
|
// Load deleted shapes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session.authed || !session.username || !slug) return;
|
||||||
|
const deleted = getDeletedShapes(session.username, slug);
|
||||||
|
setDeletedShapes(deleted);
|
||||||
|
}, [session.authed, session.username, slug]);
|
||||||
|
|
||||||
|
// Listen for toggle events
|
||||||
|
useEffect(() => {
|
||||||
|
const handleToggle = (event: CustomEvent<boolean>) => {
|
||||||
|
setVisible(event.detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShapeRestored = (event: CustomEvent<TLShapeId>) => {
|
||||||
|
if (!session.username || !slug) return;
|
||||||
|
removeDeletedShape(session.username, slug, event.detail);
|
||||||
|
setDeletedShapes(prev => prev.filter(s => s.id !== event.detail));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('toggle-deleted-shapes', handleToggle as EventListener);
|
||||||
|
window.addEventListener('shape-restored', handleShapeRestored as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('toggle-deleted-shapes', handleToggle as EventListener);
|
||||||
|
window.removeEventListener('shape-restored', handleShapeRestored as EventListener);
|
||||||
|
};
|
||||||
|
}, [session.username, slug]);
|
||||||
|
|
||||||
|
// Restore a deleted shape
|
||||||
|
const handleRestore = useCallback((deletedShape: DeletedShape) => {
|
||||||
|
if (!editor || !session.username || !slug) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
editor.createShape({
|
||||||
|
id: deletedShape.id,
|
||||||
|
type: deletedShape.type,
|
||||||
|
x: deletedShape.x,
|
||||||
|
y: deletedShape.y,
|
||||||
|
props: deletedShape.props,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Remove from deleted list
|
||||||
|
removeDeletedShape(session.username, slug, deletedShape.id);
|
||||||
|
setDeletedShapes(prev => prev.filter(s => s.id !== deletedShape.id));
|
||||||
|
|
||||||
|
// Close panel if no more deleted shapes
|
||||||
|
if (deletedShapes.length <= 1) {
|
||||||
|
setShowPanel(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error restoring shape:', error);
|
||||||
|
}
|
||||||
|
}, [editor, session.username, slug, deletedShapes.length]);
|
||||||
|
|
||||||
|
// Restore all deleted shapes
|
||||||
|
const handleRestoreAll = useCallback(() => {
|
||||||
|
if (!editor || !session.username || !slug) return;
|
||||||
|
|
||||||
|
deletedShapes.forEach(deletedShape => {
|
||||||
|
try {
|
||||||
|
editor.createShape({
|
||||||
|
id: deletedShape.id,
|
||||||
|
type: deletedShape.type,
|
||||||
|
x: deletedShape.x,
|
||||||
|
y: deletedShape.y,
|
||||||
|
props: deletedShape.props,
|
||||||
|
} as any);
|
||||||
|
removeDeletedShape(session.username, slug, deletedShape.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error restoring shape:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setDeletedShapes([]);
|
||||||
|
setShowPanel(false);
|
||||||
|
}, [editor, session.username, slug, deletedShapes]);
|
||||||
|
|
||||||
|
// Dismiss all deleted shapes (clear from storage)
|
||||||
|
const handleDismissAll = useCallback(() => {
|
||||||
|
if (!session.username || !slug) return;
|
||||||
|
|
||||||
|
deletedShapes.forEach(d => {
|
||||||
|
removeDeletedShape(session.username!, slug!, d.id);
|
||||||
|
});
|
||||||
|
setDeletedShapes([]);
|
||||||
|
setShowPanel(false);
|
||||||
|
}, [session.username, slug, deletedShapes]);
|
||||||
|
|
||||||
|
// Don't render if no deleted shapes or not visible
|
||||||
|
if (!visible || deletedShapes.length === 0 || !session.authed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating Indicator */}
|
||||||
|
<button
|
||||||
|
className="deleted-shapes-floating-indicator"
|
||||||
|
onClick={() => setShowPanel(!showPanel)}
|
||||||
|
title={`${deletedShapes.length} deleted shape${deletedShapes.length > 1 ? 's' : ''} can be restored`}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.506a.58.58 0 0 0-.01 0H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1h-.995a.59.59 0 0 0-.01 0H11Zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5h9.916Zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47ZM8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5Z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{deletedShapes.length} deleted</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Restore Panel */}
|
||||||
|
{showPanel && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
onClick={() => setShowPanel(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '130px',
|
||||||
|
right: '20px',
|
||||||
|
width: '280px',
|
||||||
|
maxHeight: '400px',
|
||||||
|
backgroundColor: 'var(--bg-color, #fff)',
|
||||||
|
border: '1px solid var(--border-color, #e1e4e8)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
|
||||||
|
zIndex: 10001,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid var(--border-color, #e1e4e8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>
|
||||||
|
Deleted Shapes
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPanel(false)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deleted shapes list */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{deletedShapes.map((deleted) => (
|
||||||
|
<div
|
||||||
|
key={deleted.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
border: '1px solid #ef4444',
|
||||||
|
borderRadius: '6px',
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '14px', opacity: 0.5 }}>
|
||||||
|
{deleted.type === 'draw' ? '✏️' :
|
||||||
|
deleted.type === 'text' ? '📝' :
|
||||||
|
deleted.type === 'image' ? '🖼️' :
|
||||||
|
deleted.type === 'embed' ? '🔗' :
|
||||||
|
'📦'}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<p style={{ fontSize: '12px', margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{deleted.type}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: '10px', color: '#666', margin: '2px 0 0 0' }}>
|
||||||
|
{formatRelativeTime(deleted.deletedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canRestoreDeleted && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRestore(deleted)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '10px',
|
||||||
|
backgroundColor: '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px',
|
||||||
|
borderTop: '1px solid var(--border-color, #e1e4e8)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{canRestoreDeleted && (
|
||||||
|
<button
|
||||||
|
onClick={handleRestoreAll}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
backgroundColor: '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restore All
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleDismissAll}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#666',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
flex: canRestoreDeleted ? undefined : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useEditor } from 'tldraw';
|
||||||
|
import { VersionHistoryPanel } from './VersionHistoryPanel';
|
||||||
|
|
||||||
|
interface VersionHistoryButtonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version History Button - displays next to the star button
|
||||||
|
* Opens a popup panel showing historical versions and diff controls
|
||||||
|
*/
|
||||||
|
const VersionHistoryButton: React.FC<VersionHistoryButtonProps> = ({ className = '' }) => {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const { session } = useAuth();
|
||||||
|
const editor = useEditor();
|
||||||
|
const [showPanel, setShowPanel] = useState(false);
|
||||||
|
const [hasNewChanges, setHasNewChanges] = useState(false);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Check for new changes indicator
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session.authed || !session.username || !slug) return;
|
||||||
|
|
||||||
|
// Check if there are unseen changes
|
||||||
|
const lastSeenKey = `canvas_version_state_${session.username}_${slug}`;
|
||||||
|
const lastSeen = localStorage.getItem(lastSeenKey);
|
||||||
|
|
||||||
|
if (lastSeen) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(lastSeen);
|
||||||
|
const currentShapes = editor?.getCurrentPageShapes() || [];
|
||||||
|
|
||||||
|
// If shape count differs, there are changes
|
||||||
|
if (currentShapes.length !== state.shapeIds.length) {
|
||||||
|
setHasNewChanges(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [session.authed, session.username, slug, editor]);
|
||||||
|
|
||||||
|
// Close panel when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
panelRef.current &&
|
||||||
|
!panelRef.current.contains(event.target as Node) &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setShowPanel(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showPanel) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showPanel]);
|
||||||
|
|
||||||
|
// Don't show if not authenticated
|
||||||
|
if (!session.authed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
setShowPanel(!showPanel);
|
||||||
|
if (!showPanel) {
|
||||||
|
// Clear new changes indicator when opening
|
||||||
|
setHasNewChanges(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
onClick={handleToggle}
|
||||||
|
className={`toolbar-btn version-history-button ${className} ${showPanel ? 'active' : ''}`}
|
||||||
|
title="Version History"
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
>
|
||||||
|
{/* Time rewind icon */}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022l-.074.997zm2.004.45a7.003 7.003 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342l-.36.933zm1.37.71a7.01 7.01 0 0 0-.439-.27l.493-.87a8.025 8.025 0 0 1 .979.654l-.615.789a6.996 6.996 0 0 0-.418-.302zm1.834 1.79a6.99 6.99 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7.08 7.08 0 0 0-.214-.468l.893-.45a7.976 7.976 0 0 1 .45 1.088l-.95.313a7.023 7.023 0 0 0-.179-.483zm.53 2.507a6.991 6.991 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.131 1.538c.033-.17.06-.339.081-.51l.993.123a7.957 7.957 0 0 1-.23 1.155l-.964-.267c.046-.165.086-.332.12-.501zm-.952 2.379c.184-.29.346-.594.486-.908l.914.405c-.16.36-.345.706-.555 1.038l-.845-.535zm-.964 1.205c.122-.122.239-.248.35-.378l.758.653a8.073 8.073 0 0 1-.401.432l-.707-.707z"/>
|
||||||
|
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0v1z"/>
|
||||||
|
<path d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5z"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* New changes indicator dot */}
|
||||||
|
{hasNewChanges && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-2px',
|
||||||
|
right: '-2px',
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#fbbf24',
|
||||||
|
border: '1px solid white',
|
||||||
|
boxShadow: '0 0 4px rgba(251, 191, 36, 0.5)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Version History Panel */}
|
||||||
|
{showPanel && (
|
||||||
|
<div ref={panelRef}>
|
||||||
|
<VersionHistoryPanel
|
||||||
|
boardId={slug || ''}
|
||||||
|
userId={session.username || ''}
|
||||||
|
onClose={() => setShowPanel(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VersionHistoryButton;
|
||||||
|
|
@ -0,0 +1,511 @@
|
||||||
|
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<VersionHistoryPanelProps> = ({
|
||||||
|
boardId,
|
||||||
|
userId,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const editor = useEditor();
|
||||||
|
const { canRevert, canRestoreDeleted, canMarkAsSeen, role } = usePermissions(boardId);
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('changes');
|
||||||
|
const [versions, setVersions] = useState<VersionSnapshot[]>([]);
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="version-history-panel"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 'calc(100% + 8px)',
|
||||||
|
right: 0,
|
||||||
|
width: '320px',
|
||||||
|
maxHeight: '70vh',
|
||||||
|
backgroundColor: 'var(--bg-color, #fff)',
|
||||||
|
border: '1px solid var(--border-color, #e1e4e8)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
|
||||||
|
zIndex: 100000,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid var(--border-color, #e1e4e8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>
|
||||||
|
Version History
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: role === 'admin' ? '#8b5cf6' : role === 'editor' ? '#3b82f6' : '#6b7280',
|
||||||
|
color: 'white',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
borderBottom: '1px solid var(--border-color, #e1e4e8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['changes', 'versions', 'deleted'] as TabType[]).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px',
|
||||||
|
border: 'none',
|
||||||
|
background: activeTab === tab ? 'var(--color-muted-2, #f5f5f5)' : 'transparent',
|
||||||
|
borderBottom: activeTab === tab ? '2px solid var(--color-primary, #3b82f6)' : '2px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: activeTab === tab ? 600 : 400,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
{tab === 'changes' && diff.newShapes.length > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '4px',
|
||||||
|
backgroundColor: '#fbbf24',
|
||||||
|
color: '#000',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{diff.newShapes.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tab === 'deleted' && deletedShapes.length > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '4px',
|
||||||
|
backgroundColor: '#ef4444',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deletedShapes.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '12px' }}>
|
||||||
|
{/* Changes Tab */}
|
||||||
|
{activeTab === 'changes' && (
|
||||||
|
<div>
|
||||||
|
{/* Controls */}
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showNewShapeGlow}
|
||||||
|
onChange={handleToggleNewShapeGlow}
|
||||||
|
/>
|
||||||
|
Highlight new shapes
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showDeletedShapes}
|
||||||
|
onChange={handleToggleDeletedShapes}
|
||||||
|
/>
|
||||||
|
Show deleted shapes
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Shapes */}
|
||||||
|
{diff.newShapes.length > 0 ? (
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<h4 style={{ fontSize: '11px', color: '#666', marginBottom: '8px', textTransform: 'uppercase' }}>
|
||||||
|
New Shapes ({diff.newShapes.length})
|
||||||
|
</h4>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
{diff.newShapes.map((shapeId) => {
|
||||||
|
const shape = editor?.getShape(shapeId);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={shapeId}
|
||||||
|
onClick={() => handleNavigateToShape(shapeId)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
border: '1px solid #fbbf24',
|
||||||
|
borderRadius: '6px',
|
||||||
|
backgroundColor: 'rgba(251, 191, 36, 0.1)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '14px' }}>
|
||||||
|
{shape?.type === 'draw' ? '✏️' :
|
||||||
|
shape?.type === 'text' ? '📝' :
|
||||||
|
shape?.type === 'image' ? '🖼️' :
|
||||||
|
shape?.type === 'embed' ? '🔗' :
|
||||||
|
'📦'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '12px', flex: 1 }}>
|
||||||
|
{shape?.type || 'Shape'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '10px', color: '#666' }}>→</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: '12px', color: '#666', textAlign: 'center', padding: '20px' }}>
|
||||||
|
No new shapes since your last visit
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mark as Seen Button */}
|
||||||
|
{canMarkAsSeen && (
|
||||||
|
<button
|
||||||
|
onClick={handleMarkAsSeen}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px',
|
||||||
|
backgroundColor: 'var(--color-primary, #3b82f6)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mark All as Seen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Versions Tab */}
|
||||||
|
{activeTab === 'versions' && (
|
||||||
|
<div>
|
||||||
|
{isLoading ? (
|
||||||
|
<p style={{ fontSize: '12px', color: '#666', textAlign: 'center', padding: '20px' }}>
|
||||||
|
Loading versions...
|
||||||
|
</p>
|
||||||
|
) : versions.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{versions.map((version) => (
|
||||||
|
<div
|
||||||
|
key={version.id}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid var(--border-color, #e1e4e8)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: 'var(--color-muted-2, #f9f9f9)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: '12px', fontWeight: 500, margin: 0 }}>
|
||||||
|
{formatRelativeTime(version.timestamp)}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: '10px', color: '#666', margin: '2px 0 0 0' }}>
|
||||||
|
{formatTimestamp(version.timestamp)} • {version.shapeCount} shapes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canRevert && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevertToVersion(version)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '10px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: '12px', color: '#666', textAlign: 'center', padding: '20px' }}>
|
||||||
|
No saved versions available
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deleted Tab */}
|
||||||
|
{activeTab === 'deleted' && (
|
||||||
|
<div>
|
||||||
|
{deletedShapes.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{deletedShapes.map((deleted) => (
|
||||||
|
<div
|
||||||
|
key={deleted.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid #ef4444',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '14px', opacity: 0.5 }}>
|
||||||
|
{deleted.type === 'draw' ? '✏️' :
|
||||||
|
deleted.type === 'text' ? '📝' :
|
||||||
|
deleted.type === 'image' ? '🖼️' :
|
||||||
|
deleted.type === 'embed' ? '🔗' :
|
||||||
|
'📦'}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<p style={{ fontSize: '12px', margin: 0 }}>{deleted.type}</p>
|
||||||
|
<p style={{ fontSize: '10px', color: '#666', margin: '2px 0 0 0' }}>
|
||||||
|
Deleted {formatRelativeTime(deleted.deletedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canRestoreDeleted && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRestoreShape(deleted)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: '10px',
|
||||||
|
backgroundColor: '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: '12px', color: '#666', textAlign: 'center', padding: '20px' }}>
|
||||||
|
No recently deleted shapes
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
@import url("reset.css");
|
@import url("reset.css");
|
||||||
|
@import url("version-history.css");
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--border-radius: 10px;
|
--border-radius: 10px;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,332 @@
|
||||||
|
/**
|
||||||
|
* Version History Visual Effects
|
||||||
|
*
|
||||||
|
* These styles create the visual diff highlighting for:
|
||||||
|
* - New shapes (yellow glow animation)
|
||||||
|
* - Deleted shapes (dimmed with restore option)
|
||||||
|
* - Modified shapes (subtle blue indicator)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* === New Shape Glow Effect === */
|
||||||
|
|
||||||
|
@keyframes newShapeGlow {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.7);
|
||||||
|
filter: drop-shadow(0 0 0 rgba(251, 191, 36, 0));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px 8px rgba(251, 191, 36, 0.4);
|
||||||
|
filter: drop-shadow(0 0 10px rgba(251, 191, 36, 0.5));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0);
|
||||||
|
filter: drop-shadow(0 0 0 rgba(251, 191, 36, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes newShapePulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Applied to shape containers with new shapes */
|
||||||
|
.tl-shape.shape-new {
|
||||||
|
animation: newShapeGlow 2s ease-in-out infinite, newShapePulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow indicator overlay for new shapes */
|
||||||
|
.shape-new-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.2);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Deleted Shape Ghost Effect === */
|
||||||
|
|
||||||
|
.shape-deleted-ghost {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.25;
|
||||||
|
filter: grayscale(100%);
|
||||||
|
border: 2px dashed #9ca3af;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-deleted-ghost:hover {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-deleted-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-deleted-ghost:hover .shape-deleted-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-deleted-button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-deleted-button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Modified Shape Indicator === */
|
||||||
|
|
||||||
|
.tl-shape.shape-modified {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-shape.shape-modified::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
left: -2px;
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
border: 2px solid rgba(59, 130, 246, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-modified-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
left: -4px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Version History Button === */
|
||||||
|
|
||||||
|
.version-history-button {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history-button:hover {
|
||||||
|
background-color: var(--color-muted-2, rgba(0,0,0,0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history-button.active {
|
||||||
|
background-color: var(--color-muted-2, rgba(0,0,0,0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Version History Panel === */
|
||||||
|
|
||||||
|
.version-history-panel {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history-panel::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history-panel::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history-panel::-webkit-scrollbar-thumb {
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history-panel::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Timeline View (for version stepping) === */
|
||||||
|
|
||||||
|
.version-timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--border-color, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-timeline-item {
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-timeline-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -18px;
|
||||||
|
top: 12px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid var(--color-primary, #3b82f6);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-timeline-item.current::before {
|
||||||
|
background: var(--color-primary, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Dark Mode Support === */
|
||||||
|
|
||||||
|
.dark .version-history-panel {
|
||||||
|
background-color: var(--bg-color, #1f2937);
|
||||||
|
border-color: var(--border-color, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .shape-deleted-ghost {
|
||||||
|
border-color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .version-history-button:hover {
|
||||||
|
background-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .version-history-panel::-webkit-scrollbar-thumb {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .version-history-panel::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Animation for Fading Out Glow === */
|
||||||
|
|
||||||
|
.shape-new.glow-fading {
|
||||||
|
animation: newShapeGlowFadeOut 3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes newShapeGlowFadeOut {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 15px 5px rgba(251, 191, 36, 0.4);
|
||||||
|
filter: drop-shadow(0 0 8px rgba(251, 191, 36, 0.5));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0);
|
||||||
|
filter: drop-shadow(0 0 0 rgba(251, 191, 36, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Deleted Shape Floating Indicator === */
|
||||||
|
|
||||||
|
.deleted-shapes-floating-indicator {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: rgba(239, 68, 68, 0.9);
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
z-index: 10000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-shapes-floating-indicator:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-shapes-floating-indicator svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Transition Animations === */
|
||||||
|
|
||||||
|
.version-panel-enter {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-panel-enter-active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: opacity 200ms ease-out, transform 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-panel-exit {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-panel-exit-active {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
transition: opacity 150ms ease-in, transform 150ms ease-in;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* usePermissions Hook
|
||||||
|
*
|
||||||
|
* React hook for accessing user permissions on a board.
|
||||||
|
* Integrates with AuthContext and permission utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import {
|
||||||
|
getUserPermissionContext,
|
||||||
|
UserPermissionContext,
|
||||||
|
BoardPermission,
|
||||||
|
BoardRole,
|
||||||
|
} from '../lib/permissions';
|
||||||
|
|
||||||
|
export interface UsePermissionsReturn extends UserPermissionContext {
|
||||||
|
userId: string | undefined;
|
||||||
|
boardId: string;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
// Convenience permission checks
|
||||||
|
canView: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
canRevert: boolean;
|
||||||
|
canRestoreDeleted: boolean;
|
||||||
|
canViewHistory: boolean;
|
||||||
|
canMarkAsSeen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get current user's permissions for a board
|
||||||
|
*/
|
||||||
|
export function usePermissions(boardId: string): UsePermissionsReturn {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const userId = session.authed ? session.username : undefined;
|
||||||
|
|
||||||
|
const permissionContext = useMemo(() => {
|
||||||
|
return getUserPermissionContext(userId || '', boardId);
|
||||||
|
}, [userId, boardId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
boardId,
|
||||||
|
isAuthenticated: session.authed,
|
||||||
|
loading: session.loading,
|
||||||
|
...permissionContext,
|
||||||
|
// Flatten permissions for convenience
|
||||||
|
canView: permissionContext.permissions.canView,
|
||||||
|
canEdit: permissionContext.permissions.canEdit,
|
||||||
|
canDelete: permissionContext.permissions.canDelete,
|
||||||
|
canRevert: permissionContext.permissions.canRevert,
|
||||||
|
canRestoreDeleted: permissionContext.permissions.canRestoreDeleted,
|
||||||
|
canViewHistory: permissionContext.permissions.canViewHistory,
|
||||||
|
canMarkAsSeen: permissionContext.permissions.canMarkAsSeen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePermissions;
|
||||||
|
|
@ -0,0 +1,315 @@
|
||||||
|
/**
|
||||||
|
* useVersionHistory Hook
|
||||||
|
*
|
||||||
|
* Manages version history state, tracks shape changes, and applies
|
||||||
|
* visual effects to highlight new, modified, and deleted shapes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import { Editor, TLShape, TLShapeId, TLRecord } from 'tldraw';
|
||||||
|
import {
|
||||||
|
computeShapeDiff,
|
||||||
|
saveLastSeenState,
|
||||||
|
getLastSeenState,
|
||||||
|
storeDeletedShape,
|
||||||
|
removeDeletedShape,
|
||||||
|
getDeletedShapes,
|
||||||
|
ShapeDiff,
|
||||||
|
DeletedShape,
|
||||||
|
NEW_SHAPE_GLOW_DURATION,
|
||||||
|
} from '../lib/versionHistory';
|
||||||
|
|
||||||
|
export interface VersionHistoryState {
|
||||||
|
diff: ShapeDiff;
|
||||||
|
deletedShapes: DeletedShape[];
|
||||||
|
showNewShapeGlow: boolean;
|
||||||
|
showDeletedShapes: boolean;
|
||||||
|
isFirstVisit: boolean;
|
||||||
|
lastSeenTimestamp: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseVersionHistoryOptions {
|
||||||
|
userId: string;
|
||||||
|
boardId: string;
|
||||||
|
editor: Editor | null;
|
||||||
|
autoSaveOnChange?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseVersionHistoryReturn extends VersionHistoryState {
|
||||||
|
markAsSeen: () => void;
|
||||||
|
toggleNewShapeGlow: (show: boolean) => void;
|
||||||
|
toggleDeletedShapes: (show: boolean) => void;
|
||||||
|
restoreShape: (deletedShape: DeletedShape) => void;
|
||||||
|
navigateToShape: (shapeId: TLShapeId) => void;
|
||||||
|
refreshDiff: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage version history and visual diff highlighting
|
||||||
|
*/
|
||||||
|
export function useVersionHistory({
|
||||||
|
userId,
|
||||||
|
boardId,
|
||||||
|
editor,
|
||||||
|
autoSaveOnChange = false,
|
||||||
|
}: UseVersionHistoryOptions): UseVersionHistoryReturn {
|
||||||
|
const [diff, setDiff] = useState<ShapeDiff>({
|
||||||
|
newShapes: [],
|
||||||
|
deletedShapes: [],
|
||||||
|
modifiedShapes: [],
|
||||||
|
});
|
||||||
|
const [deletedShapes, setDeletedShapes] = useState<DeletedShape[]>([]);
|
||||||
|
const [showNewShapeGlow, setShowNewShapeGlow] = useState(true);
|
||||||
|
const [showDeletedShapes, setShowDeletedShapes] = useState(true);
|
||||||
|
const [isFirstVisit, setIsFirstVisit] = useState(true);
|
||||||
|
const [lastSeenTimestamp, setLastSeenTimestamp] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Track shapes that should lose their glow effect after timeout
|
||||||
|
const glowTimeoutRef = useRef<Map<TLShapeId, NodeJS.Timeout>>(new Map());
|
||||||
|
const previousShapeIdsRef = useRef<Set<TLShapeId>>(new Set());
|
||||||
|
|
||||||
|
// Compute initial diff on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !userId || !boardId) return;
|
||||||
|
|
||||||
|
const lastSeen = getLastSeenState(userId, boardId);
|
||||||
|
setIsFirstVisit(!lastSeen);
|
||||||
|
setLastSeenTimestamp(lastSeen?.lastSeenTimestamp || null);
|
||||||
|
|
||||||
|
const shapes = editor.getCurrentPageShapes();
|
||||||
|
const computed = computeShapeDiff(userId, boardId, shapes);
|
||||||
|
setDiff(computed);
|
||||||
|
|
||||||
|
const deleted = getDeletedShapes(userId, boardId);
|
||||||
|
setDeletedShapes(deleted);
|
||||||
|
|
||||||
|
// Store current shape IDs for tracking deletions
|
||||||
|
previousShapeIdsRef.current = new Set(shapes.map(s => s.id));
|
||||||
|
}, [editor, userId, boardId]);
|
||||||
|
|
||||||
|
// Listen for store changes to detect new/deleted shapes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !userId || !boardId) return;
|
||||||
|
|
||||||
|
const handleStoreChange = () => {
|
||||||
|
const shapes = editor.getCurrentPageShapes();
|
||||||
|
const currentIds = new Set(shapes.map(s => s.id));
|
||||||
|
const previousIds = previousShapeIdsRef.current;
|
||||||
|
|
||||||
|
// Detect newly deleted shapes (were in previous, not in current)
|
||||||
|
previousIds.forEach(id => {
|
||||||
|
if (!currentIds.has(id)) {
|
||||||
|
// Shape was deleted - try to get its last known state
|
||||||
|
// Note: The shape is already gone from the editor, so we can't get it
|
||||||
|
// This is handled by the deletion tracking in the shape lifecycle
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update previous IDs
|
||||||
|
previousShapeIdsRef.current = currentIds;
|
||||||
|
|
||||||
|
// Recompute diff
|
||||||
|
const computed = computeShapeDiff(userId, boardId, shapes);
|
||||||
|
setDiff(computed);
|
||||||
|
|
||||||
|
// Auto-save if enabled
|
||||||
|
if (autoSaveOnChange) {
|
||||||
|
saveLastSeenState(userId, boardId, shapes);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = editor.store.listen(handleStoreChange, {
|
||||||
|
source: 'all',
|
||||||
|
scope: 'document',
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [editor, userId, boardId, autoSaveOnChange]);
|
||||||
|
|
||||||
|
// Apply visual classes to new shapes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !showNewShapeGlow) return;
|
||||||
|
|
||||||
|
// Apply glow class to new shapes
|
||||||
|
diff.newShapes.forEach(shapeId => {
|
||||||
|
const element = document.querySelector(`[data-shape-id="${shapeId}"]`);
|
||||||
|
if (element) {
|
||||||
|
element.classList.add('shape-new');
|
||||||
|
|
||||||
|
// Set up timeout to remove glow after duration
|
||||||
|
const existingTimeout = glowTimeoutRef.current.get(shapeId);
|
||||||
|
if (existingTimeout) {
|
||||||
|
clearTimeout(existingTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
element.classList.add('glow-fading');
|
||||||
|
setTimeout(() => {
|
||||||
|
element.classList.remove('shape-new', 'glow-fading');
|
||||||
|
}, 3000); // Match CSS animation duration
|
||||||
|
glowTimeoutRef.current.delete(shapeId);
|
||||||
|
}, NEW_SHAPE_GLOW_DURATION);
|
||||||
|
|
||||||
|
glowTimeoutRef.current.set(shapeId, timeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup timeouts
|
||||||
|
glowTimeoutRef.current.forEach(timeout => clearTimeout(timeout));
|
||||||
|
};
|
||||||
|
}, [editor, diff.newShapes, showNewShapeGlow]);
|
||||||
|
|
||||||
|
// Listen for custom events from the panel
|
||||||
|
useEffect(() => {
|
||||||
|
const handleToggleGlow = (event: CustomEvent<boolean>) => {
|
||||||
|
setShowNewShapeGlow(event.detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleDeleted = (event: CustomEvent<boolean>) => {
|
||||||
|
setShowDeletedShapes(event.detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShapeRestored = (event: CustomEvent<TLShapeId>) => {
|
||||||
|
removeDeletedShape(userId, boardId, event.detail);
|
||||||
|
setDeletedShapes(prev => prev.filter(s => s.id !== event.detail));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('toggle-new-shape-glow', handleToggleGlow as EventListener);
|
||||||
|
window.addEventListener('toggle-deleted-shapes', handleToggleDeleted as EventListener);
|
||||||
|
window.addEventListener('shape-restored', handleShapeRestored as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('toggle-new-shape-glow', handleToggleGlow as EventListener);
|
||||||
|
window.removeEventListener('toggle-deleted-shapes', handleToggleDeleted as EventListener);
|
||||||
|
window.removeEventListener('shape-restored', handleShapeRestored as EventListener);
|
||||||
|
};
|
||||||
|
}, [userId, boardId]);
|
||||||
|
|
||||||
|
// Mark current state as seen
|
||||||
|
const markAsSeen = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const shapes = editor.getCurrentPageShapes();
|
||||||
|
saveLastSeenState(userId, boardId, shapes);
|
||||||
|
setDiff({
|
||||||
|
newShapes: [],
|
||||||
|
deletedShapes: [],
|
||||||
|
modifiedShapes: [],
|
||||||
|
});
|
||||||
|
setLastSeenTimestamp(Date.now());
|
||||||
|
setIsFirstVisit(false);
|
||||||
|
}, [editor, userId, boardId]);
|
||||||
|
|
||||||
|
// Toggle glow visibility
|
||||||
|
const toggleNewShapeGlow = useCallback((show: boolean) => {
|
||||||
|
setShowNewShapeGlow(show);
|
||||||
|
if (!show) {
|
||||||
|
// Remove glow from all shapes
|
||||||
|
document.querySelectorAll('.shape-new').forEach(el => {
|
||||||
|
el.classList.remove('shape-new', 'glow-fading');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toggle deleted shapes visibility
|
||||||
|
const toggleDeletedShapes = useCallback((show: boolean) => {
|
||||||
|
setShowDeletedShapes(show);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Restore a deleted shape
|
||||||
|
const restoreShape = useCallback((deletedShape: DeletedShape) => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
editor.createShape({
|
||||||
|
id: deletedShape.id,
|
||||||
|
type: deletedShape.type,
|
||||||
|
x: deletedShape.x,
|
||||||
|
y: deletedShape.y,
|
||||||
|
props: deletedShape.props,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
removeDeletedShape(userId, boardId, deletedShape.id);
|
||||||
|
setDeletedShapes(prev => prev.filter(s => s.id !== deletedShape.id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error restoring shape:', error);
|
||||||
|
}
|
||||||
|
}, [editor, userId, boardId]);
|
||||||
|
|
||||||
|
// Navigate to a shape
|
||||||
|
const navigateToShape = useCallback((shapeId: TLShapeId) => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const shape = editor.getShape(shapeId);
|
||||||
|
if (!shape) return;
|
||||||
|
|
||||||
|
const bounds = editor.getShapePageBounds(shape);
|
||||||
|
if (bounds) {
|
||||||
|
editor.zoomToBounds(bounds, {
|
||||||
|
animation: { duration: 300 },
|
||||||
|
inset: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.setSelectedShapes([shapeId]);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
// Refresh diff manually
|
||||||
|
const refreshDiff = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const shapes = editor.getCurrentPageShapes();
|
||||||
|
const computed = computeShapeDiff(userId, boardId, shapes);
|
||||||
|
setDiff(computed);
|
||||||
|
}, [editor, userId, boardId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
diff,
|
||||||
|
deletedShapes,
|
||||||
|
showNewShapeGlow,
|
||||||
|
showDeletedShapes,
|
||||||
|
isFirstVisit,
|
||||||
|
lastSeenTimestamp,
|
||||||
|
markAsSeen,
|
||||||
|
toggleNewShapeGlow,
|
||||||
|
toggleDeletedShapes,
|
||||||
|
restoreShape,
|
||||||
|
navigateToShape,
|
||||||
|
refreshDiff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track shape deletions by intercepting the delete operation
|
||||||
|
* This is meant to be called from the Editor's onMount or similar
|
||||||
|
*/
|
||||||
|
export function trackShapeDeletions(
|
||||||
|
editor: Editor,
|
||||||
|
userId: string,
|
||||||
|
boardId: string
|
||||||
|
): () => void {
|
||||||
|
// Store shapes before they're deleted
|
||||||
|
const handleBeforeDelete = (records: TLRecord[]) => {
|
||||||
|
records.forEach(record => {
|
||||||
|
if (record.typeName === 'shape') {
|
||||||
|
const shape = record as TLShape;
|
||||||
|
storeDeletedShape(userId, boardId, shape);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for store changes with before state
|
||||||
|
const unsubscribe = editor.store.listen(
|
||||||
|
(entry) => {
|
||||||
|
// Check for deleted shapes
|
||||||
|
if (entry.changes.removed) {
|
||||||
|
const removedRecords = Object.values(entry.changes.removed);
|
||||||
|
handleBeforeDelete(removedRecords as TLRecord[]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ source: 'user', scope: 'document' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
/**
|
||||||
|
* Permission Types and Utilities
|
||||||
|
*
|
||||||
|
* Defines roles and permissions for board access control.
|
||||||
|
* Currently uses localStorage-based role assignment per board.
|
||||||
|
* Can be extended to integrate with server-side permission checking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BoardRole = 'admin' | 'editor' | 'viewer';
|
||||||
|
|
||||||
|
export interface BoardPermission {
|
||||||
|
canView: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
canRevert: boolean;
|
||||||
|
canRestoreDeleted: boolean;
|
||||||
|
canViewHistory: boolean;
|
||||||
|
canMarkAsSeen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission matrix by role
|
||||||
|
*/
|
||||||
|
const ROLE_PERMISSIONS: Record<BoardRole, BoardPermission> = {
|
||||||
|
admin: {
|
||||||
|
canView: true,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
|
canRevert: true,
|
||||||
|
canRestoreDeleted: true,
|
||||||
|
canViewHistory: true,
|
||||||
|
canMarkAsSeen: true,
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
canView: true,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
|
canRevert: false, // Editors cannot revert to old versions
|
||||||
|
canRestoreDeleted: true,
|
||||||
|
canViewHistory: true,
|
||||||
|
canMarkAsSeen: true,
|
||||||
|
},
|
||||||
|
viewer: {
|
||||||
|
canView: true,
|
||||||
|
canEdit: false,
|
||||||
|
canDelete: false,
|
||||||
|
canRevert: false,
|
||||||
|
canRestoreDeleted: false,
|
||||||
|
canViewHistory: true, // Viewers can see history but not act on it
|
||||||
|
canMarkAsSeen: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get permissions for a given role
|
||||||
|
*/
|
||||||
|
export function getPermissionsForRole(role: BoardRole): BoardPermission {
|
||||||
|
return ROLE_PERMISSIONS[role];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage key for user role per board
|
||||||
|
*/
|
||||||
|
function getRoleStorageKey(userId: string, boardId: string): string {
|
||||||
|
return `board_role_${userId}_${boardId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's role for a specific board
|
||||||
|
* Falls back to 'editor' if not set (default for authenticated users)
|
||||||
|
*/
|
||||||
|
export function getUserBoardRole(userId: string, boardId: string): BoardRole {
|
||||||
|
if (!userId) return 'viewer'; // Unauthenticated users are viewers
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(getRoleStorageKey(userId, boardId));
|
||||||
|
if (stored && ['admin', 'editor', 'viewer'].includes(stored)) {
|
||||||
|
return stored as BoardRole;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Error reading board role:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: check if user is the board creator
|
||||||
|
const boardCreator = getBoardCreator(boardId);
|
||||||
|
if (boardCreator === userId) {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'editor'; // Default for authenticated users
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the user's role for a specific board (admin-only operation)
|
||||||
|
*/
|
||||||
|
export function setUserBoardRole(userId: string, boardId: string, role: BoardRole): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(getRoleStorageKey(userId, boardId), role);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving board role:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage key for board creator
|
||||||
|
*/
|
||||||
|
function getCreatorStorageKey(boardId: string): string {
|
||||||
|
return `board_creator_${boardId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the creator of a board
|
||||||
|
*/
|
||||||
|
export function getBoardCreator(boardId: string): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(getCreatorStorageKey(boardId));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Error reading board creator:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the creator of a board (called when board is created)
|
||||||
|
*/
|
||||||
|
export function setBoardCreator(boardId: string, userId: string): void {
|
||||||
|
try {
|
||||||
|
const existing = getBoardCreator(boardId);
|
||||||
|
if (!existing) {
|
||||||
|
localStorage.setItem(getCreatorStorageKey(boardId), userId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving board creator:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has a specific permission on a board
|
||||||
|
*/
|
||||||
|
export function hasPermission(
|
||||||
|
userId: string,
|
||||||
|
boardId: string,
|
||||||
|
permission: keyof BoardPermission
|
||||||
|
): boolean {
|
||||||
|
const role = getUserBoardRole(userId, boardId);
|
||||||
|
const permissions = getPermissionsForRole(role);
|
||||||
|
return permissions[permission];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all permissions for a user on a board
|
||||||
|
*/
|
||||||
|
export function getUserBoardPermissions(userId: string, boardId: string): BoardPermission {
|
||||||
|
const role = getUserBoardRole(userId, boardId);
|
||||||
|
return getPermissionsForRole(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook-friendly permission checker
|
||||||
|
* Returns permission object and role for a user on a board
|
||||||
|
*/
|
||||||
|
export interface UserPermissionContext {
|
||||||
|
role: BoardRole;
|
||||||
|
permissions: BoardPermission;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isEditor: boolean;
|
||||||
|
isViewer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserPermissionContext(userId: string, boardId: string): UserPermissionContext {
|
||||||
|
const role = getUserBoardRole(userId, boardId);
|
||||||
|
const permissions = getPermissionsForRole(role);
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
isAdmin: role === 'admin',
|
||||||
|
isEditor: role === 'editor',
|
||||||
|
isViewer: role === 'viewer',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,285 @@
|
||||||
|
/**
|
||||||
|
* Version History System
|
||||||
|
*
|
||||||
|
* Tracks user's last-seen board state and computes diffs to highlight
|
||||||
|
* new, modified, and deleted shapes. Integrates with R2 backups for
|
||||||
|
* coarse-grained history and Automerge CRDT for fine-grained history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TLShape, TLShapeId } from 'tldraw';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
export interface UserBoardState {
|
||||||
|
userId: string;
|
||||||
|
boardId: string;
|
||||||
|
lastSeenTimestamp: number;
|
||||||
|
shapeIds: string[];
|
||||||
|
shapeHashes: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShapeDiff {
|
||||||
|
newShapes: TLShapeId[]; // Shapes added since last visit
|
||||||
|
deletedShapes: DeletedShape[]; // Shapes removed since last visit
|
||||||
|
modifiedShapes: TLShapeId[]; // Shapes changed since last visit
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeletedShape {
|
||||||
|
id: TLShapeId;
|
||||||
|
type: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
props: Record<string, unknown>;
|
||||||
|
deletedAt: number;
|
||||||
|
deletedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionSnapshot {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
source: 'automerge' | 'r2';
|
||||||
|
shapeCount: number;
|
||||||
|
label?: string;
|
||||||
|
actorId?: string; // For Automerge heads
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Storage Keys ===
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'canvas_version_';
|
||||||
|
|
||||||
|
function getStateKey(userId: string, boardId: string): string {
|
||||||
|
return `${STORAGE_PREFIX}state_${userId}_${boardId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeletedKey(userId: string, boardId: string): string {
|
||||||
|
return `${STORAGE_PREFIX}deleted_${userId}_${boardId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Hash Utilities ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a simple hash for a shape's content to detect modifications
|
||||||
|
*/
|
||||||
|
function hashShape(shape: TLShape): string {
|
||||||
|
// Create a deterministic string from shape properties that matter
|
||||||
|
const relevant = {
|
||||||
|
type: shape.type,
|
||||||
|
x: Math.round(shape.x * 100) / 100,
|
||||||
|
y: Math.round(shape.y * 100) / 100,
|
||||||
|
rotation: shape.rotation,
|
||||||
|
props: shape.props,
|
||||||
|
};
|
||||||
|
return simpleHash(JSON.stringify(relevant));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple string hash for quick comparison (DJB2 algorithm)
|
||||||
|
*/
|
||||||
|
function simpleHash(str: string): string {
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === State Management ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's last-seen state for a board
|
||||||
|
*/
|
||||||
|
export function getLastSeenState(userId: string, boardId: string): UserBoardState | null {
|
||||||
|
try {
|
||||||
|
const key = getStateKey(userId, boardId);
|
||||||
|
const stored = localStorage.getItem(key);
|
||||||
|
if (!stored) return null;
|
||||||
|
return JSON.parse(stored);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading last-seen state:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the current state as the user's last-seen state
|
||||||
|
*/
|
||||||
|
export function saveLastSeenState(
|
||||||
|
userId: string,
|
||||||
|
boardId: string,
|
||||||
|
shapes: TLShape[]
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const state: UserBoardState = {
|
||||||
|
userId,
|
||||||
|
boardId,
|
||||||
|
lastSeenTimestamp: Date.now(),
|
||||||
|
shapeIds: shapes.map(s => s.id),
|
||||||
|
shapeHashes: shapes.reduce((acc, shape) => {
|
||||||
|
acc[shape.id] = hashShape(shape);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
const key = getStateKey(userId, boardId);
|
||||||
|
localStorage.setItem(key, JSON.stringify(state));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving last-seen state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a deleted shape for potential restoration
|
||||||
|
*/
|
||||||
|
export function storeDeletedShape(
|
||||||
|
userId: string,
|
||||||
|
boardId: string,
|
||||||
|
shape: TLShape,
|
||||||
|
deletedBy?: string
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const key = getDeletedKey(userId, boardId);
|
||||||
|
const stored = localStorage.getItem(key);
|
||||||
|
const deletedShapes: DeletedShape[] = stored ? JSON.parse(stored) : [];
|
||||||
|
|
||||||
|
// Add the newly deleted shape
|
||||||
|
deletedShapes.push({
|
||||||
|
id: shape.id,
|
||||||
|
type: shape.type,
|
||||||
|
x: shape.x,
|
||||||
|
y: shape.y,
|
||||||
|
props: shape.props as Record<string, unknown>,
|
||||||
|
deletedAt: Date.now(),
|
||||||
|
deletedBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only last 100 deleted shapes per board to prevent storage bloat
|
||||||
|
const trimmed = deletedShapes.slice(-100);
|
||||||
|
localStorage.setItem(key, JSON.stringify(trimmed));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error storing deleted shape:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get deleted shapes that can be restored
|
||||||
|
*/
|
||||||
|
export function getDeletedShapes(userId: string, boardId: string): DeletedShape[] {
|
||||||
|
try {
|
||||||
|
const key = getDeletedKey(userId, boardId);
|
||||||
|
const stored = localStorage.getItem(key);
|
||||||
|
if (!stored) return [];
|
||||||
|
return JSON.parse(stored);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading deleted shapes:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a deleted shape from storage (after restoration)
|
||||||
|
*/
|
||||||
|
export function removeDeletedShape(userId: string, boardId: string, shapeId: TLShapeId): void {
|
||||||
|
try {
|
||||||
|
const key = getDeletedKey(userId, boardId);
|
||||||
|
const stored = localStorage.getItem(key);
|
||||||
|
if (!stored) return;
|
||||||
|
|
||||||
|
const deletedShapes: DeletedShape[] = JSON.parse(stored);
|
||||||
|
const filtered = deletedShapes.filter(s => s.id !== shapeId);
|
||||||
|
localStorage.setItem(key, JSON.stringify(filtered));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing deleted shape:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Diff Computation ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the difference between last-seen state and current shapes
|
||||||
|
*/
|
||||||
|
export function computeShapeDiff(
|
||||||
|
userId: string,
|
||||||
|
boardId: string,
|
||||||
|
currentShapes: TLShape[]
|
||||||
|
): ShapeDiff {
|
||||||
|
const lastSeen = getLastSeenState(userId, boardId);
|
||||||
|
|
||||||
|
// If no last-seen state, nothing is "new" - first visit
|
||||||
|
if (!lastSeen) {
|
||||||
|
return {
|
||||||
|
newShapes: [],
|
||||||
|
deletedShapes: [],
|
||||||
|
modifiedShapes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeenIds = new Set(lastSeen.shapeIds);
|
||||||
|
const currentIds = new Set(currentShapes.map(s => s.id));
|
||||||
|
|
||||||
|
// Find new shapes (in current but not in last-seen)
|
||||||
|
const newShapes = currentShapes
|
||||||
|
.filter(s => !lastSeenIds.has(s.id))
|
||||||
|
.map(s => s.id);
|
||||||
|
|
||||||
|
// Find modified shapes (in both, but hash changed)
|
||||||
|
const modifiedShapes = currentShapes
|
||||||
|
.filter(s => {
|
||||||
|
if (!lastSeenIds.has(s.id)) return false;
|
||||||
|
const oldHash = lastSeen.shapeHashes[s.id];
|
||||||
|
const newHash = hashShape(s);
|
||||||
|
return oldHash !== newHash;
|
||||||
|
})
|
||||||
|
.map(s => s.id);
|
||||||
|
|
||||||
|
// Get stored deleted shapes (shapes that were in last-seen but removed)
|
||||||
|
const deletedShapes = getDeletedShapes(userId, boardId)
|
||||||
|
.filter(d => lastSeenIds.has(d.id) && !currentIds.has(d.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
newShapes,
|
||||||
|
deletedShapes,
|
||||||
|
modifiedShapes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Version Snapshot Management ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a timestamp into a human-readable relative time
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(timestamp: number): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
|
||||||
|
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
||||||
|
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
||||||
|
return 'Just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a timestamp into a readable date string
|
||||||
|
*/
|
||||||
|
export function formatTimestamp(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Effect Duration ===
|
||||||
|
|
||||||
|
// How long new shape glow should last (in ms)
|
||||||
|
export const NEW_SHAPE_GLOW_DURATION = 30000; // 30 seconds
|
||||||
|
|
||||||
|
// How long to show deleted shapes before auto-hiding
|
||||||
|
export const DELETED_SHAPE_VISIBLE_DURATION = 60000; // 1 minute
|
||||||
|
|
@ -8,6 +8,7 @@ import { SettingsDialog } from "./SettingsDialog"
|
||||||
import { useAuth } from "../context/AuthContext"
|
import { useAuth } from "../context/AuthContext"
|
||||||
import LoginButton from "../components/auth/LoginButton"
|
import LoginButton from "../components/auth/LoginButton"
|
||||||
import StarBoardButton from "../components/StarBoardButton"
|
import StarBoardButton from "../components/StarBoardButton"
|
||||||
|
import VersionHistoryButton from "../components/VersionHistoryButton"
|
||||||
import { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
|
import { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
|
||||||
import { HolonBrowser } from "../components/HolonBrowser"
|
import { HolonBrowser } from "../components/HolonBrowser"
|
||||||
import { ObsNoteShape } from "../shapes/ObsNoteShapeUtil"
|
import { ObsNoteShape } from "../shapes/ObsNoteShapeUtil"
|
||||||
|
|
@ -534,6 +535,7 @@ export function CustomToolbar() {
|
||||||
>
|
>
|
||||||
<LoginButton className="toolbar-btn" />
|
<LoginButton className="toolbar-btn" />
|
||||||
<StarBoardButton className="toolbar-btn" />
|
<StarBoardButton className="toolbar-btn" />
|
||||||
|
<VersionHistoryButton className="toolbar-btn" />
|
||||||
|
|
||||||
{session.authed && (
|
{session.authed && (
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
useValue,
|
useValue,
|
||||||
} from "tldraw"
|
} from "tldraw"
|
||||||
import { SlidesPanel } from "@/slides/SlidesPanel"
|
import { SlidesPanel } from "@/slides/SlidesPanel"
|
||||||
|
import { DeletedShapesOverlay } from "@/components/DeletedShapesOverlay"
|
||||||
|
|
||||||
// Custom People Menu component for showing connected users
|
// Custom People Menu component for showing connected users
|
||||||
function CustomPeopleMenu() {
|
function CustomPeopleMenu() {
|
||||||
|
|
@ -233,6 +234,7 @@ function CustomInFrontOfCanvas() {
|
||||||
<MycelialIntelligenceBar />
|
<MycelialIntelligenceBar />
|
||||||
<FocusLockIndicator />
|
<FocusLockIndicator />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
<DeletedShapesOverlay />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
166
worker/worker.ts
166
worker/worker.ts
|
|
@ -905,7 +905,7 @@ router
|
||||||
try {
|
try {
|
||||||
// Simple test to check R2 access
|
// Simple test to check R2 access
|
||||||
const testResult = await env.TLDRAW_BUCKET.list({ prefix: 'rooms/', limit: 1 })
|
const testResult = await env.TLDRAW_BUCKET.list({ prefix: 'rooms/', limit: 1 })
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'R2 access test successful',
|
message: 'R2 access test successful',
|
||||||
|
|
@ -925,6 +925,170 @@ router
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// === Version History API ===
|
||||||
|
.get("/api/versions/:roomId", async (request, env) => {
|
||||||
|
try {
|
||||||
|
const roomId = request.params.roomId
|
||||||
|
if (!roomId) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Room ID is required' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all version snapshots from the backups folder
|
||||||
|
const backupPrefix = `backups/${roomId}/`
|
||||||
|
const listResult = await env.TLDRAW_BUCKET.list({ prefix: backupPrefix })
|
||||||
|
|
||||||
|
const versions = await Promise.all(
|
||||||
|
listResult.objects.map(async (obj) => {
|
||||||
|
// Extract timestamp from backup filename (format: backups/roomId/timestamp.json)
|
||||||
|
const filename = obj.key.replace(backupPrefix, '')
|
||||||
|
const timestamp = parseInt(filename.replace('.json', ''), 10)
|
||||||
|
|
||||||
|
// Try to get shape count from the backup
|
||||||
|
let shapeCount = 0
|
||||||
|
try {
|
||||||
|
const backupData = await env.TLDRAW_BUCKET.get(obj.key)
|
||||||
|
if (backupData) {
|
||||||
|
const json = JSON.parse(await backupData.text())
|
||||||
|
if (json.store) {
|
||||||
|
shapeCount = Object.values(json.store).filter((r: any) =>
|
||||||
|
r && typeof r === 'object' && (r as any).typeName === 'shape'
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors getting shape count
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: obj.key,
|
||||||
|
timestamp: isNaN(timestamp) ? obj.uploaded.getTime() : timestamp,
|
||||||
|
source: 'r2' as const,
|
||||||
|
shapeCount,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sort by timestamp descending (newest first)
|
||||||
|
versions.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ versions }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Version list failed:', error)
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Failed to list versions',
|
||||||
|
message: (error as Error).message
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.get("/api/versions/:roomId/:versionId", async (request, env) => {
|
||||||
|
try {
|
||||||
|
const { roomId, versionId } = request.params
|
||||||
|
if (!roomId || !versionId) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Room ID and Version ID are required' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the specific version from R2
|
||||||
|
// versionId might be the full key or just the timestamp
|
||||||
|
const key = versionId.startsWith('backups/') ? versionId : `backups/${roomId}/${versionId}.json`
|
||||||
|
const versionData = await env.TLDRAW_BUCKET.get(key)
|
||||||
|
|
||||||
|
if (!versionData) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Version not found' }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await versionData.text()
|
||||||
|
return new Response(json, {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get version failed:', error)
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Failed to get version',
|
||||||
|
message: (error as Error).message
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.post("/api/versions/:roomId/:versionId", async (request, env) => {
|
||||||
|
try {
|
||||||
|
const { roomId, versionId } = request.params
|
||||||
|
if (!roomId || !versionId) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Room ID and Version ID are required' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body to check action
|
||||||
|
const body = await request.json().catch(() => ({})) as { action?: string }
|
||||||
|
if (body.action !== 'revert') {
|
||||||
|
return new Response(JSON.stringify({ error: 'Invalid action. Use action: "revert"' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the version to revert to
|
||||||
|
const key = versionId.startsWith('backups/') ? versionId : `backups/${roomId}/${versionId}.json`
|
||||||
|
const versionData = await env.TLDRAW_BUCKET.get(key)
|
||||||
|
|
||||||
|
if (!versionData) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Version not found' }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a backup of the current state before reverting
|
||||||
|
const currentRoom = await env.TLDRAW_BUCKET.get(`rooms/${roomId}`)
|
||||||
|
if (currentRoom) {
|
||||||
|
const backupTimestamp = Date.now()
|
||||||
|
const backupKey = `backups/${roomId}/${backupTimestamp}-pre-revert.json`
|
||||||
|
await env.TLDRAW_BUCKET.put(backupKey, await currentRoom.text())
|
||||||
|
console.log(`Created pre-revert backup: ${backupKey}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite the current room data with the version data
|
||||||
|
const versionJson = await versionData.text()
|
||||||
|
await env.TLDRAW_BUCKET.put(`rooms/${roomId}`, versionJson)
|
||||||
|
|
||||||
|
console.log(`Reverted room ${roomId} to version ${versionId}`)
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `Successfully reverted to version`,
|
||||||
|
roomId,
|
||||||
|
versionId
|
||||||
|
}), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Revert failed:', error)
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Failed to revert to version',
|
||||||
|
message: (error as Error).message
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Handle scheduled events (cron jobs)
|
// Handle scheduled events (cron jobs)
|
||||||
export async function scheduled(_event: ScheduledEvent, env: Environment, _ctx: ExecutionContext) {
|
export async function scheduled(_event: ScheduledEvent, env: Environment, _ctx: ExecutionContext) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue