diff --git a/src/backend/db.py b/src/backend/db.py index 46a5224..019f350 100644 --- a/src/backend/db.py +++ b/src/backend/db.py @@ -1,7 +1,8 @@ import os -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List +from datetime import datetime from dotenv import load_dotenv -from sqlalchemy import Column, String, JSON, DateTime, func, create_engine +from sqlalchemy import Column, String, JSON, DateTime, Integer, func, create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker @@ -31,6 +32,20 @@ # Create base model Base = declarative_base() +# Canvas backup configuration +BACKUP_INTERVAL_SECONDS = 300 # 5 minutes between backups +MAX_BACKUPS_PER_USER = 10 # Maximum number of backups to keep per user + +# In-memory dictionaries to track user activity +user_last_backup_time = {} # Tracks when each user last had a backup +user_last_activity_time = {} # Tracks when each user was last active + +# Memory management configuration +INACTIVITY_THRESHOLD_MINUTES = 30 # Remove users from memory after this many minutes of inactivity +MAX_USERS_BEFORE_CLEANUP = 1000 # Trigger cleanup when we have this many users in memory +CLEANUP_INTERVAL_SECONDS = 3600 # Run cleanup at least once per hour (1 hour) +last_cleanup_time = datetime.now() # Track when we last ran cleanup + class CanvasData(Base): """Model for canvas data table""" __tablename__ = "canvas_data" @@ -42,6 +57,18 @@ class CanvasData(Base): def __repr__(self): return f"" +class CanvasBackup(Base): + """Model for canvas backups table""" + __tablename__ = "canvas_backups" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(String, nullable=False) + timestamp = Column(DateTime(timezone=True), server_default=func.now()) + canvas_data = Column(JSON, nullable=False) + + def __repr__(self): + return f"" + async def get_db_session(): """Get a database session""" async with async_session() as session: @@ -52,8 +79,47 @@ async def init_db(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) +async def cleanup_inactive_users(inactivity_threshold_minutes: int = INACTIVITY_THRESHOLD_MINUTES): + """Remove users from memory tracking if they've been inactive for the specified time""" + current_time = datetime.now() + inactive_users = [] + + for user_id, last_activity in user_last_activity_time.items(): + # Check if user has been inactive for longer than the threshold + if (current_time - last_activity).total_seconds() > (inactivity_threshold_minutes * 60): + inactive_users.append(user_id) + + # Remove inactive users from both dictionaries + for user_id in inactive_users: + if user_id in user_last_backup_time: + del user_last_backup_time[user_id] + if user_id in user_last_activity_time: + del user_last_activity_time[user_id] + + return len(inactive_users) # Return count of removed users for logging + +async def check_if_cleanup_needed(): + """Check if we should run the cleanup function""" + global last_cleanup_time + current_time = datetime.now() + time_since_last_cleanup = (current_time - last_cleanup_time).total_seconds() + + # Run cleanup if we have too many users or it's been too long + if (len(user_last_activity_time) > MAX_USERS_BEFORE_CLEANUP or + time_since_last_cleanup > CLEANUP_INTERVAL_SECONDS): + removed_count = await cleanup_inactive_users() + last_cleanup_time = current_time + print(f"[db.py] Cleanup completed: removed {removed_count} inactive users from memory") + async def store_canvas_data(user_id: str, data: Dict[str, Any]) -> bool: try: + # Update user's last activity time + current_time = datetime.now() + user_last_activity_time[user_id] = current_time + + # Check if cleanup is needed + await check_if_cleanup_needed() + async with async_session() as session: # Check if record exists 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: canvas_data = CanvasData(user_id=user_id, data=data) session.add(canvas_data) + # Check if we should create a backup + should_backup = False + if user_id not in user_last_backup_time: + # First time this user is saving, create a backup + should_backup = True + else: + # Check if backup interval has passed since last backup + time_since_last_backup = (current_time - user_last_backup_time[user_id]).total_seconds() + if time_since_last_backup >= BACKUP_INTERVAL_SECONDS: + should_backup = True + + if should_backup: + # Update the backup timestamp + user_last_backup_time[user_id] = current_time + + # Count existing backups for this user + backup_count_stmt = select(func.count()).select_from(CanvasBackup).where(CanvasBackup.user_id == user_id) + backup_count_result = await session.execute(backup_count_stmt) + backup_count = backup_count_result.scalar() + + # If user has reached the maximum number of backups, delete the oldest one + if backup_count >= MAX_BACKUPS_PER_USER: + oldest_backup_stmt = select(CanvasBackup).where(CanvasBackup.user_id == user_id).order_by(CanvasBackup.timestamp).limit(1) + oldest_backup_result = await session.execute(oldest_backup_stmt) + oldest_backup = oldest_backup_result.scalars().first() + + if oldest_backup: + await session.delete(oldest_backup) + + # Create new backup + new_backup = CanvasBackup(user_id=user_id, canvas_data=data) + session.add(new_backup) + await session.commit() return True except Exception as e: @@ -76,6 +175,9 @@ async def store_canvas_data(user_id: str, data: Dict[str, Any]) -> bool: async def get_canvas_data(user_id: str) -> Optional[Dict[str, Any]]: try: + # Update user's last activity time + user_last_activity_time[user_id] = datetime.now() + async with async_session() as session: stmt = select(CanvasData).where(CanvasData.user_id == user_id) result = await session.execute(stmt) @@ -87,3 +189,20 @@ async def get_canvas_data(user_id: str) -> Optional[Dict[str, Any]]: except Exception as e: print(f"Error retrieving canvas data: {e}") return None + +async def get_recent_canvases(user_id: str, limit: int = MAX_BACKUPS_PER_USER) -> List[Dict[str, Any]]: + """Get the most recent canvas backups for a user""" + try: + # Update user's last activity time + user_last_activity_time[user_id] = datetime.now() + + async with async_session() as session: + # Get the most recent backups, limited to MAX_BACKUPS_PER_USER + stmt = select(CanvasBackup).where(CanvasBackup.user_id == user_id).order_by(CanvasBackup.timestamp.desc()).limit(limit) + result = await session.execute(stmt) + backups = result.scalars().all() + + return [{"id": backup.id, "timestamp": backup.timestamp, "data": backup.canvas_data} for backup in backups] + except Exception as e: + print(f"Error retrieving canvas backups: {e}") + return [] diff --git a/src/backend/routers/canvas.py b/src/backend/routers/canvas.py index 629a34a..b5c45c0 100644 --- a/src/backend/routers/canvas.py +++ b/src/backend/routers/canvas.py @@ -1,11 +1,11 @@ import json import jwt -from typing import Dict, Any +from typing import Dict, Any, List from fastapi import APIRouter, HTTPException, Depends, Request from fastapi.responses import JSONResponse from dependencies import SessionData, require_auth -from db import store_canvas_data, get_canvas_data +from db import store_canvas_data, get_canvas_data, get_recent_canvases, MAX_BACKUPS_PER_USER import posthog canvas_router = APIRouter() @@ -74,3 +74,17 @@ async def get_canvas(auth: SessionData = Depends(require_auth)): if data is None: return get_default_canvas_data() return data + +@canvas_router.get("/recent") +async def get_recent_canvas_backups(limit: int = MAX_BACKUPS_PER_USER, auth: SessionData = Depends(require_auth)): + """Get the most recent canvas backups for the authenticated user""" + access_token = auth.token_data.get("access_token") + decoded = jwt.decode(access_token, options={"verify_signature": False}) + user_id = decoded["sub"] + + # Limit the number of backups to the maximum configured value + if limit > MAX_BACKUPS_PER_USER: + limit = MAX_BACKUPS_PER_USER + + backups = await get_recent_canvases(user_id, limit) + return {"backups": backups} diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 216b873..d3cac36 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { ExcalidrawWrapper } from "./ExcalidrawWrapper"; import { debounce } from "./utils/debounce"; import { capture } from "./utils/posthog"; import posthog from "./utils/posthog"; +import { normalizeCanvasData } from "./utils/canvasUtils"; import { useSaveCanvas } from "./api/hooks"; import type * as TExcalidraw from "@atyrode/excalidraw"; import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; @@ -40,22 +41,6 @@ export default function App({ useCustom(excalidrawAPI, customArgs); useHandleLibrary({ excalidrawAPI }); - function normalizeCanvasData(data: any) { - if (!data) return data; - const appState = { ...data.appState }; - appState.width = undefined; - if ("width" in appState) { - delete appState.width; - } - if ("height" in appState) { - delete appState.height; - } - if (!(appState.collaborators instanceof Map)) { - appState.collaborators = new Map(); - } - return { ...data, appState }; - } - useEffect(() => { if (excalidrawAPI && canvasData) { excalidrawAPI.updateScene(normalizeCanvasData(canvasData)); diff --git a/src/frontend/src/CustomEmbeddableRenderer.tsx b/src/frontend/src/CustomEmbeddableRenderer.tsx index 9af04ff..4bffe2f 100644 --- a/src/frontend/src/CustomEmbeddableRenderer.tsx +++ b/src/frontend/src/CustomEmbeddableRenderer.tsx @@ -6,7 +6,7 @@ import { StateIndicator, ControlButton, HtmlEditor, - Editor + Editor, } from './pad'; import { ActionButton } from './pad/buttons'; diff --git a/src/frontend/src/ExcalidrawWrapper.tsx b/src/frontend/src/ExcalidrawWrapper.tsx index 5bc7692..0d084c8 100644 --- a/src/frontend/src/ExcalidrawWrapper.tsx +++ b/src/frontend/src/ExcalidrawWrapper.tsx @@ -7,6 +7,7 @@ import type { AppState } from '@atyrode/excalidraw/types'; import { MainMenuConfig } from './ui/MainMenu'; import { renderCustomEmbeddable } from './CustomEmbeddableRenderer'; import AuthDialog from './ui/AuthDialog'; +import BackupsModal from './ui/BackupsDialog'; const defaultInitialData = { elements: [], @@ -44,6 +45,9 @@ export const ExcalidrawWrapper: React.FC = ({ // Add state for modal animation const [isExiting, setIsExiting] = useState(false); + // State for BackupsModal + const [showBackupsModal, setShowBackupsModal] = useState(false); + // Handle auth state changes useEffect(() => { if (isAuthenticated === true) { @@ -51,6 +55,11 @@ export const ExcalidrawWrapper: React.FC = ({ } }, [isAuthenticated]); + // Handler for closing the backups modal + const handleCloseBackupsModal = () => { + setShowBackupsModal(false); + }; + const renderExcalidraw = (children: React.ReactNode) => { const Excalidraw = Children.toArray(children).find( (child: any) => @@ -81,12 +90,24 @@ export const ExcalidrawWrapper: React.FC = ({ )), }, <> - + {!isAuthLoading && isAuthenticated === false && ( {}} /> )} + + {showBackupsModal && ( + + )} ); }; diff --git a/src/frontend/src/api/hooks.ts b/src/frontend/src/api/hooks.ts index 4ad37e9..58d198f 100644 --- a/src/frontend/src/api/hooks.ts +++ b/src/frontend/src/api/hooks.ts @@ -28,6 +28,16 @@ export interface CanvasData { files: any; } +export interface CanvasBackup { + id: number; + timestamp: string; + data: CanvasData; +} + +export interface CanvasBackupsResponse { + backups: CanvasBackup[]; +} + // API functions export const api = { // Authentication @@ -113,6 +123,16 @@ export const api = { throw error; } }, + + // Canvas Backups + getCanvasBackups: async (limit: number = 10): Promise => { + try { + const result = await fetchApi(`/api/canvas/recent?limit=${limit}`); + return result; + } catch (error) { + throw error; + } + }, }; // Query hooks @@ -163,6 +183,14 @@ export function useDefaultCanvas(options?: UseQueryOptions) { }); } +export function useCanvasBackups(limit: number = 10, options?: UseQueryOptions) { + return useQuery({ + queryKey: ['canvasBackups', limit], + queryFn: () => api.getCanvasBackups(limit), + ...options, + }); +} + // Mutation hooks export function useStartWorkspace(options?: UseMutationOptions) { return useMutation({ @@ -189,6 +217,10 @@ export function useStopWorkspace(options?: UseMutationOptions) { export function useSaveCanvas(options?: UseMutationOptions) { return useMutation({ mutationFn: api.saveCanvas, + onSuccess: () => { + // Invalidate canvas backups query to trigger refetch + queryClient.invalidateQueries({ queryKey: ['canvasBackups'] }); + }, ...options, }); } diff --git a/src/frontend/src/pad/styles/CanvasBackups.scss b/src/frontend/src/pad/styles/CanvasBackups.scss new file mode 100644 index 0000000..7dea2d8 --- /dev/null +++ b/src/frontend/src/pad/styles/CanvasBackups.scss @@ -0,0 +1,205 @@ +.canvas-backups { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + padding: 12px; + overflow-y: auto; + background-color: transparent; + border-radius: 7px; + font-family: Arial, sans-serif; + box-sizing: border-box; + transition: all 0.3s ease-in-out; + + &--loading, + &--error, + &--empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #ffffff; + font-style: italic; + opacity: 0.9; + animation: fadeIn 0.5s ease-in-out; + } + + &--error { + color: #f44336; + } + + &__title { + margin: 0 0 1rem; + font-size: 1.2rem; + font-weight: 500; + color: #ffffff; + text-align: center; + opacity: 0.9; + } + + &__list { + list-style: none; + padding: 0; + margin: 0; + flex-grow: 1; + overflow-y: auto; + } + + &__item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 15px; + margin-bottom: 8px; + background-color: #32373c; + border-radius: 10px; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0); + transition: background-color 0.3s ease; + pointer-events: none; + border-radius: 10px; + } + + &:hover::after { + background-color: rgba(255, 255, 255, 0.1); + } + + &:active::after { + background-color: rgba(255, 255, 255, 0.05); + } + + &:last-child { + margin-bottom: 0; + } + } + + &__item-content { + display: flex; + align-items: center; + gap: 10px; + } + + &__number { + font-size: 0.9rem; + font-weight: 600; + color: #cc6d24; + background-color: rgba(106, 122, 255, 0.1); + padding: 4px 8px; + border-radius: 4px; + min-width: 28px; + text-align: center; + } + + &__timestamp { + font-size: 0.9rem; + color: #ffffff; + opacity: 0.9; + } + + &__restore-button { + background-color: transparent; + border: none; + color: #cc6d24; + font-size: 0.9rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: all 0.2s ease; + + &:hover { + background-color: rgba(106, 122, 255, 0.1); + } + } + + &__confirmation { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + background-color: #32373c; + border-radius: 10px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + text-align: center; + color: #ffffff; + animation: fadeIn 0.4s ease-in-out; + } + + &__warning { + color: #f44336; + font-weight: 500; + margin: 0.5rem 0 1rem; + } + + &__actions { + display: flex; + gap: 1rem; + } + + &__button { + padding: 10px 16px; + border: none; + border-radius: 7px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0); + transition: background-color 0.3s ease; + pointer-events: none; + border-radius: 7px; + } + + &:hover::after { + background-color: rgba(255, 255, 255, 0.1); + } + + &:active::after { + background-color: rgba(255, 255, 255, 0.05); + } + + &--restore { + background-color: #cc6d24; + border: 1px solid #cecece00; + color: white; + + &:hover { + border: 1px solid #cecece; + } + } + + &--cancel { + background-color: #4a4a54; + color: #ffffff; + + &:hover { + background-color: #3a3a44; + } + } + } +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/src/frontend/src/pad/styles/index.scss b/src/frontend/src/pad/styles/index.scss index a1a7361..3a2e46b 100644 --- a/src/frontend/src/pad/styles/index.scss +++ b/src/frontend/src/pad/styles/index.scss @@ -3,3 +3,4 @@ @import './ControlButton.scss'; @import './StateIndicator.scss'; @import './Dashboard.scss'; +@import './CanvasBackups.scss'; diff --git a/src/frontend/src/styles/AuthDialog.scss b/src/frontend/src/styles/AuthDialog.scss index 1aab2b1..adac568 100644 --- a/src/frontend/src/styles/AuthDialog.scss +++ b/src/frontend/src/styles/AuthDialog.scss @@ -1,5 +1,13 @@ /* Auth Modal Styles */ +.excalidraw .Dialog--fullscreen { + &.auth-modal { + .Dialog__close { + display: none; + } + } +} + .excalidraw .Dialog--fullscreen { .auth-modal { &__logo-container { @@ -18,10 +26,12 @@ } } - .Island { - height: 100%; - display: flex; - flex-direction: column; + &.auth-modal { + .Island { + height: 100%; + display: flex; + flex-direction: column; + } } } diff --git a/src/frontend/src/styles/BackupsDialog.scss b/src/frontend/src/styles/BackupsDialog.scss new file mode 100644 index 0000000..4c8b072 --- /dev/null +++ b/src/frontend/src/styles/BackupsDialog.scss @@ -0,0 +1,233 @@ +/* Backups Modal Styles */ + +.excalidraw .Dialog--fullscreen { + &.backups-modal { + .Dialog { + &__content { + margin-top: 0 !important; + } + } + .Island { + padding-left: 8px !important; + padding-right: 10px !important; + } + } +} + +.backups-modal { + + .Island { + padding-top: 15px !important; + padding-bottom: 20px !important; + } + + &__wrapper { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 5; + background-color: rgba(0, 0, 0, 0.2); + backdrop-filter: blur(1px); + } + + &__title-container { + display: flex; + align-items: center; + } + + &__title { + margin: 0 auto; + font-size: 1.5rem; + font-weight: 600; + color: white; + text-align: center; + } + + &__content { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + max-height: 80vh; + overflow-y: auto; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + width: 100%; + } + + &__close-button { + background: none; + border: none; + color: #ffffff; + font-size: 1.5rem; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background-color 0.2s ease; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + } + + &__loading, + &__error, + &__empty { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + color: #a0a0a9; + font-style: italic; + font-size: 18px; + animation: fadeIn 0.5s cubic-bezier(0.00, 1.26, 0.64, 0.95) forwards; + } + + &__error { + color: #f44336; + } + + &__list { + list-style: none; + padding: 0; + margin: 0; + max-height: 100%; + overflow-y: auto; + width: 100%; + } + + &__item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 15px; + margin-bottom: 8px; + background-color: #464652; + border: 2px solid #727279; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + overflow: hidden; + + &:hover { + border: 2px solid #cc6d24; + } + + &:last-child { + margin-bottom: 0; + } + } + + &__item-content { + display: flex; + align-items: center; + gap: 10px; + } + + &__number { + font-size: 0.9rem; + font-weight: 600; + color: #fa8933; + background-color: rgba(250, 137, 51, 0.1); + padding: 4px 8px; + border-radius: 4px; + min-width: 28px; + text-align: center; + } + + &__timestamp { + font-size: 0.9rem; + color: #ffffff; + } + + &__restore-button { + background-color: transparent; + border: none; + color: #fa8933; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: all 0.2s ease; + + &:hover { + background-color: rgba(250, 137, 51, 0.1); + } + } + + &__confirmation { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + background-color: #464652; + border: 2px solid #727279; + border-radius: 6px; + text-align: center; + color: #ffffff; + animation: fadeIn 0.4s cubic-bezier(0.00, 1.26, 0.64, 0.95) forwards; + width: 80%; + max-width: 500px; + } + + &__warning { + color: #f44336; + font-weight: 500; + margin: 0.5rem 0 1rem; + } + + &__actions { + display: flex; + gap: 1rem; + margin-top: 20px; + } + + &__button { + display: flex; + align-items: center; + justify-content: center; + padding: 10px 16px; + height: 44px; + border-radius: 6px; + border: 2px solid #727279; + font-size: 15px; + font-weight: 500; + transition: all 0.2s ease; + cursor: pointer; + min-width: 120px; + + &:hover { + border: 2px solid #cc6d24; + } + + &--restore { + background-color: #464652; + color: white; + } + + &--cancel { + background-color: #464652; + color: #ffffff; + } + } +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/src/frontend/src/styles/index.scss b/src/frontend/src/styles/index.scss index 8fa96ed..0ba50a2 100644 --- a/src/frontend/src/styles/index.scss +++ b/src/frontend/src/styles/index.scss @@ -55,12 +55,6 @@ body { font-size: 15px !important; } -.excalidraw .Dialog--fullscreen { - .Dialog__close { - display: none; - } -} - .excalidraw .Modal__background { background-color: rgba(0, 0, 0, 0); backdrop-filter: blur(0px); diff --git a/src/frontend/src/ui/BackupsDialog.tsx b/src/frontend/src/ui/BackupsDialog.tsx new file mode 100644 index 0000000..0018714 --- /dev/null +++ b/src/frontend/src/ui/BackupsDialog.tsx @@ -0,0 +1,128 @@ +import React, { useState, useCallback } from "react"; +import { Dialog } from "@atyrode/excalidraw"; +import { useCanvasBackups, CanvasBackup } from "../api/hooks"; +import { normalizeCanvasData } from "../utils/canvasUtils"; +import "../styles/BackupsDialog.scss"; + +interface BackupsModalProps { + excalidrawAPI?: any; + onClose?: () => void; +} + +const BackupsModal: React.FC = ({ + excalidrawAPI, + onClose, +}) => { + const [modalIsShown, setModalIsShown] = useState(true); + const { data, isLoading, error } = useCanvasBackups(); + const [selectedBackup, setSelectedBackup] = useState(null); + + // Functions from CanvasBackups.tsx + const handleBackupSelect = (backup: CanvasBackup) => { + setSelectedBackup(backup); + }; + + const handleRestoreBackup = () => { + if (selectedBackup && excalidrawAPI) { + // Load the backup data into the canvas + const normalizedData = normalizeCanvasData(selectedBackup.data); + excalidrawAPI.updateScene(normalizedData); + setSelectedBackup(null); + handleClose(); + } + }; + + const handleCancel = () => { + setSelectedBackup(null); + }; + + // Format date function from CanvasBackups.tsx + const formatDate = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + const handleClose = useCallback(() => { + setModalIsShown(false); + if (onClose) { + onClose(); + } + }, [onClose]); + + // Dialog content + const dialogContent = ( +
+ {isLoading ? ( +
Loading backups...
+ ) : error ? ( +
Error loading backups
+ ) : !data || data.backups.length === 0 ? ( +
No backups available
+ ) : selectedBackup ? ( +
+

Restore canvas from backup #{data.backups.findIndex(b => b.id === selectedBackup.id) + 1} created on {formatDate(selectedBackup.timestamp)}?

+

This will replace your current canvas!

+
+ + +
+
+ ) : ( +
    + {data.backups.map((backup, index) => ( +
  • handleBackupSelect(backup)} + > +
    + #{index + 1} + {formatDate(backup.timestamp)} +
    + +
  • + ))} +
+ )} +
+ ); + + return ( + <> + {modalIsShown && ( +
+ +

Canvas Backups

+
+ } + closeOnClickOutside={true} + children={dialogContent} + /> + + )} + + ); +}; + +export default BackupsModal; diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index 397c5de..a33dde1 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -1,23 +1,26 @@ -import React from 'react'; +import React, { useState } from 'react'; import type { ExcalidrawImperativeAPI } from '@atyrode/excalidraw/types'; import type { MainMenu as MainMenuType } from '@atyrode/excalidraw'; -import { LogOut, SquarePlus, LayoutDashboard, SquareCode, Eye, Coffee, Grid2x2, User, Text } from 'lucide-react'; +import { LogOut, SquarePlus, LayoutDashboard, SquareCode, Eye, Coffee, Grid2x2, User, Text, ArchiveRestore } from 'lucide-react'; import { capture } from '../utils/posthog'; import { ExcalidrawElementFactory, PlacementMode } from '../lib/ExcalidrawElementFactory'; import { useUserProfile } from "../api/hooks"; import { queryClient } from "../api/queryClient"; -import Editor from '../pad/editors/Editor'; interface MainMenuConfigProps { MainMenu: typeof MainMenuType; excalidrawAPI: ExcalidrawImperativeAPI | null; + showBackupsModal: boolean; + setShowBackupsModal: (show: boolean) => void; } export const MainMenuConfig: React.FC = ({ MainMenu, excalidrawAPI, + showBackupsModal, + setShowBackupsModal, }) => { const { data, isLoading, isError } = useUserProfile(); @@ -93,6 +96,10 @@ export const MainMenuConfig: React.FC = ({ }); }; + const handleCanvasBackupsClick = () => { + setShowBackupsModal(true); + }; + const handleGridToggle = () => { if (!excalidrawAPI) return; const appState = excalidrawAPI.getAppState(); @@ -136,63 +143,70 @@ export const MainMenuConfig: React.FC = ({ - - - - - - } - onClick={handleGridToggle} - > - Toggle Grid - - } - onClick={handleViewModeToggle} - > - View Mode - } - onClick={handleZenModeToggle} + icon={} + onClick={handleCanvasBackupsClick} > - Zen Mode + Load backup... - + } onClick={handleHtmlEditorClick} > - Insert HTML Editor + HTML Editor } onClick={handleEditorClick} > - Insert Code Editor + Code Editor } onClick={handleDashboardButtonClick} > - Insert Dashboard + Dashboard } onClick={handleInsertButtonClick} > - Insert Button + Action Button + + + } + onClick={handleGridToggle} + > + Toggle grid + + } + onClick={handleViewModeToggle} + > + View mode + + } + onClick={handleZenModeToggle} + > + Zen mode + + + + + } onClick={async () => { diff --git a/src/frontend/src/utils/canvasUtils.ts b/src/frontend/src/utils/canvasUtils.ts new file mode 100644 index 0000000..fa1dc3d --- /dev/null +++ b/src/frontend/src/utils/canvasUtils.ts @@ -0,0 +1,28 @@ +/** + * Normalizes canvas data by removing width and height properties from appState + * and resetting collaborators to an empty Map. + * + * This is necessary when loading canvas data to ensure it fits properly in the current viewport + * and doesn't carry over collaborator information that might be stale. + * + * @param data The canvas data to normalize + * @returns Normalized canvas data + */ +export function normalizeCanvasData(data: any) { + if (!data) return data; + + const appState = { ...data.appState }; + + // Remove width and height properties + if ("width" in appState) { + delete appState.width; + } + if ("height" in appState) { + delete appState.height; + } + + // Reset collaborators (https://github.com/excalidraw/excalidraw/issues/8637) + appState.collaborators = new Map(); + + return { ...data, appState }; +}