From e0285df89e0a71a123b934d37fe0dd4797cc17d6 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 25 Apr 2025 01:54:50 +0000 Subject: [PATCH 01/12] 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. --- src/backend/db.py | 123 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/src/backend/db.py b/src/backend/db.py index 1d628c3..86b1f96 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 @@ -30,6 +31,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" @@ -41,6 +56,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: @@ -51,8 +78,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) @@ -67,6 +133,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: @@ -75,6 +174,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) @@ -86,3 +188,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 [] From 4b35fa94298e27fd3053d4a4d9f4a7e285b22a67 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 25 Apr 2025 01:55:11 +0000 Subject: [PATCH 02/12] 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. --- src/backend/routers/canvas.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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} From 8dac1375161f549566bc60367535de897ea55be6 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 25 Apr 2025 02:34:25 +0000 Subject: [PATCH 03/12] fix: ensure collaborators state is always a Map instance --- src/frontend/src/App.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 6fa94fb..60d5479 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -50,9 +50,7 @@ export default function App({ if ("height" in appState) { delete appState.height; } - if (!(appState.collaborators instanceof Map)) { - appState.collaborators = new Map(); - } + appState.collaborators = new Map(); return { ...data, appState }; } From fb90e1f264ea18e632a3550349fee5d68bf7479a Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 25 Apr 2025 02:41:39 +0000 Subject: [PATCH 04/12] 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. --- src/frontend/src/CustomEmbeddableRenderer.tsx | 5 +- src/frontend/src/api/hooks.ts | 32 +++++ .../src/pad/backups/CanvasBackups.tsx | 101 +++++++++++++ src/frontend/src/pad/backups/index.ts | 2 + src/frontend/src/pad/index.ts | 2 + .../src/pad/styles/CanvasBackups.scss | 134 ++++++++++++++++++ src/frontend/src/pad/styles/index.scss | 1 + 7 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/pad/backups/CanvasBackups.tsx create mode 100644 src/frontend/src/pad/backups/index.ts create mode 100644 src/frontend/src/pad/styles/CanvasBackups.scss diff --git a/src/frontend/src/CustomEmbeddableRenderer.tsx b/src/frontend/src/CustomEmbeddableRenderer.tsx index 9af04ff..d8bed8f 100644 --- a/src/frontend/src/CustomEmbeddableRenderer.tsx +++ b/src/frontend/src/CustomEmbeddableRenderer.tsx @@ -6,7 +6,8 @@ import { StateIndicator, ControlButton, HtmlEditor, - Editor + Editor, + CanvasBackups } from './pad'; import { ActionButton } from './pad/buttons'; @@ -37,6 +38,8 @@ export const renderCustomEmbeddable = ( />; case 'dashboard': return ; + case 'backups': + return ; default: return null; } diff --git a/src/frontend/src/api/hooks.ts b/src/frontend/src/api/hooks.ts index 91373d1..10632a7 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 @@ -112,6 +122,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 @@ -162,6 +182,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({ @@ -188,6 +216,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/backups/CanvasBackups.tsx b/src/frontend/src/pad/backups/CanvasBackups.tsx new file mode 100644 index 0000000..a38278f --- /dev/null +++ b/src/frontend/src/pad/backups/CanvasBackups.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types'; +import type { AppState } from '@atyrode/excalidraw/types'; +import { useCanvasBackups, CanvasBackup } from '../../api/hooks'; +import '../styles/CanvasBackups.scss'; + +interface CanvasBackupsProps { + element: NonDeleted; + appState: AppState; + excalidrawAPI?: any; +} + +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' + }); +}; + +export const CanvasBackups: React.FC = ({ + element, + appState, + excalidrawAPI +}) => { + const { data, isLoading, error } = useCanvasBackups(); + const [selectedBackup, setSelectedBackup] = useState(null); + + const handleBackupSelect = (backup: CanvasBackup) => { + setSelectedBackup(backup); + }; + + const handleRestoreBackup = () => { + if (selectedBackup && excalidrawAPI) { + // Load the backup data into the canvas + excalidrawAPI.updateScene(selectedBackup.data); + setSelectedBackup(null); + } + }; + + const handleCancel = () => { + setSelectedBackup(null); + }; + + if (isLoading) { + return
Loading backups...
; + } + + if (error) { + return
Error loading backups
; + } + + if (!data || data.backups.length === 0) { + return
No backups available
; + } + + return ( +
+

Recent Canvas Backups

+ + {selectedBackup ? ( +
+

Restore canvas from backup created on {formatDate(selectedBackup.timestamp)}?

+

This will replace your current canvas!

+
+ + +
+
+ ) : ( +
    + {data.backups.map((backup) => ( +
  • handleBackupSelect(backup)} + > + {formatDate(backup.timestamp)} + +
  • + ))} +
+ )} +
+ ); +}; + +export default CanvasBackups; diff --git a/src/frontend/src/pad/backups/index.ts b/src/frontend/src/pad/backups/index.ts new file mode 100644 index 0000000..7425c4e --- /dev/null +++ b/src/frontend/src/pad/backups/index.ts @@ -0,0 +1,2 @@ +export * from './CanvasBackups'; +export { default as CanvasBackups } from './CanvasBackups'; diff --git a/src/frontend/src/pad/index.ts b/src/frontend/src/pad/index.ts index e8df624..1108256 100644 --- a/src/frontend/src/pad/index.ts +++ b/src/frontend/src/pad/index.ts @@ -4,8 +4,10 @@ export * from './controls/StateIndicator'; export * from './containers/Dashboard'; export * from './buttons'; export * from './editors'; +export * from './backups'; // Default exports export { default as ControlButton } from './controls/ControlButton'; export { default as StateIndicator } from './controls/StateIndicator'; export { default as Dashboard } from './containers/Dashboard'; +export { default as CanvasBackups } from './backups/CanvasBackups'; diff --git a/src/frontend/src/pad/styles/CanvasBackups.scss b/src/frontend/src/pad/styles/CanvasBackups.scss new file mode 100644 index 0000000..c802446 --- /dev/null +++ b/src/frontend/src/pad/styles/CanvasBackups.scss @@ -0,0 +1,134 @@ +.canvas-backups { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + padding: 1rem; + overflow-y: auto; + background-color: #f5f5f5; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + font-family: 'Roboto', sans-serif; + + &--loading, + &--error, + &--empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #666; + font-style: italic; + } + + &--error { + color: #d32f2f; + } + + &__title { + margin: 0 0 1rem; + font-size: 1.2rem; + font-weight: 500; + color: #333; + text-align: center; + } + + &__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: 0.75rem; + margin-bottom: 0.5rem; + background-color: white; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: #f0f0f0; + } + + &:last-child { + margin-bottom: 0; + } + } + + &__timestamp { + font-size: 0.9rem; + color: #555; + } + + &__restore-button { + background-color: transparent; + border: none; + color: #2196f3; + font-size: 0.9rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: background-color 0.2s ease; + + &:hover { + background-color: rgba(33, 150, 243, 0.1); + } + } + + &__confirmation { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1rem; + background-color: white; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + text-align: center; + } + + &__warning { + color: #f44336; + font-weight: 500; + margin: 0.5rem 0 1rem; + } + + &__actions { + display: flex; + gap: 1rem; + } + + &__button { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; + + &--restore { + background-color: #2196f3; + color: white; + + &:hover { + background-color: #1976d2; + } + } + + &--cancel { + background-color: #e0e0e0; + color: #333; + + &:hover { + background-color: #d5d5d5; + } + } + } +} 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'; From f91f38265378115e5cf5708184800447ee019a7c Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 25 Apr 2025 03:01:46 +0000 Subject: [PATCH 05/12] 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. --- .../src/pad/backups/CanvasBackups.tsx | 11 +- .../src/pad/styles/CanvasBackups.scss | 137 +++++++++++++----- 2 files changed, 111 insertions(+), 37 deletions(-) diff --git a/src/frontend/src/pad/backups/CanvasBackups.tsx b/src/frontend/src/pad/backups/CanvasBackups.tsx index a38278f..d7229d8 100644 --- a/src/frontend/src/pad/backups/CanvasBackups.tsx +++ b/src/frontend/src/pad/backups/CanvasBackups.tsx @@ -59,11 +59,11 @@ export const CanvasBackups: React.FC = ({ return (
-

