From 03894d2146d7e7aeeb6fb9c1d559d6f8527919a1 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 4 Dec 2025 15:01:30 -0800 Subject: [PATCH] feat: add version history and change tracking system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/DeletedShapesOverlay.tsx | 300 ++++++++++++++ src/components/VersionHistoryButton.tsx | 130 ++++++ src/components/VersionHistoryPanel.tsx | 511 ++++++++++++++++++++++++ src/css/style.css | 1 + src/css/version-history.css | 332 +++++++++++++++ src/hooks/usePermissions.ts | 60 +++ src/hooks/useVersionHistory.ts | 315 +++++++++++++++ src/lib/permissions.ts | 181 +++++++++ src/lib/versionHistory.ts | 285 +++++++++++++ src/ui/CustomToolbar.tsx | 2 + src/ui/components.tsx | 2 + worker/worker.ts | 166 +++++++- 12 files changed, 2284 insertions(+), 1 deletion(-) create mode 100644 src/components/DeletedShapesOverlay.tsx create mode 100644 src/components/VersionHistoryButton.tsx create mode 100644 src/components/VersionHistoryPanel.tsx create mode 100644 src/css/version-history.css create mode 100644 src/hooks/usePermissions.ts create mode 100644 src/hooks/useVersionHistory.ts create mode 100644 src/lib/permissions.ts create mode 100644 src/lib/versionHistory.ts diff --git a/src/components/DeletedShapesOverlay.tsx b/src/components/DeletedShapesOverlay.tsx new file mode 100644 index 0000000..4b433b0 --- /dev/null +++ b/src/components/DeletedShapesOverlay.tsx @@ -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 = ({ + show = true, +}) => { + const editor = useEditor(); + const { session } = useAuth(); + const { slug } = useParams<{ slug: string }>(); + const { canRestoreDeleted } = usePermissions(slug || ''); + const [deletedShapes, setDeletedShapes] = useState([]); + 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) => { + setVisible(event.detail); + }; + + const handleShapeRestored = (event: CustomEvent) => { + 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 */} + + + {/* Restore Panel */} + {showPanel && ( + <> + {/* Backdrop */} +
setShowPanel(false)} + /> + + {/* Panel */} +
+ {/* Header */} +
+

+ Deleted Shapes +

+ +
+ + {/* Deleted shapes list */} +
+
+ {deletedShapes.map((deleted) => ( +
+ + {deleted.type === 'draw' ? '✏️' : + deleted.type === 'text' ? '📝' : + deleted.type === 'image' ? '🖼️' : + deleted.type === 'embed' ? '🔗' : + '📦'} + +
+

+ {deleted.type} +

+

+ {formatRelativeTime(deleted.deletedAt)} +

+
+ {canRestoreDeleted && ( + + )} +
+ ))} +
+
+ + {/* Actions */} +
+ {canRestoreDeleted && ( + + )} + +
+
+ + )} + + ); +}; diff --git a/src/components/VersionHistoryButton.tsx b/src/components/VersionHistoryButton.tsx new file mode 100644 index 0000000..48498bf --- /dev/null +++ b/src/components/VersionHistoryButton.tsx @@ -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 = ({ 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(null); + const panelRef = useRef(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 ( +
+ + + {/* Version History Panel */} + {showPanel && ( +
+ setShowPanel(false)} + /> +
+ )} +
+ ); +}; + +export default VersionHistoryButton; diff --git a/src/components/VersionHistoryPanel.tsx b/src/components/VersionHistoryPanel.tsx new file mode 100644 index 0000000..35561d1 --- /dev/null +++ b/src/components/VersionHistoryPanel.tsx @@ -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 = ({ + boardId, + userId, + onClose, +}) => { + const editor = useEditor(); + const { canRevert, canRestoreDeleted, canMarkAsSeen, role } = usePermissions(boardId); + const [activeTab, setActiveTab] = useState('changes'); + const [versions, setVersions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [showNewShapeGlow, setShowNewShapeGlow] = useState(true); + const [showDeletedShapes, setShowDeletedShapes] = useState(true); + + // Compute current diff + const diff = useMemo(() => { + if (!editor) return { newShapes: [], deletedShapes: [], modifiedShapes: [] }; + const shapes = editor.getCurrentPageShapes(); + return computeShapeDiff(userId, boardId, shapes); + }, [editor, userId, boardId]); + + // Get deleted shapes + const deletedShapes = useMemo(() => { + return getDeletedShapes(userId, boardId); + }, [userId, boardId]); + + // Fetch R2 version snapshots + useEffect(() => { + const fetchVersions = async () => { + setIsLoading(true); + try { + const response = await fetch(`${WORKER_URL}/api/versions/${boardId}`); + if (response.ok) { + const data = await response.json() as { versions?: VersionSnapshot[] }; + setVersions(data.versions || []); + } + } catch (error) { + console.error('Error fetching versions:', error); + } finally { + setIsLoading(false); + } + }; + + if (activeTab === 'versions') { + fetchVersions(); + } + }, [boardId, activeTab]); + + // Toggle new shape highlighting + const handleToggleNewShapeGlow = () => { + setShowNewShapeGlow(!showNewShapeGlow); + // Dispatch event to toggle visual effects + window.dispatchEvent(new CustomEvent('toggle-new-shape-glow', { detail: !showNewShapeGlow })); + }; + + // Toggle deleted shape visibility + const handleToggleDeletedShapes = () => { + setShowDeletedShapes(!showDeletedShapes); + window.dispatchEvent(new CustomEvent('toggle-deleted-shapes', { detail: !showDeletedShapes })); + }; + + // Mark current state as "seen" + const handleMarkAsSeen = () => { + if (!editor) return; + const shapes = editor.getCurrentPageShapes(); + saveLastSeenState(userId, boardId, shapes); + // Force re-render by closing and reopening + onClose(); + }; + + // Restore a deleted shape + const handleRestoreShape = (deletedShape: DeletedShape) => { + if (!editor) return; + + try { + // Create a new shape with the deleted shape's properties + editor.createShape({ + id: deletedShape.id, + type: deletedShape.type, + x: deletedShape.x, + y: deletedShape.y, + props: deletedShape.props, + } as any); + + // Remove from deleted list + window.dispatchEvent(new CustomEvent('shape-restored', { detail: deletedShape.id })); + } catch (error) { + console.error('Error restoring shape:', error); + } + }; + + // Navigate to a shape + const handleNavigateToShape = (shapeId: TLShapeId) => { + if (!editor) return; + + const shape = editor.getShape(shapeId); + if (!shape) return; + + // Center viewport on the shape + const bounds = editor.getShapePageBounds(shape); + if (bounds) { + editor.zoomToBounds(bounds, { + animation: { duration: 300 }, + inset: 100, + }); + } + + // Select the shape + editor.setSelectedShapes([shapeId]); + }; + + // Revert to a version + const handleRevertToVersion = async (version: VersionSnapshot) => { + if (!editor || !version) return; + + const confirmRevert = window.confirm( + `Revert to version from ${formatTimestamp(version.timestamp)}?\n\nThis will replace the current board state with the selected version.` + ); + + if (!confirmRevert) return; + + try { + setIsLoading(true); + const response = await fetch(`${WORKER_URL}/api/versions/${boardId}/${version.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'revert' }), + }); + + if (response.ok) { + // Reload the page to get the reverted state + window.location.reload(); + } else { + console.error('Failed to revert version'); + } + } catch (error) { + console.error('Error reverting to version:', error); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Header */} +
+
+

+ Version History +

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

+ New Shapes ({diff.newShapes.length}) +

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

+ No new shapes since your last visit +

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

+ Loading versions... +

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

+ {formatRelativeTime(version.timestamp)} +

+

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

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

+ No saved versions available +

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

{deleted.type}

+

+ Deleted {formatRelativeTime(deleted.deletedAt)} +

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

+ No recently deleted shapes +

+ )} +
+ )} +
+
+ ); +}; diff --git a/src/css/style.css b/src/css/style.css index 125980c..3baa011 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1,4 +1,5 @@ @import url("reset.css"); +@import url("version-history.css"); :root { --border-radius: 10px; diff --git a/src/css/version-history.css b/src/css/version-history.css new file mode 100644 index 0000000..92a4432 --- /dev/null +++ b/src/css/version-history.css @@ -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; +} diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts new file mode 100644 index 0000000..4516cb3 --- /dev/null +++ b/src/hooks/usePermissions.ts @@ -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; diff --git a/src/hooks/useVersionHistory.ts b/src/hooks/useVersionHistory.ts new file mode 100644 index 0000000..5732aae --- /dev/null +++ b/src/hooks/useVersionHistory.ts @@ -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({ + newShapes: [], + deletedShapes: [], + modifiedShapes: [], + }); + const [deletedShapes, setDeletedShapes] = useState([]); + const [showNewShapeGlow, setShowNewShapeGlow] = useState(true); + const [showDeletedShapes, setShowDeletedShapes] = useState(true); + const [isFirstVisit, setIsFirstVisit] = useState(true); + const [lastSeenTimestamp, setLastSeenTimestamp] = useState(null); + + // Track shapes that should lose their glow effect after timeout + const glowTimeoutRef = useRef>(new Map()); + const previousShapeIdsRef = useRef>(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) => { + setShowNewShapeGlow(event.detail); + }; + + const handleToggleDeleted = (event: CustomEvent) => { + setShowDeletedShapes(event.detail); + }; + + const handleShapeRestored = (event: CustomEvent) => { + 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; +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..d885dfe --- /dev/null +++ b/src/lib/permissions.ts @@ -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 = { + 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', + }; +} diff --git a/src/lib/versionHistory.ts b/src/lib/versionHistory.ts new file mode 100644 index 0000000..4cd5bcc --- /dev/null +++ b/src/lib/versionHistory.ts @@ -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; +} + +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; + 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), + }; + + 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, + 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 diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx index a8e0d9d..cbc371f 100644 --- a/src/ui/CustomToolbar.tsx +++ b/src/ui/CustomToolbar.tsx @@ -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() { > + {session.authed && (
diff --git a/src/ui/components.tsx b/src/ui/components.tsx index e280c55..2a15b26 100644 --- a/src/ui/components.tsx +++ b/src/ui/components.tsx @@ -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() { + ) } diff --git a/worker/worker.ts b/worker/worker.ts index 0a5ed77..9118016 100644 --- a/worker/worker.ts +++ b/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) {