├── packages ├── client │ ├── src │ │ ├── vite-env.d.ts │ │ ├── styles │ │ │ ├── reset.scss │ │ │ ├── ExcalidrawEditor.scss │ │ │ ├── Loader.scss │ │ │ ├── BoardPage.scss │ │ │ ├── Toast.scss │ │ │ ├── Tab.scss │ │ │ ├── Header.scss │ │ │ ├── App.scss │ │ │ ├── TrashPopup.scss │ │ │ └── global.scss │ │ ├── types │ │ │ └── types.ts │ │ ├── components │ │ │ ├── Loader.tsx │ │ │ ├── Toast.tsx │ │ │ ├── BoardPage.tsx │ │ │ ├── Header.tsx │ │ │ ├── Tab.tsx │ │ │ ├── Icon.tsx │ │ │ ├── TrashPopup.tsx │ │ │ └── ExcalidrawEditor.tsx │ │ ├── services │ │ │ ├── libraryService.ts │ │ │ ├── elementService.ts │ │ │ ├── boardService.ts │ │ │ └── api.ts │ │ ├── main.tsx │ │ ├── utils │ │ │ ├── index.ts │ │ │ └── logger.ts │ │ ├── App.tsx │ │ ├── hooks │ │ │ └── useExcalidrawEditor.ts │ │ └── contexts │ │ │ ├── ToastProvider.tsx │ │ │ ├── ThemeProvider.tsx │ │ │ └── BoardProvider.tsx │ ├── public │ │ ├── Assistant-VariableFont.ttf │ │ └── icons │ │ │ └── excalidraw-icon.svg │ ├── tsconfig.node.json │ ├── .eslintrc.cjs │ ├── vite.config.ts │ ├── nginx.conf │ ├── tsconfig.json │ ├── index.html │ ├── Dockerfile │ ├── .eslintrc.json │ ├── package.json │ └── vite.config.ts.timestamp-1742747302607-98de530cc8682.mjs └── server │ ├── .env.example │ ├── src │ ├── models │ │ ├── index.ts │ │ ├── fileModel.ts │ │ ├── libraryModel.ts │ │ ├── boardModel.ts │ │ └── elementModel.ts │ ├── routes │ │ ├── index.ts │ │ ├── libraryRoutes.ts │ │ ├── elementRoutes.ts │ │ └── boardRoutes.ts │ ├── config │ │ ├── env.ts │ │ └── index.ts │ ├── middleware │ │ └── errorHandler.ts │ ├── utils │ │ └── logger.ts │ ├── lib │ │ ├── schema.sql │ │ └── database.ts │ ├── index.ts │ ├── controllers │ │ ├── libraryController.ts │ │ ├── elementController.ts │ │ └── boardController.ts │ └── types │ │ └── index.ts │ ├── tsconfig.json │ ├── .eslintrc.json │ ├── package.json │ └── Dockerfile ├── .eslintignore ├── .prettierignore ├── pnpm-workspace.yaml ├── .prettierrc ├── .editorconfig ├── docker-compose.yml ├── .gitignore ├── .dockerignore ├── supervisord.conf ├── docker-compose.dev.yml ├── package.json ├── .github └── workflows │ └── docker-publish.yml ├── Dockerfile └── README.md /packages/client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/server/.env.example: -------------------------------------------------------------------------------- 1 | PORT=4000 2 | NODE_ENV=development 3 | DB_PATH=database.sqlite 4 | -------------------------------------------------------------------------------- /packages/client/src/styles/reset.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | coverage 5 | .pnpm-store 6 | .vscode 7 | .idea 8 | *.min.js 9 | packages/*/public 10 | -------------------------------------------------------------------------------- /packages/client/public/Assistant-VariableFont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozencb/excalidraw-persist/HEAD/packages/client/public/Assistant-VariableFont.ttf -------------------------------------------------------------------------------- /packages/server/src/models/index.ts: -------------------------------------------------------------------------------- 1 | export { BoardModel } from './boardModel'; 2 | export { ElementModel } from './elementModel'; 3 | export * from '../types'; 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | coverage 5 | .pnpm-store 6 | .vscode 7 | .idea 8 | *.min.js 9 | *.svg 10 | *.json 11 | pnpm-lock.yaml 12 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | 4 | ignoredBuiltDependencies: 5 | - '@parcel/watcher' 6 | - esbuild 7 | 8 | onlyBuiltDependencies: 9 | - sqlite3 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "tabWidth": 2, 6 | "printWidth": 100, 7 | "arrowParens": "avoid", 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /packages/client/src/types/types.ts: -------------------------------------------------------------------------------- 1 | export interface Board { 2 | id: number; 3 | name: string; 4 | status: 'ACTIVE' | 'DELETED'; 5 | created_at: number; 6 | updated_at: number; 7 | } 8 | 9 | export interface TrashBoard extends Board { 10 | status: 'DELETED'; 11 | } 12 | -------------------------------------------------------------------------------- /packages/client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import boardRoutes from './boardRoutes'; 3 | 4 | const router = Router(); 5 | 6 | router.use('/boards', boardRoutes); 7 | 8 | router.get('/health', (req, res) => { 9 | res.status(200).json({ status: 'ok' }); 10 | }); 11 | 12 | export default router; 13 | -------------------------------------------------------------------------------- /packages/server/src/routes/libraryRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { libraryController } from '../controllers/libraryController'; 3 | 4 | const router = Router({ mergeParams: true }); 5 | 6 | router.get('/', libraryController.getByBoardId); 7 | router.put('/', libraryController.save); 8 | 9 | export default router; 10 | -------------------------------------------------------------------------------- /packages/server/src/routes/elementRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { elementController } from '../controllers/elementController'; 3 | 4 | const router = Router({ mergeParams: true }); 5 | 6 | router.get('/', elementController.getByBoardId); 7 | router.put('/', elementController.replaceAll); 8 | 9 | export default router; 10 | -------------------------------------------------------------------------------- /packages/client/src/styles/ExcalidrawEditor.scss: -------------------------------------------------------------------------------- 1 | .excalidraw-editor { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; // Adjust for header and tab bar 5 | width: 100%; 6 | 7 | .excalidraw-container { 8 | flex: 1; 9 | position: relative; 10 | overflow: hidden; 11 | 12 | .excalidraw { 13 | width: 100%; 14 | height: 100%; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/client/src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/Loader.scss'; 2 | 3 | interface LoaderProps { 4 | message?: string; 5 | } 6 | 7 | const Loader = ({ message = 'Loading...' }: LoaderProps) => { 8 | return ( 9 |
10 |
11 |

{message}

12 |
13 | ); 14 | }; 15 | 16 | export default Loader; 17 | -------------------------------------------------------------------------------- /packages/server/src/config/env.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import path from 'path'; 3 | 4 | // Load environment variables from .env file 5 | dotenv.config(); 6 | 7 | // Environment variables with defaults 8 | export const env = { 9 | NODE_ENV: process.env.NODE_ENV || 'development', 10 | PORT: parseInt(process.env.PORT || '4000', 10), 11 | DB_PATH: process.env.DB_PATH || path.join(process.cwd(), 'data', 'excalidraw.db'), 12 | }; 13 | -------------------------------------------------------------------------------- /packages/server/src/middleware/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | 3 | export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => { 4 | console.error(err.stack); 5 | 6 | res.status(500).json({ 7 | error: 'Internal Server Error', 8 | message: process.env.NODE_ENV === 'production' ? 'Something went wrong' : err.message, 9 | }); 10 | }; 11 | 12 | export default errorHandler; 13 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "CommonJS", 5 | "moduleResolution": "Node", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "outDir": "./dist", 11 | "sourceMap": true, 12 | "resolveJsonModule": true 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "**/*.test.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | excalidraw: 3 | image: ghcr.io/ozencb/excalidraw-persist:latest 4 | ports: 5 | - '4002:80' # Web client - change first number if needed 6 | - '4001:4000' # Server API - matches original setup 7 | volumes: 8 | - data:/app/data 9 | environment: 10 | - PORT=4000 11 | - NODE_ENV=production 12 | - DB_PATH=/app/data/database.sqlite 13 | restart: unless-stopped 14 | 15 | volumes: 16 | data: 17 | driver: local 18 | -------------------------------------------------------------------------------- /packages/server/src/config/index.ts: -------------------------------------------------------------------------------- 1 | import { env } from './env'; 2 | import path from 'path'; 3 | 4 | export const serverConfig = { 5 | port: env.PORT || 3001, 6 | nodeEnv: env.NODE_ENV || 'development', 7 | isDev: env.NODE_ENV !== 'production', 8 | dbPath: path.join(process.cwd(), 'data', 'excalidraw.db'), 9 | }; 10 | 11 | export const dbConfig = { 12 | dbPath: serverConfig.dbPath, 13 | schemaPath: path.join(process.cwd(), 'src', 'lib', 'schema.sql'), 14 | }; 15 | 16 | export * from './env'; 17 | -------------------------------------------------------------------------------- /packages/client/src/services/libraryService.ts: -------------------------------------------------------------------------------- 1 | import type { LibraryItems } from '@excalidraw/excalidraw/types'; 2 | import { api } from './api'; 3 | 4 | interface LibraryResponse { 5 | libraryItems: LibraryItems; 6 | } 7 | 8 | export const LibraryService = { 9 | getBoardLibrary: (boardId: string) => 10 | api.get(`/boards/${boardId}/library`), 11 | saveBoardLibrary: (boardId: string, libraryItems: LibraryItems) => 12 | api.put(`/boards/${boardId}/library`, { libraryItems }), 13 | }; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js 2 | node_modules/ 3 | npm-debug.log 4 | yarn-debug.log 5 | yarn-error.log 6 | pnpm-debug.log 7 | .pnpm-store/ 8 | 9 | # Build outputs 10 | dist/ 11 | build/ 12 | 13 | # Environment variables 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | # Editor directories and files 21 | .idea/ 22 | .vscode/ 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | .DS_Store 29 | 30 | # Database 31 | *.sqlite 32 | *.sqlite3 33 | *.db 34 | data/ 35 | -------------------------------------------------------------------------------- /packages/client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /packages/client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import path from 'path'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | '@': path.resolve(__dirname, './src'), 11 | }, 12 | }, 13 | server: { 14 | port: 3000, 15 | proxy: { 16 | '/api': { 17 | target: 'http://localhost:4000', 18 | changeOrigin: true, 19 | }, 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /packages/client/public/icons/excalidraw-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /packages/client/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | root /usr/share/nginx/html; 5 | index index.html; 6 | 7 | # Handle SPA routing 8 | location / { 9 | try_files $uri $uri/ /index.html; 10 | } 11 | 12 | # Proxy API requests to the backend server 13 | location /api/ { 14 | proxy_pass http://localhost:4000; 15 | proxy_http_version 1.1; 16 | proxy_set_header Upgrade $http_upgrade; 17 | proxy_set_header Connection 'upgrade'; 18 | proxy_set_header Host $host; 19 | proxy_cache_bypass $http_upgrade; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/client/src/services/elementService.ts: -------------------------------------------------------------------------------- 1 | import { api } from './api'; 2 | import { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'; 3 | import type { BinaryFiles } from '@excalidraw/excalidraw/types'; 4 | 5 | export interface BoardSceneData { 6 | elements: ExcalidrawElement[]; 7 | files: BinaryFiles; 8 | [key: string]: unknown; 9 | } 10 | 11 | export const ElementService = { 12 | getBoardElements: (boardId: string) => api.get(`/boards/${boardId}/elements`), 13 | 14 | replaceAllElements: (boardId: string, scene: BoardSceneData) => 15 | api.put(`/boards/${boardId}/elements`, scene), 16 | }; 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Version control 2 | .git 3 | .gitignore 4 | 5 | # Node.js dependencies 6 | node_modules 7 | **/node_modules 8 | 9 | # Build outputs 10 | **/dist 11 | **/build 12 | 13 | # Environment variables 14 | .env 15 | **/.env 16 | **/.env.* 17 | 18 | # Logs 19 | logs 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | pnpm-debug.log* 25 | lerna-debug.log* 26 | 27 | # Editor directories and files 28 | .vscode 29 | .idea 30 | *.suo 31 | *.ntvs* 32 | *.njsproj 33 | *.sln 34 | *.sw? 35 | .DS_Store 36 | 37 | # Docker files 38 | Dockerfile 39 | **/*Dockerfile* 40 | docker-compose.yml 41 | 42 | # Local data 43 | data/ 44 | **/data/ 45 | -------------------------------------------------------------------------------- /packages/server/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | // Simple logging utility 2 | export const logger = { 3 | info: (message: string, ...args: unknown[]) => { 4 | console.log(`[INFO] ${message}`, ...args); 5 | }, 6 | error: (message: string, ...args: unknown[]) => { 7 | console.error(`[ERROR] ${message}`, ...args); 8 | }, 9 | warn: (message: string, ...args: unknown[]) => { 10 | console.warn(`[WARN] ${message}`, ...args); 11 | }, 12 | debug: (message: string, ...args: unknown[]) => { 13 | if (process.env.NODE_ENV !== 'production') { 14 | console.debug(`[DEBUG] ${message}`, ...args); 15 | } 16 | }, 17 | }; 18 | 19 | export default logger; 20 | -------------------------------------------------------------------------------- /packages/client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import './styles/global.scss'; 5 | import ToastProvider, { useToast } from './contexts/ToastProvider'; 6 | import { initializeLogger } from './utils/logger'; 7 | 8 | const AppWithToasts = () => { 9 | const { showToast } = useToast(); 10 | 11 | React.useEffect(() => { 12 | initializeLogger(showToast); 13 | }, [showToast]); 14 | 15 | return ; 16 | }; 17 | 18 | ReactDOM.createRoot(document.getElementById('root')!).render( 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | -------------------------------------------------------------------------------- /packages/client/src/services/boardService.ts: -------------------------------------------------------------------------------- 1 | import { api } from './api'; 2 | import { Board, TrashBoard } from '../types/types'; 3 | 4 | export const BoardService = { 5 | getAllBoards: () => api.get('/boards'), 6 | 7 | getTrashedBoards: () => api.get('/boards/trash'), 8 | 9 | createBoard: () => api.post('/boards'), 10 | 11 | updateBoardName: (boardId: string, name: string) => 12 | api.put(`/boards/${boardId}`, { name }), 13 | 14 | moveToTrash: (boardId: string) => api.delete(`/boards/${boardId}`), 15 | 16 | restoreBoard: (boardId: string) => api.post(`/boards/${boardId}/restore`), 17 | 18 | permanentlyDeleteBoard: (boardId: string) => api.delete(`/boards/${boardId}/permanent`), 19 | }; 20 | -------------------------------------------------------------------------------- /packages/client/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | const exhaustiveMatchingGuard = (value: never): never => { 2 | throw new Error(`Unhandled value: ${value}`); 3 | }; 4 | 5 | type DebouncedFunction any> = (...args: Parameters) => void; 6 | 7 | function debounce any>(func: T, wait: number): DebouncedFunction { 8 | let timeoutId: ReturnType | null = null; 9 | return (...args: Parameters) => { 10 | if (timeoutId) { 11 | clearTimeout(timeoutId); 12 | } 13 | timeoutId = setTimeout(() => { 14 | func(...args); 15 | }, wait); 16 | }; 17 | } 18 | 19 | const Utils = { 20 | exhaustiveMatchingGuard, 21 | debounce, 22 | }; 23 | 24 | export default Utils; 25 | -------------------------------------------------------------------------------- /supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | logfile=/var/log/supervisor/supervisord.log 4 | logfile_maxbytes=50MB 5 | logfile_backups=10 6 | loglevel=info 7 | pidfile=/tmp/supervisord.pid 8 | 9 | [program:nginx] 10 | command=nginx -g "daemon off;" 11 | autostart=true 12 | autorestart=true 13 | stdout_logfile=/var/log/supervisor/nginx.log 14 | stderr_logfile=/var/log/supervisor/nginx_error.log 15 | priority=10 16 | 17 | [program:server] 18 | command=node /app/packages/server/dist/index.js 19 | directory=/app 20 | autostart=true 21 | autorestart=true 22 | stdout_logfile=/var/log/supervisor/server.log 23 | stderr_logfile=/var/log/supervisor/server_error.log 24 | priority=20 25 | environment=PORT=4000,NODE_ENV=production,DB_PATH=/app/data/database.sqlite 26 | -------------------------------------------------------------------------------- /packages/server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "es2021": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaVersion": "latest", 15 | "sourceType": "module" 16 | }, 17 | "plugins": [ 18 | "@typescript-eslint", 19 | "prettier" 20 | ], 21 | "rules": { 22 | "prettier/prettier": "error", 23 | "@typescript-eslint/no-unused-vars": ["warn", { 24 | "argsIgnorePattern": "^_", 25 | "varsIgnorePattern": "^_", 26 | "caughtErrorsIgnorePattern": "^_" 27 | }], 28 | "@typescript-eslint/no-explicit-any": "warn" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/server/src/routes/boardRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { boardController } from '../controllers/boardController'; 3 | import elementRoutes from './elementRoutes'; 4 | import libraryRoutes from './libraryRoutes'; 5 | 6 | const router = Router(); 7 | 8 | router.get('/', boardController.listActive); 9 | router.get('/trash', boardController.listTrash); 10 | router.post('/', boardController.create); 11 | router.put('/:id', boardController.update); 12 | router.delete('/:id', boardController.moveToTrash); 13 | router.post('/:id/restore', boardController.restoreFromTrash); 14 | router.delete('/:id/permanent', boardController.permanentDelete); 15 | router.use('/:boardId/elements', elementRoutes); 16 | router.use('/:boardId/library', libraryRoutes); 17 | 18 | export default router; 19 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | client: 5 | build: 6 | context: . 7 | dockerfile: ./packages/client/Dockerfile 8 | ports: 9 | - '80:80' 10 | depends_on: 11 | - server 12 | restart: unless-stopped 13 | networks: 14 | - excalidraw-network 15 | 16 | server: 17 | build: 18 | context: . 19 | dockerfile: ./packages/server/Dockerfile 20 | ports: 21 | - '4001:4000' 22 | volumes: 23 | - ./data:/app/data 24 | environment: 25 | - PORT=4000 26 | - NODE_ENV=production 27 | - DB_PATH=/app/data/database.sqlite 28 | restart: unless-stopped 29 | networks: 30 | - excalidraw-network 31 | 32 | networks: 33 | excalidraw-network: 34 | driver: bridge 35 | 36 | volumes: 37 | data: 38 | driver: local 39 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | /* Paths */ 24 | "baseUrl": ".", 25 | "paths": { 26 | "@/*": ["src/*"] 27 | } 28 | }, 29 | "include": ["src"], 30 | "references": [{ "path": "./tsconfig.node.json" }] 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "excalidraw-persist", 3 | "version": "0.18.0-persist.1", 4 | "description": "Self-hostable Excalidraw server-side persistence, and multi-boards", 5 | "private": true, 6 | "scripts": { 7 | "dev": "pnpm -r run dev", 8 | "build": "pnpm -r run build", 9 | "lint": "pnpm -r run lint", 10 | "lint:fix": "pnpm -r run lint:fix", 11 | "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\" && pnpm -r run format", 12 | "docker:build": "docker-compose build", 13 | "docker:up": "docker-compose up -d", 14 | "docker:down": "docker-compose down", 15 | "docker:logs": "docker-compose logs -f" 16 | }, 17 | "keywords": [ 18 | "excalidraw", 19 | "drawing" 20 | ], 21 | "author": "", 22 | "license": "MIT", 23 | "devDependencies": { 24 | "prettier": "^3.0.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/client/src/styles/Loader.scss: -------------------------------------------------------------------------------- 1 | .loader-container { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | height: 100%; // Take full height of parent 7 | gap: calc(4 * var(--space-factor)); // Use space factor for gap 8 | padding: calc(5 * var(--space-factor)); // Use space factor for padding 9 | 10 | .loader-spinner { 11 | width: 40px; 12 | height: 40px; 13 | border: 4px solid var(--color-gray-20); /* Light grey */ 14 | border-top: 4px solid var(--color-primary); /* Use primary color variable */ 15 | border-radius: 50%; 16 | animation: spin 1s linear infinite; 17 | } 18 | 19 | p { 20 | font-size: 1rem; 21 | color: var(--color-gray-70); 22 | } 23 | } 24 | 25 | @keyframes spin { 26 | 0% { 27 | transform: rotate(0deg); 28 | } 29 | 100% { 30 | transform: rotate(360deg); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | Excalidraw Persist 18 | 19 | 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /packages/client/src/styles/BoardPage.scss: -------------------------------------------------------------------------------- 1 | .board-page { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100vh; 5 | 6 | .editor-container { 7 | flex: 1; 8 | overflow: hidden; 9 | } 10 | 11 | &.loading { 12 | // The Loader component itself handles centering and styling 13 | // We just need to ensure the board-page container allows it to fill space 14 | // height: 100vh; is already set above 15 | } 16 | 17 | &.error { 18 | display: flex; 19 | flex-direction: column; 20 | justify-content: center; 21 | align-items: center; 22 | flex: 1; 23 | 24 | .error-container { 25 | padding: calc(5 * var(--space-factor)); 26 | text-align: center; 27 | 28 | h2 { 29 | color: var(--color-danger); 30 | margin-bottom: calc(2.5 * var(--space-factor)); 31 | } 32 | 33 | p { 34 | color: var(--color-gray-70); 35 | margin-bottom: calc(5 * var(--space-factor)); 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS base 2 | 3 | # Install pnpm 4 | ENV PNPM_HOME="/pnpm" 5 | ENV PATH="$PNPM_HOME:$PATH" 6 | RUN corepack enable 7 | 8 | # Setup build stage 9 | FROM base AS build 10 | WORKDIR /app 11 | 12 | # Copy root workspace files 13 | COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ 14 | 15 | # Copy client package 16 | COPY packages/client ./packages/client/ 17 | 18 | # Install dependencies 19 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 20 | 21 | # Build the application 22 | RUN pnpm --filter "@excalidraw-persist/client" build 23 | 24 | # Setup production stage 25 | FROM nginx:alpine AS production 26 | 27 | # Copy built static files to nginx serve directory 28 | COPY --from=build /app/packages/client/dist /usr/share/nginx/html 29 | 30 | # Copy nginx configuration 31 | COPY packages/client/nginx.conf /etc/nginx/conf.d/default.conf 32 | 33 | # Expose port 34 | EXPOSE 80 35 | 36 | # Start nginx 37 | CMD ["nginx", "-g", "daemon off;"] 38 | -------------------------------------------------------------------------------- /packages/client/src/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import '../styles/Toast.scss'; 3 | 4 | export type ToastType = 'info' | 'success' | 'warning' | 'error'; 5 | 6 | interface ToastProps { 7 | message: string; 8 | type: ToastType; 9 | duration?: number; 10 | onClose: () => void; 11 | } 12 | 13 | const Toast = ({ message, type = 'info', duration = 3000, onClose }: ToastProps) => { 14 | const [isVisible, setIsVisible] = useState(true); 15 | 16 | useEffect(() => { 17 | const timer = setTimeout(() => { 18 | setIsVisible(false); 19 | setTimeout(onClose, 300); // Wait for the fade-out animation 20 | }, duration); 21 | 22 | return () => clearTimeout(timer); 23 | }, [duration, onClose]); 24 | 25 | return ( 26 |
27 |
28 | {message} 29 |
30 |
31 | ); 32 | }; 33 | 34 | export default Toast; 35 | -------------------------------------------------------------------------------- /packages/client/src/components/BoardPage.tsx: -------------------------------------------------------------------------------- 1 | import Header from './Header'; 2 | import { useBoardContext } from '../contexts/BoardProvider'; 3 | import '../styles/BoardPage.scss'; 4 | import ExcalidrawEditor from './ExcalidrawEditor'; 5 | import Loader from './Loader'; 6 | 7 | const BoardPage = () => { 8 | const { isLoading, activeBoardId } = useBoardContext(); 9 | 10 | if (isLoading) { 11 | return ( 12 |
13 | 14 |
15 | ); 16 | } 17 | 18 | if (!activeBoardId) { 19 | return ( 20 |
21 |
22 |

Error: Missing Board ID

23 |

Please select a board or create a new one.

24 |
25 |
26 | ); 27 | } 28 | 29 | return ( 30 |
31 |
32 |
33 | 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default BoardPage; 40 | -------------------------------------------------------------------------------- /packages/client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2021": true, 6 | "node": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:react/recommended", 11 | "plugin:react-hooks/recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "prettier" 14 | ], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaFeatures": { 18 | "jsx": true 19 | }, 20 | "ecmaVersion": "latest", 21 | "sourceType": "module" 22 | }, 23 | "plugins": [ 24 | "react", 25 | "react-hooks", 26 | "@typescript-eslint", 27 | "prettier" 28 | ], 29 | "settings": { 30 | "react": { 31 | "version": "detect" 32 | } 33 | }, 34 | "rules": { 35 | "react/react-in-jsx-scope": "off", 36 | "react/prop-types": "off", 37 | "react/display-name": "off", 38 | "prettier/prettier": "error", 39 | "@typescript-eslint/no-unused-vars": ["warn", { 40 | "argsIgnorePattern": "^_", 41 | "varsIgnorePattern": "^_", 42 | "caughtErrorsIgnorePattern": "^_" 43 | }], 44 | "@typescript-eslint/no-explicit-any": "warn" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/client/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import '../styles/Header.scss'; 3 | import TrashPopup from './TrashPopup'; 4 | import Tab from './Tab'; 5 | import { useBoardContext } from '../contexts/BoardProvider'; 6 | import Icon from './Icon'; 7 | 8 | const Header = () => { 9 | const [isTrashPopupOpen, setIsTrashPopupOpen] = useState(false); 10 | 11 | const { boards, isLoading, activeBoardId, handleCreateBoard } = useBoardContext(); 12 | 13 | if (isLoading) { 14 | return
Loading boards...
; 15 | } 16 | 17 | return ( 18 |
19 | 22 | 23 |
24 | {boards.map(board => ( 25 | 26 | ))} 27 | 34 |
35 | 36 | setIsTrashPopupOpen(false)} /> 37 |
38 | ); 39 | }; 40 | 41 | export default Header; 42 | -------------------------------------------------------------------------------- /packages/client/src/styles/Toast.scss: -------------------------------------------------------------------------------- 1 | .toast-container { 2 | position: fixed; 3 | bottom: 20px; 4 | right: 20px; 5 | z-index: var(--zIndex-toast, 9000); 6 | display: flex; 7 | flex-direction: column; 8 | gap: 10px; 9 | max-width: 350px; 10 | } 11 | 12 | .toast { 13 | padding: 12px 16px; 14 | border-radius: var(--border-radius-md, 6px); 15 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 16 | background-color: var(--island-bg-color, #fff); 17 | opacity: 0; 18 | transform: translateY(20px); 19 | transition: 20 | opacity 0.3s, 21 | transform 0.3s; 22 | overflow: hidden; 23 | display: flex; 24 | align-items: center; 25 | border-left: 4px solid; 26 | 27 | &.visible { 28 | opacity: 1; 29 | transform: translateY(0); 30 | } 31 | 32 | &.hidden { 33 | opacity: 0; 34 | transform: translateY(-20px); 35 | } 36 | 37 | .toast-content { 38 | flex: 1; 39 | } 40 | 41 | .toast-message { 42 | font-size: 0.875rem; 43 | color: var(--text-primary-color, #333); 44 | } 45 | 46 | &.toast-info { 47 | border-left-color: var(--color-primary, #4285f4); 48 | } 49 | 50 | &.toast-success { 51 | border-left-color: var(--color-success, #34a853); 52 | } 53 | 54 | &.toast-warning { 55 | border-left-color: var(--color-warning, #fbbc05); 56 | } 57 | 58 | &.toast-error { 59 | border-left-color: var(--color-danger, #ea4335); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@excalidraw-persist/client", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "lint:fix": "eslint . --ext ts,tsx --fix", 11 | "format": "prettier --write \"src/**/*.{ts,tsx,scss}\"", 12 | "preview": "vite preview" 13 | }, 14 | "dependencies": { 15 | "@excalidraw/excalidraw": "0.18.0", 16 | "lodash.debounce": "^4.0.8", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "react-router-dom": "^7.4.0", 20 | "uuid": "^11.1.0" 21 | }, 22 | "devDependencies": { 23 | "@types/lodash.debounce": "^4.0.9", 24 | "@types/react": "^18.2.15", 25 | "@types/react-dom": "^18.2.7", 26 | "@types/uuid": "^10.0.0", 27 | "@typescript-eslint/eslint-plugin": "^6.0.0", 28 | "@typescript-eslint/parser": "^6.0.0", 29 | "@vitejs/plugin-react": "^4.0.3", 30 | "eslint": "^8.45.0", 31 | "eslint-config-prettier": "^9.0.0", 32 | "eslint-plugin-prettier": "^5.0.0", 33 | "eslint-plugin-react": "^7.33.2", 34 | "eslint-plugin-react-hooks": "^4.6.0", 35 | "eslint-plugin-react-refresh": "^0.4.3", 36 | "prettier": "^3.0.0", 37 | "sass": "^1.69.5", 38 | "typescript": "^5.0.2", 39 | "vite": "^4.4.5" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@excalidraw-persist/server", 3 | "version": "1.0.0", 4 | "description": "Server for Excalidraw Persist with persistence", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "dev": "nodemon --watch src -e ts --exec \"ts-node src/index.ts\"", 8 | "build": "tsc", 9 | "start": "node dist/index.js", 10 | "test:api": "ts-node src/scripts/test-api.ts", 11 | "lint": "eslint . --ext ts", 12 | "lint:fix": "eslint . --ext ts --fix", 13 | "format": "prettier --write \"src/**/*.ts\"" 14 | }, 15 | "keywords": [ 16 | "excalidraw", 17 | "api" 18 | ], 19 | "author": "", 20 | "license": "MIT", 21 | "dependencies": { 22 | "@types/morgan": "^1.9.9", 23 | "cors": "^2.8.5", 24 | "dotenv": "^16.3.1", 25 | "express": "^4.18.2", 26 | "morgan": "^1.10.0", 27 | "sqlite": "^5.1.1", 28 | "sqlite3": "^5.1.6" 29 | }, 30 | "devDependencies": { 31 | "@types/cors": "^2.8.14", 32 | "@types/express": "^4.17.19", 33 | "@types/node": "^20.8.4", 34 | "@types/sqlite3": "^5.1.0", 35 | "@typescript-eslint/eslint-plugin": "^6.8.0", 36 | "@typescript-eslint/parser": "^6.8.0", 37 | "eslint": "^8.51.0", 38 | "eslint-config-prettier": "^9.0.0", 39 | "eslint-plugin-prettier": "^5.0.0", 40 | "nodemon": "^3.0.1", 41 | "prettier": "^3.0.0", 42 | "ts-node": "^10.9.2", 43 | "typescript": "^5.2.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; 2 | import BoardPage from './components/BoardPage'; 3 | import Loader from './components/Loader'; 4 | import { BoardProvider, useBoardContext } from './contexts/BoardProvider'; 5 | import { ThemeProvider } from './contexts/ThemeProvider'; 6 | import './styles/App.scss'; 7 | import '@excalidraw/excalidraw/index.css'; 8 | 9 | const HomePage = () => { 10 | const { isLoading, boards } = useBoardContext(); 11 | 12 | if (isLoading) { 13 | return ; 14 | } 15 | 16 | if (boards.length === 0) { 17 | return
No boards found
; 18 | } 19 | 20 | return ; 21 | }; 22 | 23 | const App = () => { 24 | return ( 25 | 26 | 27 | 28 | 32 | 33 | 34 | } 35 | /> 36 | 40 | 41 | 42 | } 43 | /> 44 | } /> 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default App; 52 | -------------------------------------------------------------------------------- /packages/client/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { ToastType } from '../components/Toast'; 2 | 3 | type WrappedConsoleMethods = 'log' | 'info' | 'warn' | 'error' | 'debug'; 4 | 5 | const toastTypeMap: Record = { 6 | log: 'info', 7 | info: 'info', 8 | warn: 'warning', 9 | error: 'error', 10 | debug: 'info', 11 | }; 12 | 13 | type ShowToastFn = (message: string, type: ToastType) => void; 14 | 15 | let showToastFn: ShowToastFn | null = null; 16 | 17 | export const initializeLogger = (showToast: ShowToastFn): void => { 18 | showToastFn = showToast; 19 | }; 20 | 21 | const createLogger = ( 22 | method: T, 23 | originalFn: Console[T] 24 | ): Console[T] => { 25 | return ((...args: any[]) => { 26 | let shouldShowToast = false; 27 | if (args.length > 0 && typeof args[args.length - 1] === 'boolean') { 28 | shouldShowToast = args.pop() as boolean; 29 | } 30 | 31 | originalFn.apply(console, args); 32 | 33 | if (shouldShowToast && showToastFn) { 34 | const message = args 35 | .map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg))) 36 | .join(' '); 37 | showToastFn(message, toastTypeMap[method]); 38 | } 39 | }) as Console[T]; 40 | }; 41 | 42 | export const logger = { 43 | log: createLogger('log', console.log), 44 | info: createLogger('info', console.info), 45 | warn: createLogger('warn', console.warn), 46 | error: createLogger('error', console.error), 47 | debug: createLogger('debug', console.debug), 48 | }; 49 | 50 | export default logger; 51 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docker Image 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | 9 | jobs: 10 | build-and-push: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | packages: write 15 | 16 | steps: 17 | # Checkout the code 18 | - name: Checkout code 19 | uses: actions/checkout@v3 20 | 21 | # Set up Docker Buildx 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v2 24 | 25 | # Log in to GHCR using GitHub token 26 | - name: Log in to GHCR 27 | uses: docker/login-action@v2 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | # Extract version from package.json 34 | - name: Extract package version 35 | id: package_version 36 | run: echo "version=$(jq -r .version package.json)" >> $GITHUB_OUTPUT 37 | 38 | # Build and push the Docker image 39 | - name: Build and push Docker image 40 | uses: docker/build-push-action@v3 41 | with: 42 | context: . 43 | push: true 44 | tags: | 45 | ghcr.io/${{ github.repository_owner }}/excalidraw-persist:latest 46 | ghcr.io/${{ github.repository_owner }}/excalidraw-persist:${{ steps.package_version.outputs.version }} 47 | platforms: linux/amd64,linux/arm64 48 | file: Dockerfile 49 | 50 | # Optional: Clear Docker cache 51 | - name: Clear Docker cache 52 | run: | 53 | docker builder prune -f 54 | -------------------------------------------------------------------------------- /packages/client/src/components/Tab.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import { Board } from '../types/types'; 3 | import { useBoardContext } from '../contexts/BoardProvider'; 4 | import '../styles/Tab.scss'; 5 | import Icon from './Icon'; 6 | 7 | interface TabProps { 8 | board: Board; 9 | activeBoardId: string | undefined; 10 | } 11 | 12 | const Tab = ({ board, activeBoardId }: TabProps) => { 13 | const { handleRenameBoard, handleDeleteBoard } = useBoardContext(); 14 | 15 | const isActive = board.id.toString() === activeBoardId; 16 | 17 | return ( 18 | 24 | 27 | handleRenameBoard(board.id, e.target.value)} 33 | aria-label={`Edit name for board ${board.name}`} 34 | readOnly={!isActive} 35 | /> 36 | {isActive && ( 37 | 48 | )} 49 | 50 | ); 51 | }; 52 | 53 | export default Tab; 54 | -------------------------------------------------------------------------------- /packages/server/src/lib/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS boards ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | name TEXT NOT NULL DEFAULT ( 4 | strftime('%Y-%m-%d %H:%M:%S','now') 5 | ), 6 | status TEXT NOT NULL DEFAULT 'ACTIVE', 7 | created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 8 | updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 9 | ); 10 | 11 | CREATE TABLE IF NOT EXISTS elements ( 12 | id TEXT NOT NULL, 13 | board_id INTEGER NOT NULL, 14 | data TEXT NOT NULL, 15 | element_index TEXT NOT NULL, 16 | type TEXT NOT NULL, 17 | created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 18 | updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 19 | is_deleted BOOLEAN NOT NULL DEFAULT 0, 20 | PRIMARY KEY (id, board_id), 21 | FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE, 22 | UNIQUE (element_index, board_id) 23 | ); 24 | 25 | CREATE INDEX IF NOT EXISTS idx_elements_board_id ON elements(board_id); 26 | 27 | CREATE TABLE IF NOT EXISTS files ( 28 | id TEXT NOT NULL, 29 | board_id INTEGER NOT NULL, 30 | data TEXT NOT NULL, 31 | created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 32 | updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 33 | PRIMARY KEY (id, board_id), 34 | FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE 35 | ); 36 | 37 | CREATE INDEX IF NOT EXISTS idx_files_board_id ON files(board_id); 38 | 39 | CREATE TABLE IF NOT EXISTS libraries ( 40 | board_id INTEGER PRIMARY KEY, 41 | data TEXT NOT NULL DEFAULT '[]', 42 | updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 43 | FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE 44 | ); 45 | -------------------------------------------------------------------------------- /packages/client/src/hooks/useExcalidrawEditor.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'; 3 | import type { BinaryFiles, ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'; 4 | import { ElementService, type BoardSceneData } from '../services/elementService'; 5 | import Utils from '../utils'; 6 | import logger from '../utils/logger'; 7 | 8 | const debouncedSave = Utils.debounce((boardId: string, scene: BoardSceneData) => { 9 | if (boardId) { 10 | ElementService.replaceAllElements(boardId, scene).catch(error => 11 | logger.error('Error saving scene data:', error, true) 12 | ); 13 | } 14 | }, 500); 15 | 16 | export const useExcalidrawEditor = (boardId: string | undefined) => { 17 | const [elements, setElements] = useState([]); 18 | const [files, setFiles] = useState({}); 19 | const [excalidrawAPI, setExcalidrawAPI] = useState(null); 20 | 21 | const handleChange = useCallback( 22 | (excalidrawElements: readonly ExcalidrawElement[], excalidrawFiles: BinaryFiles | null) => { 23 | const elementsArray = [...excalidrawElements]; 24 | const filesMap: BinaryFiles = excalidrawFiles ? { ...excalidrawFiles } : {}; 25 | 26 | setElements(elementsArray); 27 | setFiles(filesMap); 28 | 29 | if (boardId) { 30 | debouncedSave(boardId, { elements: elementsArray, files: filesMap }); 31 | } 32 | }, 33 | [boardId] 34 | ); 35 | 36 | return { 37 | elements, 38 | setElements, 39 | files, 40 | setFiles, 41 | excalidrawAPI, 42 | setExcalidrawAPI, 43 | handleChange, 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /packages/client/src/contexts/ToastProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, ReactNode } from 'react'; 2 | import Toast, { ToastType } from '../components/Toast'; 3 | 4 | interface ToastMessage { 5 | id: string; 6 | message: string; 7 | type: ToastType; 8 | } 9 | 10 | interface ToastContextType { 11 | showToast: (message: string, type: ToastType) => void; 12 | } 13 | 14 | const ToastContext = createContext(undefined); 15 | 16 | export const useToast = (): ToastContextType => { 17 | const context = useContext(ToastContext); 18 | if (!context) { 19 | throw new Error('useToast must be used within a ToastProvider'); 20 | } 21 | return context; 22 | }; 23 | 24 | interface ToastProviderProps { 25 | children: ReactNode; 26 | } 27 | 28 | export const ToastProvider = ({ children }: ToastProviderProps) => { 29 | const [toasts, setToasts] = useState([]); 30 | 31 | const showToast = (message: string, type: ToastType = 'info') => { 32 | const id = Date.now().toString(); 33 | setToasts(prev => [...prev, { id, message, type }]); 34 | }; 35 | 36 | const removeToast = (id: string) => { 37 | setToasts(prev => prev.filter(toast => toast.id !== id)); 38 | }; 39 | 40 | return ( 41 | 42 | {children} 43 |
44 | {toasts.map(toast => ( 45 | removeToast(toast.id)} 50 | /> 51 | ))} 52 |
53 |
54 | ); 55 | }; 56 | 57 | export default ToastProvider; 58 | -------------------------------------------------------------------------------- /packages/server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS base 2 | 3 | # Install pnpm 4 | ENV PNPM_HOME="/pnpm" 5 | ENV PATH="$PNPM_HOME:$PATH" 6 | RUN corepack enable 7 | 8 | # Setup build stage 9 | FROM base AS build 10 | WORKDIR /app 11 | 12 | # Install build dependencies for sqlite3 13 | RUN apk add --no-cache python3 make g++ 14 | 15 | # Copy root workspace files 16 | COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ 17 | 18 | # Copy server package 19 | COPY packages/server ./packages/server/ 20 | 21 | # Install dependencies 22 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 23 | 24 | # Build the application 25 | RUN pnpm --filter "@excalidraw-persist/server" build 26 | 27 | # Setup production stage 28 | FROM base AS production 29 | WORKDIR /app 30 | 31 | # Install build dependencies for sqlite3 32 | RUN apk add --no-cache python3 make g++ 33 | 34 | # Copy package.json files 35 | COPY --from=build /app/package.json ./ 36 | COPY --from=build /app/pnpm-lock.yaml ./ 37 | COPY --from=build /app/pnpm-workspace.yaml ./ 38 | COPY --from=build /app/packages/server/package.json ./packages/server/ 39 | 40 | # Install production dependencies only and rebuild sqlite3 41 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --prod 42 | 43 | # Copy built JavaScript files 44 | COPY --from=build /app/packages/server/dist ./packages/server/dist 45 | 46 | # Create src directory structure and copy schema file 47 | RUN mkdir -p /app/src/lib 48 | COPY --from=build /app/packages/server/src/lib/schema.sql ./src/lib/ 49 | 50 | # Create directory for SQLite database 51 | RUN mkdir -p /app/data 52 | 53 | # Set environment variables 54 | ENV PORT=4000 55 | ENV NODE_ENV=production 56 | ENV DB_PATH=/app/data/database.sqlite 57 | 58 | # Expose port 59 | EXPOSE 4000 60 | 61 | # Start the application 62 | CMD ["node", "packages/server/dist/index.js"] 63 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response, NextFunction } from 'express'; 2 | import cors from 'cors'; 3 | import morgan from 'morgan'; 4 | import { serverConfig } from './config'; 5 | import { openDatabase, initializeDatabase, closeDatabase } from './lib/database'; 6 | import apiRoutes from './routes'; 7 | import logger from './utils/logger'; 8 | 9 | const app = express(); 10 | const PORT = serverConfig.port; 11 | 12 | app.use(cors()); 13 | app.use(morgan('dev')); 14 | app.use(express.json({ limit: '50mb' })); 15 | app.use(express.urlencoded({ extended: true })); 16 | 17 | app.use('/api', apiRoutes); 18 | 19 | app.use((err: Error, req: Request, res: Response, next: NextFunction) => { 20 | logger.error('Unhandled error:', err); 21 | res.status(500).json({ 22 | success: false, 23 | message: 'Internal server error', 24 | }); 25 | }); 26 | 27 | const startServer = async () => { 28 | try { 29 | await openDatabase(); 30 | logger.info('Database connection established'); 31 | 32 | await initializeDatabase(); 33 | logger.info('Database initialized'); 34 | 35 | app.listen(PORT, () => { 36 | logger.info(`Server is running on http://localhost:${PORT}`); 37 | }); 38 | 39 | process.on('SIGINT', async () => { 40 | logger.info('Shutting down server...'); 41 | await closeDatabase(); 42 | logger.info('Database connection closed'); 43 | process.exit(0); 44 | }); 45 | 46 | process.on('SIGTERM', async () => { 47 | logger.info('Shutting down server...'); 48 | await closeDatabase(); 49 | logger.info('Database connection closed'); 50 | process.exit(0); 51 | }); 52 | 53 | process.on('uncaughtException', function (err) { 54 | logger.error('Unhandled Exception:', err); 55 | }); 56 | } catch (error) { 57 | logger.error('Failed to start server:', error); 58 | process.exit(1); 59 | } 60 | }; 61 | 62 | startServer(); 63 | -------------------------------------------------------------------------------- /packages/client/src/styles/Tab.scss: -------------------------------------------------------------------------------- 1 | .tab { 2 | display: flex; 3 | align-items: center; 4 | padding: 0 calc(4 * var(--space-factor)); 5 | padding-right: var(--space-factor); 6 | height: 100%; 7 | background-color: var(--color-surface-low); 8 | color: var(--text-primary-color); 9 | text-decoration: none; 10 | font-size: 0.875rem; 11 | position: relative; 12 | box-sizing: border-box; 13 | -moz-box-sizing: border-box; 14 | -webkit-box-sizing: border-box; 15 | 16 | &:hover { 17 | background-color: var(--button-hover-bg); 18 | } 19 | 20 | &.active { 21 | background-color: var(--island-bg-color); 22 | color: var(--color-primary); 23 | border-bottom: 2px solid var(--color-primary); 24 | margin-bottom: -1px; 25 | } 26 | 27 | &:not(.active) { 28 | cursor: pointer; 29 | 30 | .tab-name { 31 | cursor: pointer; 32 | } 33 | 34 | &:hover { 35 | background-color: var(--button-hover-bg); 36 | } 37 | } 38 | 39 | .tab-name { 40 | max-width: 120px; 41 | overflow: hidden; 42 | text-overflow: ellipsis; 43 | border: none; 44 | background: none; 45 | width: 100%; 46 | font-family: inherit; 47 | font-size: inherit; 48 | color: inherit; 49 | padding: 0; 50 | outline: none; 51 | 52 | &:focus { 53 | outline: 1px solid var(--color-primary); 54 | } 55 | } 56 | 57 | .close-tab-button { 58 | background: none; 59 | border: none; 60 | padding: 0; 61 | margin-left: calc(2 * var(--space-factor)); 62 | cursor: pointer; 63 | font-size: var(--default-icon-size); 64 | opacity: 0.6; 65 | line-height: 1; 66 | color: var(--icon-fill-color); 67 | // center vertically 68 | align-self: center; 69 | 70 | &:hover { 71 | background-color: var(--button-hover-bg); 72 | color: var(--color-primary); 73 | opacity: 1; 74 | } 75 | } 76 | 77 | .visually-hidden { 78 | display: none; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/client/src/contexts/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useState, useContext, useEffect, ReactNode, useCallback } from 'react'; 2 | import { THEME } from '@excalidraw/excalidraw'; 3 | 4 | type Theme = typeof THEME.LIGHT | typeof THEME.DARK; 5 | 6 | interface ThemeContextProps { 7 | theme: Theme; 8 | setTheme: (theme: Theme) => void; 9 | } 10 | 11 | const ThemeContext = createContext(undefined); 12 | 13 | interface ThemeProviderProps { 14 | children: ReactNode; 15 | } 16 | 17 | export const ThemeProvider = ({ children }: ThemeProviderProps) => { 18 | const [theme, setThemeState] = useState(THEME.LIGHT); 19 | 20 | const setTheme = useCallback((newTheme: Theme) => { 21 | setThemeState(prevTheme => { 22 | if (prevTheme !== newTheme) { 23 | localStorage.setItem('appTheme', newTheme); 24 | return newTheme; 25 | } 26 | return prevTheme; 27 | }); 28 | }, []); 29 | 30 | useEffect(() => { 31 | const root = document.documentElement; 32 | root.classList.add(theme === THEME.LIGHT ? 'theme--light' : 'theme--dark'); 33 | root.classList.remove(theme === THEME.LIGHT ? 'theme--dark' : 'theme--light'); 34 | }, [theme]); 35 | 36 | useEffect(() => { 37 | const storedTheme = localStorage.getItem('appTheme') as Theme; 38 | const prefersDark = 39 | window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 40 | const initialTheme = storedTheme || (prefersDark ? THEME.DARK : THEME.LIGHT); 41 | setThemeState(initialTheme); 42 | }, []); 43 | 44 | const value = { theme, setTheme }; 45 | 46 | return {children}; 47 | }; 48 | 49 | export const useTheme = (): ThemeContextProps => { 50 | const context = useContext(ThemeContext); 51 | if (context === undefined) { 52 | throw new Error('useTheme must be used within a ThemeProvider'); 53 | } 54 | return context; 55 | }; 56 | -------------------------------------------------------------------------------- /packages/client/src/services/api.ts: -------------------------------------------------------------------------------- 1 | const API_BASE_URL = '/api'; 2 | 3 | interface ApiResponse { 4 | success: boolean; 5 | data?: T; 6 | message?: string; 7 | } 8 | 9 | type RequestBody = Record | unknown[]; 10 | 11 | export const api = { 12 | async get(endpoint: string): Promise { 13 | const response = await fetch(`${API_BASE_URL}${endpoint}`); 14 | 15 | const data: ApiResponse = await response.json(); 16 | 17 | if (!response.ok) { 18 | throw new Error(data.message || 'Request failed'); 19 | } 20 | 21 | return (data.data ?? data) as T; 22 | }, 23 | 24 | async post(endpoint: string, body?: RequestBody): Promise { 25 | const response = await fetch(`${API_BASE_URL}${endpoint}`, { 26 | method: 'POST', 27 | headers: { 28 | 'Content-Type': 'application/json', 29 | }, 30 | body: JSON.stringify(body), 31 | }); 32 | const data: ApiResponse = await response.json(); 33 | 34 | if (!data.success) { 35 | throw new Error(data.message || 'Request failed'); 36 | } 37 | 38 | return data.data as T; 39 | }, 40 | 41 | async put(endpoint: string, body: RequestBody): Promise { 42 | const response = await fetch(`${API_BASE_URL}${endpoint}`, { 43 | method: 'PUT', 44 | headers: { 45 | 'Content-Type': 'application/json', 46 | }, 47 | body: JSON.stringify(body), 48 | }); 49 | const data: ApiResponse = await response.json(); 50 | 51 | if (!data.success) { 52 | throw new Error(data.message || 'Request failed'); 53 | } 54 | 55 | return data.data as T; 56 | }, 57 | 58 | async delete(endpoint: string): Promise { 59 | const response = await fetch(`${API_BASE_URL}${endpoint}`, { 60 | method: 'DELETE', 61 | }); 62 | const data: ApiResponse = await response.json(); 63 | 64 | if (!data.success) { 65 | throw new Error(data.message || 'Request failed'); 66 | } 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /packages/client/src/styles/Header.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | justify-content: flex-start; 4 | height: var(--lg-button-size); 5 | background-color: var(--color-surface-low); 6 | border-bottom: 1px solid var(--sidebar-border-color); 7 | 8 | .trash-button { 9 | background: none; 10 | border: none; 11 | cursor: pointer; 12 | font-size: var(--default-icon-size); 13 | padding-inline: calc(2.5 * var(--space-factor)); 14 | color: var(--icon-fill-color); 15 | display: flex; 16 | align-items: center; 17 | 18 | &:hover { 19 | background-color: var(--button-hover-bg); 20 | color: var(--color-primary); 21 | } 22 | } 23 | 24 | .tab-bar-loading { 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | height: var(--lg-button-size); 29 | background-color: var(--color-surface-low); 30 | border-bottom: 1px solid var(--sidebar-border-color); 31 | color: var(--color-gray-70); 32 | font-size: 0.875rem; 33 | padding: 0 calc(4 * var(--space-factor)); 34 | font-style: italic; 35 | } 36 | } 37 | 38 | .tab-bar { 39 | display: flex; 40 | overflow-x: auto; 41 | flex-grow: 1; 42 | white-space: nowrap; 43 | scrollbar-width: thin; 44 | background-color: var(--color-surface-low); 45 | 46 | &::-webkit-scrollbar { 47 | height: 5px; 48 | } 49 | 50 | &::-webkit-scrollbar-track { 51 | background: transparent; 52 | } 53 | 54 | &::-webkit-scrollbar-thumb { 55 | background-color: var(--scrollbar-thumb); 56 | border-radius: 6px; 57 | 58 | &:hover { 59 | background-color: var(--scrollbar-thumb-hover); 60 | } 61 | } 62 | } 63 | 64 | .create-board-button { 65 | background-color: var(--color-surface-low); 66 | border: none; 67 | padding: 0 calc(4 * var(--space-factor)); 68 | height: 100%; 69 | font-size: 1rem; 70 | color: var(--color-gray-70); 71 | display: flex; 72 | align-items: center; 73 | cursor: pointer; 74 | 75 | &:hover { 76 | background-color: var(--button-hover-bg); 77 | color: var(--color-primary); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/client/src/styles/App.scss: -------------------------------------------------------------------------------- 1 | @import './global.scss'; 2 | 3 | .home-page { 4 | display: flex; 5 | flex-direction: column; 6 | min-height: 100vh; 7 | 8 | .content { 9 | flex: 1; 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: center; 13 | align-items: center; 14 | padding: calc(5 * var(--space-factor)); 15 | background-color: var(--color-surface-mid); 16 | 17 | .welcome-container { 18 | max-width: 800px; 19 | background-color: var(--island-bg-color); 20 | border-radius: var(--border-radius-lg); 21 | box-shadow: var(--shadow-island); 22 | padding: calc(10 * var(--space-factor)); 23 | text-align: center; 24 | 25 | h1 { 26 | font-size: 2rem; 27 | margin-bottom: calc(4 * var(--space-factor)); 28 | color: var(--text-primary-color); 29 | } 30 | 31 | p { 32 | font-size: 1.1rem; 33 | color: var(--color-gray-70); 34 | margin-bottom: calc(8 * var(--space-factor)); 35 | } 36 | 37 | .instructions { 38 | text-align: left; 39 | margin-top: calc(8 * var(--space-factor)); 40 | padding-top: calc(5 * var(--space-factor)); 41 | border-top: 1px solid var(--dialog-border-color); 42 | 43 | h2 { 44 | font-size: 1.4rem; 45 | margin-bottom: calc(4 * var(--space-factor)); 46 | color: var(--text-primary-color); 47 | } 48 | 49 | ol { 50 | padding-left: calc(8 * var(--space-factor)); 51 | 52 | li { 53 | margin-bottom: calc(2.5 * var(--space-factor)); 54 | line-height: 1.5; 55 | color: var(--color-gray-70); 56 | } 57 | } 58 | } 59 | } 60 | 61 | .loading-container { 62 | display: flex; 63 | flex-direction: column; 64 | align-items: center; 65 | gap: calc(5 * var(--space-factor)); 66 | 67 | .loading-spinner { 68 | width: 40px; 69 | height: 40px; 70 | border: 4px solid var(--color-gray-20); 71 | border-top: 4px solid var(--color-primary); 72 | border-radius: 50%; 73 | animation: spin 1s linear infinite; 74 | } 75 | 76 | p { 77 | color: var(--color-gray-70); 78 | font-size: 1rem; 79 | } 80 | } 81 | } 82 | } 83 | 84 | @keyframes spin { 85 | 0% { 86 | transform: rotate(0deg); 87 | } 88 | 100% { 89 | transform: rotate(360deg); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /packages/server/src/models/fileModel.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from 'sqlite'; 2 | import { getDb } from '../lib/database'; 3 | import { ExcalidrawBinaryFileData, ExcalidrawFilesMap, StoredFile } from '../types'; 4 | 5 | interface ReplaceAllOptions { 6 | db?: Database; 7 | useTransaction?: boolean; 8 | } 9 | 10 | export class FileModel { 11 | public static async replaceAll( 12 | boardId: number, 13 | files: ExcalidrawFilesMap = {}, 14 | options: ReplaceAllOptions = {} 15 | ): Promise { 16 | const db = options.db ?? (await getDb()); 17 | const shouldManageTransaction = options.useTransaction ?? !options.db; 18 | const now = Date.now(); 19 | 20 | if (shouldManageTransaction) { 21 | await db.run('BEGIN TRANSACTION'); 22 | } 23 | 24 | try { 25 | await db.run('DELETE FROM files WHERE board_id = ?', [boardId]); 26 | 27 | const entries = Object.entries(files); 28 | if (entries.length > 0) { 29 | const sql = `INSERT INTO files (id, board_id, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`; 30 | const stmt = await db.prepare(sql); 31 | 32 | for (const [id, file] of entries) { 33 | const fileId = file.id || id; 34 | const serializedFile = JSON.stringify({ ...file, id: fileId }); 35 | await stmt.run([fileId, boardId, serializedFile, now, now]); 36 | } 37 | 38 | await stmt.finalize(); 39 | } 40 | 41 | if (shouldManageTransaction) { 42 | await db.run('COMMIT'); 43 | } 44 | } catch (error) { 45 | if (shouldManageTransaction) { 46 | await db.run('ROLLBACK'); 47 | } 48 | console.error(`Error replacing files for board ${boardId}:`, error); 49 | throw error; 50 | } 51 | } 52 | 53 | public static async findAllByBoardId(boardId: number): Promise { 54 | const db = await getDb(); 55 | return db.all('SELECT * FROM files WHERE board_id = ?', [boardId]); 56 | } 57 | 58 | public static convertToExcalidrawFiles(files: StoredFile[]): ExcalidrawFilesMap { 59 | return files.reduce((acc, file) => { 60 | try { 61 | const parsed = JSON.parse(file.data) as ExcalidrawBinaryFileData; 62 | if (!parsed.id) { 63 | parsed.id = file.id; 64 | } 65 | acc[file.id] = parsed; 66 | } catch (error) { 67 | console.error('Error parsing file data:', error); 68 | } 69 | return acc; 70 | }, {}); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/server/src/models/libraryModel.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from 'sqlite'; 2 | import { getDb } from '../lib/database'; 3 | import type { ExcalidrawLibraryItems, LibraryPersistedData, LibraryRecord } from '../types'; 4 | import logger from '../utils/logger'; 5 | 6 | interface SaveOptions { 7 | db?: Database; 8 | useTransaction?: boolean; 9 | } 10 | 11 | export class LibraryModel { 12 | public static async getByBoardId(boardId: number): Promise { 13 | const db = await getDb(); 14 | const record = await db.get( 15 | 'SELECT board_id, data, updated_at FROM libraries WHERE board_id = ?', 16 | [boardId] 17 | ); 18 | 19 | if (!record) { 20 | return null; 21 | } 22 | 23 | try { 24 | const parsed = JSON.parse(record.data) as unknown; 25 | if (!Array.isArray(parsed)) { 26 | logger.warn( 27 | `Library data for board ${boardId} is not an array. Returning empty library instead.` 28 | ); 29 | return { libraryItems: [] }; 30 | } 31 | 32 | return { libraryItems: parsed as ExcalidrawLibraryItems }; 33 | } catch (error) { 34 | logger.error(`Failed to parse library data for board ${boardId}:`, error); 35 | return { libraryItems: [] }; 36 | } 37 | } 38 | 39 | public static async save( 40 | boardId: number, 41 | libraryItems: ExcalidrawLibraryItems, 42 | options: SaveOptions = {} 43 | ): Promise { 44 | const db = options.db ?? (await getDb()); 45 | const shouldManageTransaction = options.useTransaction ?? !options.db; 46 | const now = Date.now(); 47 | 48 | const payload = JSON.stringify(libraryItems ?? []); 49 | 50 | if (shouldManageTransaction) { 51 | await db.run('BEGIN TRANSACTION'); 52 | } 53 | 54 | try { 55 | await db.run( 56 | ` 57 | INSERT INTO libraries (board_id, data, updated_at) 58 | VALUES (?, ?, ?) 59 | ON CONFLICT(board_id) DO UPDATE SET 60 | data = excluded.data, 61 | updated_at = excluded.updated_at 62 | `, 63 | [boardId, payload, now] 64 | ); 65 | 66 | if (shouldManageTransaction) { 67 | await db.run('COMMIT'); 68 | } 69 | } catch (error) { 70 | if (shouldManageTransaction) { 71 | await db.run('ROLLBACK'); 72 | } 73 | logger.error(`Failed to save library for board ${boardId}:`, error); 74 | throw error; 75 | } 76 | } 77 | 78 | public static async deleteByBoardId(boardId: number): Promise { 79 | const db = await getDb(); 80 | await db.run('DELETE FROM libraries WHERE board_id = ?', [boardId]); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/server/src/controllers/libraryController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { BoardModel } from '../models/boardModel'; 3 | import { LibraryModel } from '../models/libraryModel'; 4 | import type { LibraryPersistedData } from '../types'; 5 | import logger from '../utils/logger'; 6 | 7 | export const libraryController = { 8 | async getByBoardId(req: Request<{ boardId: string }>, res: Response) { 9 | try { 10 | const { boardId: boardIdParam } = req.params; 11 | const boardId = parseInt(boardIdParam, 10); 12 | 13 | if (isNaN(boardId)) { 14 | return res.status(400).json({ success: false, message: 'Invalid board ID format' }); 15 | } 16 | 17 | const board = await BoardModel.findById(boardId); 18 | if (!board) { 19 | return res.status(404).json({ success: false, message: 'Board not found' }); 20 | } 21 | 22 | const libraryData = await LibraryModel.getByBoardId(boardId); 23 | 24 | return res.status(200).json({ 25 | success: true, 26 | data: libraryData ?? { libraryItems: [] }, 27 | }); 28 | } catch (error) { 29 | logger.error(`Error getting library for board ${req.params.boardId}:`, error); 30 | return res.status(500).json({ 31 | success: false, 32 | message: 'Failed to get library data', 33 | }); 34 | } 35 | }, 36 | 37 | async save(req: Request<{ boardId: string }, unknown, LibraryPersistedData>, res: Response) { 38 | try { 39 | const { boardId: boardIdParam } = req.params; 40 | const boardId = parseInt(boardIdParam, 10); 41 | 42 | if (isNaN(boardId)) { 43 | return res.status(400).json({ success: false, message: 'Invalid board ID format' }); 44 | } 45 | 46 | const { libraryItems } = req.body ?? {}; 47 | 48 | if (!Array.isArray(libraryItems)) { 49 | return res.status(400).json({ 50 | success: false, 51 | message: 'Invalid payload: libraryItems must be an array', 52 | }); 53 | } 54 | 55 | const board = await BoardModel.findById(boardId); 56 | if (!board) { 57 | return res.status(404).json({ success: false, message: 'Board not found' }); 58 | } 59 | 60 | await LibraryModel.save(boardId, libraryItems); 61 | await BoardModel.update(boardId, {}); 62 | 63 | return res.status(200).json({ 64 | success: true, 65 | message: `Library saved for board ${boardId}`, 66 | }); 67 | } catch (error) { 68 | logger.error(`Error saving library for board ${req.params.boardId}:`, error); 69 | return res.status(500).json({ 70 | success: false, 71 | message: 'Failed to save library data', 72 | }); 73 | } 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /packages/server/src/models/boardModel.ts: -------------------------------------------------------------------------------- 1 | import { getDb } from '../lib/database'; 2 | import { Board, BoardStatus, UpdateBoardInput } from '../types'; 3 | 4 | export class BoardModel { 5 | public static async create(): Promise { 6 | const db = await getDb(); 7 | const result = await db.get('INSERT INTO boards DEFAULT VALUES RETURNING *'); 8 | 9 | if (!result) { 10 | throw new Error('Failed to create board'); 11 | } 12 | 13 | return result; 14 | } 15 | 16 | public static async findById(id: number): Promise { 17 | const db = await getDb(); 18 | return db.get('SELECT * FROM boards WHERE id = ?', [id]); 19 | } 20 | 21 | public static async findAllActive(): Promise { 22 | const db = await getDb(); 23 | const result = await db.all('SELECT * FROM boards WHERE status = ? ORDER BY id ASC', [ 24 | BoardStatus.ACTIVE, 25 | ]); 26 | return result; 27 | } 28 | 29 | public static async findAllDeleted(): Promise { 30 | const db = await getDb(); 31 | const result = await db.all( 32 | 'SELECT * FROM boards WHERE status = ? ORDER BY updated_at DESC', 33 | [BoardStatus.DELETED] 34 | ); 35 | return result; 36 | } 37 | 38 | public static async update(id: number, input: UpdateBoardInput = {}): Promise { 39 | const db = await getDb(); 40 | const board = await this.findById(id); 41 | 42 | if (!board) { 43 | return undefined; 44 | } 45 | 46 | const now = Date.now(); 47 | const updates: Partial> = { 48 | updated_at: now, 49 | }; 50 | 51 | if (input.name !== undefined) { 52 | updates.name = input.name; 53 | } 54 | 55 | if (input.status !== undefined) { 56 | updates.status = input.status; 57 | } 58 | 59 | const setClause = Object.keys(updates) 60 | .map(key => `${key} = ?`) 61 | .join(', '); 62 | const values = [...Object.values(updates), id]; 63 | 64 | await db.run(`UPDATE boards SET ${setClause} WHERE id = ?`, values); 65 | 66 | return { 67 | ...board, 68 | ...updates, 69 | }; 70 | } 71 | 72 | public static async moveToTrash(id: number): Promise { 73 | return this.update(id, { status: BoardStatus.DELETED }); 74 | } 75 | 76 | public static async restoreFromTrash(id: number): Promise { 77 | return this.update(id, { status: BoardStatus.ACTIVE }); 78 | } 79 | 80 | public static async permanentlyDelete(id: number): Promise { 81 | const db = await getDb(); 82 | await db.run('DELETE FROM boards WHERE id = ?', [id]); 83 | } 84 | 85 | public static async delete(id: number): Promise { 86 | return this.permanentlyDelete(id); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS base 2 | 3 | # Install pnpm 4 | ENV PNPM_HOME="/pnpm" 5 | ENV PATH="$PNPM_HOME:$PATH" 6 | RUN corepack enable 7 | 8 | # Setup common build stage 9 | FROM base AS builder 10 | WORKDIR /app 11 | 12 | # Install build dependencies for sqlite3 13 | RUN apk add --no-cache python3 make g++ 14 | 15 | # Copy root workspace files 16 | COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ 17 | 18 | # Copy both packages 19 | COPY packages/server ./packages/server/ 20 | COPY packages/client ./packages/client/ 21 | 22 | # Install dependencies 23 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 24 | 25 | # Build both applications 26 | RUN pnpm --filter "@excalidraw-persist/server" build 27 | RUN pnpm --filter "@excalidraw-persist/client" build 28 | 29 | # Setup server production stage 30 | FROM base AS server 31 | WORKDIR /app 32 | 33 | # Install build dependencies for sqlite3 34 | RUN apk add --no-cache python3 make g++ 35 | 36 | # Copy package.json files 37 | COPY --from=builder /app/package.json ./ 38 | COPY --from=builder /app/pnpm-lock.yaml ./ 39 | COPY --from=builder /app/pnpm-workspace.yaml ./ 40 | COPY --from=builder /app/packages/server/package.json ./packages/server/ 41 | 42 | # Install production dependencies only and rebuild sqlite3 43 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --prod 44 | 45 | # Copy built JavaScript files 46 | COPY --from=builder /app/packages/server/dist ./packages/server/dist 47 | 48 | # Create src directory structure and copy schema file 49 | RUN mkdir -p /app/src/lib 50 | COPY --from=builder /app/packages/server/src/lib/schema.sql ./src/lib/ 51 | 52 | # Create directory for SQLite database 53 | RUN mkdir -p /app/data 54 | 55 | # Setup client files 56 | FROM nginx:alpine AS client 57 | COPY --from=builder /app/packages/client/dist /usr/share/nginx/html 58 | COPY packages/client/nginx.conf /etc/nginx/http.d/default.conf 59 | 60 | # Final combined image 61 | FROM alpine:latest 62 | 63 | # Install Node.js and Nginx 64 | RUN apk add --no-cache nodejs nginx supervisor 65 | 66 | # Set up directories 67 | RUN mkdir -p /app /app/data /var/log/supervisor 68 | 69 | # Copy supervisor configuration 70 | COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf 71 | 72 | # Copy server files 73 | COPY --from=server /app /app 74 | # Copy client files 75 | COPY --from=client /usr/share/nginx/html /usr/share/nginx/html 76 | COPY --from=client /etc/nginx/http.d/default.conf /etc/nginx/http.d/default.conf 77 | 78 | # Set environment variables 79 | ENV PORT=4000 80 | ENV NODE_ENV=production 81 | ENV DB_PATH=/app/data/database.sqlite 82 | 83 | # Expose ports 84 | EXPOSE 80 4000 85 | 86 | # Start supervisord to manage both services 87 | CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] 88 | -------------------------------------------------------------------------------- /packages/client/vite.config.ts.timestamp-1742747302607-98de530cc8682.mjs: -------------------------------------------------------------------------------- 1 | // vite.config.ts 2 | import { defineConfig } from "file:///Users/ozenc/Developer/excalidraw-persist/node_modules/.pnpm/vite@4.4.5_sass@1.69.5/node_modules/vite/dist/node/index.js"; 3 | import react from "file:///Users/ozenc/Developer/excalidraw-persist/node_modules/.pnpm/@vitejs+plugin-react@4.0.3_vite@4.4.5/node_modules/@vitejs/plugin-react/dist/index.mjs"; 4 | import path from "path"; 5 | var __vite_injected_original_dirname = "/Users/ozenc/Developer/excalidraw-persist/packages/client"; 6 | var vite_config_default = defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__vite_injected_original_dirname, "./src") 11 | } 12 | }, 13 | server: { 14 | port: 3e3, 15 | proxy: { 16 | "/api": { 17 | target: "http://localhost:4000", 18 | changeOrigin: true 19 | }, 20 | "/socket.io": { 21 | target: "http://localhost:4000", 22 | ws: true 23 | } 24 | } 25 | } 26 | }); 27 | export { 28 | vite_config_default as default 29 | }; 30 | //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvb3plbmMvRGV2ZWxvcGVyL2V4Y2FsaWRyYXctcGVyc2lzdC9wYWNrYWdlcy9jbGllbnRcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9vemVuYy9EZXZlbG9wZXIvZXhjYWxpZHJhdy1wZXJzaXN0L3BhY2thZ2VzL2NsaWVudC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMvb3plbmMvRGV2ZWxvcGVyL2V4Y2FsaWRyYXctcGVyc2lzdC9wYWNrYWdlcy9jbGllbnQvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJztcbmltcG9ydCByZWFjdCBmcm9tICdAdml0ZWpzL3BsdWdpbi1yZWFjdCc7XG5pbXBvcnQgcGF0aCBmcm9tICdwYXRoJztcblxuLy8gaHR0cHM6Ly92aXRlanMuZGV2L2NvbmZpZy9cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XG4gIHBsdWdpbnM6IFtyZWFjdCgpXSxcbiAgcmVzb2x2ZToge1xuICAgIGFsaWFzOiB7XG4gICAgICAnQCc6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsICcuL3NyYycpLFxuICAgIH0sXG4gIH0sXG4gIHNlcnZlcjoge1xuICAgIHBvcnQ6IDMwMDAsXG4gICAgcHJveHk6IHtcbiAgICAgICcvYXBpJzoge1xuICAgICAgICB0YXJnZXQ6ICdodHRwOi8vbG9jYWxob3N0OjQwMDAnLFxuICAgICAgICBjaGFuZ2VPcmlnaW46IHRydWUsXG4gICAgICB9LFxuICAgICAgJy9zb2NrZXQuaW8nOiB7XG4gICAgICAgIHRhcmdldDogJ2h0dHA6Ly9sb2NhbGhvc3Q6NDAwMCcsXG4gICAgICAgIHdzOiB0cnVlLFxuICAgICAgfSxcbiAgICB9LFxuICB9LFxufSk7XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQTZWLFNBQVMsb0JBQW9CO0FBQzFYLE9BQU8sV0FBVztBQUNsQixPQUFPLFVBQVU7QUFGakIsSUFBTSxtQ0FBbUM7QUFLekMsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDMUIsU0FBUyxDQUFDLE1BQU0sQ0FBQztBQUFBLEVBQ2pCLFNBQVM7QUFBQSxJQUNQLE9BQU87QUFBQSxNQUNMLEtBQUssS0FBSyxRQUFRLGtDQUFXLE9BQU87QUFBQSxJQUN0QztBQUFBLEVBQ0Y7QUFBQSxFQUNBLFFBQVE7QUFBQSxJQUNOLE1BQU07QUFBQSxJQUNOLE9BQU87QUFBQSxNQUNMLFFBQVE7QUFBQSxRQUNOLFFBQVE7QUFBQSxRQUNSLGNBQWM7QUFBQSxNQUNoQjtBQUFBLE1BQ0EsY0FBYztBQUFBLFFBQ1osUUFBUTtBQUFBLFFBQ1IsSUFBSTtBQUFBLE1BQ047QUFBQSxJQUNGO0FBQUEsRUFDRjtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg== 31 | -------------------------------------------------------------------------------- /packages/server/src/lib/database.ts: -------------------------------------------------------------------------------- 1 | import sqlite3 from 'sqlite3'; 2 | import * as sqlite from 'sqlite'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import { dbConfig } from '../config'; 6 | import logger from '../utils/logger'; 7 | 8 | class Database { 9 | private static instance: Database; 10 | private db: sqlite.Database | null = null; 11 | 12 | private constructor() {} 13 | 14 | public static getInstance(): Database { 15 | if (!Database.instance) { 16 | Database.instance = new Database(); 17 | } 18 | return Database.instance; 19 | } 20 | 21 | public async open(): Promise { 22 | if (this.db) { 23 | return this.db; 24 | } 25 | 26 | try { 27 | const dbDir = path.dirname(dbConfig.dbPath); 28 | if (!fs.existsSync(dbDir)) { 29 | fs.mkdirSync(dbDir, { recursive: true }); 30 | logger.info(`Created database directory: ${dbDir}`); 31 | } 32 | 33 | logger.info(`Opening database at ${dbConfig.dbPath}`); 34 | const openedDb = await sqlite.open({ 35 | filename: dbConfig.dbPath, 36 | driver: sqlite3.Database, 37 | }); 38 | 39 | await openedDb.run('PRAGMA foreign_keys = ON'); 40 | logger.info('Foreign key support enabled.'); 41 | 42 | this.db = openedDb; 43 | return this.db; 44 | } catch (error) { 45 | logger.error('Error opening database:', error); 46 | throw error; 47 | } 48 | } 49 | 50 | public async initializeSchema(): Promise { 51 | try { 52 | const currentDb = await this.getDb(); 53 | 54 | const schemaPath = dbConfig.schemaPath; 55 | if (!fs.existsSync(schemaPath)) { 56 | throw new Error(`Schema file not found at ${schemaPath}`); 57 | } 58 | const schema = fs.readFileSync(schemaPath, 'utf8'); 59 | await currentDb.exec(schema); 60 | logger.info('Database schema initialized successfully.'); 61 | } catch (error) { 62 | logger.error('Error initializing database schema:', error); 63 | throw error; 64 | } 65 | } 66 | 67 | public async close(): Promise { 68 | if (!this.db) { 69 | return; 70 | } 71 | try { 72 | await this.db.close(); 73 | this.db = null; 74 | logger.info('Database connection closed successfully.'); 75 | } catch (error) { 76 | logger.error('Error closing database:', error); 77 | throw error; 78 | } 79 | } 80 | 81 | public async getDb(): Promise { 82 | if (!this.db) { 83 | await this.open(); 84 | } 85 | return this.db!; 86 | } 87 | } 88 | 89 | const databaseInstance = Database.getInstance(); 90 | 91 | export const getDb = (): Promise => databaseInstance.getDb(); 92 | 93 | export const openDatabase = (): Promise => databaseInstance.open(); 94 | export const initializeDatabase = (): Promise => databaseInstance.initializeSchema(); 95 | export const closeDatabase = (): Promise => databaseInstance.close(); 96 | -------------------------------------------------------------------------------- /packages/client/src/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import Utils from '../utils'; 2 | import { useTheme } from '../contexts/ThemeProvider'; 3 | import { THEME } from '@excalidraw/excalidraw'; 4 | 5 | type AvailableIcons = 'trash' | 'close' | 'plus'; 6 | 7 | interface IconComponentProps { 8 | className?: string; 9 | } 10 | 11 | const Trash = ({ className }: IconComponentProps) => { 12 | return ( 13 | 20 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | const Close = ({ className }: IconComponentProps) => { 38 | return ( 39 | 46 | 53 | 54 | 55 | 56 | 57 | ); 58 | }; 59 | 60 | const Plus = ({ className }: IconComponentProps) => { 61 | return ( 62 | 69 | 76 | 77 | 78 | 79 | 80 | ); 81 | }; 82 | 83 | interface IconProps { 84 | name: AvailableIcons; 85 | className?: string; 86 | } 87 | 88 | const Icon = ({ name, className }: IconProps) => { 89 | const { theme } = useTheme(); 90 | 91 | const iconClassName = `${className || ''} ${ 92 | theme === THEME.LIGHT ? 'text-gray-800' : 'text-gray-200' 93 | }`; 94 | 95 | switch (name) { 96 | case 'trash': 97 | return ; 98 | case 'close': 99 | return ; 100 | case 'plus': 101 | return ; 102 | default: 103 | return Utils.exhaustiveMatchingGuard(name); 104 | } 105 | }; 106 | 107 | export default Icon; 108 | -------------------------------------------------------------------------------- /packages/server/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export enum BoardStatus { 2 | ACTIVE = 'ACTIVE', 3 | DELETED = 'DELETED', 4 | } 5 | 6 | export interface Board { 7 | id: number; 8 | name: string; 9 | status: 'ACTIVE' | 'DELETED'; 10 | created_at: number; 11 | updated_at: number; 12 | } 13 | 14 | export interface Element { 15 | id: string; 16 | board_id: number; 17 | data: string; // JSON string 18 | element_index: string; 19 | type: string; 20 | created_at: number; 21 | updated_at: number; 22 | is_deleted: boolean; 23 | } 24 | 25 | export interface StoredFile { 26 | id: string; 27 | board_id: number; 28 | data: string; 29 | created_at: number; 30 | updated_at: number; 31 | } 32 | 33 | export interface LibraryRecord { 34 | board_id: number; 35 | data: string; 36 | updated_at: number; 37 | } 38 | 39 | export interface ExcalidrawElement { 40 | id: string; 41 | type: string; 42 | x: number; 43 | y: number; 44 | width?: number; 45 | height?: number; 46 | angle: number; 47 | strokeColor: string; 48 | backgroundColor: string; 49 | fillStyle: string; 50 | strokeWidth: number; 51 | strokeStyle: string; 52 | roughness: number; 53 | opacity: number; 54 | groupIds: string[]; 55 | frameId?: string | null; 56 | index?: string; // Index from Excalidraw element (a0, a1, etc.) 57 | seed: number; 58 | version: number; 59 | versionNonce: number; 60 | isDeleted: boolean; 61 | boundElements?: BoundElement[] | null; 62 | updated: number; 63 | link?: string | null; 64 | locked: boolean; 65 | fileId?: string; // For image elements 66 | status?: string; 67 | 68 | // Additional optional properties that may be present 69 | fontSize?: number; 70 | fontFamily?: string; 71 | text?: string; 72 | textAlign?: string; 73 | verticalAlign?: string; 74 | baseline?: number; 75 | containerId?: string; 76 | points?: readonly [number, number][]; 77 | customData?: Record; 78 | 79 | // For other unknown properties 80 | [key: string]: unknown; 81 | } 82 | 83 | export interface ExcalidrawBinaryFileData { 84 | id: string; 85 | dataURL: string; 86 | mimeType: string; 87 | created?: number; 88 | lastRetrieved?: number; 89 | size?: number; 90 | type?: string; 91 | width?: number; 92 | height?: number; 93 | [key: string]: unknown; 94 | } 95 | 96 | export type ExcalidrawFilesMap = Record; 97 | 98 | export interface ExcalidrawSceneData { 99 | elements: ExcalidrawElement[]; 100 | files: ExcalidrawFilesMap; 101 | } 102 | 103 | export interface ExcalidrawLibraryItem { 104 | id: string; 105 | status: 'published' | 'unpublished'; 106 | elements: ExcalidrawElement[]; 107 | created: number; 108 | name?: string; 109 | error?: string; 110 | } 111 | 112 | export type ExcalidrawLibraryItems = ExcalidrawLibraryItem[]; 113 | 114 | export interface LibraryPersistedData { 115 | libraryItems: ExcalidrawLibraryItems; 116 | } 117 | 118 | export type UpdateBoardInput = { 119 | name?: string; 120 | status?: BoardStatus; 121 | }; 122 | 123 | export interface BoundElement { 124 | id: string; 125 | type: string; 126 | } 127 | -------------------------------------------------------------------------------- /packages/client/src/styles/TrashPopup.scss: -------------------------------------------------------------------------------- 1 | .trash-popup { 2 | position: fixed; 3 | top: 50%; 4 | left: 50%; 5 | transform: translate(-50%, -50%); 6 | background: var(--island-bg-color); 7 | border-radius: var(--border-radius-lg); 8 | border: 1px solid var(--dialog-border-color); 9 | box-shadow: var(--modal-shadow); 10 | width: 90%; 11 | max-width: 500px; 12 | max-height: 70vh; 13 | overflow-y: auto; 14 | z-index: var(--zIndex-modal); 15 | display: flex; 16 | flex-direction: column; 17 | 18 | &-header { 19 | display: flex; 20 | justify-content: space-between; 21 | align-items: center; 22 | padding: calc(3 * var(--space-factor)) calc(4 * var(--space-factor)); 23 | border-bottom: 1px solid var(--dialog-border-color); 24 | flex-shrink: 0; 25 | 26 | h2 { 27 | margin: 0; 28 | font-size: 1.25rem; 29 | font-weight: 700; 30 | color: var(--text-primary-color); 31 | } 32 | } 33 | 34 | &-close { 35 | background: none; 36 | border: none; 37 | font-size: 1.5rem; 38 | cursor: pointer; 39 | padding: calc(1.5 * var(--space-factor)); 40 | color: var(--icon-fill-color); 41 | opacity: 1; 42 | transition: opacity 0.2s; 43 | line-height: 0; 44 | 45 | &:hover { 46 | color: var(--color-gray-60); 47 | } 48 | } 49 | 50 | &-content { 51 | padding: calc(2 * var(--space-factor)) calc(4 * var(--space-factor)); 52 | overflow-y: auto; 53 | flex-grow: 1; 54 | } 55 | 56 | &-item { 57 | display: flex; 58 | justify-content: space-between; 59 | align-items: center; 60 | padding: calc(3 * var(--space-factor)) calc(2 * var(--space-factor)); 61 | border-bottom: 1px solid var(--dialog-border-color); 62 | 63 | &:last-child { 64 | border-bottom: none; 65 | } 66 | 67 | div:first-child { 68 | flex-grow: 1; 69 | margin-right: calc(4 * var(--space-factor)); 70 | } 71 | 72 | &-name { 73 | margin: 0 0 calc(0.5 * var(--space-factor)) 0; 74 | font-size: 0.875rem; 75 | font-weight: 600; 76 | color: var(--text-primary-color); 77 | } 78 | 79 | &-date { 80 | margin: 0; 81 | font-size: 0.75rem; 82 | color: var(--color-gray-70); 83 | } 84 | 85 | &-actions { 86 | display: flex; 87 | gap: calc(2 * var(--space-factor)); 88 | flex-shrink: 0; 89 | } 90 | 91 | &-action-button { 92 | padding: calc(1.25 * var(--space-factor)) calc(2.5 * var(--space-factor)); 93 | border: none; 94 | border-radius: var(--border-radius-md); 95 | cursor: pointer; 96 | transition: 97 | background-color 0.2s, 98 | color 0.2s; 99 | background-color: transparent; 100 | font-size: 0.875rem; 101 | font-weight: 500; 102 | 103 | &:hover { 104 | // underline 105 | text-decoration: underline; 106 | } 107 | 108 | &.button-restore { 109 | color: var(--color-success-contrast); 110 | &:hover { 111 | background-color: rgba(var(--rgb-success-contrast, 101 187 106), 0.1); 112 | } 113 | } 114 | 115 | &.button-delete { 116 | color: var(--color-danger); 117 | &:hover { 118 | background-color: rgba(var(--rgb-danger, 219 105 101), 0.1); 119 | } 120 | } 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /packages/server/src/models/elementModel.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from 'sqlite'; 2 | import { getDb } from '../lib/database'; 3 | import { Element, ExcalidrawElement } from '../types'; 4 | 5 | interface ReplaceAllOptions { 6 | db?: Database; 7 | useTransaction?: boolean; 8 | } 9 | 10 | export class ElementModel { 11 | public static async replaceAll( 12 | boardId: number, 13 | elements: ExcalidrawElement[], 14 | options: ReplaceAllOptions = {} 15 | ): Promise { 16 | const db = options.db ?? (await getDb()); 17 | const shouldManageTransaction = options.useTransaction ?? !options.db; 18 | const now = Date.now(); 19 | 20 | if (shouldManageTransaction) { 21 | await db.run('BEGIN TRANSACTION'); 22 | } 23 | 24 | try { 25 | await db.run('DELETE FROM elements WHERE board_id = ?', [boardId]); 26 | 27 | if (elements.length > 0) { 28 | const sql = `INSERT INTO elements 29 | (id, board_id, data, element_index, type, created_at, updated_at, is_deleted) 30 | VALUES 31 | (?, ?, ?, ?, ?, ?, ?, ?)`; 32 | 33 | const stmt = await db.prepare(sql); 34 | 35 | for (const element of elements) { 36 | const dbElementData = [ 37 | element.id, 38 | boardId, 39 | JSON.stringify(element), 40 | element.index || '', 41 | element.type, 42 | now, 43 | now, 44 | element.isDeleted || false ? 1 : 0, 45 | ]; 46 | await stmt.run(dbElementData); 47 | } 48 | await stmt.finalize(); 49 | } 50 | 51 | if (shouldManageTransaction) { 52 | await db.run('COMMIT'); 53 | } 54 | } catch (error) { 55 | if (shouldManageTransaction) { 56 | await db.run('ROLLBACK'); 57 | } 58 | console.error(`Error replacing elements for board ${boardId}:`, error); 59 | throw error; 60 | } 61 | } 62 | 63 | public static async findById(boardId: number, id: string): Promise { 64 | const db = await getDb(); 65 | const result = await db.get( 66 | 'SELECT * FROM elements WHERE board_id = ? AND id = ?', 67 | [boardId, id] 68 | ); 69 | return result; 70 | } 71 | 72 | public static async findAllByBoardId(boardId: number): Promise { 73 | const db = await getDb(); 74 | 75 | const result = await db.all( 76 | 'SELECT * FROM elements WHERE board_id = ? AND is_deleted = 0 ORDER BY element_index ASC', 77 | [boardId] 78 | ); 79 | 80 | return result; 81 | } 82 | 83 | public static async markAsDeleted(boardId: number, id: string): Promise { 84 | const db = await getDb(); 85 | const now = Date.now(); 86 | 87 | const element = await this.findById(boardId, id); 88 | 89 | if (!element) { 90 | return; 91 | } 92 | 93 | const elementData = JSON.parse(element.data) as ExcalidrawElement; 94 | elementData.isDeleted = true; 95 | 96 | await db.run( 97 | 'UPDATE elements SET data = ?, is_deleted = 1, updated_at = ? WHERE id = ? AND board_id = ?', 98 | [JSON.stringify(elementData), now, id, boardId] 99 | ); 100 | } 101 | 102 | public static async permanentlyDelete(boardId: number, id: string): Promise { 103 | const db = await getDb(); 104 | await db.run('DELETE FROM elements WHERE board_id = ? AND id = ?', [boardId, id]); 105 | } 106 | 107 | public static convertToExcalidrawElements(elements: Element[]): ExcalidrawElement[] { 108 | return elements 109 | .map(element => { 110 | try { 111 | return JSON.parse(element.data) as ExcalidrawElement; 112 | } catch (error) { 113 | console.error('Error parsing element data:', error); 114 | return null; 115 | } 116 | }) 117 | .filter((element): element is ExcalidrawElement => element !== null); 118 | } 119 | 120 | public static async countByBoardId( 121 | boardId: number, 122 | includeDeleted: boolean = false 123 | ): Promise { 124 | const db = await getDb(); 125 | 126 | let query = 'SELECT COUNT(*) as count FROM elements WHERE board_id = ?'; 127 | const params: number[] = [boardId]; 128 | 129 | if (!includeDeleted) { 130 | query += ' AND is_deleted = 0'; 131 | } 132 | 133 | const result = await db.get<{ count: number }>(query, params); 134 | return result?.count || 0; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /packages/server/src/controllers/elementController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { ElementModel } from '../models/elementModel'; 3 | import { BoardModel } from '../models/boardModel'; 4 | import { FileModel } from '../models/fileModel'; 5 | import { getDb } from '../lib/database'; 6 | import { ExcalidrawElement, ExcalidrawFilesMap, ExcalidrawSceneData } from '../types'; 7 | import logger from '../utils/logger'; 8 | 9 | export const elementController = { 10 | async getByBoardId(req: Request<{ boardId: string }>, res: Response) { 11 | try { 12 | const { boardId: boardIdParam } = req.params; 13 | const boardId = parseInt(boardIdParam, 10); 14 | if (isNaN(boardId)) { 15 | return res.status(400).json({ success: false, message: 'Invalid board ID format' }); 16 | } 17 | 18 | const board = await BoardModel.findById(boardId); 19 | 20 | if (!board) { 21 | return res.status(404).json({ 22 | success: false, 23 | message: 'Board not found', 24 | }); 25 | } 26 | 27 | const elements = await ElementModel.findAllByBoardId(boardId); 28 | const files = await FileModel.findAllByBoardId(boardId); 29 | 30 | const excalidrawElements = ElementModel.convertToExcalidrawElements(elements); 31 | const excalidrawFiles = FileModel.convertToExcalidrawFiles(files); 32 | 33 | return res.status(200).json({ 34 | success: true, 35 | data: { 36 | elements: excalidrawElements, 37 | files: excalidrawFiles, 38 | }, 39 | }); 40 | } catch (error) { 41 | logger.error(`Error getting elements for board ${req.params.boardId}:`, error); 42 | return res.status(500).json({ 43 | success: false, 44 | message: 'Failed to get elements', 45 | }); 46 | } 47 | }, 48 | 49 | async replaceAll( 50 | req: Request<{ boardId: string }, unknown, ExcalidrawSceneData | ExcalidrawElement[]>, 51 | res: Response 52 | ) { 53 | try { 54 | const { boardId: boardIdParam } = req.params; 55 | const body = req.body; 56 | const boardId = parseInt(boardIdParam, 10); 57 | if (isNaN(boardId)) { 58 | return res.status(400).json({ success: false, message: 'Invalid board ID format' }); 59 | } 60 | 61 | let elements: ExcalidrawElement[] = []; 62 | let files: ExcalidrawFilesMap = {}; 63 | 64 | if (Array.isArray(body)) { 65 | elements = body; 66 | } else if (body && typeof body === 'object') { 67 | const scenePayload = body as Partial; 68 | if (!scenePayload.elements || !Array.isArray(scenePayload.elements)) { 69 | return res.status(400).json({ 70 | success: false, 71 | message: 'Invalid scene payload: elements must be an array', 72 | }); 73 | } 74 | elements = scenePayload.elements; 75 | 76 | if ( 77 | scenePayload.files && 78 | typeof scenePayload.files === 'object' && 79 | !Array.isArray(scenePayload.files) 80 | ) { 81 | files = { ...scenePayload.files } as ExcalidrawFilesMap; 82 | } 83 | } else { 84 | return res.status(400).json({ 85 | success: false, 86 | message: 'Invalid request payload', 87 | }); 88 | } 89 | 90 | const board = await BoardModel.findById(boardId); 91 | if (!board) { 92 | return res.status(404).json({ 93 | success: false, 94 | message: 'Board not found', 95 | }); 96 | } 97 | 98 | const db = await getDb(); 99 | await db.run('BEGIN TRANSACTION'); 100 | 101 | try { 102 | await ElementModel.replaceAll(boardId, elements, { db, useTransaction: false }); 103 | await FileModel.replaceAll(boardId, files, { db, useTransaction: false }); 104 | await db.run('COMMIT'); 105 | } catch (transactionError) { 106 | await db.run('ROLLBACK'); 107 | throw transactionError; 108 | } 109 | 110 | await BoardModel.update(boardId, {}); 111 | 112 | return res.status(200).json({ 113 | success: true, 114 | message: `Elements replaced for board ${boardId}`, 115 | }); 116 | } catch (error) { 117 | logger.error(`Error replacing elements for board ${req.params.boardId}:`, error); 118 | return res.status(500).json({ 119 | success: false, 120 | message: 'Failed to replace elements', 121 | }); 122 | } 123 | }, 124 | }; 125 | -------------------------------------------------------------------------------- /packages/client/src/styles/global.scss: -------------------------------------------------------------------------------- 1 | @import './reset.scss'; 2 | 3 | :root { 4 | --ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI, Roboto, Helvetica, 5 | Arial, sans-serif; 6 | --space-factor: 0.25rem; 7 | --border-radius-md: 0.375rem; 8 | --border-radius-lg: 0.5rem; 9 | --default-icon-size: 1rem; 10 | --lg-icon-size: 1rem; 11 | --default-button-size: 2rem; 12 | --lg-button-size: 2.5rem; 13 | 14 | /* Colors */ 15 | --color-primary: #6965db; 16 | --color-on-surface: #1b1b1f; 17 | --text-primary-color: var(--color-on-surface); 18 | --icon-fill-color: var(--color-on-surface); 19 | --default-bg-color: #ffffff; 20 | --island-bg-color: #ffffff; 21 | --link-color: #1c7ed6; 22 | --color-surface-high: #f1f0ff; 23 | --color-surface-mid: #f2f2f7; 24 | --color-surface-low: #ececf4; 25 | --color-surface-lowest: #ffffff; 26 | --color-icon-white: #ffffff; 27 | --color-gray-20: #ebebeb; 28 | --color-gray-60: #7a7a7a; 29 | --color-gray-70: #5c5c5c; 30 | --color-danger: #db6965; 31 | --color-success-contrast: #65bb6a; 32 | --dialog-border-color: var(--color-gray-20); 33 | --sidebar-border-color: var(--color-surface-high); 34 | --button-gray-2: #ced4da; 35 | --button-gray-3: #adb5bd; 36 | --button-hover-bg: var(--color-surface-high); 37 | 38 | /* Scrollbar */ 39 | --scrollbar-thumb: var(--button-gray-2); 40 | --scrollbar-thumb-hover: var(--button-gray-3); 41 | 42 | /* Shadows */ 43 | --shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17), 44 | 0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgba(0, 0, 0, 0.05); 45 | --modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07), 46 | 0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198), 47 | 0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275), 0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035), 48 | 0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725), 49 | 0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802); 50 | 51 | /* Z-Index */ 52 | --zIndex-modal: 1000; 53 | } 54 | 55 | .theme--dark { 56 | --theme-filter: invert(93%) hue-rotate(180deg); 57 | 58 | /* Colors */ 59 | --color-primary: #a8a5ff; 60 | --color-on-surface: #e3e3e8; 61 | --text-primary-color: var(--color-on-surface); 62 | --icon-fill-color: var(--color-on-surface); 63 | --default-bg-color: #121212; 64 | --island-bg-color: #232329; 65 | --link-color: #4dabf7; 66 | --color-surface-high: hsl(245, 10%, 21%); 67 | --color-surface-mid: hsl(240 6% 10%); 68 | --color-surface-low: hsl(240, 8%, 15%); 69 | --color-surface-lowest: hsl(0, 0%, 7%); 70 | --color-icon-white: var(--color-gray-90); 71 | --color-gray-20: #272727; 72 | --color-gray-60: #7a7a7a; 73 | --color-gray-70: #999999; 74 | --color-gray-80: #3d3d3d; 75 | --color-gray-90: #1e1e1e; 76 | --color-danger: #ffa8a5; 77 | --color-success-contrast: #69db7c; 78 | --dialog-border-color: var(--color-gray-80); 79 | --sidebar-border-color: var(--color-surface-high); 80 | --button-gray-2: #272727; 81 | --button-gray-3: #222; 82 | --button-hover-bg: var(--color-surface-high); 83 | 84 | /* Scrollbar */ 85 | --scrollbar-thumb: #343a40; 86 | --scrollbar-thumb-hover: #495057; 87 | 88 | /* Shadows */ 89 | --shadow-island: 0px 0px 0.931px 0px rgba(0, 0, 0, 0.27), 0px 0px 3.127px 0px rgba(0, 0, 0, 0.18), 90 | 0px 7px 14px 0px rgba(0, 0, 0, 0.15); 91 | --modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.17), 0px 41.77px 33.42px rgba(0, 0, 0, 0.15), 92 | 0px 22.33px 17.86px rgba(0, 0, 0, 0.14), 0px 12.52px 10.01px rgba(0, 0, 0, 0.13), 93 | 0px 6.65px 5.32px rgba(0, 0, 0, 0.12), 0px 2.76px 2.21px rgba(0, 0, 0, 0.1); 94 | } 95 | 96 | * { 97 | margin: 0; 98 | padding: 0; 99 | box-sizing: border-box; 100 | } 101 | 102 | body { 103 | font-family: var(--ui-font); 104 | color: var(--text-primary-color); 105 | background-color: var(--default-bg-color); 106 | -webkit-font-smoothing: antialiased; 107 | -moz-osx-font-smoothing: grayscale; 108 | } 109 | 110 | button { 111 | cursor: pointer; 112 | border: none; 113 | background: none; 114 | font-family: inherit; 115 | font-size: inherit; 116 | color: inherit; 117 | 118 | &:disabled { 119 | cursor: not-allowed; 120 | opacity: 0.6; 121 | } 122 | } 123 | 124 | a { 125 | color: var(--link-color); 126 | text-decoration: none; 127 | 128 | &:hover { 129 | text-decoration: underline; 130 | } 131 | } 132 | 133 | #root { 134 | display: flex; 135 | flex-direction: column; 136 | height: 100vh; 137 | width: 100vw; 138 | overflow: hidden; 139 | } 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Excalidraw Persist 2 | 3 | A self-hostable app with server-side persistence and multiple boards based on Excalidraw. 4 | 5 | `docker run -p 80:80 -p 4000:4000 ghcr.io/ozencb/excalidraw-persist:latest` 6 | 7 | Screenshot 8 | 9 | 10 | 11 | ## Features 12 | 13 | - 💾 Server-side persistence of drawings, images, library objects 14 | - 📑 Multiple boards/tabs support 15 | - 🗑️ Trash functionality for deleted boards 16 | - 🗃️ SQLite database for simple deployment 17 | 18 | 19 | ## TODO 20 | - [ ] Collaboration support 21 | 22 | ## Development 23 | 24 | This project uses pnpm workspaces as a monorepo. Make sure to create a `.env` file with necessary values. You can take a look at `packages/server/.env.example` as a starting point. 25 | 26 | ### Prerequisites 27 | 28 | - [Node.js](https://nodejs.org/) (v22 or newer) 29 | - [pnpm](https://pnpm.io/) (v10 or newer) 30 | - Git 31 | 32 | ```bash 33 | # Clone the repository 34 | git clone https://github.com/ozencb/excalidraw-persist.git 35 | cd excalidraw-persist 36 | 37 | # Install dependencies 38 | pnpm install 39 | 40 | # Create environment configuration 41 | cp packages/server/.env.example packages/server/.env 42 | 43 | # Start development servers (client and server) 44 | pnpm dev 45 | 46 | # Build for production 47 | pnpm build 48 | ``` 49 | 50 | ## Deployment Options 51 | 52 | ### Option 1: Docker (Recommended) 53 | 54 | The easiest way to deploy Excalidraw Persist is using Docker and Docker Compose. 55 | 56 | #### Prerequisites 57 | 58 | - [Docker](https://docs.docker.com/get-docker/) 59 | - [Docker Compose](https://docs.docker.com/compose/install/) 60 | 61 | #### Deployment Steps 62 | 63 | 1. Clone the repository: 64 | ```bash 65 | git clone https://github.com/ozencb/excalidraw-persist.git 66 | cd excalidraw-persist 67 | ``` 68 | 2. Start the containers: 69 | ```bash 70 | docker-compose up -d 71 | ``` 72 | 3. Access the application at `http://localhost` (or your server's IP/domain) 73 | 74 | or run 75 | 76 | 1. `docker run -p 80:80 -p 4000:4000 ghcr.io/ozencb/excalidraw-persist:latest` 77 | 2. Access the application at `http://localhost` (or your server's IP/domain) 78 | 79 | #### Using npm Scripts 80 | 81 | There are some convenience scripts included in the root `package.json`: 82 | 83 | - `pnpm docker:build` - Build the Docker images 84 | - `pnpm docker:up` - Start the containers in detached mode 85 | - `pnpm docker:down` - Stop and remove the containers 86 | - `pnpm docker:logs` - View the container logs in follow mode 87 | 88 | #### Configuration 89 | 90 | The Docker setup uses the following default configuration: 91 | 92 | - Client accessible on port 80 93 | - Server API running on port 4001 (mapped to internal port 4000) 94 | - Data persisted in a local `./data` volume 95 | 96 | #### Environment Variables 97 | 98 | The server container accepts the following environment variables: 99 | 100 | - `PORT` - The port the server will listen on (default: 4000) 101 | - `NODE_ENV` - The environment mode (default: production) 102 | - `DB_PATH` - The path to the SQLite database file (default: /app/data/database.sqlite) 103 | 104 | You can modify these in the `docker-compose.yml` file: 105 | 106 | ```yaml 107 | # Example custom configuration 108 | server: 109 | environment: 110 | - PORT=4000 111 | - NODE_ENV=production 112 | - DB_PATH=/app/data/custom-database.sqlite 113 | ``` 114 | 115 | ### Option 2: Manual Deployment 116 | 117 | #### Prerequisites 118 | 119 | - Node.js (v22 or newer) 120 | - pnpm (v10 or newer) 121 | 122 | #### Deployment Steps 123 | 124 | 1. Clone and prepare the application: 125 | ```bash 126 | git clone https://github.com/ozencb/excalidraw-persist.git 127 | cd excalidraw-persist 128 | pnpm install 129 | cp packages/server/.env.example packages/server/.env 130 | # Configure your .env file 131 | pnpm build 132 | ``` 133 | 2. Start the server: 134 | ```bash 135 | pnpm start 136 | ``` 137 | 3. For production, consider using a process manager like PM2: 138 | ```bash 139 | npm install -g pm2 140 | pm2 start pnpm --name "excalidraw-persist" -- start 141 | pm2 save 142 | ``` 143 | 4. Set up a reverse proxy with Nginx or Apache for proper SSL termination 144 | 145 | ### Troubleshooting 146 | 147 | If you encounter issues: 148 | 149 | 1. Check the application logs: 150 | - Docker: `docker-compose logs` or `pnpm docker:logs` 151 | - Manual: Check the console output where the app is running 152 | 2. Verify network connectivity: 153 | - Ensure ports are properly exposed and not blocked by firewalls 154 | - Verify the server is accessible from the client 155 | 3. Database issues: 156 | - Check that the SQLite database file is being created 157 | - Ensure the application has write permissions to the database directory 158 | 159 | ## Backup 160 | 161 | The application stores all data in an SQLite database file. To backup your data: 162 | 163 | 1. **Docker deployment**: Copy the data from the volume: 164 | ```bash 165 | cp -r ./data/database.sqlite /your/backup/location/ 166 | ``` 167 | 168 | 2. **Manual deployment**: Copy the SQLite database file from your configured location 169 | 170 | ## License 171 | 172 | MIT 173 | -------------------------------------------------------------------------------- /packages/client/src/contexts/BoardProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useContext, 4 | useState, 5 | useEffect, 6 | useCallback, 7 | useRef, 8 | ReactNode, 9 | } from 'react'; 10 | import { useNavigate, useParams } from 'react-router-dom'; 11 | import { BoardService } from '../services/boardService'; 12 | import { Board } from '../types/types'; 13 | import Utils from '../utils'; 14 | import logger from '../utils/logger'; 15 | 16 | interface BoardContextType { 17 | boards: Board[]; 18 | isLoading: boolean; 19 | fetchBoards: () => Promise; 20 | handleRenameBoard: (id: number, newName: string) => void; 21 | handleCreateBoard: () => Promise; 22 | handleDeleteBoard: (id: number) => Promise; 23 | activeBoardId: string | undefined; 24 | } 25 | 26 | const BoardContext = createContext(null); 27 | 28 | export const useBoardContext = () => { 29 | const context = useContext(BoardContext); 30 | if (!context) { 31 | throw new Error('useBoardContext must be used within a BoardProvider'); 32 | } 33 | return context; 34 | }; 35 | 36 | interface BoardProviderProps { 37 | children: ReactNode; 38 | } 39 | 40 | export const BoardProvider: React.FC = ({ children }) => { 41 | const [boards, setBoards] = useState([]); 42 | const [isLoading, setIsLoading] = useState(true); 43 | const navigate = useNavigate(); 44 | const { boardId: activeBoardId } = useParams<{ boardId: string }>(); 45 | const didAttemptInitialBoardCreation = useRef(false); 46 | 47 | const fetchBoards = useCallback(async () => { 48 | try { 49 | setIsLoading(true); 50 | const data = await BoardService.getAllBoards(); 51 | setBoards(data); 52 | setIsLoading(false); 53 | } catch (error) { 54 | logger.error('Error fetching boards:', error, true); 55 | } 56 | }, []); 57 | 58 | const debouncedUpdateBoardName = useCallback( 59 | Utils.debounce((id: string, newName: string) => { 60 | BoardService.updateBoardName(id, newName).catch(error => { 61 | logger.error(`Failed to update board ${id} name:`, error, true); 62 | }); 63 | }, 500), 64 | [] 65 | ); 66 | 67 | const handleRenameBoard = useCallback( 68 | (id: number, newName: string) => { 69 | setBoards(prevBoards => 70 | prevBoards.map(board => (board.id === id ? { ...board, name: newName } : board)) 71 | ); 72 | debouncedUpdateBoardName(id.toString(), newName); 73 | }, 74 | [debouncedUpdateBoardName] 75 | ); 76 | 77 | const handleCreateBoard = useCallback(async () => { 78 | setIsLoading(true); 79 | try { 80 | const newBoard = await BoardService.createBoard(); 81 | setBoards(prevBoards => [...prevBoards, newBoard]); 82 | navigate(`/board/${newBoard.id}`); 83 | } catch (error) { 84 | logger.error('Failed to create board:', error, true); 85 | } finally { 86 | setIsLoading(false); 87 | } 88 | }, [navigate]); 89 | 90 | const handleDeleteBoard = useCallback( 91 | async (id: number) => { 92 | const boardToDelete = boards.find(b => b.id === id); 93 | if (!boardToDelete) return; 94 | 95 | const previousBoards = boards; 96 | setBoards(prevBoards => prevBoards.filter(board => board.id !== id)); 97 | 98 | let nextBoardId: string | undefined = undefined; 99 | const remainingBoards = previousBoards.filter(b => b.id !== id); 100 | if (activeBoardId === id.toString()) { 101 | if (remainingBoards.length > 0) { 102 | const deletedIndex = previousBoards.findIndex(b => b.id === id); 103 | const nextIndex = Math.max(0, deletedIndex - 1); 104 | nextBoardId = remainingBoards[nextIndex]?.id.toString(); 105 | } 106 | } 107 | 108 | try { 109 | await BoardService.moveToTrash(id.toString()); 110 | 111 | if (activeBoardId === id.toString()) { 112 | if (nextBoardId) { 113 | navigate(`/board/${nextBoardId}`); 114 | } else { 115 | await handleCreateBoard(); 116 | return; 117 | } 118 | } 119 | await fetchBoards(); 120 | } catch (error) { 121 | logger.error(`Failed to move board ${id} to trash:`, error, true); 122 | setBoards(previousBoards); 123 | } 124 | }, 125 | [boards, navigate, activeBoardId, handleCreateBoard] 126 | ); 127 | 128 | useEffect(() => { 129 | fetchBoards(); 130 | }, [fetchBoards]); 131 | 132 | useEffect(() => { 133 | if (!isLoading && boards.length === 0 && !didAttemptInitialBoardCreation.current) { 134 | didAttemptInitialBoardCreation.current = true; 135 | handleCreateBoard(); 136 | } 137 | if (boards.length > 0 || isLoading) { 138 | didAttemptInitialBoardCreation.current = false; 139 | } 140 | if (activeBoardId && boards.length > 0 && !boards.find(b => b.id === parseInt(activeBoardId))) { 141 | logger.warn('Invalid board id, navigating to last board', true); 142 | navigate(`/board/${boards[boards.length - 1].id}`); 143 | } 144 | }, [boards, isLoading, handleCreateBoard, activeBoardId, navigate]); 145 | 146 | const value = { 147 | boards, 148 | isLoading, 149 | fetchBoards, 150 | handleRenameBoard, 151 | handleCreateBoard, 152 | handleDeleteBoard, 153 | activeBoardId, 154 | }; 155 | 156 | return {children}; 157 | }; 158 | -------------------------------------------------------------------------------- /packages/client/src/components/TrashPopup.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { TrashBoard } from '../types/types'; 4 | import { BoardService } from '../services/boardService'; 5 | import '../styles/TrashPopup.scss'; 6 | import { useBoardContext } from '../contexts/BoardProvider'; 7 | import Icon from './Icon'; 8 | import logger from '../utils/logger'; 9 | 10 | interface TrashPopupProps { 11 | isOpen: boolean; 12 | onClose: () => void; 13 | } 14 | 15 | const TrashPopup = ({ onClose, isOpen }: TrashPopupProps) => { 16 | const [trashedBoards, setTrashedBoards] = useState([]); 17 | const [isLoading, setIsLoading] = useState(true); 18 | const [error, setError] = useState(null); 19 | const navigate = useNavigate(); 20 | const { fetchBoards } = useBoardContext(); 21 | const popupRef = useRef(null); 22 | 23 | useEffect(() => { 24 | const handleEscapeKey = (event: KeyboardEvent) => { 25 | if (event.key === 'Escape') { 26 | onClose(); 27 | } 28 | }; 29 | 30 | const handleClickOutside = (event: MouseEvent) => { 31 | if (popupRef.current && !popupRef.current.contains(event.target as Node)) { 32 | onClose(); 33 | } 34 | }; 35 | 36 | if (isOpen) { 37 | document.addEventListener('mousedown', handleClickOutside); 38 | document.addEventListener('keydown', handleEscapeKey); 39 | fetchTrashedBoards(); 40 | } 41 | 42 | return () => { 43 | document.removeEventListener('mousedown', handleClickOutside); 44 | document.removeEventListener('keydown', handleEscapeKey); 45 | }; 46 | }, [isOpen, onClose]); 47 | 48 | const fetchTrashedBoards = async () => { 49 | try { 50 | setIsLoading(true); 51 | const data = await BoardService.getTrashedBoards(); 52 | setTrashedBoards(data); 53 | } catch (error) { 54 | setError('Error connecting to server'); 55 | logger.error('Error fetching trashed boards:', error, true); 56 | } finally { 57 | setIsLoading(false); 58 | } 59 | }; 60 | 61 | const handleRestore = async (boardId: number) => { 62 | try { 63 | await BoardService.restoreBoard(boardId.toString()); 64 | setTrashedBoards(prev => prev.filter(board => board.id !== boardId)); 65 | fetchBoards(); 66 | navigate(`/board/${boardId}`); 67 | } catch (error) { 68 | setError('Error connecting to server'); 69 | logger.error('Error restoring board:', error, true); 70 | } 71 | }; 72 | 73 | const handlePermanentDelete = async (boardId: number) => { 74 | if ( 75 | !window.confirm( 76 | 'Are you sure you want to permanently delete this board? This action cannot be undone.' 77 | ) 78 | ) { 79 | return; 80 | } 81 | 82 | try { 83 | await BoardService.permanentlyDeleteBoard(boardId.toString()); 84 | setTrashedBoards(prev => prev.filter(board => board.id !== boardId)); 85 | } catch (error) { 86 | setError('Error connecting to server'); 87 | logger.error('Error deleting board:', error, true); 88 | } 89 | }; 90 | 91 | const formatDate = (timestamp: number) => { 92 | return new Date(timestamp).toLocaleString(); 93 | }; 94 | 95 | if (!isOpen) return null; 96 | 97 | if (isLoading) { 98 | return ( 99 |
100 |
101 |