Recent Canvas Backups

+

Canvas Backups

{selectedBackup ? (
-

Restore canvas from backup created on {formatDate(selectedBackup.timestamp)}?

+

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) => ( + {data.backups.map((backup, index) => (
  • handleBackupSelect(backup)} > - {formatDate(backup.timestamp)} +
    + #{index + 1} + {formatDate(backup.timestamp)} +
  • ))} diff --git a/src/frontend/src/pad/styles/CanvasBackups.scss b/src/frontend/src/pad/styles/CanvasBackups.scss index c802446..7dea2d8 100644 --- a/src/frontend/src/pad/styles/CanvasBackups.scss +++ b/src/frontend/src/pad/styles/CanvasBackups.scss @@ -3,12 +3,13 @@ flex-direction: column; height: 100%; width: 100%; - padding: 1rem; + padding: 12px; overflow-y: auto; - background-color: #f5f5f5; - border-radius: 4px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - font-family: 'Roboto', sans-serif; + background-color: transparent; + border-radius: 7px; + font-family: Arial, sans-serif; + box-sizing: border-box; + transition: all 0.3s ease-in-out; &--loading, &--error, @@ -17,20 +18,23 @@ align-items: center; justify-content: center; height: 100%; - color: #666; + color: #ffffff; font-style: italic; + opacity: 0.9; + animation: fadeIn 0.5s ease-in-out; } &--error { - color: #d32f2f; + color: #f44336; } &__title { margin: 0 0 1rem; font-size: 1.2rem; font-weight: 500; - color: #333; + color: #ffffff; text-align: center; + opacity: 0.9; } &__list { @@ -45,16 +49,34 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem; - margin-bottom: 0.5rem; - background-color: white; - border-radius: 4px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + padding: 12px 15px; + margin-bottom: 8px; + background-color: #32373c; + border-radius: 10px; cursor: pointer; - transition: background-color 0.2s ease; - - &:hover { - background-color: #f0f0f0; + 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 { @@ -62,23 +84,41 @@ } } + &__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: #555; + color: #ffffff; + opacity: 0.9; } &__restore-button { background-color: transparent; border: none; - color: #2196f3; + color: #cc6d24; font-size: 0.9rem; cursor: pointer; padding: 0.25rem 0.5rem; border-radius: 4px; - transition: background-color 0.2s ease; + transition: all 0.2s ease; &:hover { - background-color: rgba(33, 150, 243, 0.1); + background-color: rgba(106, 122, 255, 0.1); } } @@ -87,11 +127,13 @@ flex-direction: column; align-items: center; justify-content: center; - padding: 1rem; - background-color: white; - border-radius: 4px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + 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 { @@ -106,29 +148,58 @@ } &__button { - padding: 0.5rem 1rem; + padding: 10px 16px; border: none; - border-radius: 4px; + border-radius: 7px; font-weight: 500; cursor: pointer; - transition: background-color 0.2s ease; + 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: #2196f3; + background-color: #cc6d24; + border: 1px solid #cecece00; color: white; &:hover { - background-color: #1976d2; + border: 1px solid #cecece; } } &--cancel { - background-color: #e0e0e0; - color: #333; + background-color: #4a4a54; + color: #ffffff; &:hover { - background-color: #d5d5d5; + background-color: #3a3a44; } } } } + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} From 80ee80ce62516b0271d80fe82dd54aca6bce2fdf Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 25 Apr 2025 03:02:23 +0000 Subject: [PATCH 06/12] feat: add Canvas Backups option to Main Menu - Updated the menu item titles for clarity and consistency. --- src/frontend/src/ui/MainMenu.tsx | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index 397c5de..18085b0 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -3,7 +3,7 @@ import React 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"; @@ -93,6 +93,22 @@ export const MainMenuConfig: React.FC = ({ }); }; + const handleCanvasBackupsClick = () => { + if (!excalidrawAPI) return; + + const backupsElement = ExcalidrawElementFactory.createEmbeddableElement({ + link: "!backups", + width: 400, + height: 500 + }); + + ExcalidrawElementFactory.placeInScene(backupsElement, excalidrawAPI, { + mode: PlacementMode.NEAR_VIEWPORT_CENTER, + bufferPercentage: 10, + scrollToView: true + }); + }; + const handleGridToggle = () => { if (!excalidrawAPI) return; const appState = excalidrawAPI.getAppState(); @@ -136,6 +152,12 @@ export const MainMenuConfig: React.FC = ({ + } + onClick={handleCanvasBackupsClick} + > + Canvas Backups + @@ -169,25 +191,25 @@ export const MainMenuConfig: React.FC = ({ icon={} 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 From a9a9c012d974a36d66bdc3b0d9862df3f1675d92 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 25 Apr 2025 05:00:02 +0000 Subject: [PATCH 07/12] 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. --- src/frontend/src/styles/BackupsModal.scss | 226 ++++++++++++++++++++++ src/frontend/src/ui/BackupsModal.tsx | 118 +++++++++++ src/frontend/src/ui/MainMenu.tsx | 39 ++-- 3 files changed, 368 insertions(+), 15 deletions(-) create mode 100644 src/frontend/src/styles/BackupsModal.scss create mode 100644 src/frontend/src/ui/BackupsModal.tsx diff --git a/src/frontend/src/styles/BackupsModal.scss b/src/frontend/src/styles/BackupsModal.scss new file mode 100644 index 0000000..5076cc1 --- /dev/null +++ b/src/frontend/src/styles/BackupsModal.scss @@ -0,0 +1,226 @@ +.backups-modal { + &__content { + padding: 20px; + max-height: 80vh; + overflow-y: auto; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + } + + &__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); + } + } + + &__title { + margin: 0 0 1rem; + font-size: 1.2rem; + font-weight: 500; + color: #ffffff; + text-align: center; + opacity: 0.9; + } + + &__loading, + &__error, + &__empty { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + color: #ffffff; + font-style: italic; + opacity: 0.9; + animation: fadeIn 0.5s ease-in-out; + } + + &__error { + color: #f44336; + } + + &__list { + list-style: none; + padding: 0; + margin: 0; + max-height: 60vh; + 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/ui/BackupsModal.tsx b/src/frontend/src/ui/BackupsModal.tsx new file mode 100644 index 0000000..05640d7 --- /dev/null +++ b/src/frontend/src/ui/BackupsModal.tsx @@ -0,0 +1,118 @@ +import React, { useState } from "react"; +import Modal from "./Modal"; +import { useCanvasBackups, CanvasBackup } from "../api/hooks"; +import "../styles/BackupsModal.scss"; + +interface BackupsModalProps { + excalidrawAPI?: any; + isExiting?: boolean; + onExitComplete?: () => void; + onClose?: () => void; +} + +const BackupsModal: React.FC = ({ + excalidrawAPI, + isExiting = false, + onExitComplete, + onClose, +}) => { + 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 + excalidrawAPI.updateScene(selectedBackup.data); + setSelectedBackup(null); + } + }; + + 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' + }); + }; + + return ( + +
    +
    +

    Canvas Backups

    + +
    + + {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)} +
      + +
    • + ))} +
    + )} +
    +
    + ); +}; + +export default BackupsModal; diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index 18085b0..5f82513 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -1,9 +1,10 @@ -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, ArchiveRestore } from 'lucide-react'; +import BackupsModal from './BackupsModal'; import { capture } from '../utils/posthog'; import { ExcalidrawElementFactory, PlacementMode } from '../lib/ExcalidrawElementFactory'; import { useUserProfile } from "../api/hooks"; @@ -93,20 +94,20 @@ export const MainMenuConfig: React.FC = ({ }); }; + const [showBackupsModal, setShowBackupsModal] = useState(false); + const [isBackupsModalExiting, setIsBackupsModalExiting] = useState(false); + const handleCanvasBackupsClick = () => { - if (!excalidrawAPI) return; - - const backupsElement = ExcalidrawElementFactory.createEmbeddableElement({ - link: "!backups", - width: 400, - height: 500 - }); - - ExcalidrawElementFactory.placeInScene(backupsElement, excalidrawAPI, { - mode: PlacementMode.NEAR_VIEWPORT_CENTER, - bufferPercentage: 10, - scrollToView: true - }); + setShowBackupsModal(true); + setIsBackupsModalExiting(false); + }; + + const handleBackupsModalExitComplete = () => { + setShowBackupsModal(false); + }; + + const handleCloseBackupsModal = () => { + setIsBackupsModalExiting(true); }; const handleGridToggle = () => { @@ -214,7 +215,7 @@ export const MainMenuConfig: React.FC = ({ - + } onClick={async () => { @@ -243,6 +244,14 @@ export const MainMenuConfig: React.FC = ({ Logout + {showBackupsModal && ( + + )} ); }; From 05bc8a543c52fd4fb4e11a927cdf05058ae66e98 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 25 Apr 2025 18:01:23 +0000 Subject: [PATCH 08/12] 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. --- src/frontend/src/CustomEmbeddableRenderer.tsx | 3 - .../src/pad/backups/CanvasBackups.tsx | 104 --------------- src/frontend/src/pad/backups/index.ts | 2 - src/frontend/src/pad/index.ts | 2 - .../{BackupsModal.scss => BackupsDialog.scss} | 23 +++- src/frontend/src/ui/BackupsDialog.tsx | 126 ++++++++++++++++++ src/frontend/src/ui/BackupsModal.tsx | 118 ---------------- src/frontend/src/ui/MainMenu.tsx | 13 +- 8 files changed, 150 insertions(+), 241 deletions(-) delete mode 100644 src/frontend/src/pad/backups/CanvasBackups.tsx delete mode 100644 src/frontend/src/pad/backups/index.ts rename src/frontend/src/styles/{BackupsModal.scss => BackupsDialog.scss} (91%) create mode 100644 src/frontend/src/ui/BackupsDialog.tsx delete mode 100644 src/frontend/src/ui/BackupsModal.tsx diff --git a/src/frontend/src/CustomEmbeddableRenderer.tsx b/src/frontend/src/CustomEmbeddableRenderer.tsx index d8bed8f..4bffe2f 100644 --- a/src/frontend/src/CustomEmbeddableRenderer.tsx +++ b/src/frontend/src/CustomEmbeddableRenderer.tsx @@ -7,7 +7,6 @@ import { ControlButton, HtmlEditor, Editor, - CanvasBackups } from './pad'; import { ActionButton } from './pad/buttons'; @@ -38,8 +37,6 @@ export const renderCustomEmbeddable = ( />; case 'dashboard': return ; - case 'backups': - return ; default: return null; } diff --git a/src/frontend/src/pad/backups/CanvasBackups.tsx b/src/frontend/src/pad/backups/CanvasBackups.tsx deleted file mode 100644 index d7229d8..0000000 --- a/src/frontend/src/pad/backups/CanvasBackups.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React, { useState } from 'react'; -import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types'; -import type { AppState } from '@atyrode/excalidraw/types'; -import { useCanvasBackups, CanvasBackup } from '../../api/hooks'; -import '../styles/CanvasBackups.scss'; - -interface CanvasBackupsProps { - element: NonDeleted; - appState: AppState; - excalidrawAPI?: any; -} - -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' - }); -}; - -export const CanvasBackups: React.FC = ({ - element, - appState, - excalidrawAPI -}) => { - const { data, isLoading, error } = useCanvasBackups(); - const [selectedBackup, setSelectedBackup] = useState(null); - - const handleBackupSelect = (backup: CanvasBackup) => { - setSelectedBackup(backup); - }; - - const handleRestoreBackup = () => { - if (selectedBackup && excalidrawAPI) { - // Load the backup data into the canvas - excalidrawAPI.updateScene(selectedBackup.data); - setSelectedBackup(null); - } - }; - - const handleCancel = () => { - setSelectedBackup(null); - }; - - if (isLoading) { - return
    Loading backups...
    ; - } - - if (error) { - return
    Error loading backups
    ; - } - - if (!data || data.backups.length === 0) { - return
    No backups available
    ; - } - - return ( -
    -

    Canvas Backups

    - - {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)} -
      - -
    • - ))} -
    - )} -
    - ); -}; - -export default CanvasBackups; diff --git a/src/frontend/src/pad/backups/index.ts b/src/frontend/src/pad/backups/index.ts deleted file mode 100644 index 7425c4e..0000000 --- a/src/frontend/src/pad/backups/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './CanvasBackups'; -export { default as CanvasBackups } from './CanvasBackups'; diff --git a/src/frontend/src/pad/index.ts b/src/frontend/src/pad/index.ts index 1108256..e8df624 100644 --- a/src/frontend/src/pad/index.ts +++ b/src/frontend/src/pad/index.ts @@ -4,10 +4,8 @@ export * from './controls/StateIndicator'; export * from './containers/Dashboard'; export * from './buttons'; export * from './editors'; -export * from './backups'; // Default exports export { default as ControlButton } from './controls/ControlButton'; export { default as StateIndicator } from './controls/StateIndicator'; export { default as Dashboard } from './containers/Dashboard'; -export { default as CanvasBackups } from './backups/CanvasBackups'; diff --git a/src/frontend/src/styles/BackupsModal.scss b/src/frontend/src/styles/BackupsDialog.scss similarity index 91% rename from src/frontend/src/styles/BackupsModal.scss rename to src/frontend/src/styles/BackupsDialog.scss index 5076cc1..9376b99 100644 --- a/src/frontend/src/styles/BackupsModal.scss +++ b/src/frontend/src/styles/BackupsDialog.scss @@ -1,4 +1,25 @@ .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; + } + &__content { padding: 20px; max-height: 80vh; @@ -33,7 +54,7 @@ } &__title { - margin: 0 0 1rem; + margin: 0; font-size: 1.2rem; font-weight: 500; color: #ffffff; diff --git a/src/frontend/src/ui/BackupsDialog.tsx b/src/frontend/src/ui/BackupsDialog.tsx new file mode 100644 index 0000000..7af42a1 --- /dev/null +++ b/src/frontend/src/ui/BackupsDialog.tsx @@ -0,0 +1,126 @@ +import React, { useState, useCallback } from "react"; +import { Dialog } from "@atyrode/excalidraw"; +import { useCanvasBackups, CanvasBackup } from "../api/hooks"; +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 + excalidrawAPI.updateScene(selectedBackup.data); + 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={false} + children={dialogContent} + /> +
