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:
Jeff Emmett 2025-12-04 15:01:30 -08:00
parent ebb3ab661b
commit 03894d2146
12 changed files with 2284 additions and 1 deletions

View File

@ -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>
</>
)}
</>
);
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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;

332
src/css/version-history.css Normal file
View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

181
src/lib/permissions.ts Normal file
View File

@ -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',
};
}

285
src/lib/versionHistory.ts Normal file
View File

@ -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

View File

@ -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" }}>

View File

@ -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 />
</> </>
) )
} }

View File

@ -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) {