Loading trashed boards...

102 |
103 |
104 | ); 105 | } 106 | 107 | if (error) { 108 | return ( 109 |
110 |
111 |

Error

112 |

{error}

113 | 114 |
115 |
116 | ); 117 | } 118 | 119 | return ( 120 |
121 |
122 |

Trashed Boards

123 | 126 |
127 |
128 | {trashedBoards.length === 0 ? ( 129 |

No trashed boards found.

130 | ) : ( 131 | trashedBoards.map(board => ( 132 |
133 |
134 |

{board.name}

135 |

Deleted on: {formatDate(board.updated_at)}

136 |
137 |
138 | 144 | 150 |
151 |
152 | )) 153 | )} 154 |
155 |
156 | ); 157 | }; 158 | 159 | export default TrashPopup; 160 | -------------------------------------------------------------------------------- /packages/client/src/components/ExcalidrawEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback, useMemo } from 'react'; 2 | import { Excalidraw, useHandleLibrary } from '@excalidraw/excalidraw'; 3 | import type { 4 | ExcalidrawImperativeAPI, 5 | AppState, 6 | BinaryFiles, 7 | LibraryItems, 8 | } from '@excalidraw/excalidraw/types'; 9 | import '../styles/ExcalidrawEditor.scss'; 10 | import { ElementService } from '../services/elementService'; 11 | import { useExcalidrawEditor } from '../hooks/useExcalidrawEditor'; 12 | import Loader from './Loader'; 13 | import { useTheme } from '../contexts/ThemeProvider'; 14 | import { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'; 15 | import logger from '../utils/logger'; 16 | import Utils from '../utils'; 17 | import { LibraryService } from '../services/libraryService'; 18 | 19 | interface ExcalidrawEditorProps { 20 | boardId: string; 21 | } 22 | 23 | const debouncedHandleChange = Utils.debounce((f: () => void) => { 24 | f(); 25 | }, 500); 26 | 27 | const ExcalidrawEditor = ({ boardId }: ExcalidrawEditorProps) => { 28 | const [isLoading, setIsLoading] = useState(true); 29 | const { theme: currentAppTheme, setTheme: setAppTheme } = useTheme(); 30 | 31 | const { 32 | excalidrawAPI, 33 | elements, 34 | files, 35 | setElements, 36 | setFiles, 37 | setExcalidrawAPI, 38 | handleChange: originalHandleChange, 39 | } = useExcalidrawEditor(boardId); 40 | 41 | const handleExcalidrawAPI = useCallback( 42 | (api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api), 43 | [setExcalidrawAPI] 44 | ); 45 | 46 | const handleChange = useCallback( 47 | ( 48 | updatedElements: readonly ExcalidrawElement[], 49 | appState: AppState, 50 | updatedFiles: BinaryFiles | null 51 | ) => { 52 | if ( 53 | updatedElements.length === 0 && 54 | (!updatedFiles || Object.keys(updatedFiles).length === 0) 55 | ) { 56 | return; 57 | } 58 | 59 | const filesSnapshot: BinaryFiles = updatedFiles ? { ...updatedFiles } : {}; 60 | 61 | debouncedHandleChange(() => { 62 | originalHandleChange(updatedElements, filesSnapshot); 63 | }); 64 | 65 | if (appState?.theme && appState.theme !== currentAppTheme) { 66 | setAppTheme(appState.theme); 67 | } 68 | }, 69 | [originalHandleChange, currentAppTheme, setAppTheme] 70 | ); 71 | 72 | const libraryAdapter = useMemo(() => { 73 | if (!boardId) { 74 | return null; 75 | } 76 | 77 | return { 78 | load: async (): Promise<{ libraryItems: LibraryItems } | null> => { 79 | try { 80 | const response = await LibraryService.getBoardLibrary(boardId); 81 | return { 82 | libraryItems: response.libraryItems ?? [], 83 | }; 84 | } catch (error) { 85 | logger.error(`Error loading library for board ${boardId}:`, error, true); 86 | return null; 87 | } 88 | }, 89 | save: async ({ libraryItems }: { libraryItems: LibraryItems }) => { 90 | try { 91 | await LibraryService.saveBoardLibrary(boardId, libraryItems); 92 | } catch (error) { 93 | logger.error(`Error saving library for board ${boardId}:`, error, true); 94 | } 95 | }, 96 | }; 97 | }, [boardId]); 98 | 99 | useHandleLibrary(libraryAdapter ? { excalidrawAPI, adapter: libraryAdapter } : { excalidrawAPI }); 100 | 101 | useEffect(() => { 102 | if (excalidrawAPI) { 103 | const currentExcalidrawTheme = excalidrawAPI.getAppState().theme; 104 | if (currentExcalidrawTheme !== currentAppTheme) { 105 | excalidrawAPI.updateScene({ appState: { theme: currentAppTheme } }); 106 | } 107 | const updatedExcalidrawTheme = excalidrawAPI.getAppState().theme; 108 | if (updatedExcalidrawTheme !== currentAppTheme) { 109 | setAppTheme(updatedExcalidrawTheme); 110 | } 111 | } 112 | }, [excalidrawAPI, currentAppTheme, setAppTheme]); 113 | 114 | const fetchBoardElements = useCallback(async () => { 115 | if (!boardId) { 116 | setElements([]); 117 | setIsLoading(false); 118 | return; 119 | } 120 | try { 121 | setIsLoading(true); 122 | const fetchedScene = await ElementService.getBoardElements(boardId); 123 | if (fetchedScene) { 124 | setElements(fetchedScene.elements || []); 125 | setFiles(fetchedScene.files || {}); 126 | } else { 127 | setElements([]); 128 | setFiles({}); 129 | } 130 | } catch (error) { 131 | logger.error('Error fetching board scene:', error, true); 132 | setElements([]); 133 | setFiles({}); 134 | } finally { 135 | setIsLoading(false); 136 | } 137 | }, [boardId, setElements, setFiles]); 138 | 139 | useEffect(() => { 140 | fetchBoardElements(); 141 | }, [fetchBoardElements]); 142 | 143 | if (isLoading) { 144 | return ( 145 |
146 |
147 | 148 |
149 |
150 | ); 151 | } 152 | 153 | if (!boardId) { 154 | return ( 155 |
156 |
157 |

Please select or create a board.

158 |
159 |
160 | ); 161 | } 162 | 163 | return ( 164 |
165 |
166 | 187 |
188 |
189 | ); 190 | }; 191 | 192 | export default ExcalidrawEditor; 193 | -------------------------------------------------------------------------------- /packages/server/src/controllers/boardController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { BoardModel } from '../models/boardModel'; 3 | import { BoardStatus } from '../types'; 4 | import logger from '../utils/logger'; 5 | 6 | export const boardController = { 7 | async create(req: Request, res: Response) { 8 | try { 9 | const board = await BoardModel.create(); 10 | 11 | return res.status(201).json({ 12 | success: true, 13 | data: board, 14 | }); 15 | } catch (error) { 16 | logger.error('Error creating board:', error); 17 | return res.status(500).json({ 18 | success: false, 19 | message: 'Failed to create board', 20 | }); 21 | } 22 | }, 23 | 24 | async listActive(req: Request, res: Response) { 25 | try { 26 | const boards = await BoardModel.findAllActive(); 27 | 28 | return res.status(200).json({ 29 | success: true, 30 | data: boards, 31 | }); 32 | } catch (error) { 33 | logger.error('Error listing active boards:', error); 34 | return res.status(500).json({ 35 | success: false, 36 | message: 'Failed to list active boards', 37 | }); 38 | } 39 | }, 40 | 41 | async listTrash(req: Request, res: Response) { 42 | try { 43 | const boards = await BoardModel.findAllDeleted(); 44 | 45 | return res.status(200).json({ 46 | success: true, 47 | data: boards, 48 | }); 49 | } catch (error) { 50 | logger.error('Error listing boards in trash:', error); 51 | return res.status(500).json({ 52 | success: false, 53 | message: 'Failed to list boards in trash', 54 | }); 55 | } 56 | }, 57 | 58 | async update(req: Request, res: Response) { 59 | try { 60 | const { id } = req.params; 61 | const { name } = req.body; 62 | const boardId = parseInt(id, 10); 63 | if (isNaN(boardId)) { 64 | return res.status(400).json({ success: false, message: 'Invalid board ID format' }); 65 | } 66 | 67 | const board = await BoardModel.findById(boardId); 68 | 69 | if (!board) { 70 | return res.status(404).json({ 71 | success: false, 72 | message: 'Board not found', 73 | }); 74 | } 75 | 76 | if (board.status === BoardStatus.DELETED) { 77 | return res.status(400).json({ 78 | success: false, 79 | message: 'Cannot update a board in trash', 80 | }); 81 | } 82 | 83 | const updatedBoard = await BoardModel.update(boardId, { name }); 84 | 85 | return res.status(200).json({ 86 | success: true, 87 | data: updatedBoard, 88 | }); 89 | } catch (error) { 90 | logger.error(`Error updating board ${req.params.id}:`, error); 91 | return res.status(500).json({ 92 | success: false, 93 | message: 'Failed to update board', 94 | }); 95 | } 96 | }, 97 | 98 | async moveToTrash(req: Request, res: Response) { 99 | try { 100 | const { id } = req.params; 101 | const boardId = parseInt(id, 10); 102 | if (isNaN(boardId)) { 103 | return res.status(400).json({ success: false, message: 'Invalid board ID format' }); 104 | } 105 | const board = await BoardModel.findById(boardId); 106 | 107 | if (!board) { 108 | return res.status(404).json({ 109 | success: false, 110 | message: 'Board not found', 111 | }); 112 | } 113 | 114 | if (board.status === BoardStatus.DELETED) { 115 | return res.status(400).json({ 116 | success: false, 117 | message: 'Board is already in trash', 118 | }); 119 | } 120 | 121 | await BoardModel.moveToTrash(boardId); 122 | 123 | return res.status(200).json({ 124 | success: true, 125 | message: 'Board moved to trash', 126 | }); 127 | } catch (error) { 128 | logger.error(`Error moving board ${req.params.id} to trash:`, error); 129 | return res.status(500).json({ 130 | success: false, 131 | message: 'Failed to move board to trash', 132 | }); 133 | } 134 | }, 135 | 136 | async restoreFromTrash(req: Request, res: Response) { 137 | try { 138 | const { id } = req.params; 139 | const boardId = parseInt(id, 10); 140 | if (isNaN(boardId)) { 141 | return res.status(400).json({ success: false, message: 'Invalid board ID format' }); 142 | } 143 | const board = await BoardModel.findById(boardId); 144 | 145 | if (!board) { 146 | return res.status(404).json({ 147 | success: false, 148 | message: 'Board not found', 149 | }); 150 | } 151 | 152 | if (board.status !== BoardStatus.DELETED) { 153 | return res.status(400).json({ 154 | success: false, 155 | message: 'Board is not in trash', 156 | }); 157 | } 158 | 159 | await BoardModel.restoreFromTrash(boardId); 160 | 161 | return res.status(200).json({ 162 | success: true, 163 | message: 'Board restored from trash', 164 | }); 165 | } catch (error) { 166 | logger.error(`Error restoring board ${req.params.id} from trash:`, error); 167 | return res.status(500).json({ 168 | success: false, 169 | message: 'Failed to restore board from trash', 170 | }); 171 | } 172 | }, 173 | 174 | async permanentDelete(req: Request, res: Response) { 175 | try { 176 | const { id } = req.params; 177 | const boardId = parseInt(id, 10); 178 | if (isNaN(boardId)) { 179 | return res.status(400).json({ success: false, message: 'Invalid board ID format' }); 180 | } 181 | 182 | const board = await BoardModel.findById(boardId); 183 | if (!board) { 184 | return res.status(404).json({ success: false, message: 'Board not found' }); 185 | } 186 | 187 | await BoardModel.permanentlyDelete(boardId); 188 | 189 | console.log('Deleting files'); 190 | 191 | return res.status(200).json({ 192 | success: true, 193 | message: 'Board and associated data permanently deleted', 194 | }); 195 | } catch (error) { 196 | logger.error(`Error permanently deleting board ${req.params.id}:`, error); 197 | return res.status(500).json({ 198 | success: false, 199 | message: 'Failed to permanently delete board', 200 | }); 201 | } 202 | }, 203 | }; 204 | --------------------------------------------------------------------------------