Skip to content

Commit 2016cd0

Browse files
authored
feat: load canvas backups menu (#31)
* feat: canvas backup - Added CanvasBackup model to store user canvas backups. - Implemented backup creation based on user activity and defined intervals. - Enhanced existing functions to manage user activity timestamps and cleanup processes. * feat: add recent canvas backups endpoint - Introduced a new endpoint to retrieve the most recent canvas backups for authenticated users. - Implemented logic to limit the number of backups returned based on a maximum configured value. - Enhanced the canvas router with JWT decoding to identify the user associated with the backups. * fix: ensure collaborators state is always a Map instance * feat: add Canvas Backups feature - Implemented CanvasBackups component to display recent canvas backups. (!backups) - Added API hooks for fetching backups and restoring selected backups. - Enhanced the pad module to include CanvasBackups and its styles. * style: update CanvasBackups - Changed title from "Recent Canvas Backups" to "Canvas Backups" for clarity. - Enhanced backup restoration confirmation message to include backup index. - Updated styles for improved visual consistency, including background colors, padding, and hover effects. - Added new elements for displaying backup numbers alongside timestamps. - Improved overall layout and animations for a better user experience. * feat: add Canvas Backups option to Main Menu - Updated the menu item titles for clarity and consistency. * feat: implement BackupsModal component and styles - Added BackupsModal component to display and manage canvas backups. - Integrated state management for modal visibility and exit animations. - Created corresponding SCSS styles for the modal, ensuring a cohesive design. - Updated MainMenu to include the BackupsModal, enhancing user experience with backup management options. * refactor: remove CanvasBackups.tsx component and integrate BackupsDialog - Deleted the CanvasBackups component and its associated files to streamline the codebase. - Updated the MainMenu to use the new BackupsDialog component for managing canvas backups. - Introduced new styles for the BackupsDialog to enhance the user interface and experience. * feat: introduce normalizeCanvasData utility for canvas data handling - Added normalizeCanvasData function to standardize canvas data by removing width and height properties and resetting collaborators. - Updated App and BackupsDialog components to utilize the new utility for consistent canvas data normalization during scene updates. * refactor: reorganize MainMenu structure and update item titles - Removed the Canvas Backups item and replaced it with a Load backup... option for clarity. - Introduced a new View group in the MainMenu, consolidating related items such as Toggle grid, View mode, and Zen mode for better organization. * feat: integrate BackupsModal into ExcalidrawWrapper and MainMenu - Added BackupsModal component to ExcalidrawWrapper for managing canvas backups. - Updated MainMenuConfig to include handlers for showing and closing the BackupsModal. - Enhanced styles for the AuthDialog and BackupsModal to improve UI consistency. - Adjusted BackupsModal properties for better user interaction, including size and close behavior. * style: update AuthDialog and BackupsDialog styles for improved UI consistency - Refined styles in AuthDialog.scss to enhance the layout and structure of the authentication modal. - Expanded BackupsDialog.scss with new styles for better padding, margins, and hover effects, ensuring a cohesive design. - Adjusted BackupsModal component size for improved user interaction and visual appeal.
1 parent 250de02 commit 2016cd0

File tree

14 files changed

+844
-60
lines changed

14 files changed

+844
-60
lines changed

src/backend/db.py

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import os
2-
from typing import Optional, Dict, Any
2+
from typing import Optional, Dict, Any, List
3+
from datetime import datetime
34
from dotenv import load_dotenv
4-
from sqlalchemy import Column, String, JSON, DateTime, func, create_engine
5+
from sqlalchemy import Column, String, JSON, DateTime, Integer, func, create_engine
56
from sqlalchemy.ext.declarative import declarative_base
67
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
78
from sqlalchemy.orm import sessionmaker
@@ -31,6 +32,20 @@
3132
# Create base model
3233
Base = declarative_base()
3334

35+
# Canvas backup configuration
36+
BACKUP_INTERVAL_SECONDS = 300 # 5 minutes between backups
37+
MAX_BACKUPS_PER_USER = 10 # Maximum number of backups to keep per user
38+
39+
# In-memory dictionaries to track user activity
40+
user_last_backup_time = {} # Tracks when each user last had a backup
41+
user_last_activity_time = {} # Tracks when each user was last active
42+
43+
# Memory management configuration
44+
INACTIVITY_THRESHOLD_MINUTES = 30 # Remove users from memory after this many minutes of inactivity
45+
MAX_USERS_BEFORE_CLEANUP = 1000 # Trigger cleanup when we have this many users in memory
46+
CLEANUP_INTERVAL_SECONDS = 3600 # Run cleanup at least once per hour (1 hour)
47+
last_cleanup_time = datetime.now() # Track when we last ran cleanup
48+
3449
class CanvasData(Base):
3550
"""Model for canvas data table"""
3651
__tablename__ = "canvas_data"
@@ -42,6 +57,18 @@ class CanvasData(Base):
4257
def __repr__(self):
4358
return f"<CanvasData(user_id='{self.user_id}')>"
4459

60+
class CanvasBackup(Base):
61+
"""Model for canvas backups table"""
62+
__tablename__ = "canvas_backups"
63+
64+
id = Column(Integer, primary_key=True, autoincrement=True)
65+
user_id = Column(String, nullable=False)
66+
timestamp = Column(DateTime(timezone=True), server_default=func.now())
67+
canvas_data = Column(JSON, nullable=False)
68+
69+
def __repr__(self):
70+
return f"<CanvasBackup(id={self.id}, user_id='{self.user_id}')>"
71+
4572
async def get_db_session():
4673
"""Get a database session"""
4774
async with async_session() as session:
@@ -52,8 +79,47 @@ async def init_db():
5279
async with engine.begin() as conn:
5380
await conn.run_sync(Base.metadata.create_all)
5481

82+
async def cleanup_inactive_users(inactivity_threshold_minutes: int = INACTIVITY_THRESHOLD_MINUTES):
83+
"""Remove users from memory tracking if they've been inactive for the specified time"""
84+
current_time = datetime.now()
85+
inactive_users = []
86+
87+
for user_id, last_activity in user_last_activity_time.items():
88+
# Check if user has been inactive for longer than the threshold
89+
if (current_time - last_activity).total_seconds() > (inactivity_threshold_minutes * 60):
90+
inactive_users.append(user_id)
91+
92+
# Remove inactive users from both dictionaries
93+
for user_id in inactive_users:
94+
if user_id in user_last_backup_time:
95+
del user_last_backup_time[user_id]
96+
if user_id in user_last_activity_time:
97+
del user_last_activity_time[user_id]
98+
99+
return len(inactive_users) # Return count of removed users for logging
100+
101+
async def check_if_cleanup_needed():
102+
"""Check if we should run the cleanup function"""
103+
global last_cleanup_time
104+
current_time = datetime.now()
105+
time_since_last_cleanup = (current_time - last_cleanup_time).total_seconds()
106+
107+
# Run cleanup if we have too many users or it's been too long
108+
if (len(user_last_activity_time) > MAX_USERS_BEFORE_CLEANUP or
109+
time_since_last_cleanup > CLEANUP_INTERVAL_SECONDS):
110+
removed_count = await cleanup_inactive_users()
111+
last_cleanup_time = current_time
112+
print(f"[db.py] Cleanup completed: removed {removed_count} inactive users from memory")
113+
55114
async def store_canvas_data(user_id: str, data: Dict[str, Any]) -> bool:
56115
try:
116+
# Update user's last activity time
117+
current_time = datetime.now()
118+
user_last_activity_time[user_id] = current_time
119+
120+
# Check if cleanup is needed
121+
await check_if_cleanup_needed()
122+
57123
async with async_session() as session:
58124
# Check if record exists
59125
stmt = select(CanvasData).where(CanvasData.user_id == user_id)
@@ -68,6 +134,39 @@ async def store_canvas_data(user_id: str, data: Dict[str, Any]) -> bool:
68134
canvas_data = CanvasData(user_id=user_id, data=data)
69135
session.add(canvas_data)
70136

137+
# Check if we should create a backup
138+
should_backup = False
139+
if user_id not in user_last_backup_time:
140+
# First time this user is saving, create a backup
141+
should_backup = True
142+
else:
143+
# Check if backup interval has passed since last backup
144+
time_since_last_backup = (current_time - user_last_backup_time[user_id]).total_seconds()
145+
if time_since_last_backup >= BACKUP_INTERVAL_SECONDS:
146+
should_backup = True
147+
148+
if should_backup:
149+
# Update the backup timestamp
150+
user_last_backup_time[user_id] = current_time
151+
152+
# Count existing backups for this user
153+
backup_count_stmt = select(func.count()).select_from(CanvasBackup).where(CanvasBackup.user_id == user_id)
154+
backup_count_result = await session.execute(backup_count_stmt)
155+
backup_count = backup_count_result.scalar()
156+
157+
# If user has reached the maximum number of backups, delete the oldest one
158+
if backup_count >= MAX_BACKUPS_PER_USER:
159+
oldest_backup_stmt = select(CanvasBackup).where(CanvasBackup.user_id == user_id).order_by(CanvasBackup.timestamp).limit(1)
160+
oldest_backup_result = await session.execute(oldest_backup_stmt)
161+
oldest_backup = oldest_backup_result.scalars().first()
162+
163+
if oldest_backup:
164+
await session.delete(oldest_backup)
165+
166+
# Create new backup
167+
new_backup = CanvasBackup(user_id=user_id, canvas_data=data)
168+
session.add(new_backup)
169+
71170
await session.commit()
72171
return True
73172
except Exception as e:
@@ -76,6 +175,9 @@ async def store_canvas_data(user_id: str, data: Dict[str, Any]) -> bool:
76175

77176
async def get_canvas_data(user_id: str) -> Optional[Dict[str, Any]]:
78177
try:
178+
# Update user's last activity time
179+
user_last_activity_time[user_id] = datetime.now()
180+
79181
async with async_session() as session:
80182
stmt = select(CanvasData).where(CanvasData.user_id == user_id)
81183
result = await session.execute(stmt)
@@ -87,3 +189,20 @@ async def get_canvas_data(user_id: str) -> Optional[Dict[str, Any]]:
87189
except Exception as e:
88190
print(f"Error retrieving canvas data: {e}")
89191
return None
192+
193+
async def get_recent_canvases(user_id: str, limit: int = MAX_BACKUPS_PER_USER) -> List[Dict[str, Any]]:
194+
"""Get the most recent canvas backups for a user"""
195+
try:
196+
# Update user's last activity time
197+
user_last_activity_time[user_id] = datetime.now()
198+
199+
async with async_session() as session:
200+
# Get the most recent backups, limited to MAX_BACKUPS_PER_USER
201+
stmt = select(CanvasBackup).where(CanvasBackup.user_id == user_id).order_by(CanvasBackup.timestamp.desc()).limit(limit)
202+
result = await session.execute(stmt)
203+
backups = result.scalars().all()
204+
205+
return [{"id": backup.id, "timestamp": backup.timestamp, "data": backup.canvas_data} for backup in backups]
206+
except Exception as e:
207+
print(f"Error retrieving canvas backups: {e}")
208+
return []

src/backend/routers/canvas.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import json
22
import jwt
3-
from typing import Dict, Any
3+
from typing import Dict, Any, List
44
from fastapi import APIRouter, HTTPException, Depends, Request
55
from fastapi.responses import JSONResponse
66

77
from dependencies import SessionData, require_auth
8-
from db import store_canvas_data, get_canvas_data
8+
from db import store_canvas_data, get_canvas_data, get_recent_canvases, MAX_BACKUPS_PER_USER
99
import posthog
1010

1111
canvas_router = APIRouter()
@@ -74,3 +74,17 @@ async def get_canvas(auth: SessionData = Depends(require_auth)):
7474
if data is None:
7575
return get_default_canvas_data()
7676
return data
77+
78+
@canvas_router.get("/recent")
79+
async def get_recent_canvas_backups(limit: int = MAX_BACKUPS_PER_USER, auth: SessionData = Depends(require_auth)):
80+
"""Get the most recent canvas backups for the authenticated user"""
81+
access_token = auth.token_data.get("access_token")
82+
decoded = jwt.decode(access_token, options={"verify_signature": False})
83+
user_id = decoded["sub"]
84+
85+
# Limit the number of backups to the maximum configured value
86+
if limit > MAX_BACKUPS_PER_USER:
87+
limit = MAX_BACKUPS_PER_USER
88+
89+
backups = await get_recent_canvases(user_id, limit)
90+
return {"backups": backups}

src/frontend/src/App.tsx

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ExcalidrawWrapper } from "./ExcalidrawWrapper";
44
import { debounce } from "./utils/debounce";
55
import { capture } from "./utils/posthog";
66
import posthog from "./utils/posthog";
7+
import { normalizeCanvasData } from "./utils/canvasUtils";
78
import { useSaveCanvas } from "./api/hooks";
89
import type * as TExcalidraw from "@atyrode/excalidraw";
910
import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types";
@@ -40,22 +41,6 @@ export default function App({
4041
useCustom(excalidrawAPI, customArgs);
4142
useHandleLibrary({ excalidrawAPI });
4243

43-
function normalizeCanvasData(data: any) {
44-
if (!data) return data;
45-
const appState = { ...data.appState };
46-
appState.width = undefined;
47-
if ("width" in appState) {
48-
delete appState.width;
49-
}
50-
if ("height" in appState) {
51-
delete appState.height;
52-
}
53-
if (!(appState.collaborators instanceof Map)) {
54-
appState.collaborators = new Map();
55-
}
56-
return { ...data, appState };
57-
}
58-
5944
useEffect(() => {
6045
if (excalidrawAPI && canvasData) {
6146
excalidrawAPI.updateScene(normalizeCanvasData(canvasData));

src/frontend/src/CustomEmbeddableRenderer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
StateIndicator,
77
ControlButton,
88
HtmlEditor,
9-
Editor
9+
Editor,
1010
} from './pad';
1111
import { ActionButton } from './pad/buttons';
1212

src/frontend/src/ExcalidrawWrapper.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { AppState } from '@atyrode/excalidraw/types';
77
import { MainMenuConfig } from './ui/MainMenu';
88
import { renderCustomEmbeddable } from './CustomEmbeddableRenderer';
99
import AuthDialog from './ui/AuthDialog';
10+
import BackupsModal from './ui/BackupsDialog';
1011

1112
const defaultInitialData = {
1213
elements: [],
@@ -44,13 +45,21 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
4445
// Add state for modal animation
4546
const [isExiting, setIsExiting] = useState(false);
4647

48+
// State for BackupsModal
49+
const [showBackupsModal, setShowBackupsModal] = useState(false);
50+
4751
// Handle auth state changes
4852
useEffect(() => {
4953
if (isAuthenticated === true) {
5054
setIsExiting(true);
5155
}
5256
}, [isAuthenticated]);
5357

58+
// Handler for closing the backups modal
59+
const handleCloseBackupsModal = () => {
60+
setShowBackupsModal(false);
61+
};
62+
5463
const renderExcalidraw = (children: React.ReactNode) => {
5564
const Excalidraw = Children.toArray(children).find(
5665
(child: any) =>
@@ -81,12 +90,24 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
8190
)),
8291
},
8392
<>
84-
<MainMenuConfig MainMenu={MainMenu} excalidrawAPI={excalidrawAPI} />
93+
<MainMenuConfig
94+
MainMenu={MainMenu}
95+
excalidrawAPI={excalidrawAPI}
96+
showBackupsModal={showBackupsModal}
97+
setShowBackupsModal={setShowBackupsModal}
98+
/>
8599
{!isAuthLoading && isAuthenticated === false && (
86100
<AuthDialog
87101
onClose={() => {}}
88102
/>
89103
)}
104+
105+
{showBackupsModal && (
106+
<BackupsModal
107+
excalidrawAPI={excalidrawAPI}
108+
onClose={handleCloseBackupsModal}
109+
/>
110+
)}
90111
</>
91112
);
92113
};

src/frontend/src/api/hooks.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ export interface CanvasData {
2828
files: any;
2929
}
3030

31+
export interface CanvasBackup {
32+
id: number;
33+
timestamp: string;
34+
data: CanvasData;
35+
}
36+
37+
export interface CanvasBackupsResponse {
38+
backups: CanvasBackup[];
39+
}
40+
3141
// API functions
3242
export const api = {
3343
// Authentication
@@ -113,6 +123,16 @@ export const api = {
113123
throw error;
114124
}
115125
},
126+
127+
// Canvas Backups
128+
getCanvasBackups: async (limit: number = 10): Promise<CanvasBackupsResponse> => {
129+
try {
130+
const result = await fetchApi(`/api/canvas/recent?limit=${limit}`);
131+
return result;
132+
} catch (error) {
133+
throw error;
134+
}
135+
},
116136
};
117137

118138
// Query hooks
@@ -163,6 +183,14 @@ export function useDefaultCanvas(options?: UseQueryOptions<CanvasData>) {
163183
});
164184
}
165185

186+
export function useCanvasBackups(limit: number = 10, options?: UseQueryOptions<CanvasBackupsResponse>) {
187+
return useQuery({
188+
queryKey: ['canvasBackups', limit],
189+
queryFn: () => api.getCanvasBackups(limit),
190+
...options,
191+
});
192+
}
193+
166194
// Mutation hooks
167195
export function useStartWorkspace(options?: UseMutationOptions) {
168196
return useMutation({
@@ -189,6 +217,10 @@ export function useStopWorkspace(options?: UseMutationOptions) {
189217
export function useSaveCanvas(options?: UseMutationOptions<any, Error, CanvasData>) {
190218
return useMutation({
191219
mutationFn: api.saveCanvas,
220+
onSuccess: () => {
221+
// Invalidate canvas backups query to trigger refetch
222+
queryClient.invalidateQueries({ queryKey: ['canvasBackups'] });
223+
},
192224
...options,
193225
});
194226
}

0 commit comments

Comments
 (0)