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("version-history.css");
|
||||
|
||||
:root {
|
||||
--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 LoginButton from "../components/auth/LoginButton"
|
||||
import StarBoardButton from "../components/StarBoardButton"
|
||||
import VersionHistoryButton from "../components/VersionHistoryButton"
|
||||
import { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
|
||||
import { HolonBrowser } from "../components/HolonBrowser"
|
||||
import { ObsNoteShape } from "../shapes/ObsNoteShapeUtil"
|
||||
|
|
@ -534,6 +535,7 @@ export function CustomToolbar() {
|
|||
>
|
||||
<LoginButton className="toolbar-btn" />
|
||||
<StarBoardButton className="toolbar-btn" />
|
||||
<VersionHistoryButton className="toolbar-btn" />
|
||||
|
||||
{session.authed && (
|
||||
<div style={{ position: "relative" }}>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
useValue,
|
||||
} from "tldraw"
|
||||
import { SlidesPanel } from "@/slides/SlidesPanel"
|
||||
import { DeletedShapesOverlay } from "@/components/DeletedShapesOverlay"
|
||||
|
||||
// Custom People Menu component for showing connected users
|
||||
function CustomPeopleMenu() {
|
||||
|
|
@ -233,6 +234,7 @@ function CustomInFrontOfCanvas() {
|
|||
<MycelialIntelligenceBar />
|
||||
<FocusLockIndicator />
|
||||
<CommandPalette />
|
||||
<DeletedShapesOverlay />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
166
worker/worker.ts
166
worker/worker.ts
|
|
@ -905,7 +905,7 @@ router
|
|||
try {
|
||||
// Simple test to check R2 access
|
||||
const testResult = await env.TLDRAW_BUCKET.list({ prefix: 'rooms/', limit: 1 })
|
||||
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
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)
|
||||
export async function scheduled(_event: ScheduledEvent, env: Environment, _ctx: ExecutionContext) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue