├── 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 |
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 |
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 |
34 | );
35 | };
36 |
37 | const Close = ({ className }: IconComponentProps) => {
38 | return (
39 |
57 | );
58 | };
59 |
60 | const Plus = ({ className }: IconComponentProps) => {
61 | return (
62 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------