+ )} + + ); +}; + +export default BackupsModal; diff --git a/src/frontend/src/ui/BackupsModal.tsx b/src/frontend/src/ui/BackupsModal.tsx deleted file mode 100644 index 05640d7..0000000 --- a/src/frontend/src/ui/BackupsModal.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { useState } from "react"; -import Modal from "./Modal"; -import { useCanvasBackups, CanvasBackup } from "../api/hooks"; -import "../styles/BackupsModal.scss"; - -interface BackupsModalProps { - excalidrawAPI?: any; - isExiting?: boolean; - onExitComplete?: () => void; - onClose?: () => void; -} - -const BackupsModal: React.FC = ({ - excalidrawAPI, - isExiting = false, - onExitComplete, - onClose, -}) => { - 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 - excalidrawAPI.updateScene(selectedBackup.data); - setSelectedBackup(null); - } - }; - - 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' - }); - }; - - return ( - -
-
-

Canvas Backups

- -
- - {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)} -
    - -
  • - ))} -
- )} -
-
- ); -}; - -export default BackupsModal; diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index 5f82513..7a04e7a 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -4,12 +4,11 @@ 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, ArchiveRestore } from 'lucide-react'; -import BackupsModal from './BackupsModal'; +import BackupsModal from './BackupsDialog'; 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; @@ -95,19 +94,13 @@ export const MainMenuConfig: React.FC = ({ }; const [showBackupsModal, setShowBackupsModal] = useState(false); - const [isBackupsModalExiting, setIsBackupsModalExiting] = useState(false); const handleCanvasBackupsClick = () => { setShowBackupsModal(true); - setIsBackupsModalExiting(false); - }; - - const handleBackupsModalExitComplete = () => { - setShowBackupsModal(false); }; const handleCloseBackupsModal = () => { - setIsBackupsModalExiting(true); + setShowBackupsModal(false); }; const handleGridToggle = () => { @@ -247,8 +240,6 @@ export const MainMenuConfig: React.FC = ({ {showBackupsModal && ( )} From da9293ef2308bc1b12df02bccc2fefbcdf1ca9de Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 25 Apr 2025 18:09:14 +0000 Subject: [PATCH 09/12] 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. --- src/frontend/src/App.tsx | 15 +------------- src/frontend/src/ui/BackupsDialog.tsx | 4 +++- src/frontend/src/utils/canvasUtils.ts | 28 +++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 15 deletions(-) create mode 100644 src/frontend/src/utils/canvasUtils.ts diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index f70d34e..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,20 +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; - } - appState.collaborators = new Map(); - return { ...data, appState }; - } - useEffect(() => { if (excalidrawAPI && canvasData) { excalidrawAPI.updateScene(normalizeCanvasData(canvasData)); diff --git a/src/frontend/src/ui/BackupsDialog.tsx b/src/frontend/src/ui/BackupsDialog.tsx index 7af42a1..248fd17 100644 --- a/src/frontend/src/ui/BackupsDialog.tsx +++ b/src/frontend/src/ui/BackupsDialog.tsx @@ -1,6 +1,7 @@ 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 { @@ -24,7 +25,8 @@ const BackupsModal: React.FC = ({ const handleRestoreBackup = () => { if (selectedBackup && excalidrawAPI) { // Load the backup data into the canvas - excalidrawAPI.updateScene(selectedBackup.data); + const normalizedData = normalizeCanvasData(selectedBackup.data); + excalidrawAPI.updateScene(normalizedData); setSelectedBackup(null); handleClose(); } 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 }; +} From 34b19eb1058e8b39459216248db8eec0bda9b868 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 25 Apr 2025 18:19:18 +0000 Subject: [PATCH 10/12] 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. --- src/frontend/src/ui/MainMenu.tsx | 51 ++++++++++++++++---------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index 7a04e7a..9de8ab6 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -150,36 +150,13 @@ export const MainMenuConfig: React.FC = ({ icon={} onClick={handleCanvasBackupsClick} > - Canvas Backups - - - - - - - } - onClick={handleGridToggle} - > - Toggle Grid - - } - onClick={handleViewModeToggle} - > - View Mode - - } - onClick={handleZenModeToggle} - > - Zen Mode + Load backup... - + } @@ -209,6 +186,30 @@ export const MainMenuConfig: React.FC = ({ + + } + onClick={handleGridToggle} + > + Toggle grid + + } + onClick={handleViewModeToggle} + > + View mode + + } + onClick={handleZenModeToggle} + > + Zen mode + + + + + + } onClick={async () => { From 2f5619d4e20beae0e5d240a45277f1f2e4b94470 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 25 Apr 2025 18:51:18 +0000 Subject: [PATCH 11/12] 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. --- src/frontend/src/ExcalidrawWrapper.tsx | 23 ++++++++++++++++++++++- src/frontend/src/styles/AuthDialog.scss | 8 ++++++++ src/frontend/src/styles/index.scss | 6 ------ src/frontend/src/ui/BackupsDialog.tsx | 4 ++-- src/frontend/src/ui/MainMenu.tsx | 17 ++++------------- 5 files changed, 36 insertions(+), 22 deletions(-) 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/styles/AuthDialog.scss b/src/frontend/src/styles/AuthDialog.scss index 1aab2b1..318e495 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, .auth-modal { + .Dialog__close { + display: none; + } + } +} + .excalidraw .Dialog--fullscreen { .auth-modal { &__logo-container { 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 index 248fd17..788d926 100644 --- a/src/frontend/src/ui/BackupsDialog.tsx +++ b/src/frontend/src/ui/BackupsDialog.tsx @@ -109,14 +109,14 @@ const BackupsModal: React.FC = ({

Canvas Backups

} - closeOnClickOutside={false} + closeOnClickOutside={true} children={dialogContent} />
diff --git a/src/frontend/src/ui/MainMenu.tsx b/src/frontend/src/ui/MainMenu.tsx index 9de8ab6..a33dde1 100644 --- a/src/frontend/src/ui/MainMenu.tsx +++ b/src/frontend/src/ui/MainMenu.tsx @@ -4,7 +4,6 @@ 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, ArchiveRestore } from 'lucide-react'; -import BackupsModal from './BackupsDialog'; import { capture } from '../utils/posthog'; import { ExcalidrawElementFactory, PlacementMode } from '../lib/ExcalidrawElementFactory'; import { useUserProfile } from "../api/hooks"; @@ -13,11 +12,15 @@ import { queryClient } from "../api/queryClient"; 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,16 +96,10 @@ export const MainMenuConfig: React.FC = ({ }); }; - const [showBackupsModal, setShowBackupsModal] = useState(false); - const handleCanvasBackupsClick = () => { setShowBackupsModal(true); }; - const handleCloseBackupsModal = () => { - setShowBackupsModal(false); - }; - const handleGridToggle = () => { if (!excalidrawAPI) return; const appState = excalidrawAPI.getAppState(); @@ -238,12 +235,6 @@ export const MainMenuConfig: React.FC = ({ Logout - {showBackupsModal && ( - - )} ); }; From 1b17707704647f5d4594faec44b8db8719a92c3d Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Fri, 25 Apr 2025 19:15:26 +0000 Subject: [PATCH 12/12] 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. --- src/frontend/src/styles/AuthDialog.scss | 12 +- src/frontend/src/styles/BackupsDialog.scss | 144 ++++++++++----------- src/frontend/src/ui/BackupsDialog.tsx | 2 +- 3 files changed, 73 insertions(+), 85 deletions(-) diff --git a/src/frontend/src/styles/AuthDialog.scss b/src/frontend/src/styles/AuthDialog.scss index 318e495..adac568 100644 --- a/src/frontend/src/styles/AuthDialog.scss +++ b/src/frontend/src/styles/AuthDialog.scss @@ -1,7 +1,7 @@ /* Auth Modal Styles */ .excalidraw .Dialog--fullscreen { - &.auth-modal, .auth-modal { + &.auth-modal { .Dialog__close { display: none; } @@ -26,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 index 9376b99..4c8b072 100644 --- a/src/frontend/src/styles/BackupsDialog.scss +++ b/src/frontend/src/styles/BackupsDialog.scss @@ -1,4 +1,21 @@ +/* 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; @@ -20,7 +37,18 @@ 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; @@ -31,6 +59,7 @@ justify-content: space-between; align-items: center; margin-bottom: 1rem; + width: 100%; } &__close-button { @@ -53,15 +82,6 @@ } } - &__title { - margin: 0; - font-size: 1.2rem; - font-weight: 500; - color: #ffffff; - text-align: center; - opacity: 0.9; - } - &__loading, &__error, &__empty { @@ -69,10 +89,10 @@ align-items: center; justify-content: center; padding: 2rem; - color: #ffffff; + color: #a0a0a9; font-style: italic; - opacity: 0.9; - animation: fadeIn 0.5s ease-in-out; + font-size: 18px; + animation: fadeIn 0.5s cubic-bezier(0.00, 1.26, 0.64, 0.95) forwards; } &__error { @@ -83,8 +103,9 @@ list-style: none; padding: 0; margin: 0; - max-height: 60vh; + max-height: 100%; overflow-y: auto; + width: 100%; } &__item { @@ -93,34 +114,18 @@ justify-content: space-between; padding: 12px 15px; margin-bottom: 8px; - background-color: #32373c; - border-radius: 10px; + background-color: #464652; + border: 2px solid #727279; + border-radius: 6px; cursor: pointer; - transition: all 0.3s ease; + transition: all 0.2s 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); + &:hover { + border: 2px solid #cc6d24; } - &:active::after { - background-color: rgba(255, 255, 255, 0.05); - } - &:last-child { margin-bottom: 0; } @@ -135,8 +140,8 @@ &__number { font-size: 0.9rem; font-weight: 600; - color: #cc6d24; - background-color: rgba(106, 122, 255, 0.1); + color: #fa8933; + background-color: rgba(250, 137, 51, 0.1); padding: 4px 8px; border-radius: 4px; min-width: 28px; @@ -146,21 +151,21 @@ &__timestamp { font-size: 0.9rem; color: #ffffff; - opacity: 0.9; } &__restore-button { background-color: transparent; border: none; - color: #cc6d24; + 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(106, 122, 255, 0.1); + background-color: rgba(250, 137, 51, 0.1); } } @@ -170,12 +175,14 @@ 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); + background-color: #464652; + border: 2px solid #727279; + border-radius: 6px; text-align: center; color: #ffffff; - animation: fadeIn 0.4s ease-in-out; + animation: fadeIn 0.4s cubic-bezier(0.00, 1.26, 0.64, 0.95) forwards; + width: 80%; + max-width: 500px; } &__warning { @@ -187,56 +194,35 @@ &__actions { display: flex; gap: 1rem; + margin-top: 20px; } &__button { + display: flex; + align-items: center; + justify-content: center; padding: 10px 16px; - border: none; - border-radius: 7px; + height: 44px; + border-radius: 6px; + border: 2px solid #727279; + font-size: 15px; font-weight: 500; + transition: all 0.2s ease; cursor: pointer; - transition: all 0.3s ease; - position: relative; - overflow: hidden; + min-width: 120px; - &::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); + &:hover { + border: 2px solid #cc6d24; } &--restore { - background-color: #cc6d24; - border: 1px solid #cecece00; + background-color: #464652; color: white; - - &:hover { - border: 1px solid #cecece; - } } &--cancel { - background-color: #4a4a54; + background-color: #464652; color: #ffffff; - - &:hover { - background-color: #3a3a44; - } } } } diff --git a/src/frontend/src/ui/BackupsDialog.tsx b/src/frontend/src/ui/BackupsDialog.tsx index 788d926..0018714 100644 --- a/src/frontend/src/ui/BackupsDialog.tsx +++ b/src/frontend/src/ui/BackupsDialog.tsx @@ -109,7 +109,7 @@ const BackupsModal: React.FC = ({