Upload 44 files
Browse files- .gitignore +46 -0
- Dockerfile +62 -0
- README +170 -0
- backend/.env +5 -0
- backend/main.py +394 -0
- backend/requirements.txt +11 -0
- frontend/.gitignore +1 -0
- frontend/README.md +46 -0
- frontend/package.json +75 -0
- frontend/public/favicon.ico +0 -0
- frontend/public/index.html +43 -0
- frontend/public/logo192.png +0 -0
- frontend/public/logo512.png +0 -0
- frontend/public/manifest.json +25 -0
- frontend/public/robots.txt +3 -0
- frontend/src/App.css +167 -0
- frontend/src/App.tsx +43 -0
- frontend/src/components/Auth.module.css +126 -0
- frontend/src/components/Auth.tsx +141 -0
- frontend/src/components/ChatModal.d.ts +18 -0
- frontend/src/components/ChatModal.tsx +185 -0
- frontend/src/components/EditableColorNode.css +47 -0
- frontend/src/components/EditableColorNode.tsx +75 -0
- frontend/src/components/LandingPage.module.css +136 -0
- frontend/src/components/LandingPage.tsx +80 -0
- frontend/src/components/MindMapEditor.module.css +460 -0
- frontend/src/components/MindMapEditor.tsx +801 -0
- frontend/src/components/MindMapItem.module.css +159 -0
- frontend/src/components/MindMapItem.tsx +114 -0
- frontend/src/components/MindMapList.module.css +227 -0
- frontend/src/components/MindMapList.tsx +124 -0
- frontend/src/components/SidePane.module.css +160 -0
- frontend/src/components/SidePane.tsx +141 -0
- frontend/src/index.css +19 -0
- frontend/src/index.tsx +21 -0
- frontend/src/logo.svg +1 -0
- frontend/src/react-app-env.d.ts +1 -0
- frontend/src/reportWebVitals.ts +15 -0
- frontend/src/setupTests.ts +5 -0
- frontend/src/utils/apiUtils.ts +112 -0
- frontend/src/utils/axios.ts +115 -0
- frontend/src/utils/jsonUtils.ts +64 -0
- frontend/tsconfig.json +26 -0
- supervisord.conf +22 -0
.gitignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#test file
|
| 2 |
+
test.py
|
| 3 |
+
|
| 4 |
+
# Root
|
| 5 |
+
*.log
|
| 6 |
+
.env
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.pyc
|
| 9 |
+
*.pyo
|
| 10 |
+
*.pyd
|
| 11 |
+
*.sqlite3
|
| 12 |
+
|
| 13 |
+
# Environment Variables
|
| 14 |
+
.env/
|
| 15 |
+
|
| 16 |
+
# Backend (Python/Flask)
|
| 17 |
+
virtual-env/
|
| 18 |
+
venv/
|
| 19 |
+
backend/__pycache__/
|
| 20 |
+
backend/*.pyc
|
| 21 |
+
backend/*.db
|
| 22 |
+
|
| 23 |
+
# React Frontend (Node)
|
| 24 |
+
fontend/node_modules/
|
| 25 |
+
fontend/build/
|
| 26 |
+
fontend/.env
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# OS Files
|
| 30 |
+
.DS_Store
|
| 31 |
+
Thumbs.db
|
| 32 |
+
|
| 33 |
+
# IDEs and Editors
|
| 34 |
+
.vscode/
|
| 35 |
+
.idea/
|
| 36 |
+
*.swp
|
| 37 |
+
|
| 38 |
+
# Coverage / Testing
|
| 39 |
+
coverage/
|
| 40 |
+
*.cover
|
| 41 |
+
*.lcov
|
| 42 |
+
htmlcov/
|
| 43 |
+
|
| 44 |
+
# Logs
|
| 45 |
+
logs/
|
| 46 |
+
*.log
|
Dockerfile
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use official Python 3.10 image as base
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Install Node.js (for React build) and other dependencies
|
| 5 |
+
RUN apt-get update && \
|
| 6 |
+
apt-get install -y curl build-essential git && \
|
| 7 |
+
curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
|
| 8 |
+
apt-get install -y nodejs && \
|
| 9 |
+
apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# Set up a new user named "user" with user ID 1000
|
| 12 |
+
RUN useradd -m -u 1000 user
|
| 13 |
+
|
| 14 |
+
# Set environment variables
|
| 15 |
+
ENV HOME=/home/user \
|
| 16 |
+
PATH=/home/user/.local/bin:$PATH
|
| 17 |
+
|
| 18 |
+
# Set the working directory to the user's home directory
|
| 19 |
+
WORKDIR $HOME/app
|
| 20 |
+
|
| 21 |
+
# Copy requirements first for better caching
|
| 22 |
+
COPY --chown=user requirements.txt $HOME/app/requirements.txt
|
| 23 |
+
|
| 24 |
+
# Install Python dependencies
|
| 25 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 26 |
+
pip install --no-cache-dir -r requirements.txt
|
| 27 |
+
|
| 28 |
+
# Copy the rest of the code
|
| 29 |
+
COPY --chown=user . $HOME/app
|
| 30 |
+
|
| 31 |
+
# Build the React frontend
|
| 32 |
+
WORKDIR $HOME/app/frontend
|
| 33 |
+
RUN npm install --legacy-peer-deps && npm run build
|
| 34 |
+
|
| 35 |
+
# Move build to a static directory for Flask to serve
|
| 36 |
+
RUN mkdir -p $HOME/app/static && \
|
| 37 |
+
cp -r build/* $HOME/app/static/
|
| 38 |
+
|
| 39 |
+
# Switch back to backend directory
|
| 40 |
+
WORKDIR $HOME/app
|
| 41 |
+
|
| 42 |
+
# Install supervisor
|
| 43 |
+
RUN apt-get update && apt-get install -y supervisor
|
| 44 |
+
|
| 45 |
+
# Create supervisor config directory
|
| 46 |
+
RUN mkdir -p /etc/supervisor/conf.d
|
| 47 |
+
|
| 48 |
+
# Copy supervisor config
|
| 49 |
+
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
| 50 |
+
|
| 51 |
+
# Switch to the "user" user
|
| 52 |
+
USER user
|
| 53 |
+
|
| 54 |
+
# Expose both ports (adjust as needed)
|
| 55 |
+
EXPOSE 7860 3000
|
| 56 |
+
|
| 57 |
+
# Set environment for React dev server
|
| 58 |
+
ENV HOST=0.0.0.0
|
| 59 |
+
ENV PORT=3000
|
| 60 |
+
|
| 61 |
+
# Start supervisor (which starts both Flask and React)
|
| 62 |
+
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
README
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Here’s a full summary of your mind map app project, including the core idea, tech stack, features, and architecture for your XMind-like clone built with FastAPI + React Flow.
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
PROJECT NAME:
|
| 7 |
+
|
| 8 |
+
MindMapX (or any name of your choice)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
CORE IDEA:
|
| 14 |
+
|
| 15 |
+
Build a web-based XMind-like application where users can:
|
| 16 |
+
|
| 17 |
+
Create, edit, and visualize hierarchical mind maps (drag-drop nodes).
|
| 18 |
+
|
| 19 |
+
Save, export, and import maps as .json files.
|
| 20 |
+
|
| 21 |
+
Use basic authentication (login/signup) to store maps per user securely.
|
| 22 |
+
|
| 23 |
+
Support real-time interactions and modern UI using React Flow.
|
| 24 |
+
|
| 25 |
+
Backend powered by FastAPI for speed, structure, and API logic.
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
TECH STACK
|
| 32 |
+
|
| 33 |
+
Frontend (React + React Flow):
|
| 34 |
+
|
| 35 |
+
React (with Hooks & Axios)
|
| 36 |
+
|
| 37 |
+
React Flow for node/edge diagramming
|
| 38 |
+
|
| 39 |
+
JSON file export/import support
|
| 40 |
+
|
| 41 |
+
Token-based Auth using localStorage
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
Backend (FastAPI):
|
| 45 |
+
|
| 46 |
+
FastAPI for API and Auth
|
| 47 |
+
|
| 48 |
+
JWT-based Authentication
|
| 49 |
+
|
| 50 |
+
In-memory or file/DB-based storage for user maps
|
| 51 |
+
|
| 52 |
+
CORS and secure endpoints
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
---
|
| 57 |
+
|
| 58 |
+
FEATURES
|
| 59 |
+
|
| 60 |
+
1. Mind Map Editor
|
| 61 |
+
|
| 62 |
+
Add/edit/delete nodes and edges
|
| 63 |
+
|
| 64 |
+
Drag-and-drop visual interface using React Flow
|
| 65 |
+
|
| 66 |
+
Central root node support
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
2. JSON Import/Export
|
| 70 |
+
|
| 71 |
+
Export mind map to .json file
|
| 72 |
+
|
| 73 |
+
Import .json file and restore full map (nodes + edges)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
3. User Authentication
|
| 77 |
+
|
| 78 |
+
Signup/login using FastAPI backend
|
| 79 |
+
|
| 80 |
+
Passwords hashed using bcrypt
|
| 81 |
+
|
| 82 |
+
JWT tokens issued on login
|
| 83 |
+
|
| 84 |
+
Authenticated routes for saving/loading maps
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
4. Mind Map Persistence
|
| 88 |
+
|
| 89 |
+
Each user’s maps are stored independently
|
| 90 |
+
|
| 91 |
+
Save and load from backend using token-based access
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
---
|
| 96 |
+
|
| 97 |
+
ARCHITECTURE
|
| 98 |
+
|
| 99 |
+
Frontend Flow
|
| 100 |
+
|
| 101 |
+
Login Page → Token → Mind Map Canvas (React Flow)
|
| 102 |
+
↘ Import JSON
|
| 103 |
+
↘ Export JSON
|
| 104 |
+
↘ Save/Load using token
|
| 105 |
+
|
| 106 |
+
Backend (FastAPI) Routes
|
| 107 |
+
|
| 108 |
+
POST /signup -> Create user
|
| 109 |
+
POST /login -> Return JWT token
|
| 110 |
+
POST /save -> Save mind map (token required)
|
| 111 |
+
GET /load -> Load map by user (token required)
|
| 112 |
+
POST /upload -> Optional JSON upload endpoint
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
JSON Structure
|
| 118 |
+
|
| 119 |
+
{
|
| 120 |
+
"id": "map1",
|
| 121 |
+
"nodes": [
|
| 122 |
+
{
|
| 123 |
+
"id": "1",
|
| 124 |
+
"data": { "label": "Root" },
|
| 125 |
+
"position": { "x": 250, "y": 0 }
|
| 126 |
+
}
|
| 127 |
+
],
|
| 128 |
+
"edges": [
|
| 129 |
+
{
|
| 130 |
+
"id": "e1-2",
|
| 131 |
+
"source": "1",
|
| 132 |
+
"target": "2"
|
| 133 |
+
}
|
| 134 |
+
]
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
POTENTIAL FUTURE FEATURES
|
| 141 |
+
|
| 142 |
+
Cloud storage with database
|
| 143 |
+
|
| 144 |
+
Collaboration (WebSocket/Socket.IO)
|
| 145 |
+
|
| 146 |
+
Mind map templates
|
| 147 |
+
|
| 148 |
+
Rich text & icons in nodes
|
| 149 |
+
|
| 150 |
+
Zoom, pan, collapse branches
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
|
| 156 |
+
GOAL:
|
| 157 |
+
|
| 158 |
+
Build the first version in a day with:
|
| 159 |
+
|
| 160 |
+
Fully working frontend editor (React Flow)
|
| 161 |
+
|
| 162 |
+
Working backend (FastAPI Auth + Save/Load)
|
| 163 |
+
|
| 164 |
+
Export/import JSON support
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
---
|
| 169 |
+
|
| 170 |
+
Would you like me to generate the full project folder with boilerplate files for frontend and backend to get you started immediately?
|
backend/.env
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MISTRAL_API_KEY=nMXmEJvYwiSyXrPVJdBTKjRMqhbbhwmY
|
| 2 |
+
SECRET_KEY=your_super_secret_key_here
|
| 3 |
+
MONGODB_URL=mongodb://localhost:27017
|
| 4 |
+
DATABASE_NAME=mindmap_db
|
| 5 |
+
CORS_ORIGINS=http://localhost:3000
|
backend/main.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# requirements: fastapi, uvicorn, beanie, motor, pydantic, passlib[bcrypt], python-dotenv, jose, mistralai
|
| 2 |
+
from fastapi import FastAPI, Depends, HTTPException, status, Path, Request
|
| 3 |
+
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 6 |
+
from jose import JWTError, jwt
|
| 7 |
+
from passlib.context import CryptContext
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Optional, List, Dict, Any
|
| 10 |
+
from pydantic import BaseModel, EmailStr, ValidationError
|
| 11 |
+
import os
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
from beanie import Document, init_beanie
|
| 14 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 15 |
+
|
| 16 |
+
# Mistral API
|
| 17 |
+
from mistralai import Mistral
|
| 18 |
+
import uvicorn
|
| 19 |
+
|
| 20 |
+
# Load environment variables
|
| 21 |
+
load_dotenv()
|
| 22 |
+
|
| 23 |
+
MONGODB_URL = os.getenv("MONGODB_URL", "mongodb://localhost:27017")
|
| 24 |
+
DATABASE_NAME = os.getenv("DATABASE_NAME", "mindmap_db")
|
| 25 |
+
SECRET_KEY = os.getenv("SECRET_KEY")
|
| 26 |
+
MISTRAL_API_KEY = os.getenv("MISTRAL_API_KEY")
|
| 27 |
+
ALLOWED_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
|
| 28 |
+
|
| 29 |
+
if not SECRET_KEY:
|
| 30 |
+
raise RuntimeError("SECRET_KEY environment variable must be set for security!")
|
| 31 |
+
|
| 32 |
+
ALGORITHM = "HS256"
|
| 33 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
| 34 |
+
|
| 35 |
+
# Initialize FastAPI app
|
| 36 |
+
app = FastAPI(title="MindMapX API")
|
| 37 |
+
|
| 38 |
+
# CORS configuration
|
| 39 |
+
app.add_middleware(
|
| 40 |
+
CORSMiddleware,
|
| 41 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 42 |
+
allow_credentials=True,
|
| 43 |
+
allow_methods=["*"],
|
| 44 |
+
allow_headers=["*"],
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Add rate limiting
|
| 48 |
+
try:
|
| 49 |
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 50 |
+
from slowapi.util import get_remote_address
|
| 51 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 52 |
+
app.state.limiter = limiter
|
| 53 |
+
app.add_exception_handler(429, _rate_limit_exceeded_handler)
|
| 54 |
+
except ImportError:
|
| 55 |
+
limiter = None
|
| 56 |
+
|
| 57 |
+
# Add validation error handler
|
| 58 |
+
@app.exception_handler(ValidationError)
|
| 59 |
+
async def validation_exception_handler(request: Request, exc: ValidationError):
|
| 60 |
+
return JSONResponse(
|
| 61 |
+
status_code=422,
|
| 62 |
+
content={"detail": [{"msg": str(err), "loc": err["loc"]} for err in exc.errors()]},
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
@app.exception_handler(HTTPException)
|
| 66 |
+
async def http_exception_handler(request: Request, exc: HTTPException):
|
| 67 |
+
return JSONResponse(
|
| 68 |
+
status_code=exc.status_code,
|
| 69 |
+
content={"detail": exc.detail},
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# Security configuration
|
| 73 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 74 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login")
|
| 75 |
+
|
| 76 |
+
# Mistral client
|
| 77 |
+
client = Mistral(api_key=MISTRAL_API_KEY) if MISTRAL_API_KEY else None
|
| 78 |
+
|
| 79 |
+
# Models
|
| 80 |
+
class UserBase(BaseModel):
|
| 81 |
+
username: str
|
| 82 |
+
email: Optional[EmailStr] = None
|
| 83 |
+
|
| 84 |
+
class UserCreate(UserBase):
|
| 85 |
+
password: str
|
| 86 |
+
|
| 87 |
+
class User(Document, UserBase):
|
| 88 |
+
hashed_password: str
|
| 89 |
+
created_at: datetime
|
| 90 |
+
updated_at: datetime
|
| 91 |
+
|
| 92 |
+
class Settings:
|
| 93 |
+
name = "users"
|
| 94 |
+
indexes = ["username", "email"]
|
| 95 |
+
|
| 96 |
+
class Node(BaseModel):
|
| 97 |
+
id: str
|
| 98 |
+
data: Dict[str, Any]
|
| 99 |
+
position: Dict[str, float]
|
| 100 |
+
|
| 101 |
+
class Edge(BaseModel):
|
| 102 |
+
id: str
|
| 103 |
+
source: str
|
| 104 |
+
target: str
|
| 105 |
+
|
| 106 |
+
class MindMapBase(BaseModel):
|
| 107 |
+
name: str = "Untitled"
|
| 108 |
+
nodes: List[Node] = []
|
| 109 |
+
edges: List[Edge] = []
|
| 110 |
+
|
| 111 |
+
class MindMap(Document, MindMapBase):
|
| 112 |
+
user_id: str
|
| 113 |
+
created_at: datetime
|
| 114 |
+
updated_at: datetime
|
| 115 |
+
|
| 116 |
+
class Settings:
|
| 117 |
+
name = "mindmaps"
|
| 118 |
+
indexes = ["user_id", "created_at"]
|
| 119 |
+
|
| 120 |
+
# Token models
|
| 121 |
+
class Token(BaseModel):
|
| 122 |
+
access_token: str
|
| 123 |
+
token_type: str
|
| 124 |
+
|
| 125 |
+
class TokenData(BaseModel):
|
| 126 |
+
username: Optional[str] = None
|
| 127 |
+
|
| 128 |
+
# DB init
|
| 129 |
+
async def init_db():
|
| 130 |
+
client = AsyncIOMotorClient(MONGODB_URL)
|
| 131 |
+
await init_beanie(
|
| 132 |
+
database=client[DATABASE_NAME],
|
| 133 |
+
document_models=[User, MindMap],
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# Helper functions
|
| 137 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 138 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 139 |
+
|
| 140 |
+
def get_password_hash(password: str) -> str:
|
| 141 |
+
return pwd_context.hash(password)
|
| 142 |
+
|
| 143 |
+
async def get_user(username: str) -> Optional[User]:
|
| 144 |
+
return await User.find_one({"username": username})
|
| 145 |
+
|
| 146 |
+
async def authenticate_user(username: str, password: str) -> Optional[User]:
|
| 147 |
+
user = await get_user(username)
|
| 148 |
+
if not user:
|
| 149 |
+
return None
|
| 150 |
+
if not verify_password(password, user.hashed_password):
|
| 151 |
+
return None
|
| 152 |
+
return user
|
| 153 |
+
|
| 154 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 155 |
+
to_encode = data.copy()
|
| 156 |
+
if expires_delta:
|
| 157 |
+
expire = datetime.utcnow() + expires_delta
|
| 158 |
+
else:
|
| 159 |
+
expire = datetime.utcnow() + timedelta(minutes=15)
|
| 160 |
+
to_encode.update({"exp": expire})
|
| 161 |
+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 162 |
+
return encoded_jwt
|
| 163 |
+
|
| 164 |
+
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
|
| 165 |
+
credentials_exception = HTTPException(
|
| 166 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 167 |
+
detail="Could not validate credentials",
|
| 168 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 169 |
+
)
|
| 170 |
+
try:
|
| 171 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 172 |
+
username: str = payload.get("sub")
|
| 173 |
+
if username is None:
|
| 174 |
+
raise credentials_exception
|
| 175 |
+
token_data = TokenData(username=username)
|
| 176 |
+
except JWTError:
|
| 177 |
+
raise credentials_exception
|
| 178 |
+
user = await get_user(username=token_data.username)
|
| 179 |
+
if user is None:
|
| 180 |
+
raise credentials_exception
|
| 181 |
+
return user
|
| 182 |
+
|
| 183 |
+
# Startup event
|
| 184 |
+
@app.on_event("startup")
|
| 185 |
+
async def on_startup():
|
| 186 |
+
await init_db()
|
| 187 |
+
|
| 188 |
+
# Auth endpoints
|
| 189 |
+
@app.post("/signup", response_model=Token)
|
| 190 |
+
async def signup(user: UserCreate):
|
| 191 |
+
existing_user = await User.find_one({"username": user.username})
|
| 192 |
+
if existing_user:
|
| 193 |
+
raise HTTPException(
|
| 194 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 195 |
+
detail="Username already registered",
|
| 196 |
+
)
|
| 197 |
+
if user.email:
|
| 198 |
+
existing_email = await User.find_one({"email": user.email})
|
| 199 |
+
if existing_email:
|
| 200 |
+
raise HTTPException(
|
| 201 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 202 |
+
detail="Email already registered",
|
| 203 |
+
)
|
| 204 |
+
hashed_password = get_password_hash(user.password)
|
| 205 |
+
new_user = User(
|
| 206 |
+
username=user.username,
|
| 207 |
+
email=user.email,
|
| 208 |
+
hashed_password=hashed_password,
|
| 209 |
+
created_at=datetime.utcnow(),
|
| 210 |
+
updated_at=datetime.utcnow(),
|
| 211 |
+
)
|
| 212 |
+
await new_user.insert()
|
| 213 |
+
access_token = create_access_token(
|
| 214 |
+
data={"sub": user.username},
|
| 215 |
+
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
| 216 |
+
)
|
| 217 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 218 |
+
|
| 219 |
+
@app.post("/login", response_model=Token)
|
| 220 |
+
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
| 221 |
+
user = await authenticate_user(form_data.username, form_data.password)
|
| 222 |
+
if not user:
|
| 223 |
+
raise HTTPException(
|
| 224 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 225 |
+
detail="Incorrect username or password",
|
| 226 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 227 |
+
)
|
| 228 |
+
access_token = create_access_token(
|
| 229 |
+
data={"sub": user.username},
|
| 230 |
+
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
| 231 |
+
)
|
| 232 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 233 |
+
|
| 234 |
+
@app.post("/logout")
|
| 235 |
+
async def logout(current_user: User = Depends(get_current_user)):
|
| 236 |
+
return {"message": "Successfully logged out"}
|
| 237 |
+
|
| 238 |
+
# MindMap CRUD endpoints (all under /api/workflows)
|
| 239 |
+
@app.get("/api/workflows", response_model=List[Dict[str, Any]])
|
| 240 |
+
async def get_workflows(current_user: User = Depends(get_current_user)):
|
| 241 |
+
mindmaps = await MindMap.find({"user_id": current_user.username}).to_list()
|
| 242 |
+
return [
|
| 243 |
+
{
|
| 244 |
+
"id": str(m.id),
|
| 245 |
+
"name": m.name,
|
| 246 |
+
"nodes": m.nodes,
|
| 247 |
+
"edges": m.edges,
|
| 248 |
+
"updatedAt": m.updated_at.isoformat() if m.updated_at else None,
|
| 249 |
+
"createdAt": m.created_at.isoformat() if m.created_at else None,
|
| 250 |
+
}
|
| 251 |
+
for m in mindmaps
|
| 252 |
+
]
|
| 253 |
+
|
| 254 |
+
@app.post("/api/workflows", response_model=Dict[str, Any])
|
| 255 |
+
async def create_workflow(
|
| 256 |
+
workflow: MindMapBase, current_user: User = Depends(get_current_user)
|
| 257 |
+
):
|
| 258 |
+
# Validate unique node IDs
|
| 259 |
+
node_ids = [node.id for node in workflow.nodes]
|
| 260 |
+
if len(node_ids) != len(set(node_ids)):
|
| 261 |
+
raise HTTPException(status_code=400, detail="Duplicate node IDs are not allowed")
|
| 262 |
+
new_map = MindMap(
|
| 263 |
+
user_id=current_user.username,
|
| 264 |
+
name=workflow.name,
|
| 265 |
+
nodes=workflow.nodes,
|
| 266 |
+
edges=workflow.edges,
|
| 267 |
+
created_at=datetime.utcnow(),
|
| 268 |
+
updated_at=datetime.utcnow(),
|
| 269 |
+
)
|
| 270 |
+
await new_map.insert()
|
| 271 |
+
return {
|
| 272 |
+
"id": str(new_map.id),
|
| 273 |
+
"name": new_map.name,
|
| 274 |
+
"nodes": new_map.nodes,
|
| 275 |
+
"edges": new_map.edges,
|
| 276 |
+
"updatedAt": new_map.updated_at.isoformat() if new_map.updated_at else None,
|
| 277 |
+
"createdAt": new_map.created_at.isoformat() if new_map.created_at else None,
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
@app.get("/api/workflows/{workflow_id}", response_model=Dict[str, Any])
|
| 281 |
+
async def get_workflow(
|
| 282 |
+
workflow_id: str, current_user: User = Depends(get_current_user)
|
| 283 |
+
):
|
| 284 |
+
mind_map = await MindMap.get(workflow_id)
|
| 285 |
+
if not mind_map or mind_map.user_id != current_user.username:
|
| 286 |
+
raise HTTPException(status_code=404, detail="Workflow not found")
|
| 287 |
+
return {
|
| 288 |
+
"id": str(mind_map.id),
|
| 289 |
+
"name": mind_map.name,
|
| 290 |
+
"nodes": mind_map.nodes,
|
| 291 |
+
"edges": mind_map.edges,
|
| 292 |
+
"updatedAt": mind_map.updated_at.isoformat() if mind_map.updated_at else None,
|
| 293 |
+
"createdAt": mind_map.created_at.isoformat() if mind_map.created_at else None,
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
@app.put("/api/workflows/{workflow_id}", response_model=Dict[str, Any])
|
| 297 |
+
async def update_workflow(
|
| 298 |
+
workflow_id: str,
|
| 299 |
+
workflow: MindMapBase,
|
| 300 |
+
current_user: User = Depends(get_current_user),
|
| 301 |
+
):
|
| 302 |
+
mind_map = await MindMap.get(workflow_id)
|
| 303 |
+
if not mind_map or mind_map.user_id != current_user.username:
|
| 304 |
+
raise HTTPException(status_code=404, detail="Workflow not found")
|
| 305 |
+
# Validate unique node IDs
|
| 306 |
+
node_ids = [node.id for node in workflow.nodes]
|
| 307 |
+
if len(node_ids) != len(set(node_ids)):
|
| 308 |
+
raise HTTPException(status_code=400, detail="Duplicate node IDs are not allowed")
|
| 309 |
+
mind_map.name = workflow.name
|
| 310 |
+
mind_map.nodes = workflow.nodes
|
| 311 |
+
mind_map.edges = workflow.edges
|
| 312 |
+
mind_map.updated_at = datetime.utcnow()
|
| 313 |
+
await mind_map.save()
|
| 314 |
+
return {
|
| 315 |
+
"id": str(mind_map.id),
|
| 316 |
+
"name": mind_map.name,
|
| 317 |
+
"nodes": mind_map.nodes,
|
| 318 |
+
"edges": mind_map.edges,
|
| 319 |
+
"updatedAt": mind_map.updated_at.isoformat() if mind_map.updated_at else None,
|
| 320 |
+
"createdAt": mind_map.created_at.isoformat() if mind_map.created_at else None,
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
@app.delete("/api/workflows/{workflow_id}")
|
| 324 |
+
async def delete_workflow(
|
| 325 |
+
workflow_id: str, current_user: User = Depends(get_current_user)
|
| 326 |
+
):
|
| 327 |
+
mind_map = await MindMap.get(workflow_id)
|
| 328 |
+
if not mind_map or mind_map.user_id != current_user.username:
|
| 329 |
+
raise HTTPException(status_code=404, detail="Workflow not found")
|
| 330 |
+
await mind_map.delete()
|
| 331 |
+
return {"message": "Workflow deleted successfully"}
|
| 332 |
+
|
| 333 |
+
# Mistral API endpoints
|
| 334 |
+
class ChatMessage(BaseModel):
|
| 335 |
+
role: str # Options: 'user', 'assistant', 'system'
|
| 336 |
+
content: str
|
| 337 |
+
|
| 338 |
+
class ChatRequest(BaseModel):
|
| 339 |
+
messages: List[ChatMessage]
|
| 340 |
+
model: str = "mistral-large-latest"
|
| 341 |
+
stream: bool = False
|
| 342 |
+
safe_prompt: Optional[bool] = False
|
| 343 |
+
stop: Optional[List[str]] = None
|
| 344 |
+
|
| 345 |
+
@app.get("/models")
|
| 346 |
+
def list_models():
|
| 347 |
+
if not client:
|
| 348 |
+
raise HTTPException(status_code=500, detail="Mistral API key not configured.")
|
| 349 |
+
models = client.models.list()
|
| 350 |
+
return {"models": [model.id for model in models.data]}
|
| 351 |
+
|
| 352 |
+
@app.post("/chat")
|
| 353 |
+
async def chat(request: ChatRequest):
|
| 354 |
+
if not client:
|
| 355 |
+
raise HTTPException(status_code=500, detail="Mistral API key not configured.")
|
| 356 |
+
# If streaming is requested
|
| 357 |
+
if request.stream:
|
| 358 |
+
def stream_response():
|
| 359 |
+
response = client.chat.stream(
|
| 360 |
+
model=request.model,
|
| 361 |
+
messages=[msg.dict() for msg in request.messages],
|
| 362 |
+
safe_prompt=request.safe_prompt,
|
| 363 |
+
stop=request.stop
|
| 364 |
+
)
|
| 365 |
+
for chunk in response:
|
| 366 |
+
content = chunk.data.choices[0].delta.content
|
| 367 |
+
if content:
|
| 368 |
+
yield content
|
| 369 |
+
return StreamingResponse(stream_response(), media_type="text/plain")
|
| 370 |
+
# Non-streaming (standard) completion
|
| 371 |
+
else:
|
| 372 |
+
response = client.chat.complete(
|
| 373 |
+
model=request.model,
|
| 374 |
+
messages=[msg.dict() for msg in request.messages],
|
| 375 |
+
safe_prompt=request.safe_prompt,
|
| 376 |
+
stop=request.stop
|
| 377 |
+
)
|
| 378 |
+
return JSONResponse({
|
| 379 |
+
"response": response.choices[0].message.content
|
| 380 |
+
})
|
| 381 |
+
|
| 382 |
+
# Misc endpoints
|
| 383 |
+
@app.get("/")
|
| 384 |
+
async def root():
|
| 385 |
+
return {"message": "Mind Map API"}
|
| 386 |
+
|
| 387 |
+
@app.get("/health")
|
| 388 |
+
async def health():
|
| 389 |
+
return {"status": "ok"}
|
| 390 |
+
|
| 391 |
+
if __name__ == "__main__":
|
| 392 |
+
import uvicorn
|
| 393 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
| 394 |
+
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
beanie
|
| 4 |
+
motor
|
| 5 |
+
pydantic
|
| 6 |
+
passlib[bcrypt]
|
| 7 |
+
python-dotenv
|
| 8 |
+
python-jose
|
| 9 |
+
mistralai
|
| 10 |
+
pydantic[email]
|
| 11 |
+
python-multipart
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
frontend/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Getting Started with Create React App
|
| 2 |
+
|
| 3 |
+
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
| 4 |
+
|
| 5 |
+
## Available Scripts
|
| 6 |
+
|
| 7 |
+
In the project directory, you can run:
|
| 8 |
+
|
| 9 |
+
### `npm start`
|
| 10 |
+
|
| 11 |
+
Runs the app in the development mode.\
|
| 12 |
+
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
| 13 |
+
|
| 14 |
+
The page will reload if you make edits.\
|
| 15 |
+
You will also see any lint errors in the console.
|
| 16 |
+
|
| 17 |
+
### `npm test`
|
| 18 |
+
|
| 19 |
+
Launches the test runner in the interactive watch mode.\
|
| 20 |
+
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
| 21 |
+
|
| 22 |
+
### `npm run build`
|
| 23 |
+
|
| 24 |
+
Builds the app for production to the `build` folder.\
|
| 25 |
+
It correctly bundles React in production mode and optimizes the build for the best performance.
|
| 26 |
+
|
| 27 |
+
The build is minified and the filenames include the hashes.\
|
| 28 |
+
Your app is ready to be deployed!
|
| 29 |
+
|
| 30 |
+
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
| 31 |
+
|
| 32 |
+
### `npm run eject`
|
| 33 |
+
|
| 34 |
+
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
| 35 |
+
|
| 36 |
+
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
| 37 |
+
|
| 38 |
+
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
| 39 |
+
|
| 40 |
+
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
| 41 |
+
|
| 42 |
+
## Learn More
|
| 43 |
+
|
| 44 |
+
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
| 45 |
+
|
| 46 |
+
To learn React, check out the [React documentation](https://reactjs.org/).
|
frontend/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"dependencies": {
|
| 6 |
+
"@emotion/react": "^11.11.3",
|
| 7 |
+
"@emotion/styled": "^11.11.0",
|
| 8 |
+
"@testing-library/jest-dom": "^6.1.5",
|
| 9 |
+
"@testing-library/react": "^14.1.2",
|
| 10 |
+
"@testing-library/user-event": "^14.5.1",
|
| 11 |
+
"@types/jest": "^29.5.11",
|
| 12 |
+
"@types/node": "^20.10.5",
|
| 13 |
+
"@types/react": "^18.2.45",
|
| 14 |
+
"@types/react-dom": "^18.2.18",
|
| 15 |
+
"@types/react-router-dom": "^5.3.3",
|
| 16 |
+
"ajv": "8.12.0",
|
| 17 |
+
"ajv-keywords": "5.1.0",
|
| 18 |
+
"axios": "^1.6.2",
|
| 19 |
+
"react": "^18.2.0",
|
| 20 |
+
"react-dom": "^18.2.0",
|
| 21 |
+
"react-icons": "^4.12.0",
|
| 22 |
+
"react-router-dom": "^6.21.1",
|
| 23 |
+
"react-scripts": "5.0.1",
|
| 24 |
+
"reactflow": "^11.10.1",
|
| 25 |
+
"typescript": "^5.3.3",
|
| 26 |
+
"uuid": "^9.0.1",
|
| 27 |
+
"web-vitals": "^3.5.0"
|
| 28 |
+
},
|
| 29 |
+
"resolutions": {
|
| 30 |
+
"ajv": "8.12.0",
|
| 31 |
+
"ajv-keywords": "5.1.0",
|
| 32 |
+
"nth-check": "2.1.1",
|
| 33 |
+
"postcss": "8.4.32",
|
| 34 |
+
"svgo": "3.0.2",
|
| 35 |
+
"@svgr/plugin-svgo": "8.1.0",
|
| 36 |
+
"@svgr/webpack": "8.1.0",
|
| 37 |
+
"resolve-url-loader": "5.0.0",
|
| 38 |
+
"glob": "10.3.10",
|
| 39 |
+
"semver": "7.5.4"
|
| 40 |
+
},
|
| 41 |
+
"scripts": {
|
| 42 |
+
"start": "react-scripts start",
|
| 43 |
+
"build": "react-scripts build",
|
| 44 |
+
"test": "react-scripts test",
|
| 45 |
+
"eject": "react-scripts eject",
|
| 46 |
+
"preinstall": "npx npm-force-resolutions",
|
| 47 |
+
"security-check": "npm audit"
|
| 48 |
+
},
|
| 49 |
+
"eslintConfig": {
|
| 50 |
+
"extends": [
|
| 51 |
+
"react-app",
|
| 52 |
+
"react-app/jest"
|
| 53 |
+
]
|
| 54 |
+
},
|
| 55 |
+
"browserslist": {
|
| 56 |
+
"production": [
|
| 57 |
+
">0.2%",
|
| 58 |
+
"not dead",
|
| 59 |
+
"not op_mini all"
|
| 60 |
+
],
|
| 61 |
+
"development": [
|
| 62 |
+
"last 1 chrome version",
|
| 63 |
+
"last 1 firefox version",
|
| 64 |
+
"last 1 safari version"
|
| 65 |
+
]
|
| 66 |
+
},
|
| 67 |
+
"devDependencies": {
|
| 68 |
+
"@types/uuid": "^9.0.7",
|
| 69 |
+
"npm-force-resolutions": "^0.0.10"
|
| 70 |
+
},
|
| 71 |
+
"overrides": {
|
| 72 |
+
"glob": "10.3.10",
|
| 73 |
+
"semver": "7.5.4"
|
| 74 |
+
}
|
| 75 |
+
}
|
frontend/public/favicon.ico
ADDED
|
|
frontend/public/index.html
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<meta name="theme-color" content="#000000" />
|
| 8 |
+
<meta
|
| 9 |
+
name="description"
|
| 10 |
+
content="Web site created using create-react-app"
|
| 11 |
+
/>
|
| 12 |
+
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
| 13 |
+
<!--
|
| 14 |
+
manifest.json provides metadata used when your web app is installed on a
|
| 15 |
+
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
| 16 |
+
-->
|
| 17 |
+
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
| 18 |
+
<!--
|
| 19 |
+
Notice the use of %PUBLIC_URL% in the tags above.
|
| 20 |
+
It will be replaced with the URL of the `public` folder during the build.
|
| 21 |
+
Only files inside the `public` folder can be referenced from the HTML.
|
| 22 |
+
|
| 23 |
+
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
| 24 |
+
work correctly both with client-side routing and a non-root public URL.
|
| 25 |
+
Learn how to configure a non-root public URL by running `npm run build`.
|
| 26 |
+
-->
|
| 27 |
+
<title>React App</title>
|
| 28 |
+
</head>
|
| 29 |
+
<body>
|
| 30 |
+
<noscript>You need to enable JavaScript to run this app.</noscript>
|
| 31 |
+
<div id="root"></div>
|
| 32 |
+
<!--
|
| 33 |
+
This HTML file is a template.
|
| 34 |
+
If you open it directly in the browser, you will see an empty page.
|
| 35 |
+
|
| 36 |
+
You can add webfonts, meta tags, or analytics to this file.
|
| 37 |
+
The build step will place the bundled scripts into the <body> tag.
|
| 38 |
+
|
| 39 |
+
To begin the development, run `npm start` or `yarn start`.
|
| 40 |
+
To create a production bundle, use `npm run build` or `yarn build`.
|
| 41 |
+
-->
|
| 42 |
+
</body>
|
| 43 |
+
</html>
|
frontend/public/logo192.png
ADDED
|
frontend/public/logo512.png
ADDED
|
frontend/public/manifest.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"short_name": "React App",
|
| 3 |
+
"name": "Create React App Sample",
|
| 4 |
+
"icons": [
|
| 5 |
+
{
|
| 6 |
+
"src": "favicon.ico",
|
| 7 |
+
"sizes": "64x64 32x32 24x24 16x16",
|
| 8 |
+
"type": "image/x-icon"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"src": "logo192.png",
|
| 12 |
+
"type": "image/png",
|
| 13 |
+
"sizes": "192x192"
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"src": "logo512.png",
|
| 17 |
+
"type": "image/png",
|
| 18 |
+
"sizes": "512x512"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"start_url": ".",
|
| 22 |
+
"display": "standalone",
|
| 23 |
+
"theme_color": "#000000",
|
| 24 |
+
"background_color": "#ffffff"
|
| 25 |
+
}
|
frontend/public/robots.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# https://www.robotstxt.org/robotstxt.html
|
| 2 |
+
User-agent: *
|
| 3 |
+
Disallow:
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
html {
|
| 2 |
+
box-sizing: border-box;
|
| 3 |
+
}
|
| 4 |
+
*, *:before, *:after {
|
| 5 |
+
box-sizing: inherit;
|
| 6 |
+
}
|
| 7 |
+
body {
|
| 8 |
+
margin: 0;
|
| 9 |
+
padding: 0;
|
| 10 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
| 11 |
+
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
| 12 |
+
sans-serif;
|
| 13 |
+
-webkit-font-smoothing: antialiased;
|
| 14 |
+
-moz-osx-font-smoothing: grayscale;
|
| 15 |
+
background: #f5f7fa;
|
| 16 |
+
color: #222;
|
| 17 |
+
min-height: 100vh;
|
| 18 |
+
min-width: 100vw;
|
| 19 |
+
overflow-x: hidden;
|
| 20 |
+
}
|
| 21 |
+
#root {
|
| 22 |
+
width: 100vw;
|
| 23 |
+
height: 100vh;
|
| 24 |
+
overflow: hidden;
|
| 25 |
+
display: flex;
|
| 26 |
+
flex-direction: column;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/* ReactFlow customizations */
|
| 30 |
+
.react-flow__node {
|
| 31 |
+
padding: 10px;
|
| 32 |
+
border-radius: 5px;
|
| 33 |
+
width: 150px;
|
| 34 |
+
font-size: 12px;
|
| 35 |
+
color: #222;
|
| 36 |
+
text-align: center;
|
| 37 |
+
border-width: 1px;
|
| 38 |
+
border-style: solid;
|
| 39 |
+
background: white;
|
| 40 |
+
border-color: #1a192b;
|
| 41 |
+
}
|
| 42 |
+
.react-flow__node-input {
|
| 43 |
+
background: #f6f6f6;
|
| 44 |
+
border-color: #0041d0;
|
| 45 |
+
}
|
| 46 |
+
.react-flow__node-default {
|
| 47 |
+
background: #f6f6f6;
|
| 48 |
+
border-color: #1a192b;
|
| 49 |
+
}
|
| 50 |
+
.react-flow__node-output {
|
| 51 |
+
background: #f6f6f6;
|
| 52 |
+
border-color: #ff0072;
|
| 53 |
+
}
|
| 54 |
+
.react-flow__handle {
|
| 55 |
+
width: 8px;
|
| 56 |
+
height: 8px;
|
| 57 |
+
background: #1a192b;
|
| 58 |
+
border: 1px solid white;
|
| 59 |
+
}
|
| 60 |
+
.react-flow__handle-top { top: -4px; }
|
| 61 |
+
.react-flow__handle-bottom { bottom: -4px; }
|
| 62 |
+
.react-flow__handle-left { left: -4px; }
|
| 63 |
+
.react-flow__handle-right { right: -4px; }
|
| 64 |
+
.react-flow__edge-path {
|
| 65 |
+
stroke: #b1b1b7;
|
| 66 |
+
stroke-width: 2;
|
| 67 |
+
}
|
| 68 |
+
.react-flow__edge.selected .react-flow__edge-path {
|
| 69 |
+
stroke: #0041d0;
|
| 70 |
+
}
|
| 71 |
+
.react-flow__controls {
|
| 72 |
+
box-shadow: 0 0 2px 1px rgba(0, 0, 0, 0.08);
|
| 73 |
+
}
|
| 74 |
+
.react-flow__controls-button {
|
| 75 |
+
border: none;
|
| 76 |
+
background: #f8f8f8;
|
| 77 |
+
border-bottom: 1px solid #eee;
|
| 78 |
+
box-sizing: content-box;
|
| 79 |
+
display: flex;
|
| 80 |
+
justify-content: center;
|
| 81 |
+
align-items: center;
|
| 82 |
+
width: 16px;
|
| 83 |
+
height: 16px;
|
| 84 |
+
cursor: pointer;
|
| 85 |
+
user-select: none;
|
| 86 |
+
padding: 5px;
|
| 87 |
+
}
|
| 88 |
+
.react-flow__controls-button:hover {
|
| 89 |
+
background: #f1f1f1;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.mindmap-editor-container {
|
| 93 |
+
width: 100vw;
|
| 94 |
+
height: 100vh;
|
| 95 |
+
display: flex;
|
| 96 |
+
flex-direction: row;
|
| 97 |
+
}
|
| 98 |
+
@media (max-width: 700px) {
|
| 99 |
+
.mindmap-editor-container {
|
| 100 |
+
flex-direction: column;
|
| 101 |
+
height: 100dvh;
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
.toolbar {
|
| 105 |
+
display: flex;
|
| 106 |
+
gap: 8px;
|
| 107 |
+
padding: 8px;
|
| 108 |
+
background: #f5faff;
|
| 109 |
+
border-bottom: 1px solid #eaf4ff;
|
| 110 |
+
z-index: 10;
|
| 111 |
+
}
|
| 112 |
+
.toolbar button {
|
| 113 |
+
background: #fff;
|
| 114 |
+
border: 1px solid #cce3ff;
|
| 115 |
+
border-radius: 4px;
|
| 116 |
+
padding: 6px 12px;
|
| 117 |
+
font-size: 1rem;
|
| 118 |
+
cursor: pointer;
|
| 119 |
+
transition: background 0.2s;
|
| 120 |
+
}
|
| 121 |
+
.toolbar button:focus {
|
| 122 |
+
outline: 2px solid #007bff;
|
| 123 |
+
}
|
| 124 |
+
.toolbar button:hover {
|
| 125 |
+
background: #eaf4ff;
|
| 126 |
+
}
|
| 127 |
+
.error-toast {
|
| 128 |
+
position: fixed;
|
| 129 |
+
top: 20px;
|
| 130 |
+
left: 50%;
|
| 131 |
+
transform: translateX(-50%);
|
| 132 |
+
background: #dc3545;
|
| 133 |
+
color: white;
|
| 134 |
+
padding: 10px 20px;
|
| 135 |
+
border-radius: 4px;
|
| 136 |
+
z-index: 1000;
|
| 137 |
+
display: flex;
|
| 138 |
+
align-items: center;
|
| 139 |
+
gap: 8px;
|
| 140 |
+
min-width: 200px;
|
| 141 |
+
}
|
| 142 |
+
.error-toast button {
|
| 143 |
+
background: none;
|
| 144 |
+
border: none;
|
| 145 |
+
color: white;
|
| 146 |
+
font-size: 1.2rem;
|
| 147 |
+
cursor: pointer;
|
| 148 |
+
margin-left: 8px;
|
| 149 |
+
}
|
| 150 |
+
.touch-friendly .toolbar button {
|
| 151 |
+
min-width: 44px;
|
| 152 |
+
min-height: 44px;
|
| 153 |
+
font-size: 1.1rem;
|
| 154 |
+
}
|
| 155 |
+
@media (max-width: 700px) {
|
| 156 |
+
.toolbar {
|
| 157 |
+
flex-wrap: wrap;
|
| 158 |
+
gap: 4px;
|
| 159 |
+
padding: 4px;
|
| 160 |
+
}
|
| 161 |
+
.error-toast {
|
| 162 |
+
top: 60px;
|
| 163 |
+
min-width: 120px;
|
| 164 |
+
font-size: 0.95rem;
|
| 165 |
+
padding: 8px 10px;
|
| 166 |
+
}
|
| 167 |
+
}
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
| 3 |
+
import Auth from './components/Auth';
|
| 4 |
+
import { ReactFlowProvider } from 'reactflow';
|
| 5 |
+
import MindMapEditor from './components/MindMapEditor';
|
| 6 |
+
import LandingPage from './components/LandingPage';
|
| 7 |
+
|
| 8 |
+
interface PrivateRouteProps {
|
| 9 |
+
children: React.ReactNode;
|
| 10 |
+
redirectTo?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const PrivateRoute: React.FC<PrivateRouteProps> = ({
|
| 14 |
+
children,
|
| 15 |
+
redirectTo = "/"
|
| 16 |
+
}) => {
|
| 17 |
+
const token = localStorage.getItem('token');
|
| 18 |
+
return token ? <>{children}</> : <Navigate to={redirectTo} />;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const App: React.FC = () => {
|
| 22 |
+
return (
|
| 23 |
+
<Router>
|
| 24 |
+
<Routes>
|
| 25 |
+
<Route path="/" element={<LandingPage />} />
|
| 26 |
+
<Route path="/login" element={<Auth isLogin={true} />} />
|
| 27 |
+
<Route path="/signup" element={<Auth isLogin={false} />} />
|
| 28 |
+
<Route
|
| 29 |
+
path="/editor"
|
| 30 |
+
element={
|
| 31 |
+
<PrivateRoute>
|
| 32 |
+
<ReactFlowProvider>
|
| 33 |
+
<MindMapEditor />
|
| 34 |
+
</ReactFlowProvider>
|
| 35 |
+
</PrivateRoute>
|
| 36 |
+
}
|
| 37 |
+
/>
|
| 38 |
+
</Routes>
|
| 39 |
+
</Router>
|
| 40 |
+
);
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
export default App;
|
frontend/src/components/Auth.module.css
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.authContainer {
|
| 2 |
+
display: flex;
|
| 3 |
+
justify-content: center;
|
| 4 |
+
align-items: center;
|
| 5 |
+
min-height: 100vh;
|
| 6 |
+
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
| 7 |
+
padding: 20px;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.authForm {
|
| 11 |
+
background: white;
|
| 12 |
+
padding: 2rem;
|
| 13 |
+
border-radius: 8px;
|
| 14 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 15 |
+
width: 100%;
|
| 16 |
+
max-width: 400px;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.authForm h2 {
|
| 20 |
+
text-align: center;
|
| 21 |
+
color: #2c3e50;
|
| 22 |
+
margin-bottom: 1.5rem;
|
| 23 |
+
font-size: 1.8rem;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.formGroup {
|
| 27 |
+
margin-bottom: 1.2rem;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.formGroup label {
|
| 31 |
+
display: block;
|
| 32 |
+
margin-bottom: 0.5rem;
|
| 33 |
+
color: #4a5568;
|
| 34 |
+
font-weight: 500;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.formGroup input {
|
| 38 |
+
width: 100%;
|
| 39 |
+
padding: 0.75rem;
|
| 40 |
+
border: 1px solid #e2e8f0;
|
| 41 |
+
border-radius: 4px;
|
| 42 |
+
font-size: 1rem;
|
| 43 |
+
transition: all 0.2s ease;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.formGroup input:focus {
|
| 47 |
+
outline: none;
|
| 48 |
+
border-color: #4299e1;
|
| 49 |
+
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.2);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.formGroup input:disabled {
|
| 53 |
+
background-color: #f7fafc;
|
| 54 |
+
cursor: not-allowed;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.error {
|
| 58 |
+
background-color: #fff5f5;
|
| 59 |
+
color: #c53030;
|
| 60 |
+
padding: 0.75rem;
|
| 61 |
+
border-radius: 4px;
|
| 62 |
+
margin-bottom: 1rem;
|
| 63 |
+
font-size: 0.875rem;
|
| 64 |
+
border: 1px solid #feb2b2;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.submitButton {
|
| 68 |
+
width: 100%;
|
| 69 |
+
padding: 0.75rem;
|
| 70 |
+
background-color: #4299e1;
|
| 71 |
+
color: white;
|
| 72 |
+
border: none;
|
| 73 |
+
border-radius: 4px;
|
| 74 |
+
font-size: 1rem;
|
| 75 |
+
font-weight: 500;
|
| 76 |
+
cursor: pointer;
|
| 77 |
+
transition: all 0.2s ease;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.submitButton:hover:not(:disabled) {
|
| 81 |
+
background-color: #3182ce;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.submitButton:disabled {
|
| 85 |
+
background-color: #a0aec0;
|
| 86 |
+
cursor: not-allowed;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.submitButton:focus {
|
| 90 |
+
outline: none;
|
| 91 |
+
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.2);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
@media (max-width: 480px) {
|
| 95 |
+
.authForm {
|
| 96 |
+
padding: 1.5rem;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.authForm h2 {
|
| 100 |
+
font-size: 1.5rem;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.formGroup input {
|
| 104 |
+
padding: 0.625rem;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.submitButton {
|
| 108 |
+
padding: 0.625rem;
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.switchText {
|
| 113 |
+
text-align: center;
|
| 114 |
+
margin-top: 1.2rem;
|
| 115 |
+
color: #6c757d;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.switchText a {
|
| 119 |
+
color: #007bff;
|
| 120 |
+
text-decoration: none;
|
| 121 |
+
font-weight: 700;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.switchText a:hover {
|
| 125 |
+
text-decoration: underline;
|
| 126 |
+
}
|
frontend/src/components/Auth.tsx
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useCallback } from 'react';
|
| 2 |
+
import axios, { handleApiError } from '../utils/axios';
|
| 3 |
+
import styles from './Auth.module.css';
|
| 4 |
+
|
| 5 |
+
interface AuthForm {
|
| 6 |
+
username: string;
|
| 7 |
+
email: string;
|
| 8 |
+
password: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
interface AuthProps {
|
| 12 |
+
isLogin: boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const Auth: React.FC<AuthProps> = ({ isLogin }) => {
|
| 16 |
+
const [form, setForm] = useState<AuthForm>({ username: '', email: '', password: '' });
|
| 17 |
+
const [error, setError] = useState<string>('');
|
| 18 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 19 |
+
|
| 20 |
+
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
| 21 |
+
setForm(prev => ({ ...prev, [e.target.name]: e.target.value }));
|
| 22 |
+
setError(''); // Clear error when user starts typing
|
| 23 |
+
}, []);
|
| 24 |
+
|
| 25 |
+
const validateForm = useCallback((): boolean => {
|
| 26 |
+
if (!form.username.trim()) {
|
| 27 |
+
setError('Username is required');
|
| 28 |
+
return false;
|
| 29 |
+
}
|
| 30 |
+
if (!form.password.trim()) {
|
| 31 |
+
setError('Password is required');
|
| 32 |
+
return false;
|
| 33 |
+
}
|
| 34 |
+
if (!isLogin && !form.email.trim()) {
|
| 35 |
+
setError('Email is required');
|
| 36 |
+
return false;
|
| 37 |
+
}
|
| 38 |
+
if (!isLogin && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
|
| 39 |
+
setError('Please enter a valid email address');
|
| 40 |
+
return false;
|
| 41 |
+
}
|
| 42 |
+
return true;
|
| 43 |
+
}, [form, isLogin]);
|
| 44 |
+
|
| 45 |
+
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
| 46 |
+
e.preventDefault();
|
| 47 |
+
setError('');
|
| 48 |
+
|
| 49 |
+
if (!validateForm()) {
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
setIsLoading(true);
|
| 54 |
+
try {
|
| 55 |
+
if (isLogin) {
|
| 56 |
+
const formData = new FormData();
|
| 57 |
+
formData.append('username', form.username);
|
| 58 |
+
formData.append('password', form.password);
|
| 59 |
+
|
| 60 |
+
const res = await axios.post('/login', formData, {
|
| 61 |
+
headers: {
|
| 62 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
| 63 |
+
},
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
const { access_token, refresh_token } = res.data;
|
| 67 |
+
localStorage.setItem('token', access_token);
|
| 68 |
+
localStorage.setItem('refreshToken', refresh_token);
|
| 69 |
+
window.location.href = '/editor';
|
| 70 |
+
} else {
|
| 71 |
+
await axios.post('/signup', form);
|
| 72 |
+
window.location.href = '/login';
|
| 73 |
+
}
|
| 74 |
+
} catch (error) {
|
| 75 |
+
setError(handleApiError(error));
|
| 76 |
+
} finally {
|
| 77 |
+
setIsLoading(false);
|
| 78 |
+
}
|
| 79 |
+
}, [form, isLogin, validateForm]);
|
| 80 |
+
|
| 81 |
+
return (
|
| 82 |
+
<div className={styles.authContainer}>
|
| 83 |
+
<form onSubmit={handleSubmit} className={styles.authForm}>
|
| 84 |
+
<h2>{isLogin ? 'Login' : 'Sign Up'}</h2>
|
| 85 |
+
|
| 86 |
+
<div className={styles.formGroup}>
|
| 87 |
+
<label htmlFor="username">Username</label>
|
| 88 |
+
<input
|
| 89 |
+
type="text"
|
| 90 |
+
id="username"
|
| 91 |
+
name="username"
|
| 92 |
+
value={form.username}
|
| 93 |
+
onChange={handleChange}
|
| 94 |
+
disabled={isLoading}
|
| 95 |
+
required
|
| 96 |
+
/>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
{!isLogin && (
|
| 100 |
+
<div className={styles.formGroup}>
|
| 101 |
+
<label htmlFor="email">Email</label>
|
| 102 |
+
<input
|
| 103 |
+
type="email"
|
| 104 |
+
id="email"
|
| 105 |
+
name="email"
|
| 106 |
+
value={form.email}
|
| 107 |
+
onChange={handleChange}
|
| 108 |
+
disabled={isLoading}
|
| 109 |
+
required
|
| 110 |
+
/>
|
| 111 |
+
</div>
|
| 112 |
+
)}
|
| 113 |
+
|
| 114 |
+
<div className={styles.formGroup}>
|
| 115 |
+
<label htmlFor="password">Password</label>
|
| 116 |
+
<input
|
| 117 |
+
type="password"
|
| 118 |
+
id="password"
|
| 119 |
+
name="password"
|
| 120 |
+
value={form.password}
|
| 121 |
+
onChange={handleChange}
|
| 122 |
+
disabled={isLoading}
|
| 123 |
+
required
|
| 124 |
+
/>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
{error && <div className={styles.error}>{error}</div>}
|
| 128 |
+
|
| 129 |
+
<button
|
| 130 |
+
type="submit"
|
| 131 |
+
className={styles.submitButton}
|
| 132 |
+
disabled={isLoading}
|
| 133 |
+
>
|
| 134 |
+
{isLoading ? 'Processing...' : (isLogin ? 'Login' : 'Sign Up')}
|
| 135 |
+
</button>
|
| 136 |
+
</form>
|
| 137 |
+
</div>
|
| 138 |
+
);
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
export default Auth;
|
frontend/src/components/ChatModal.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
export interface MindMapData {
|
| 4 |
+
nodes: any[];
|
| 5 |
+
edges: any[];
|
| 6 |
+
config: any;
|
| 7 |
+
mindMapName: string;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export interface ChatModalProps {
|
| 11 |
+
open: boolean;
|
| 12 |
+
onClose: () => void;
|
| 13 |
+
onImportJSON: (event: any) => void;
|
| 14 |
+
currentMindMapData: MindMapData;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
declare const ChatModal: React.FC<ChatModalProps>;
|
| 18 |
+
export default ChatModal;
|
frontend/src/components/ChatModal.tsx
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { MindMapData } from './ChatModal.d';
|
| 3 |
+
|
| 4 |
+
interface ChatModalProps {
|
| 5 |
+
open: boolean;
|
| 6 |
+
onClose: () => void;
|
| 7 |
+
onImportJSON: (event: any) => void;
|
| 8 |
+
currentMindMapData: MindMapData;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const createSystemPrompt = (currentData: MindMapData) => {
|
| 12 |
+
return `You are the best mindmap builder to build and optimize mindmap content to return the best organized json response for reactflow library.
|
| 13 |
+
|
| 14 |
+
Current Mindmap State:
|
| 15 |
+
- Name: ${currentData.mindMapName}
|
| 16 |
+
- Number of Nodes: ${currentData.nodes.length}
|
| 17 |
+
- Number of Connections: ${currentData.edges.length}
|
| 18 |
+
- Current Configuration: ${JSON.stringify(currentData.config)}
|
| 19 |
+
|
| 20 |
+
IMPORTANT: Your response must be a valid JSON object that follows this exact structure:
|
| 21 |
+
{
|
| 22 |
+
"id": "string (optional)",
|
| 23 |
+
"name": "string",
|
| 24 |
+
"updatedAt": "string (ISO date)",
|
| 25 |
+
"nodes": [
|
| 26 |
+
{
|
| 27 |
+
"id": "string",
|
| 28 |
+
"type": "editableColor",
|
| 29 |
+
"data": {
|
| 30 |
+
"label": "string",
|
| 31 |
+
"bgColor": "string (hex color)",
|
| 32 |
+
"textColor": "string (hex color)"
|
| 33 |
+
},
|
| 34 |
+
"position": {
|
| 35 |
+
"x": number,
|
| 36 |
+
"y": number
|
| 37 |
+
},
|
| 38 |
+
"width": number,
|
| 39 |
+
"height": number
|
| 40 |
+
}
|
| 41 |
+
],
|
| 42 |
+
"edges": [
|
| 43 |
+
{
|
| 44 |
+
"id": "string (reactflow__edge-{source}-{target})",
|
| 45 |
+
"source": "string (node id)",
|
| 46 |
+
"target": "string (node id)"
|
| 47 |
+
}
|
| 48 |
+
],
|
| 49 |
+
"config": {
|
| 50 |
+
"nodeBgColor": "string (hex color)",
|
| 51 |
+
"nodeTextColor": "string (hex color)"
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
Please analyze the current mindmap structure and provide optimized suggestions or modifications based on the user's input. Return ONLY the JSON object without any additional text or markdown formatting.`;
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const ChatModal: React.FC<ChatModalProps> = ({ open, onClose, onImportJSON, currentMindMapData }) => {
|
| 59 |
+
const [userInput, setUserInput] = useState('');
|
| 60 |
+
const [response, setResponse] = useState('');
|
| 61 |
+
const [loading, setLoading] = useState(false);
|
| 62 |
+
const [jsonReady, setJsonReady] = useState(false);
|
| 63 |
+
|
| 64 |
+
if (!open) return null;
|
| 65 |
+
|
| 66 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 67 |
+
e.preventDefault();
|
| 68 |
+
setLoading(true);
|
| 69 |
+
setResponse('');
|
| 70 |
+
setJsonReady(false);
|
| 71 |
+
try {
|
| 72 |
+
// Use the correct API endpoint from environment variables
|
| 73 |
+
const apiUrl = process.env.REACT_APP_CHAT_API_URL || 'http://localhost:3001/api/chat';
|
| 74 |
+
console.log('Sending request to:', apiUrl);
|
| 75 |
+
|
| 76 |
+
const systemPrompt = createSystemPrompt(currentMindMapData);
|
| 77 |
+
|
| 78 |
+
const res = await fetch(apiUrl, {
|
| 79 |
+
method: 'POST',
|
| 80 |
+
headers: {
|
| 81 |
+
'Content-Type': 'application/json',
|
| 82 |
+
'Accept': 'application/json'
|
| 83 |
+
},
|
| 84 |
+
body: JSON.stringify({
|
| 85 |
+
model: 'mistral-large-latest',
|
| 86 |
+
stream: false,
|
| 87 |
+
messages: [
|
| 88 |
+
{ role: 'system', content: systemPrompt },
|
| 89 |
+
{ role: 'user', content: userInput },
|
| 90 |
+
],
|
| 91 |
+
}),
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
if (!res.ok) {
|
| 95 |
+
throw new Error(`HTTP error! status: ${res.status}`);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const data = await res.json();
|
| 99 |
+
console.log('API Response:', data);
|
| 100 |
+
|
| 101 |
+
let content = data.choices?.[0]?.message?.content || data.content || JSON.stringify(data);
|
| 102 |
+
setResponse(content);
|
| 103 |
+
|
| 104 |
+
// Try to extract JSON from markdown/code block if present
|
| 105 |
+
let jsonText = content;
|
| 106 |
+
const match = content.match(/```json\s*([\s\S]*?)```/i) || content.match(/```([\s\S]*?)```/i);
|
| 107 |
+
if (match) {
|
| 108 |
+
jsonText = match[1].trim();
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// Try to parse as JSON for import
|
| 112 |
+
try {
|
| 113 |
+
// Log the content for debugging
|
| 114 |
+
console.log('Raw content:', content);
|
| 115 |
+
console.log('Extracted JSON text:', jsonText);
|
| 116 |
+
|
| 117 |
+
// Clean the JSON text
|
| 118 |
+
jsonText = jsonText.replace(/[\u0000-\u001F\u007F-\u009F]/g, ''); // Remove control characters
|
| 119 |
+
jsonText = jsonText.replace(/\n/g, ' ').replace(/\r/g, ''); // Remove newlines
|
| 120 |
+
jsonText = jsonText.replace(/\s+/g, ' ').trim(); // Normalize whitespace
|
| 121 |
+
|
| 122 |
+
const parsedJson = JSON.parse(jsonText);
|
| 123 |
+
console.log('Parsed JSON:', parsedJson);
|
| 124 |
+
setJsonReady(true);
|
| 125 |
+
} catch (parseError) {
|
| 126 |
+
console.error('JSON Parse Error:', parseError);
|
| 127 |
+
console.error('Failed JSON text:', jsonText);
|
| 128 |
+
setJsonReady(false);
|
| 129 |
+
setResponse(prev => prev + '\n\n[Error: Invalid JSON format. Please try rephrasing your prompt or check the AI output.]');
|
| 130 |
+
}
|
| 131 |
+
} catch (err) {
|
| 132 |
+
console.error('API Error:', err);
|
| 133 |
+
setResponse(`Error: ${err instanceof Error ? err.message : 'Failed to connect to chat service'}`);
|
| 134 |
+
setJsonReady(false);
|
| 135 |
+
} finally {
|
| 136 |
+
setLoading(false);
|
| 137 |
+
}
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
const handleImport = () => {
|
| 141 |
+
try {
|
| 142 |
+
let jsonText = response;
|
| 143 |
+
const match = response.match(/```json\s*([\s\S]*?)```/i) || response.match(/```([\s\S]*?)```/i);
|
| 144 |
+
if (match) {
|
| 145 |
+
jsonText = match[1].trim();
|
| 146 |
+
}
|
| 147 |
+
const file = new File([jsonText], 'mindmap.json', { type: 'application/json' });
|
| 148 |
+
const event = { target: { files: [file] } };
|
| 149 |
+
onImportJSON(event);
|
| 150 |
+
onClose();
|
| 151 |
+
} catch {}
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
return (
|
| 155 |
+
<div style={{ position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.25)', zIndex: 20000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
| 156 |
+
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 4px 24px rgba(0,0,0,0.18)', padding: 32, minWidth: 340, maxWidth: 480, width: '100%', position: 'relative' }}>
|
| 157 |
+
<button onClick={onClose} style={{ position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 22, cursor: 'pointer' }} aria-label="Close chat">×</button>
|
| 158 |
+
<h3 style={{ marginTop: 0 }}>Mind Map Chat Assistant</h3>
|
| 159 |
+
<form onSubmit={handleSubmit} style={{ marginBottom: 16 }}>
|
| 160 |
+
<textarea
|
| 161 |
+
value={userInput}
|
| 162 |
+
onChange={e => setUserInput(e.target.value)}
|
| 163 |
+
rows={4}
|
| 164 |
+
style={{ width: '100%', borderRadius: 6, border: '1.5px solid #e0e0e0', padding: 8, fontSize: 15, marginBottom: 8 }}
|
| 165 |
+
placeholder="Describe your mind map or ask for optimization..."
|
| 166 |
+
required
|
| 167 |
+
/>
|
| 168 |
+
<button type="submit" style={{ background: '#007bff', color: '#fff', border: 'none', borderRadius: 6, padding: '8px 18px', fontWeight: 600, fontSize: 15, cursor: 'pointer' }} disabled={loading}>
|
| 169 |
+
{loading ? 'Thinking...' : 'Send'}
|
| 170 |
+
</button>
|
| 171 |
+
</form>
|
| 172 |
+
<div style={{ minHeight: 60, background: '#f7fafc', borderRadius: 6, padding: 10, fontSize: 14, color: '#222', marginBottom: 8, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
| 173 |
+
{loading ? 'Loading...' : response}
|
| 174 |
+
</div>
|
| 175 |
+
{jsonReady && (
|
| 176 |
+
<button onClick={handleImport} style={{ background: '#28a745', color: '#fff', border: 'none', borderRadius: 6, padding: '8px 18px', fontWeight: 600, fontSize: 15, cursor: 'pointer', marginTop: 4 }}>
|
| 177 |
+
Import to Mind Map
|
| 178 |
+
</button>
|
| 179 |
+
)}
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
);
|
| 183 |
+
};
|
| 184 |
+
|
| 185 |
+
export default ChatModal;
|
frontend/src/components/EditableColorNode.css
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Simple one-box layout for React Flow node */
|
| 2 |
+
.editable-color-node {
|
| 3 |
+
padding: 10px 20px;
|
| 4 |
+
border-radius: 8px;
|
| 5 |
+
min-width: 150px;
|
| 6 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 7 |
+
transition: all 0.3s ease;
|
| 8 |
+
border: 2px solid transparent;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
.editable-color-node:hover {
|
| 12 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.editable-color-node.selected {
|
| 16 |
+
border-color: #2196f3;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/* Label style inside the node */
|
| 20 |
+
.editable-color-node-label {
|
| 21 |
+
font-size: 14px;
|
| 22 |
+
font-weight: 500;
|
| 23 |
+
text-align: center;
|
| 24 |
+
cursor: text;
|
| 25 |
+
padding: 4px;
|
| 26 |
+
border-radius: 4px;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.editable-color-node-label:hover {
|
| 30 |
+
background-color: rgba(0, 0, 0, 0.05);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/* Input style for editing the label */
|
| 34 |
+
.editable-color-node-label-input {
|
| 35 |
+
width: 100%;
|
| 36 |
+
padding: 4px;
|
| 37 |
+
border: 1px solid #ccc;
|
| 38 |
+
border-radius: 4px;
|
| 39 |
+
font-size: 14px;
|
| 40 |
+
outline: none;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.editable-color-node-label-input:focus {
|
| 44 |
+
border-color: #2196f3;
|
| 45 |
+
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
|
| 46 |
+
}
|
| 47 |
+
|
frontend/src/components/EditableColorNode.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Handle, NodeProps, Position } from 'reactflow';
|
| 3 |
+
import './EditableColorNode.css';
|
| 4 |
+
import * as MdIcons from 'react-icons/md';
|
| 5 |
+
|
| 6 |
+
const icons = MdIcons as Record<string, React.ComponentType<any>>;
|
| 7 |
+
|
| 8 |
+
const EditableColorNode: React.FC<NodeProps> = ({ id, data, selected, isConnectable, ...props }) => {
|
| 9 |
+
const [editing, setEditing] = useState(false);
|
| 10 |
+
const [label, setLabel] = useState(data.label || '');
|
| 11 |
+
const [nodeStyle, setNodeStyle] = useState({
|
| 12 |
+
backgroundColor: data.bgColor || '#fff',
|
| 13 |
+
color: data.textColor || '#222',
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
setNodeStyle({
|
| 18 |
+
backgroundColor: data.bgColor || '#fff',
|
| 19 |
+
color: data.textColor || '#222',
|
| 20 |
+
});
|
| 21 |
+
}, [data.bgColor, data.textColor]);
|
| 22 |
+
|
| 23 |
+
// Save label on blur or Enter
|
| 24 |
+
const handleLabelChange = (e: React.ChangeEvent<HTMLInputElement>) => setLabel(e.target.value);
|
| 25 |
+
const handleLabelBlur = () => {
|
| 26 |
+
setEditing(false);
|
| 27 |
+
if (data.onChange) data.onChange(id, { ...data, label });
|
| 28 |
+
};
|
| 29 |
+
const handleLabelKeyDown = (e: React.KeyboardEvent) => {
|
| 30 |
+
if (e.key === 'Enter') handleLabelBlur();
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
// Drag-and-drop icon support
|
| 34 |
+
const handleDrop = (e: React.DragEvent) => {
|
| 35 |
+
e.preventDefault();
|
| 36 |
+
const iconName = e.dataTransfer.getData('icon');
|
| 37 |
+
if (iconName && data.onChange) {
|
| 38 |
+
data.onChange(id, { ...data, icon: iconName });
|
| 39 |
+
}
|
| 40 |
+
};
|
| 41 |
+
const handleDragOver = (e: React.DragEvent) => e.preventDefault();
|
| 42 |
+
|
| 43 |
+
// Render icon if present
|
| 44 |
+
let IconComponent = null;
|
| 45 |
+
if (data.icon && icons[data.icon]) {
|
| 46 |
+
IconComponent = React.createElement(icons[data.icon], { size: 24, style: { marginBottom: 4 } });
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<div
|
| 51 |
+
className={`editable-color-node ${selected ? 'selected' : ''}`}
|
| 52 |
+
style={{
|
| 53 |
+
...nodeStyle,
|
| 54 |
+
boxShadow: selected ? '0 0 0 3px #007bff' : undefined,
|
| 55 |
+
border: selected ? '2px solid #007bff' : '1px solid #ccc',
|
| 56 |
+
transition: 'box-shadow 0.2s, border 0.2s',
|
| 57 |
+
}}
|
| 58 |
+
onDrop={handleDrop}
|
| 59 |
+
onDragOver={handleDragOver}
|
| 60 |
+
>
|
| 61 |
+
<Handle type="target" position={Position.Top} isConnectable={isConnectable} />
|
| 62 |
+
{IconComponent && <div style={{ display: 'flex', justifyContent: 'center' }}>{IconComponent}</div>}
|
| 63 |
+
<div
|
| 64 |
+
className="editable-color-node-label"
|
| 65 |
+
title={label}
|
| 66 |
+
style={{ color: data.textColor || '#222' }}
|
| 67 |
+
>
|
| 68 |
+
{label}
|
| 69 |
+
</div>
|
| 70 |
+
<Handle type="source" position={Position.Bottom} isConnectable={isConnectable} />
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
export default EditableColorNode;
|
frontend/src/components/LandingPage.module.css
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.container {
|
| 2 |
+
min-height: 100vh;
|
| 3 |
+
display: flex;
|
| 4 |
+
flex-direction: column;
|
| 5 |
+
align-items: center;
|
| 6 |
+
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
.header {
|
| 10 |
+
width: 100%;
|
| 11 |
+
padding: 1.2rem 2rem;
|
| 12 |
+
background: white;
|
| 13 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
|
| 14 |
+
display: flex;
|
| 15 |
+
justify-content: space-between;
|
| 16 |
+
align-items: center;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.logo {
|
| 20 |
+
margin: 0;
|
| 21 |
+
color: #1a2947;
|
| 22 |
+
font-size: 2rem;
|
| 23 |
+
font-weight: 900;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.navButtons {
|
| 27 |
+
display: flex;
|
| 28 |
+
gap: 1.2rem;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.button {
|
| 32 |
+
padding: 0.6rem 1.2rem;
|
| 33 |
+
border: none;
|
| 34 |
+
border-radius: 6px;
|
| 35 |
+
cursor: pointer;
|
| 36 |
+
font-weight: 700;
|
| 37 |
+
font-size: 1rem;
|
| 38 |
+
transition: background 0.2s, color 0.2s, transform 0.2s;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.buttonPrimary {
|
| 42 |
+
background: #007bff;
|
| 43 |
+
color: white;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.buttonPrimary:hover {
|
| 47 |
+
background: #0056b3;
|
| 48 |
+
transform: translateY(-2px) scale(1.03);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.buttonSecondary {
|
| 52 |
+
background: #6c757d;
|
| 53 |
+
color: white;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.buttonSecondary:hover {
|
| 57 |
+
background: #5a6268;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.hero {
|
| 61 |
+
text-align: center;
|
| 62 |
+
padding: 5rem 2rem 3rem 2rem;
|
| 63 |
+
max-width: 900px;
|
| 64 |
+
margin: 0 auto;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.title {
|
| 68 |
+
font-size: 2.8rem;
|
| 69 |
+
color: #1a2947;
|
| 70 |
+
margin-bottom: 1.2rem;
|
| 71 |
+
font-weight: 900;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.description {
|
| 75 |
+
font-size: 1.25rem;
|
| 76 |
+
color: #34495e;
|
| 77 |
+
line-height: 1.7;
|
| 78 |
+
margin-bottom: 2.2rem;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.features {
|
| 82 |
+
display: grid;
|
| 83 |
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
| 84 |
+
gap: 2.2rem;
|
| 85 |
+
padding: 2.2rem;
|
| 86 |
+
max-width: 1200px;
|
| 87 |
+
margin: 0 auto;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.featureCard {
|
| 91 |
+
background: white;
|
| 92 |
+
padding: 2.2rem 1.5rem;
|
| 93 |
+
border-radius: 10px;
|
| 94 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
|
| 95 |
+
text-align: center;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.featureTitle {
|
| 99 |
+
color: #1a2947;
|
| 100 |
+
margin-bottom: 1.2rem;
|
| 101 |
+
font-size: 1.2rem;
|
| 102 |
+
font-weight: 800;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.featureDescription {
|
| 106 |
+
color: #7f8c8d;
|
| 107 |
+
line-height: 1.5;
|
| 108 |
+
font-size: 1rem;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.ctaButton {
|
| 112 |
+
background: #007bff;
|
| 113 |
+
color: white;
|
| 114 |
+
padding: 1.1rem 2.2rem;
|
| 115 |
+
font-size: 1.15rem;
|
| 116 |
+
border: none;
|
| 117 |
+
border-radius: 8px;
|
| 118 |
+
font-weight: 800;
|
| 119 |
+
margin-top: 2rem;
|
| 120 |
+
transition: background 0.2s, transform 0.2s;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.ctaButton:hover {
|
| 124 |
+
background: #0056b3;
|
| 125 |
+
transform: translateY(-2px) scale(1.03);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
@media (max-width: 700px) {
|
| 129 |
+
.hero {
|
| 130 |
+
padding: 3rem 1rem 2rem 1rem;
|
| 131 |
+
}
|
| 132 |
+
.features {
|
| 133 |
+
padding: 1rem;
|
| 134 |
+
gap: 1.2rem;
|
| 135 |
+
}
|
| 136 |
+
}
|
frontend/src/components/LandingPage.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import styles from './LandingPage.module.css';
|
| 4 |
+
|
| 5 |
+
const LandingPage: React.FC = () => {
|
| 6 |
+
const navigate = useNavigate();
|
| 7 |
+
const isAuthenticated = !!localStorage.getItem('token');
|
| 8 |
+
|
| 9 |
+
return (
|
| 10 |
+
<div className={styles.container}>
|
| 11 |
+
<header className={styles.header}>
|
| 12 |
+
<h1 className={styles.logo}>MindMapX</h1>
|
| 13 |
+
<div className={styles.navButtons}>
|
| 14 |
+
{isAuthenticated ? (
|
| 15 |
+
<button
|
| 16 |
+
className={styles.buttonPrimary}
|
| 17 |
+
onClick={() => navigate('/editor')}
|
| 18 |
+
>
|
| 19 |
+
Go to Dashboard
|
| 20 |
+
</button>
|
| 21 |
+
) : (
|
| 22 |
+
<>
|
| 23 |
+
<button
|
| 24 |
+
className={styles.buttonSecondary}
|
| 25 |
+
onClick={() => navigate('/login')}
|
| 26 |
+
>
|
| 27 |
+
Login
|
| 28 |
+
</button>
|
| 29 |
+
<button
|
| 30 |
+
className={styles.buttonPrimary}
|
| 31 |
+
onClick={() => navigate('/signup')}
|
| 32 |
+
>
|
| 33 |
+
Sign Up
|
| 34 |
+
</button>
|
| 35 |
+
</>
|
| 36 |
+
)}
|
| 37 |
+
</div>
|
| 38 |
+
</header>
|
| 39 |
+
|
| 40 |
+
<section className={styles.hero}>
|
| 41 |
+
<h2 className={styles.title}>Create Beautiful Mind Maps</h2>
|
| 42 |
+
<p className={styles.description}>
|
| 43 |
+
MindMapX is a powerful tool for creating, sharing, and collaborating on mind maps.
|
| 44 |
+
Organize your thoughts, plan projects, and visualize complex ideas with our intuitive interface.
|
| 45 |
+
</p>
|
| 46 |
+
{!isAuthenticated && (
|
| 47 |
+
<button
|
| 48 |
+
className={styles.ctaButton}
|
| 49 |
+
onClick={() => navigate('/signup')}
|
| 50 |
+
>
|
| 51 |
+
Get Started Free
|
| 52 |
+
</button>
|
| 53 |
+
)}
|
| 54 |
+
</section>
|
| 55 |
+
|
| 56 |
+
<section className={styles.features}>
|
| 57 |
+
<div className={styles.featureCard}>
|
| 58 |
+
<h3 className={styles.featureTitle}>Intuitive Interface</h3>
|
| 59 |
+
<p className={styles.featureDescription}>
|
| 60 |
+
Drag and drop nodes, create connections, and organize your ideas with ease.
|
| 61 |
+
</p>
|
| 62 |
+
</div>
|
| 63 |
+
<div className={styles.featureCard}>
|
| 64 |
+
<h3 className={styles.featureTitle}>Real-time Collaboration</h3>
|
| 65 |
+
<p className={styles.featureDescription}>
|
| 66 |
+
Work together with your team in real-time, share your mind maps, and get instant feedback.
|
| 67 |
+
</p>
|
| 68 |
+
</div>
|
| 69 |
+
<div className={styles.featureCard}>
|
| 70 |
+
<h3 className={styles.featureTitle}>Cloud Storage</h3>
|
| 71 |
+
<p className={styles.featureDescription}>
|
| 72 |
+
Access your mind maps from anywhere, anytime. Your work is automatically saved and synced.
|
| 73 |
+
</p>
|
| 74 |
+
</div>
|
| 75 |
+
</section>
|
| 76 |
+
</div>
|
| 77 |
+
);
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
export default LandingPage;
|
frontend/src/components/MindMapEditor.module.css
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.flowContainer {
|
| 2 |
+
width: 100%;
|
| 3 |
+
height: 100%;
|
| 4 |
+
position: relative;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
.errorToast {
|
| 8 |
+
position: fixed;
|
| 9 |
+
top: 1rem;
|
| 10 |
+
right: 1rem;
|
| 11 |
+
padding: 1rem;
|
| 12 |
+
background-color: #f8d7da;
|
| 13 |
+
color: #721c24;
|
| 14 |
+
border: 1px solid #f5c6cb;
|
| 15 |
+
border-radius: 4px;
|
| 16 |
+
z-index: 1000;
|
| 17 |
+
display: flex;
|
| 18 |
+
align-items: center;
|
| 19 |
+
gap: 0.5rem;
|
| 20 |
+
animation: slideIn 0.3s ease-out;
|
| 21 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.errorToast button {
|
| 25 |
+
background: none;
|
| 26 |
+
border: none;
|
| 27 |
+
color: #721c24;
|
| 28 |
+
font-size: 1.25rem;
|
| 29 |
+
cursor: pointer;
|
| 30 |
+
padding: 0;
|
| 31 |
+
margin-left: 0.5rem;
|
| 32 |
+
transition: color 0.2s ease;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.errorToast button:hover {
|
| 36 |
+
color: #dc3545;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.errorToast button:focus {
|
| 40 |
+
outline: 2px solid #dc3545;
|
| 41 |
+
outline-offset: 2px;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@keyframes slideIn {
|
| 45 |
+
from {
|
| 46 |
+
transform: translateX(100%);
|
| 47 |
+
opacity: 0;
|
| 48 |
+
}
|
| 49 |
+
to {
|
| 50 |
+
transform: translateX(0);
|
| 51 |
+
opacity: 1;
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.editOverlay {
|
| 56 |
+
position: absolute;
|
| 57 |
+
z-index: 1000;
|
| 58 |
+
background-color: white;
|
| 59 |
+
border: 1px solid #e2e8f0;
|
| 60 |
+
border-radius: 4px;
|
| 61 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.editInput {
|
| 65 |
+
padding: 0.5rem;
|
| 66 |
+
border: 1px solid #e2e8f0;
|
| 67 |
+
border-radius: 4px;
|
| 68 |
+
font-size: 1rem;
|
| 69 |
+
width: 200px;
|
| 70 |
+
outline: none;
|
| 71 |
+
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.editInput:focus {
|
| 75 |
+
border-color: #4299e1;
|
| 76 |
+
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.2);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.actionButton {
|
| 80 |
+
padding: 8px 16px;
|
| 81 |
+
background-color: #007bff;
|
| 82 |
+
color: white;
|
| 83 |
+
border: none;
|
| 84 |
+
border-radius: 4px;
|
| 85 |
+
cursor: pointer;
|
| 86 |
+
font-size: 14px;
|
| 87 |
+
font-weight: 500;
|
| 88 |
+
transition: background-color 0.2s ease, transform 0.1s ease;
|
| 89 |
+
display: inline-flex;
|
| 90 |
+
align-items: center;
|
| 91 |
+
justify-content: center;
|
| 92 |
+
min-width: 100px;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.actionButton:hover {
|
| 96 |
+
background-color: #0056b3;
|
| 97 |
+
transform: translateY(-1px);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.actionButton:active {
|
| 101 |
+
transform: translateY(0);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.actionButton:focus {
|
| 105 |
+
outline: 2px solid #0056b3;
|
| 106 |
+
outline-offset: 2px;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.actionButton:disabled {
|
| 110 |
+
background-color: #6c757d;
|
| 111 |
+
cursor: not-allowed;
|
| 112 |
+
transform: none;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.actionButton.saving {
|
| 116 |
+
background-color: #6c757d;
|
| 117 |
+
cursor: wait;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.actionButton.dirty {
|
| 121 |
+
background-color: #28a745;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.actionButton.dirty:hover {
|
| 125 |
+
background-color: #218838;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/* Loading state styles */
|
| 129 |
+
.loading {
|
| 130 |
+
position: relative;
|
| 131 |
+
pointer-events: none;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.loading::after {
|
| 135 |
+
content: '';
|
| 136 |
+
position: absolute;
|
| 137 |
+
top: 0;
|
| 138 |
+
left: 0;
|
| 139 |
+
right: 0;
|
| 140 |
+
bottom: 0;
|
| 141 |
+
background-color: rgba(255, 255, 255, 0.7);
|
| 142 |
+
display: flex;
|
| 143 |
+
align-items: center;
|
| 144 |
+
justify-content: center;
|
| 145 |
+
z-index: 1000;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/* Accessibility focus styles */
|
| 149 |
+
:focus-visible {
|
| 150 |
+
outline: 2px solid #007bff;
|
| 151 |
+
outline-offset: 2px;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/* High contrast mode support */
|
| 155 |
+
@media (forced-colors: active) {
|
| 156 |
+
.actionButton {
|
| 157 |
+
border: 2px solid currentColor;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.errorToast {
|
| 161 |
+
border: 2px solid currentColor;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.editInput {
|
| 165 |
+
border: 2px solid currentColor;
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/* Reduced motion preferences */
|
| 170 |
+
@media (prefers-reduced-motion: reduce) {
|
| 171 |
+
.errorToast {
|
| 172 |
+
animation: none;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.actionButton {
|
| 176 |
+
transition: none;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.editInput {
|
| 180 |
+
transition: none;
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.verticalToolbar {
|
| 185 |
+
position: absolute;
|
| 186 |
+
top: 24px;
|
| 187 |
+
right: 32px;
|
| 188 |
+
display: flex;
|
| 189 |
+
flex-direction: column;
|
| 190 |
+
gap: 10px;
|
| 191 |
+
z-index: 1100;
|
| 192 |
+
background: rgba(255,255,255,0.55);
|
| 193 |
+
backdrop-filter: blur(8px);
|
| 194 |
+
border-radius: 14px;
|
| 195 |
+
box-shadow: 0 4px 16px rgba(0,0,0,0.10);
|
| 196 |
+
padding: 18px 12px 18px 12px;
|
| 197 |
+
align-items: stretch;
|
| 198 |
+
min-width: 120px;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.toolbarDivider {
|
| 202 |
+
height: 1px;
|
| 203 |
+
background: #e0e0e0;
|
| 204 |
+
margin: 10px 0;
|
| 205 |
+
border: none;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.verticalToolbar .actionButton {
|
| 209 |
+
width: 100%;
|
| 210 |
+
margin-bottom: 2px;
|
| 211 |
+
font-size: 15px;
|
| 212 |
+
font-weight: 600;
|
| 213 |
+
letter-spacing: 0.01em;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.verticalToolbar .actionButton.saving {
|
| 217 |
+
background-color: #6c757d;
|
| 218 |
+
cursor: wait;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.verticalToolbar .actionButton.dirty {
|
| 222 |
+
background-color: #28a745;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.verticalToolbar .actionButton.dirty:hover {
|
| 226 |
+
background-color: #218838;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.verticalToolbar .toolButton {
|
| 230 |
+
width: 40px;
|
| 231 |
+
height: 40px;
|
| 232 |
+
margin: 0 auto;
|
| 233 |
+
margin-bottom: 2px;
|
| 234 |
+
border-radius: 8px;
|
| 235 |
+
font-size: 1.3rem;
|
| 236 |
+
background: #f7fafc;
|
| 237 |
+
color: #222;
|
| 238 |
+
border: 1px solid #e0e0e0;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.verticalToolbar .toolButton:hover {
|
| 242 |
+
background: #eaf4ff;
|
| 243 |
+
color: #007bff;
|
| 244 |
+
border-color: #007bff;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.verticalToolbar .toolButton:active {
|
| 248 |
+
background: #d0e7ff;
|
| 249 |
+
color: #0056b3;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.mindMapHeader {
|
| 253 |
+
display: flex;
|
| 254 |
+
align-items: center;
|
| 255 |
+
justify-content: space-between;
|
| 256 |
+
padding: 0.5rem 1.5rem 0.5rem 1rem;
|
| 257 |
+
background: rgba(255,255,255,0.45);
|
| 258 |
+
backdrop-filter: blur(8px);
|
| 259 |
+
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
| 260 |
+
z-index: 1050;
|
| 261 |
+
position: relative;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.titleInput {
|
| 265 |
+
font-size: 1.25rem;
|
| 266 |
+
font-weight: 600;
|
| 267 |
+
padding: 0.5rem;
|
| 268 |
+
border: 1px solid var(--border-color, #e0e0e0);
|
| 269 |
+
border-radius: 4px;
|
| 270 |
+
background-color: #fff;
|
| 271 |
+
color: #222;
|
| 272 |
+
transition: background-color 0.15s, color 0.15s, border-color 0.15s;
|
| 273 |
+
min-width: 220px;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.titleInput:focus {
|
| 277 |
+
outline: none;
|
| 278 |
+
border-color: var(--primary-color, #007bff);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.colorControls {
|
| 282 |
+
position: relative;
|
| 283 |
+
display: flex;
|
| 284 |
+
align-items: center;
|
| 285 |
+
gap: 1rem;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.colorButton {
|
| 289 |
+
display: flex;
|
| 290 |
+
align-items: center;
|
| 291 |
+
gap: 0.5rem;
|
| 292 |
+
padding: 0.5rem 1rem;
|
| 293 |
+
border: 1px solid var(--border-color, #e0e0e0);
|
| 294 |
+
border-radius: 4px;
|
| 295 |
+
background-color: #fff;
|
| 296 |
+
color: #222;
|
| 297 |
+
cursor: pointer;
|
| 298 |
+
transition: background-color 0.15s, color 0.15s, border-color 0.15s;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.colorButton:hover {
|
| 302 |
+
background-color: #f0f4fa;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.colorButton.active {
|
| 306 |
+
background-color: #eaf4ff;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.colorPicker {
|
| 310 |
+
position: absolute;
|
| 311 |
+
top: 100%;
|
| 312 |
+
left: 50%;
|
| 313 |
+
transform: translateX(-50%);
|
| 314 |
+
margin-top: 0.5rem;
|
| 315 |
+
padding: 1rem;
|
| 316 |
+
background-color: #fff;
|
| 317 |
+
border: 1px solid var(--border-color, #e0e0e0);
|
| 318 |
+
border-radius: 8px;
|
| 319 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 320 |
+
z-index: 1000;
|
| 321 |
+
min-width: 300px;
|
| 322 |
+
animation: slideDown 0.2s ease-out;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.colorPresets {
|
| 326 |
+
display: grid;
|
| 327 |
+
grid-template-columns: repeat(3, 1fr);
|
| 328 |
+
gap: 0.5rem;
|
| 329 |
+
margin-bottom: 1rem;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.colorPreset {
|
| 333 |
+
width: 100%;
|
| 334 |
+
aspect-ratio: 1;
|
| 335 |
+
border: 2px solid transparent;
|
| 336 |
+
border-radius: 4px;
|
| 337 |
+
cursor: pointer;
|
| 338 |
+
transition: transform 0.15s, border-color 0.15s;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.colorPreset:hover {
|
| 342 |
+
transform: scale(1.05);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.colorPreset.selected {
|
| 346 |
+
border-color: var(--primary-color, #007bff);
|
| 347 |
+
transform: scale(1.05);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.customColors {
|
| 351 |
+
display: flex;
|
| 352 |
+
flex-direction: column;
|
| 353 |
+
gap: 1rem;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
.colorControl {
|
| 357 |
+
display: flex;
|
| 358 |
+
flex-direction: column;
|
| 359 |
+
gap: 0.5rem;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.colorInputWrapper {
|
| 363 |
+
display: flex;
|
| 364 |
+
align-items: center;
|
| 365 |
+
gap: 0.5rem;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.colorInput {
|
| 369 |
+
width: 50px;
|
| 370 |
+
height: 30px;
|
| 371 |
+
padding: 0;
|
| 372 |
+
border: 1px solid var(--border-color, #e0e0e0);
|
| 373 |
+
border-radius: 4px;
|
| 374 |
+
cursor: pointer;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.colorValue {
|
| 378 |
+
font-family: monospace;
|
| 379 |
+
font-size: 0.875rem;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.colorPickerToolbar {
|
| 383 |
+
position: absolute;
|
| 384 |
+
top: 60px;
|
| 385 |
+
right: 110%;
|
| 386 |
+
margin-right: 12px;
|
| 387 |
+
padding: 1rem;
|
| 388 |
+
background-color: #fff;
|
| 389 |
+
border: 1px solid var(--border-color, #e0e0e0);
|
| 390 |
+
border-radius: 8px;
|
| 391 |
+
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
| 392 |
+
z-index: 2000;
|
| 393 |
+
min-width: 300px;
|
| 394 |
+
animation: slideDown 0.2s ease-out;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
.nodeLabelInput {
|
| 398 |
+
width: 100%;
|
| 399 |
+
padding: 8px 10px;
|
| 400 |
+
font-size: 1rem;
|
| 401 |
+
border: 1.5px solid #e0e0e0;
|
| 402 |
+
border-radius: 6px;
|
| 403 |
+
margin-bottom: 8px;
|
| 404 |
+
background: #f7fafc;
|
| 405 |
+
color: #222;
|
| 406 |
+
transition: border-color 0.15s, box-shadow 0.15s;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.nodeLabelInput:focus {
|
| 410 |
+
border-color: #007bff;
|
| 411 |
+
outline: none;
|
| 412 |
+
box-shadow: 0 0 0 2px #eaf4ff;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
@media (max-width: 700px) {
|
| 416 |
+
.verticalToolbar {
|
| 417 |
+
position: fixed;
|
| 418 |
+
top: auto;
|
| 419 |
+
bottom: 0;
|
| 420 |
+
right: 0;
|
| 421 |
+
left: 0;
|
| 422 |
+
flex-direction: row;
|
| 423 |
+
align-items: center;
|
| 424 |
+
justify-content: space-between;
|
| 425 |
+
min-width: 0;
|
| 426 |
+
width: 100vw;
|
| 427 |
+
border-radius: 0;
|
| 428 |
+
box-shadow: 0 -2px 12px rgba(0,0,0,0.10);
|
| 429 |
+
padding: 10px 6px 10px 6px;
|
| 430 |
+
gap: 8px;
|
| 431 |
+
z-index: 1200;
|
| 432 |
+
background: rgba(255,255,255,0.98);
|
| 433 |
+
}
|
| 434 |
+
.verticalToolbar .actionButton,
|
| 435 |
+
.verticalToolbar .toolButton {
|
| 436 |
+
width: auto;
|
| 437 |
+
min-width: 20px;
|
| 438 |
+
margin-bottom: 0;
|
| 439 |
+
margin-right: 2px;
|
| 440 |
+
margin-left: 2px;
|
| 441 |
+
}
|
| 442 |
+
.toolbarDivider {
|
| 443 |
+
width: 1px;
|
| 444 |
+
height: 16px;
|
| 445 |
+
margin: 0 8px;
|
| 446 |
+
/* background: #e0e0e0; */
|
| 447 |
+
border: none;
|
| 448 |
+
}
|
| 449 |
+
.colorPickerToolbar {
|
| 450 |
+
position: fixed;
|
| 451 |
+
bottom: 60px;
|
| 452 |
+
right: 16px;
|
| 453 |
+
left: auto;
|
| 454 |
+
top: auto;
|
| 455 |
+
margin: 0;
|
| 456 |
+
z-index: 2001;
|
| 457 |
+
min-width: 90vw;
|
| 458 |
+
max-width: 98vw;
|
| 459 |
+
}
|
| 460 |
+
}
|
frontend/src/components/MindMapEditor.tsx
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useCallback, useState, useRef, useEffect, useMemo } from 'react';
|
| 2 |
+
import ReactFlow, {
|
| 3 |
+
Node,
|
| 4 |
+
Edge,
|
| 5 |
+
Controls,
|
| 6 |
+
Background,
|
| 7 |
+
useNodesState,
|
| 8 |
+
useEdgesState,
|
| 9 |
+
addEdge,
|
| 10 |
+
Connection,
|
| 11 |
+
Panel,
|
| 12 |
+
NodeMouseHandler,
|
| 13 |
+
ReactFlowInstance,
|
| 14 |
+
MiniMap,
|
| 15 |
+
NodeTypes,
|
| 16 |
+
} from 'reactflow';
|
| 17 |
+
import 'reactflow/dist/style.css';
|
| 18 |
+
import axios from '../utils/axios';
|
| 19 |
+
import SidePane from './SidePane';
|
| 20 |
+
import { v4 as uuidv4 } from 'uuid';
|
| 21 |
+
import EditableColorNode from './EditableColorNode';
|
| 22 |
+
import { saveMindMapToJSON, loadMindMapFromJSON, MindMapData } from '../utils/jsonUtils';
|
| 23 |
+
import styles from './MindMapEditor.module.css';
|
| 24 |
+
import * as MdIcons from 'react-icons/md';
|
| 25 |
+
import ChatModal from './ChatModal';
|
| 26 |
+
|
| 27 |
+
// Types
|
| 28 |
+
interface NodeData {
|
| 29 |
+
label: string;
|
| 30 |
+
bgColor: string;
|
| 31 |
+
textColor: string;
|
| 32 |
+
onChange: (nodeId: string, data: Partial<NodeData>) => void;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
interface HistoryState {
|
| 36 |
+
nodes: Node[];
|
| 37 |
+
edges: Edge[];
|
| 38 |
+
config: any;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Constants
|
| 42 |
+
const MAX_HISTORY_SIZE = 50;
|
| 43 |
+
const AUTO_SAVE_DELAY = 1000;
|
| 44 |
+
|
| 45 |
+
const initialEdges: Edge[] = [];
|
| 46 |
+
|
| 47 |
+
const FALLBACK_COLOR_CONFIG = { nodeBgColor: '#ffffff', nodeTextColor: '#000000' };
|
| 48 |
+
|
| 49 |
+
const initialNodesWithHandler: Node[] = [
|
| 50 |
+
{
|
| 51 |
+
id: '1',
|
| 52 |
+
type: 'editableColor',
|
| 53 |
+
data: {
|
| 54 |
+
label: 'Main Idea',
|
| 55 |
+
bgColor: FALLBACK_COLOR_CONFIG.nodeBgColor,
|
| 56 |
+
textColor: FALLBACK_COLOR_CONFIG.nodeTextColor,
|
| 57 |
+
onChange: () => {} // Will be replaced with actual handler
|
| 58 |
+
},
|
| 59 |
+
position: { x: 250, y: 25 },
|
| 60 |
+
},
|
| 61 |
+
];
|
| 62 |
+
|
| 63 |
+
const nodeTypes: NodeTypes = {
|
| 64 |
+
editableColor: EditableColorNode,
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
// Add color presets
|
| 68 |
+
const COLOR_PRESETS = [
|
| 69 |
+
{ name: 'Default', bg: '#ffffff', text: '#000000' },
|
| 70 |
+
{ name: 'Dark', bg: '#2d3748', text: '#ffffff' },
|
| 71 |
+
{ name: 'Light Blue', bg: '#ebf8ff', text: '#2b6cb0' },
|
| 72 |
+
{ name: 'Warm', bg: '#fffaf0', text: '#744210' },
|
| 73 |
+
{ name: 'Cool', bg: '#f0fff4', text: '#22543d' },
|
| 74 |
+
{ name: 'Modern', bg: '#f7fafc', text: '#1a202c' },
|
| 75 |
+
];
|
| 76 |
+
|
| 77 |
+
const MindMapEditor = () => {
|
| 78 |
+
// State declarations
|
| 79 |
+
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
| 80 |
+
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
| 81 |
+
const [currentMindMapId, setCurrentMindMapId] = useState<string | null>(null);
|
| 82 |
+
const [isSaving, setIsSaving] = useState(false);
|
| 83 |
+
const [error, setError] = useState<string | null>(null);
|
| 84 |
+
const [mindMapName, setMindMapName] = useState<string>('Untitled Mind Map');
|
| 85 |
+
const [isSidePaneCollapsed, setIsSidePaneCollapsed] = useState(false);
|
| 86 |
+
const [isDirty, setIsDirty] = useState(false);
|
| 87 |
+
const [errorStack, setErrorStack] = useState<string[]>([]);
|
| 88 |
+
const [history, setHistory] = useState<HistoryState[]>([]);
|
| 89 |
+
const [future, setFuture] = useState<HistoryState[]>([]);
|
| 90 |
+
const [config, setConfig] = useState(() => FALLBACK_COLOR_CONFIG);
|
| 91 |
+
const [saveTimeout, setSaveTimeout] = useState<NodeJS.Timeout | null>(null);
|
| 92 |
+
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
| 93 |
+
const [reactFlowInstance, setReactFlowInstance] = useState<ReactFlowInstance | null>(null);
|
| 94 |
+
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
| 95 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 96 |
+
const [editValue, setEditValue] = useState('');
|
| 97 |
+
const [editPosition, setEditPosition] = useState({ x: 0, y: 0 });
|
| 98 |
+
const editInputRef = useRef<HTMLInputElement>(null);
|
| 99 |
+
const [isHistoryEnabled, setIsHistoryEnabled] = useState(true);
|
| 100 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 101 |
+
const [showColorPicker, setShowColorPicker] = useState(false);
|
| 102 |
+
const [isChatOpen, setIsChatOpen] = useState(false);
|
| 103 |
+
const [selectedNodes, setSelectedNodes] = useState<Node[]>([]);
|
| 104 |
+
const [copiedNodes, setCopiedNodes] = useState<Node[] | null>(null);
|
| 105 |
+
|
| 106 |
+
// Refs
|
| 107 |
+
const errorTimeout = useRef<NodeJS.Timeout | null>(null);
|
| 108 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 109 |
+
|
| 110 |
+
// Node data change handler - Moved before initialNodes
|
| 111 |
+
const handleNodeDataChange = useCallback((nodeId: string, newData: Partial<NodeData>) => {
|
| 112 |
+
setNodes((nds) =>
|
| 113 |
+
nds.map((node) => {
|
| 114 |
+
if (node.id === nodeId) {
|
| 115 |
+
return {
|
| 116 |
+
...node,
|
| 117 |
+
data: {
|
| 118 |
+
...node.data,
|
| 119 |
+
...newData,
|
| 120 |
+
bgColor: newData.bgColor || config.nodeBgColor,
|
| 121 |
+
textColor: newData.textColor || config.nodeTextColor,
|
| 122 |
+
},
|
| 123 |
+
};
|
| 124 |
+
}
|
| 125 |
+
return node;
|
| 126 |
+
})
|
| 127 |
+
);
|
| 128 |
+
setIsDirty(true);
|
| 129 |
+
}, [setNodes, config]);
|
| 130 |
+
|
| 131 |
+
// Memoized initial nodes
|
| 132 |
+
const initialNodes = useMemo(() => [{
|
| 133 |
+
id: '1',
|
| 134 |
+
type: 'editableColor',
|
| 135 |
+
data: {
|
| 136 |
+
label: 'Main Idea',
|
| 137 |
+
bgColor: config.nodeBgColor,
|
| 138 |
+
textColor: config.nodeTextColor,
|
| 139 |
+
onChange: handleNodeDataChange
|
| 140 |
+
},
|
| 141 |
+
position: { x: 250, y: 25 },
|
| 142 |
+
}], [handleNodeDataChange]);
|
| 143 |
+
|
| 144 |
+
// Initialize nodes with handler
|
| 145 |
+
useEffect(() => {
|
| 146 |
+
setNodes(initialNodes);
|
| 147 |
+
}, [initialNodes, setNodes]);
|
| 148 |
+
|
| 149 |
+
// History management
|
| 150 |
+
const pushHistory = useCallback((state: HistoryState) => {
|
| 151 |
+
setHistory((h) => {
|
| 152 |
+
const newHistory = [...h, state];
|
| 153 |
+
if (newHistory.length > MAX_HISTORY_SIZE) {
|
| 154 |
+
return newHistory.slice(-MAX_HISTORY_SIZE);
|
| 155 |
+
}
|
| 156 |
+
return newHistory;
|
| 157 |
+
});
|
| 158 |
+
setFuture([]);
|
| 159 |
+
}, []);
|
| 160 |
+
|
| 161 |
+
// Error handling
|
| 162 |
+
const addError = useCallback((msg: string, details?: string) => {
|
| 163 |
+
const errorMessage = details ? `${msg}: ${details}` : msg;
|
| 164 |
+
setErrorStack((stack) => [...stack, errorMessage]);
|
| 165 |
+
}, []);
|
| 166 |
+
|
| 167 |
+
const removeError = useCallback((idx: number) => {
|
| 168 |
+
setErrorStack((stack) => stack.filter((_, i) => i !== idx));
|
| 169 |
+
}, []);
|
| 170 |
+
|
| 171 |
+
// Auto-save functionality
|
| 172 |
+
const debouncedAutoSave = useCallback(async () => {
|
| 173 |
+
if (saveTimeout) {
|
| 174 |
+
clearTimeout(saveTimeout);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
const timeout = setTimeout(async () => {
|
| 178 |
+
if (!isDirty) return;
|
| 179 |
+
|
| 180 |
+
setIsSaving(true);
|
| 181 |
+
try {
|
| 182 |
+
const data = {
|
| 183 |
+
nodes,
|
| 184 |
+
edges,
|
| 185 |
+
name: mindMapName,
|
| 186 |
+
config,
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
const apiBase = process.env.REACT_APP_API_URL || '';
|
| 190 |
+
if (currentMindMapId) {
|
| 191 |
+
await axios.put(`${apiBase}/api/workflows/${currentMindMapId}`, data);
|
| 192 |
+
} else {
|
| 193 |
+
const response = await axios.post(`${apiBase}/api/workflows`, data);
|
| 194 |
+
setCurrentMindMapId(response.data.id);
|
| 195 |
+
}
|
| 196 |
+
setIsDirty(false);
|
| 197 |
+
} catch (error) {
|
| 198 |
+
addError('Failed to save mind map', error instanceof Error ? error.message : 'Unknown error');
|
| 199 |
+
} finally {
|
| 200 |
+
setIsSaving(false);
|
| 201 |
+
}
|
| 202 |
+
}, 1000);
|
| 203 |
+
|
| 204 |
+
setSaveTimeout(timeout);
|
| 205 |
+
}, [isDirty, nodes, edges, mindMapName, config, currentMindMapId, addError]);
|
| 206 |
+
|
| 207 |
+
// Cleanup on unmount
|
| 208 |
+
useEffect(() => {
|
| 209 |
+
return () => {
|
| 210 |
+
if (saveTimeout) {
|
| 211 |
+
clearTimeout(saveTimeout);
|
| 212 |
+
}
|
| 213 |
+
if (errorTimeout.current) {
|
| 214 |
+
clearTimeout(errorTimeout.current);
|
| 215 |
+
}
|
| 216 |
+
};
|
| 217 |
+
}, [saveTimeout]);
|
| 218 |
+
|
| 219 |
+
// Event handlers
|
| 220 |
+
const handleConfigChange = useCallback((newConfig: any) => {
|
| 221 |
+
const currentState = { nodes, edges, config };
|
| 222 |
+
pushHistory(currentState);
|
| 223 |
+
setConfig(newConfig);
|
| 224 |
+
setNodes((nds) => nds.map((node: any) => ({
|
| 225 |
+
...node,
|
| 226 |
+
data: {
|
| 227 |
+
...node.data,
|
| 228 |
+
bgColor: newConfig.nodeBgColor,
|
| 229 |
+
textColor: newConfig.nodeTextColor,
|
| 230 |
+
},
|
| 231 |
+
})));
|
| 232 |
+
setIsDirty(true);
|
| 233 |
+
}, [nodes, edges, config, pushHistory, setNodes]);
|
| 234 |
+
|
| 235 |
+
const handleNameChange = useCallback(async (newName: string) => {
|
| 236 |
+
if (newName.trim().length === 0) {
|
| 237 |
+
addError('Mind map name cannot be empty');
|
| 238 |
+
return;
|
| 239 |
+
}
|
| 240 |
+
setMindMapName(newName);
|
| 241 |
+
setIsDirty(true);
|
| 242 |
+
}, [addError]);
|
| 243 |
+
|
| 244 |
+
const handleSelectMindMap = useCallback(async (mindMap: any) => {
|
| 245 |
+
setIsLoading(true);
|
| 246 |
+
try {
|
| 247 |
+
setError(null);
|
| 248 |
+
const loadedConfig = mindMap.config || FALLBACK_COLOR_CONFIG;
|
| 249 |
+
React.startTransition(() => {
|
| 250 |
+
setConfig(loadedConfig);
|
| 251 |
+
setNodes(
|
| 252 |
+
mindMap.nodes.map((node: any) => ({
|
| 253 |
+
...node,
|
| 254 |
+
type: 'editableColor',
|
| 255 |
+
data: {
|
| 256 |
+
...node.data,
|
| 257 |
+
onChange: handleNodeDataChange,
|
| 258 |
+
},
|
| 259 |
+
}))
|
| 260 |
+
);
|
| 261 |
+
setEdges(mindMap.edges);
|
| 262 |
+
setCurrentMindMapId(mindMap.id);
|
| 263 |
+
setMindMapName(mindMap.name || 'Untitled Mind Map');
|
| 264 |
+
});
|
| 265 |
+
} catch (error) {
|
| 266 |
+
console.error('Error loading mind map:', error);
|
| 267 |
+
addError('Failed to load mind map', error instanceof Error ? error.message : 'Unknown error');
|
| 268 |
+
} finally {
|
| 269 |
+
setIsLoading(false);
|
| 270 |
+
}
|
| 271 |
+
}, [setNodes, setEdges, handleNodeDataChange, addError]);
|
| 272 |
+
|
| 273 |
+
const handleNewMindMap = useCallback(() => {
|
| 274 |
+
setConfig(config || FALLBACK_COLOR_CONFIG);
|
| 275 |
+
setNodes([
|
| 276 |
+
{
|
| 277 |
+
id: '1',
|
| 278 |
+
type: 'editableColor',
|
| 279 |
+
data: {
|
| 280 |
+
label: 'Main Idea',
|
| 281 |
+
bgColor: (config && config.nodeBgColor) || FALLBACK_COLOR_CONFIG.nodeBgColor,
|
| 282 |
+
textColor: (config && config.nodeTextColor) || FALLBACK_COLOR_CONFIG.nodeTextColor,
|
| 283 |
+
onChange: handleNodeDataChange,
|
| 284 |
+
},
|
| 285 |
+
position: { x: 250, y: 25 },
|
| 286 |
+
},
|
| 287 |
+
]);
|
| 288 |
+
setEdges([]);
|
| 289 |
+
setCurrentMindMapId(null);
|
| 290 |
+
setMindMapName('Untitled Mind Map');
|
| 291 |
+
setError(null);
|
| 292 |
+
}, [setNodes, setEdges, handleNodeDataChange, config]);
|
| 293 |
+
|
| 294 |
+
const handleExportJSON = useCallback(() => {
|
| 295 |
+
try {
|
| 296 |
+
const mindMapData: MindMapData = {
|
| 297 |
+
id: currentMindMapId || uuidv4(),
|
| 298 |
+
name: mindMapName,
|
| 299 |
+
updatedAt: new Date().toISOString(),
|
| 300 |
+
nodes,
|
| 301 |
+
edges,
|
| 302 |
+
config,
|
| 303 |
+
};
|
| 304 |
+
saveMindMapToJSON(mindMapData);
|
| 305 |
+
} catch (error) {
|
| 306 |
+
addError('Failed to export mind map', error instanceof Error ? error.message : 'Unknown error');
|
| 307 |
+
}
|
| 308 |
+
}, [currentMindMapId, mindMapName, nodes, edges, config, addError]);
|
| 309 |
+
|
| 310 |
+
const handleImportJSON = (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 311 |
+
const file = event.target?.files?.[0];
|
| 312 |
+
if (!file) return;
|
| 313 |
+
const reader = new FileReader();
|
| 314 |
+
reader.onload = (e) => {
|
| 315 |
+
try {
|
| 316 |
+
const json = JSON.parse(e.target?.result as string);
|
| 317 |
+
if (json.nodes && json.edges) {
|
| 318 |
+
const importedNodes = json.nodes.map((node: any) => ({
|
| 319 |
+
...node,
|
| 320 |
+
type: 'editableColor',
|
| 321 |
+
data: {
|
| 322 |
+
...node.data,
|
| 323 |
+
onChange: handleNodeDataChange,
|
| 324 |
+
bgColor: node.data?.bgColor || (json.config?.nodeBgColor || FALLBACK_COLOR_CONFIG.nodeBgColor),
|
| 325 |
+
textColor: node.data?.textColor || (json.config?.nodeTextColor || FALLBACK_COLOR_CONFIG.nodeTextColor),
|
| 326 |
+
},
|
| 327 |
+
}));
|
| 328 |
+
setNodes(importedNodes);
|
| 329 |
+
setEdges(json.edges);
|
| 330 |
+
} else {
|
| 331 |
+
addError('Invalid mind map JSON: Missing nodes or edges');
|
| 332 |
+
}
|
| 333 |
+
if (json.config) setConfig(json.config);
|
| 334 |
+
if (json.name) setMindMapName(json.name);
|
| 335 |
+
} catch (err) {
|
| 336 |
+
addError('Invalid mind map JSON', err instanceof Error ? err.message : 'Unknown error');
|
| 337 |
+
}
|
| 338 |
+
};
|
| 339 |
+
reader.readAsText(file);
|
| 340 |
+
};
|
| 341 |
+
|
| 342 |
+
const handleDeleteMindMap = useCallback(async (id: string) => {
|
| 343 |
+
if (currentMindMapId === id) {
|
| 344 |
+
// Create a new mind map before deleting the current one
|
| 345 |
+
const data = {
|
| 346 |
+
nodes,
|
| 347 |
+
edges,
|
| 348 |
+
name: mindMapName,
|
| 349 |
+
config,
|
| 350 |
+
};
|
| 351 |
+
try {
|
| 352 |
+
const response = await axios.post('/api/workflows', data);
|
| 353 |
+
setCurrentMindMapId(response.data.id);
|
| 354 |
+
setIsDirty(false);
|
| 355 |
+
} catch (error) {
|
| 356 |
+
console.error('Error creating new mind map:', error);
|
| 357 |
+
addError('Failed to create new mind map');
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
}, [currentMindMapId, nodes, edges, mindMapName, config, addError]);
|
| 361 |
+
|
| 362 |
+
// Effects
|
| 363 |
+
useEffect(() => {
|
| 364 |
+
if (isDirty) {
|
| 365 |
+
debouncedAutoSave();
|
| 366 |
+
}
|
| 367 |
+
}, [isDirty, debouncedAutoSave]);
|
| 368 |
+
|
| 369 |
+
useEffect(() => {
|
| 370 |
+
if (errorStack.length > 0) {
|
| 371 |
+
const timeout = setTimeout(() => removeError(0), 3000);
|
| 372 |
+
return () => clearTimeout(timeout);
|
| 373 |
+
}
|
| 374 |
+
}, [errorStack, removeError]);
|
| 375 |
+
|
| 376 |
+
useEffect(() => {
|
| 377 |
+
return () => {
|
| 378 |
+
if (saveTimeout) {
|
| 379 |
+
clearTimeout(saveTimeout);
|
| 380 |
+
}
|
| 381 |
+
};
|
| 382 |
+
}, [saveTimeout]);
|
| 383 |
+
|
| 384 |
+
const onConnect = useCallback(
|
| 385 |
+
(params: Connection) => {
|
| 386 |
+
setEdges((eds) => addEdge(params, eds));
|
| 387 |
+
},
|
| 388 |
+
[setEdges]
|
| 389 |
+
);
|
| 390 |
+
|
| 391 |
+
const onAddNode = useCallback(() => {
|
| 392 |
+
pushHistory({ nodes, edges, config });
|
| 393 |
+
const newNode: Node = {
|
| 394 |
+
id: uuidv4(),
|
| 395 |
+
type: 'editableColor',
|
| 396 |
+
data: { label: `Node ${nodes.length + 1}`, bgColor: config.nodeBgColor, textColor: config.nodeTextColor, onChange: handleNodeDataChange },
|
| 397 |
+
position: {
|
| 398 |
+
x: Math.random() * 500,
|
| 399 |
+
y: Math.random() * 500,
|
| 400 |
+
},
|
| 401 |
+
};
|
| 402 |
+
setNodes((nds) => [...nds, newNode]);
|
| 403 |
+
setIsDirty(true);
|
| 404 |
+
}, [nodes.length, setNodes, handleNodeDataChange, config, pushHistory]);
|
| 405 |
+
|
| 406 |
+
const undo = useCallback(() => {
|
| 407 |
+
if (history.length > 0) {
|
| 408 |
+
setFuture((f) => [ { nodes, edges, config }, ...f ]);
|
| 409 |
+
const prev = history[history.length - 1];
|
| 410 |
+
setNodes(prev.nodes);
|
| 411 |
+
setEdges(prev.edges);
|
| 412 |
+
setConfig(prev.config);
|
| 413 |
+
setHistory((h) => h.slice(0, -1));
|
| 414 |
+
}
|
| 415 |
+
}, [history, nodes, edges, config, setNodes, setEdges, setConfig]);
|
| 416 |
+
|
| 417 |
+
const redo = useCallback(() => {
|
| 418 |
+
if (future.length > 0) {
|
| 419 |
+
setHistory((h) => [...h, { nodes, edges, config }]);
|
| 420 |
+
const next = future[0];
|
| 421 |
+
setNodes(next.nodes);
|
| 422 |
+
setEdges(next.edges);
|
| 423 |
+
setConfig(next.config);
|
| 424 |
+
setFuture((f) => f.slice(1));
|
| 425 |
+
}
|
| 426 |
+
}, [future, nodes, edges, config, setNodes, setEdges, setConfig]);
|
| 427 |
+
|
| 428 |
+
const zoomIn = useCallback(() => {
|
| 429 |
+
reactFlowInstance?.zoomIn();
|
| 430 |
+
}, [reactFlowInstance]);
|
| 431 |
+
|
| 432 |
+
const zoomOut = useCallback(() => {
|
| 433 |
+
reactFlowInstance?.zoomOut();
|
| 434 |
+
}, [reactFlowInstance]);
|
| 435 |
+
|
| 436 |
+
const centerView = useCallback(() => {
|
| 437 |
+
reactFlowInstance?.fitView();
|
| 438 |
+
}, [reactFlowInstance]);
|
| 439 |
+
|
| 440 |
+
useEffect(() => {
|
| 441 |
+
const handleKeyDown = (e: KeyboardEvent) => {
|
| 442 |
+
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
| 443 |
+
e.preventDefault();
|
| 444 |
+
undo();
|
| 445 |
+
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) {
|
| 446 |
+
e.preventDefault();
|
| 447 |
+
redo();
|
| 448 |
+
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
|
| 449 |
+
// Copy selected nodes and their connecting edges
|
| 450 |
+
if (selectedNodes.length > 0) {
|
| 451 |
+
const selectedIds = selectedNodes.map(n => n.id);
|
| 452 |
+
const copied = selectedNodes.map(n => ({ ...n, position: { ...n.position } }));
|
| 453 |
+
const copiedEdges = edges.filter(e => selectedIds.includes(e.source) && selectedIds.includes(e.target));
|
| 454 |
+
setCopiedNodes(copied);
|
| 455 |
+
// Store copied edges in a ref for pasting
|
| 456 |
+
(window as any)._copiedEdges = copiedEdges;
|
| 457 |
+
}
|
| 458 |
+
e.preventDefault();
|
| 459 |
+
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
|
| 460 |
+
// Paste nodes and edges
|
| 461 |
+
if (copiedNodes && copiedNodes.length > 0) {
|
| 462 |
+
const idMap: Record<string, string> = {};
|
| 463 |
+
const newNodes = copiedNodes.map(n => {
|
| 464 |
+
const newId = uuidv4();
|
| 465 |
+
idMap[n.id] = newId;
|
| 466 |
+
return {
|
| 467 |
+
...n,
|
| 468 |
+
id: newId,
|
| 469 |
+
position: { x: n.position.x + 40, y: n.position.y + 40 },
|
| 470 |
+
data: { ...n.data, label: n.data.label + ' (copy)', onChange: handleNodeDataChange },
|
| 471 |
+
};
|
| 472 |
+
});
|
| 473 |
+
// Paste only edges between copied nodes
|
| 474 |
+
const copiedEdges = (window as any)._copiedEdges || [];
|
| 475 |
+
const newEdges = copiedEdges.map((e: Edge) => ({
|
| 476 |
+
...e,
|
| 477 |
+
id: uuidv4(),
|
| 478 |
+
source: idMap[e.source],
|
| 479 |
+
target: idMap[e.target],
|
| 480 |
+
}));
|
| 481 |
+
setNodes(nds => [...nds, ...newNodes]);
|
| 482 |
+
setEdges(eds => [...eds, ...newEdges]);
|
| 483 |
+
setSelectedNodes(newNodes);
|
| 484 |
+
setIsDirty(true);
|
| 485 |
+
}
|
| 486 |
+
e.preventDefault();
|
| 487 |
+
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
| 488 |
+
setSelectedNodes(nodes);
|
| 489 |
+
e.preventDefault();
|
| 490 |
+
} else if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodes.length > 0) {
|
| 491 |
+
const idsToDelete = selectedNodes.map(n => n.id);
|
| 492 |
+
setNodes(nds => nds.filter(n => !idsToDelete.includes(n.id)));
|
| 493 |
+
setEdges(eds => eds.filter(e => !idsToDelete.includes(e.source) && !idsToDelete.includes(e.target)));
|
| 494 |
+
setSelectedNodes([]);
|
| 495 |
+
setSelectedNode(null);
|
| 496 |
+
setIsDirty(true);
|
| 497 |
+
e.preventDefault();
|
| 498 |
+
}
|
| 499 |
+
};
|
| 500 |
+
window.addEventListener('keydown', handleKeyDown);
|
| 501 |
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
| 502 |
+
}, [undo, redo, selectedNodes, copiedNodes, nodes, edges, setNodes, setEdges, handleNodeDataChange]);
|
| 503 |
+
|
| 504 |
+
// Warn before leaving with unsaved changes
|
| 505 |
+
useEffect(() => {
|
| 506 |
+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
| 507 |
+
if (isDirty) {
|
| 508 |
+
e.preventDefault();
|
| 509 |
+
e.returnValue = '';
|
| 510 |
+
}
|
| 511 |
+
};
|
| 512 |
+
window.addEventListener('beforeunload', handleBeforeUnload);
|
| 513 |
+
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
| 514 |
+
}, [isDirty]);
|
| 515 |
+
|
| 516 |
+
// Auto-dismiss error
|
| 517 |
+
useEffect(() => {
|
| 518 |
+
if (error) {
|
| 519 |
+
if (errorTimeout.current) clearTimeout(errorTimeout.current);
|
| 520 |
+
errorTimeout.current = setTimeout(() => setError(null), 3000);
|
| 521 |
+
}
|
| 522 |
+
return () => {
|
| 523 |
+
if (errorTimeout.current) clearTimeout(errorTimeout.current);
|
| 524 |
+
};
|
| 525 |
+
}, [error]);
|
| 526 |
+
|
| 527 |
+
// Add missing handlers
|
| 528 |
+
const handleNodeClick: NodeMouseHandler = useCallback((event, node) => {
|
| 529 |
+
if (event.ctrlKey || event.metaKey) {
|
| 530 |
+
setSelectedNodes((prev) => {
|
| 531 |
+
if (prev.find((n) => n.id === node.id)) {
|
| 532 |
+
return prev.filter((n) => n.id !== node.id);
|
| 533 |
+
} else {
|
| 534 |
+
return [...prev, node];
|
| 535 |
+
}
|
| 536 |
+
});
|
| 537 |
+
} else {
|
| 538 |
+
setSelectedNodes([node]);
|
| 539 |
+
}
|
| 540 |
+
setSelectedNode(node);
|
| 541 |
+
}, []);
|
| 542 |
+
|
| 543 |
+
const handlePaneClick = useCallback(() => {
|
| 544 |
+
setSelectedNode(null);
|
| 545 |
+
setSelectedNodes([]);
|
| 546 |
+
}, []);
|
| 547 |
+
|
| 548 |
+
const handleNodeDragStop: NodeMouseHandler = useCallback((_, node) => {
|
| 549 |
+
setNodes((nds) =>
|
| 550 |
+
nds.map((n) => {
|
| 551 |
+
if (n.id === node.id) {
|
| 552 |
+
return {
|
| 553 |
+
...n,
|
| 554 |
+
position: node.position,
|
| 555 |
+
};
|
| 556 |
+
}
|
| 557 |
+
return n;
|
| 558 |
+
})
|
| 559 |
+
);
|
| 560 |
+
setIsDirty(true);
|
| 561 |
+
}, [setNodes]);
|
| 562 |
+
|
| 563 |
+
const handleNodeDoubleClick: NodeMouseHandler = useCallback((_, node) => {
|
| 564 |
+
setSelectedNode(node);
|
| 565 |
+
}, []);
|
| 566 |
+
|
| 567 |
+
// Add handlePresetSelect
|
| 568 |
+
const handlePresetSelect = (preset: { name: string; bg: string; text: string }) => {
|
| 569 |
+
handleConfigChange({ ...config, nodeBgColor: preset.bg, nodeTextColor: preset.text });
|
| 570 |
+
setShowColorPicker(false);
|
| 571 |
+
};
|
| 572 |
+
|
| 573 |
+
return (
|
| 574 |
+
<div style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'row' }}>
|
| 575 |
+
<div style={{
|
| 576 |
+
width: isSidePaneCollapsed ? '64px' : '260px',
|
| 577 |
+
minWidth: isSidePaneCollapsed ? '64px' : '260px',
|
| 578 |
+
maxWidth: isSidePaneCollapsed ? '64px' : '260px',
|
| 579 |
+
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
| 580 |
+
height: '100%',
|
| 581 |
+
zIndex: 1000,
|
| 582 |
+
}}>
|
| 583 |
+
<SidePane
|
| 584 |
+
onSelectMindMap={handleSelectMindMap}
|
| 585 |
+
onNewMindMap={handleNewMindMap}
|
| 586 |
+
isCollapsed={isSidePaneCollapsed}
|
| 587 |
+
setIsCollapsed={setIsSidePaneCollapsed}
|
| 588 |
+
onDeleteMindMap={handleDeleteMindMap}
|
| 589 |
+
/>
|
| 590 |
+
</div>
|
| 591 |
+
<div
|
| 592 |
+
style={{
|
| 593 |
+
flexGrow: 1,
|
| 594 |
+
minWidth: 0,
|
| 595 |
+
height: '100%',
|
| 596 |
+
overflow: 'auto',
|
| 597 |
+
display: 'flex',
|
| 598 |
+
flexDirection: 'column',
|
| 599 |
+
position: 'relative',
|
| 600 |
+
}}
|
| 601 |
+
>
|
| 602 |
+
<div className={styles.mindMapHeader}>
|
| 603 |
+
<input
|
| 604 |
+
type="text"
|
| 605 |
+
value={mindMapName}
|
| 606 |
+
onChange={e => handleNameChange(e.target.value)}
|
| 607 |
+
className={styles.titleInput}
|
| 608 |
+
placeholder="Untitled Mind Map"
|
| 609 |
+
aria-label="Mind Map Title"
|
| 610 |
+
/>
|
| 611 |
+
</div>
|
| 612 |
+
<div className={styles.verticalToolbar}>
|
| 613 |
+
{selectedNode && (
|
| 614 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
|
| 615 |
+
<label style={{ fontSize: 12 }}>Node Background:</label>
|
| 616 |
+
<input
|
| 617 |
+
type="color"
|
| 618 |
+
value={selectedNode.data.bgColor}
|
| 619 |
+
onChange={e => handleNodeDataChange(selectedNode.id, { bgColor: e.target.value })}
|
| 620 |
+
aria-label="Node background color"
|
| 621 |
+
title="Node background color"
|
| 622 |
+
style={{ width: 32, height: 24, border: 'none', background: 'none' }}
|
| 623 |
+
/>
|
| 624 |
+
<label style={{ fontSize: 12 }}>Node Text:</label>
|
| 625 |
+
<input
|
| 626 |
+
type="color"
|
| 627 |
+
value={selectedNode.data.textColor}
|
| 628 |
+
onChange={e => handleNodeDataChange(selectedNode.id, { textColor: e.target.value })}
|
| 629 |
+
aria-label="Node text color"
|
| 630 |
+
title="Node text color"
|
| 631 |
+
style={{ width: 32, height: 24, border: 'none', background: 'none' }}
|
| 632 |
+
/>
|
| 633 |
+
</div>
|
| 634 |
+
)}
|
| 635 |
+
<button
|
| 636 |
+
onClick={onAddNode}
|
| 637 |
+
className={styles.toolButton}
|
| 638 |
+
title="Add Node"
|
| 639 |
+
aria-label="Add Node"
|
| 640 |
+
>
|
| 641 |
+
<MdIcons.MdAdd />
|
| 642 |
+
</button>
|
| 643 |
+
<button
|
| 644 |
+
onClick={debouncedAutoSave}
|
| 645 |
+
disabled={isSaving || !isDirty}
|
| 646 |
+
className={`${styles.toolButton} ${isSaving ? styles.saving : isDirty ? styles.dirty : ''}`}
|
| 647 |
+
title={isSaving ? 'Saving changes' : isDirty ? 'Save changes' : 'No changes to save'}
|
| 648 |
+
aria-label={isSaving ? 'Saving changes' : isDirty ? 'Save changes' : 'No changes to save'}
|
| 649 |
+
>
|
| 650 |
+
{isSaving ? <MdIcons.MdOutlineSaveAlt /> : isDirty ? <MdIcons.MdOutlineSaveAlt /> : <MdIcons.MdOutlineSave />}
|
| 651 |
+
</button>
|
| 652 |
+
<hr className={styles.toolbarDivider} />
|
| 653 |
+
<button
|
| 654 |
+
className={styles.toolButton}
|
| 655 |
+
onClick={() => setShowColorPicker(!showColorPicker)}
|
| 656 |
+
aria-label="Toggle color picker"
|
| 657 |
+
aria-expanded={showColorPicker}
|
| 658 |
+
aria-controls="color-picker"
|
| 659 |
+
title="Change mind map colors"
|
| 660 |
+
>
|
| 661 |
+
<MdIcons.MdColorLens />
|
| 662 |
+
</button>
|
| 663 |
+
{showColorPicker && (
|
| 664 |
+
<div
|
| 665 |
+
id="color-picker"
|
| 666 |
+
className={styles.colorPickerToolbar}
|
| 667 |
+
role="dialog"
|
| 668 |
+
aria-label="Color picker"
|
| 669 |
+
>
|
| 670 |
+
<div className={styles.colorPresets}>
|
| 671 |
+
{COLOR_PRESETS.map((preset) => (
|
| 672 |
+
<button
|
| 673 |
+
key={preset.name}
|
| 674 |
+
className={styles.colorPreset}
|
| 675 |
+
onClick={() => handlePresetSelect(preset)}
|
| 676 |
+
style={{
|
| 677 |
+
background: `linear-gradient(45deg, ${preset.bg} 50%, ${preset.text} 50%)`,
|
| 678 |
+
}}
|
| 679 |
+
title={preset.name}
|
| 680 |
+
aria-label={`Select ${preset.name} color preset`}
|
| 681 |
+
/>
|
| 682 |
+
))}
|
| 683 |
+
</div>
|
| 684 |
+
<div className={styles.customColors}>
|
| 685 |
+
<div className={styles.colorControl}>
|
| 686 |
+
<label htmlFor="bgColor">Background:</label>
|
| 687 |
+
<div className={styles.colorInputWrapper}>
|
| 688 |
+
<input
|
| 689 |
+
type="color"
|
| 690 |
+
id="bgColor"
|
| 691 |
+
value={config.nodeBgColor}
|
| 692 |
+
onChange={e => handleConfigChange({ ...config, nodeBgColor: e.target.value })}
|
| 693 |
+
className={styles.colorInput}
|
| 694 |
+
title="Choose background color"
|
| 695 |
+
aria-label="Background Color"
|
| 696 |
+
/>
|
| 697 |
+
<span className={styles.colorValue}>{config.nodeBgColor}</span>
|
| 698 |
+
</div>
|
| 699 |
+
</div>
|
| 700 |
+
<div className={styles.colorControl}>
|
| 701 |
+
<label htmlFor="textColor">Text:</label>
|
| 702 |
+
<div className={styles.colorInputWrapper}>
|
| 703 |
+
<input
|
| 704 |
+
type="color"
|
| 705 |
+
id="textColor"
|
| 706 |
+
value={config.nodeTextColor}
|
| 707 |
+
onChange={e => handleConfigChange({ ...config, nodeTextColor: e.target.value })}
|
| 708 |
+
className={styles.colorInput}
|
| 709 |
+
title="Choose text color"
|
| 710 |
+
aria-label="Text Color"
|
| 711 |
+
/>
|
| 712 |
+
<span className={styles.colorValue}>{config.nodeTextColor}</span>
|
| 713 |
+
</div>
|
| 714 |
+
</div>
|
| 715 |
+
</div>
|
| 716 |
+
</div>
|
| 717 |
+
)}
|
| 718 |
+
<button
|
| 719 |
+
className={styles.toolButton}
|
| 720 |
+
onClick={() => setIsChatOpen(true)}
|
| 721 |
+
title="Open Mind Map Chat Assistant"
|
| 722 |
+
aria-label="Open Mind Map Chat Assistant"
|
| 723 |
+
>
|
| 724 |
+
<MdIcons.MdChatBubbleOutline />
|
| 725 |
+
</button>
|
| 726 |
+
<button onClick={undo} className={styles.toolButton} title="Undo" aria-label="Undo"><MdIcons.MdUndo /></button>
|
| 727 |
+
<button onClick={redo} className={styles.toolButton} title="Redo" aria-label="Redo"><MdIcons.MdRedo /></button>
|
| 728 |
+
<button onClick={zoomIn} className={styles.toolButton} title="Zoom In" aria-label="Zoom In"><MdIcons.MdZoomIn /></button>
|
| 729 |
+
<button onClick={zoomOut} className={styles.toolButton} title="Zoom Out" aria-label="Zoom Out"><MdIcons.MdZoomOut /></button>
|
| 730 |
+
<button onClick={centerView} className={styles.toolButton} title="Center View" aria-label="Center View"><MdIcons.MdCenterFocusStrong /></button>
|
| 731 |
+
<button
|
| 732 |
+
onClick={handleExportJSON}
|
| 733 |
+
className={`${styles.toolButton} ${isDirty ? styles.dirty : ''}`}
|
| 734 |
+
title={isDirty ? "Save Changes" : "All Changes Saved"}
|
| 735 |
+
aria-label={isDirty ? "Save Changes" : "All Changes Saved"}
|
| 736 |
+
>
|
| 737 |
+
<MdIcons.MdOutlineSaveAlt />
|
| 738 |
+
</button>
|
| 739 |
+
<button onClick={() => fileInputRef.current?.click()} className={styles.toolButton} title="Import from JSON" aria-label="Import from JSON"><MdIcons.MdFileUpload /></button>
|
| 740 |
+
</div>
|
| 741 |
+
<input
|
| 742 |
+
type="file"
|
| 743 |
+
ref={fileInputRef}
|
| 744 |
+
onChange={handleImportJSON}
|
| 745 |
+
accept=".json"
|
| 746 |
+
style={{ display: 'none' }}
|
| 747 |
+
aria-label="Import mind map"
|
| 748 |
+
/>
|
| 749 |
+
{errorStack.map((err, idx) => (
|
| 750 |
+
<div key={idx} className={styles.errorToast} role="alert">
|
| 751 |
+
{err}
|
| 752 |
+
<button
|
| 753 |
+
aria-label="Close error"
|
| 754 |
+
onClick={() => removeError(idx)}
|
| 755 |
+
tabIndex={0}
|
| 756 |
+
className={styles.errorCloseButton}
|
| 757 |
+
>
|
| 758 |
+
×
|
| 759 |
+
</button>
|
| 760 |
+
</div>
|
| 761 |
+
))}
|
| 762 |
+
<div style={{ flexGrow: 1, minHeight: 0 }}>
|
| 763 |
+
<div className={styles.flowContainer} ref={reactFlowWrapper}>
|
| 764 |
+
<ReactFlow
|
| 765 |
+
nodes={nodes}
|
| 766 |
+
edges={edges}
|
| 767 |
+
onNodesChange={onNodesChange}
|
| 768 |
+
onEdgesChange={onEdgesChange}
|
| 769 |
+
onConnect={onConnect}
|
| 770 |
+
onInit={setReactFlowInstance}
|
| 771 |
+
onNodeClick={handleNodeClick}
|
| 772 |
+
onPaneClick={handlePaneClick}
|
| 773 |
+
onNodeDragStop={handleNodeDragStop}
|
| 774 |
+
onNodeDoubleClick={handleNodeDoubleClick}
|
| 775 |
+
nodeTypes={nodeTypes}
|
| 776 |
+
fitView
|
| 777 |
+
attributionPosition="bottom-right"
|
| 778 |
+
>
|
| 779 |
+
<Background />
|
| 780 |
+
<Controls />
|
| 781 |
+
<MiniMap />
|
| 782 |
+
</ReactFlow>
|
| 783 |
+
</div>
|
| 784 |
+
</div>
|
| 785 |
+
</div>
|
| 786 |
+
<ChatModal
|
| 787 |
+
open={isChatOpen}
|
| 788 |
+
onClose={() => setIsChatOpen(false)}
|
| 789 |
+
onImportJSON={handleImportJSON}
|
| 790 |
+
currentMindMapData={{
|
| 791 |
+
nodes,
|
| 792 |
+
edges,
|
| 793 |
+
config,
|
| 794 |
+
mindMapName
|
| 795 |
+
}}
|
| 796 |
+
/>
|
| 797 |
+
</div>
|
| 798 |
+
);
|
| 799 |
+
};
|
| 800 |
+
|
| 801 |
+
export default MindMapEditor;
|
frontend/src/components/MindMapItem.module.css
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.mindMapItem {
|
| 2 |
+
display: flex;
|
| 3 |
+
align-items: center;
|
| 4 |
+
justify-content: space-between;
|
| 5 |
+
padding: 18px 22px;
|
| 6 |
+
margin: 10px 0;
|
| 7 |
+
border-radius: 14px;
|
| 8 |
+
cursor: pointer;
|
| 9 |
+
background: #fff;
|
| 10 |
+
border: 1.5px solid #e0e0e0;
|
| 11 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
| 12 |
+
position: relative;
|
| 13 |
+
transition: box-shadow 0.2s, background 0.2s, transform 0.15s;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.mindMapItem:hover {
|
| 17 |
+
background: #f5faff;
|
| 18 |
+
box-shadow: 0 4px 16px rgba(0,123,255,0.08);
|
| 19 |
+
transform: translateY(-2px) scale(1.01);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.mindMapItem.active {
|
| 23 |
+
background: #eaf4ff;
|
| 24 |
+
border-left: 4px solid #007bff;
|
| 25 |
+
box-shadow: 0 6px 24px rgba(0,123,255,0.10);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.mindMapInfo {
|
| 29 |
+
display: flex;
|
| 30 |
+
flex-direction: column;
|
| 31 |
+
gap: 2px;
|
| 32 |
+
flex: 1;
|
| 33 |
+
min-width: 0;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.mindMapName {
|
| 37 |
+
font-weight: 700;
|
| 38 |
+
color: #1a2947;
|
| 39 |
+
font-size: 1.08rem;
|
| 40 |
+
letter-spacing: 0.01em;
|
| 41 |
+
white-space: nowrap;
|
| 42 |
+
overflow: hidden;
|
| 43 |
+
text-overflow: ellipsis;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.mindMapDate {
|
| 47 |
+
font-size: 0.9rem;
|
| 48 |
+
color: #7a8ca3;
|
| 49 |
+
font-weight: 400;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.menuContainer {
|
| 53 |
+
position: relative;
|
| 54 |
+
margin-left: 12px;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.menuButton {
|
| 58 |
+
background: none;
|
| 59 |
+
border: none;
|
| 60 |
+
padding: 6px;
|
| 61 |
+
cursor: pointer;
|
| 62 |
+
color: #7a8ca3;
|
| 63 |
+
border-radius: 6px;
|
| 64 |
+
display: flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
justify-content: center;
|
| 67 |
+
transition: background 0.18s, color 0.18s, box-shadow 0.18s;
|
| 68 |
+
box-shadow: none;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.menuButton:focus {
|
| 72 |
+
outline: 2px solid #007bff;
|
| 73 |
+
outline-offset: 2px;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.menuButton:hover {
|
| 77 |
+
background: #eaf4ff;
|
| 78 |
+
color: #007bff;
|
| 79 |
+
box-shadow: 0 2px 8px rgba(0,123,255,0.08);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.menu {
|
| 83 |
+
position: absolute;
|
| 84 |
+
right: 0;
|
| 85 |
+
top: 110%;
|
| 86 |
+
margin-top: 6px;
|
| 87 |
+
background: #fff;
|
| 88 |
+
border-radius: 10px;
|
| 89 |
+
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18), 0 1.5px 4px rgba(0,0,0,0.08);
|
| 90 |
+
min-width: 180px;
|
| 91 |
+
z-index: 2000;
|
| 92 |
+
animation: menuFadeIn 0.18s cubic-bezier(0.4,0,0.2,1);
|
| 93 |
+
transition: box-shadow 0.2s, transform 0.2s;
|
| 94 |
+
will-change: transform, box-shadow;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.menuItem {
|
| 98 |
+
display: flex;
|
| 99 |
+
align-items: center;
|
| 100 |
+
gap: 10px;
|
| 101 |
+
padding: 10px 18px;
|
| 102 |
+
width: 100%;
|
| 103 |
+
border: none;
|
| 104 |
+
background: none;
|
| 105 |
+
color: #1a2947;
|
| 106 |
+
cursor: pointer;
|
| 107 |
+
font-size: 1rem;
|
| 108 |
+
border-radius: 6px;
|
| 109 |
+
transition: background 0.18s, color 0.18s;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.menuItem:focus, .menuItem:hover {
|
| 113 |
+
background: #eaf4ff;
|
| 114 |
+
color: #007bff;
|
| 115 |
+
outline: none;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.menuItem.delete {
|
| 119 |
+
color: #dc3545;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.menuItem.delete:hover {
|
| 123 |
+
background: #fff0f3;
|
| 124 |
+
color: #dc3545;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.renameForm {
|
| 128 |
+
width: 100%;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.renameInput {
|
| 132 |
+
width: 100%;
|
| 133 |
+
padding: 10px 14px;
|
| 134 |
+
border: 1.5px solid #007bff;
|
| 135 |
+
border-radius: 6px;
|
| 136 |
+
font-size: 1rem;
|
| 137 |
+
color: #1a2947;
|
| 138 |
+
background: white;
|
| 139 |
+
transition: box-shadow 0.18s, border 0.18s;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.renameInput:focus {
|
| 143 |
+
outline: none;
|
| 144 |
+
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.18);
|
| 145 |
+
border-color: #0056b3;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
@keyframes menuFadeIn {
|
| 149 |
+
from { opacity: 0; transform: translateY(-8px) scale(0.98);}
|
| 150 |
+
to { opacity: 1; transform: translateY(0) scale(1);}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/*
|
| 154 |
+
For accessibility, add in your React code:
|
| 155 |
+
- role="menu" to .menu
|
| 156 |
+
- role="menuitem" to .menuItem
|
| 157 |
+
- Keyboard navigation (arrow keys, esc to close) in JS/TS
|
| 158 |
+
- Focus trap when menu is open
|
| 159 |
+
*/
|
frontend/src/components/MindMapItem.tsx
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import styles from './MindMapItem.module.css';
|
| 3 |
+
|
| 4 |
+
interface MindMapItemProps {
|
| 5 |
+
id: string;
|
| 6 |
+
name: string;
|
| 7 |
+
updatedAt: string;
|
| 8 |
+
onSelect: () => void;
|
| 9 |
+
onDelete: () => void;
|
| 10 |
+
onRename: (newName: string) => void;
|
| 11 |
+
onShare: () => void;
|
| 12 |
+
isActive: boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const MindMapItem: React.FC<MindMapItemProps> = ({
|
| 16 |
+
id,
|
| 17 |
+
name,
|
| 18 |
+
updatedAt,
|
| 19 |
+
onSelect,
|
| 20 |
+
onDelete,
|
| 21 |
+
onRename,
|
| 22 |
+
onShare,
|
| 23 |
+
isActive,
|
| 24 |
+
}) => {
|
| 25 |
+
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
| 26 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 27 |
+
const [editedName, setEditedName] = useState(name);
|
| 28 |
+
const menuRef = useRef<HTMLDivElement>(null);
|
| 29 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
const handleClickOutside = (event: MouseEvent) => {
|
| 33 |
+
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
| 34 |
+
setIsMenuOpen(false);
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 39 |
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
| 40 |
+
}, []);
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
if (isEditing && inputRef.current) {
|
| 44 |
+
inputRef.current.focus();
|
| 45 |
+
inputRef.current.select();
|
| 46 |
+
}
|
| 47 |
+
}, [isEditing]);
|
| 48 |
+
|
| 49 |
+
const handleMenuClick = (e: React.MouseEvent) => {
|
| 50 |
+
e.stopPropagation();
|
| 51 |
+
setIsMenuOpen(!isMenuOpen);
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const handleRename = () => {
|
| 55 |
+
setIsEditing(true);
|
| 56 |
+
setIsMenuOpen(false);
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const handleRenameSubmit = (e: React.FormEvent) => {
|
| 60 |
+
e.preventDefault();
|
| 61 |
+
if (editedName.trim()) {
|
| 62 |
+
onRename(editedName.trim());
|
| 63 |
+
}
|
| 64 |
+
setIsEditing(false);
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const handleDelete = () => {
|
| 68 |
+
setIsMenuOpen(false);
|
| 69 |
+
onDelete();
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const handleShare = () => {
|
| 73 |
+
setIsMenuOpen(false);
|
| 74 |
+
onShare();
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<div className={`${styles.mindMapItem} ${isActive ? styles.active : ''}`} onClick={onSelect} style={{ position: 'relative' }}>
|
| 79 |
+
<div className={styles.mindMapInfo}>
|
| 80 |
+
<span className={styles.mindMapName}>{name}</span>
|
| 81 |
+
<span className={styles.mindMapDate}>
|
| 82 |
+
{new Date(updatedAt).toLocaleDateString()}
|
| 83 |
+
</span>
|
| 84 |
+
</div>
|
| 85 |
+
<div className={styles.verticalToolbar}>
|
| 86 |
+
{isEditing ? (
|
| 87 |
+
<form onSubmit={handleRenameSubmit} style={{ width: '100%' }}>
|
| 88 |
+
<input
|
| 89 |
+
ref={inputRef}
|
| 90 |
+
type="text"
|
| 91 |
+
value={editedName}
|
| 92 |
+
onChange={(e) => setEditedName(e.target.value)}
|
| 93 |
+
onBlur={handleRenameSubmit}
|
| 94 |
+
className={styles.renameInput}
|
| 95 |
+
placeholder="Rename mind map"
|
| 96 |
+
style={{ marginBottom: 8 }}
|
| 97 |
+
/>
|
| 98 |
+
</form>
|
| 99 |
+
) : null}
|
| 100 |
+
<button onClick={handleRename} className={styles.toolbarButton} aria-label="Rename mind map">
|
| 101 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>
|
| 102 |
+
</button>
|
| 103 |
+
<button onClick={handleShare} className={styles.toolbarButton} aria-label="Share mind map">
|
| 104 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" /></svg>
|
| 105 |
+
</button>
|
| 106 |
+
<button onClick={handleDelete} className={`${styles.toolbarButton} ${styles.delete}`} aria-label="Delete mind map">
|
| 107 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
|
| 108 |
+
</button>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
);
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
export default MindMapItem;
|
frontend/src/components/MindMapList.module.css
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.mindMapList {
|
| 2 |
+
padding: 20px 0;
|
| 3 |
+
display: flex;
|
| 4 |
+
flex-direction: column;
|
| 5 |
+
gap: 18px;
|
| 6 |
+
background: transparent;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
.header {
|
| 10 |
+
display: flex;
|
| 11 |
+
align-items: center;
|
| 12 |
+
justify-content: space-between;
|
| 13 |
+
margin-bottom: 10px;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.title {
|
| 17 |
+
font-size: 1.1rem;
|
| 18 |
+
font-weight: 700;
|
| 19 |
+
color: #1a2947;
|
| 20 |
+
margin: 0;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.newButton {
|
| 24 |
+
display: flex;
|
| 25 |
+
align-items: center;
|
| 26 |
+
gap: 8px;
|
| 27 |
+
padding: 8px 14px;
|
| 28 |
+
background: #007bff;
|
| 29 |
+
color: white;
|
| 30 |
+
border: none;
|
| 31 |
+
border-radius: 8px;
|
| 32 |
+
cursor: pointer;
|
| 33 |
+
font-size: 1rem;
|
| 34 |
+
font-weight: 600;
|
| 35 |
+
transition: background 0.2s, transform 0.2s;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.newButton:hover {
|
| 39 |
+
background: #0056b3;
|
| 40 |
+
transform: translateY(-2px) scale(1.03);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.newButton svg {
|
| 44 |
+
width: 16px;
|
| 45 |
+
height: 16px;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.list {
|
| 49 |
+
display: flex;
|
| 50 |
+
flex-direction: column;
|
| 51 |
+
gap: 14px;
|
| 52 |
+
overflow-y: auto;
|
| 53 |
+
max-height: calc(100vh - 180px);
|
| 54 |
+
padding-right: 8px;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.list::-webkit-scrollbar {
|
| 58 |
+
width: 4px;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.list::-webkit-scrollbar-track {
|
| 62 |
+
background: transparent;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.list::-webkit-scrollbar-thumb {
|
| 66 |
+
background: #e0e0e0;
|
| 67 |
+
border-radius: 20px;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.empty, .loading, .error {
|
| 71 |
+
text-align: center;
|
| 72 |
+
padding: 28px 0;
|
| 73 |
+
font-size: 1rem;
|
| 74 |
+
border-radius: 8px;
|
| 75 |
+
margin: 0 10px;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.empty {
|
| 79 |
+
color: #7a8ca3;
|
| 80 |
+
background: #f8f9fa;
|
| 81 |
+
border: 1px dashed #e0e0e0;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.loading {
|
| 85 |
+
color: #007bff;
|
| 86 |
+
background: #eaf4ff;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.error {
|
| 90 |
+
color: #dc3545;
|
| 91 |
+
background: #fff0f3;
|
| 92 |
+
border: 1px solid #ffd6db;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.mindMapListTitle {
|
| 96 |
+
padding: 0 20px;
|
| 97 |
+
margin: 10px 0;
|
| 98 |
+
font-size: 14px;
|
| 99 |
+
color: #6c757d;
|
| 100 |
+
font-weight: 500;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.mindMapItem {
|
| 104 |
+
display: flex;
|
| 105 |
+
align-items: center;
|
| 106 |
+
padding: 12px 20px;
|
| 107 |
+
color: #2c3e50;
|
| 108 |
+
cursor: pointer;
|
| 109 |
+
transition: background-color 0.2s;
|
| 110 |
+
border-bottom: 1px solid #f0f0f0;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.mindMapItem:hover {
|
| 114 |
+
background-color: #f8f9fa;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.mindMapItem.active {
|
| 118 |
+
background-color: #e9ecef;
|
| 119 |
+
border-left: 3px solid #007bff;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.mindMapIcon {
|
| 123 |
+
width: 20px;
|
| 124 |
+
height: 20px;
|
| 125 |
+
margin-right: 12px;
|
| 126 |
+
color: #6c757d;
|
| 127 |
+
flex-shrink: 0;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.mindMapInfo {
|
| 131 |
+
display: flex;
|
| 132 |
+
flex-direction: column;
|
| 133 |
+
flex-grow: 1;
|
| 134 |
+
min-width: 0;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.mindMapName {
|
| 138 |
+
font-size: 14px;
|
| 139 |
+
font-weight: 500;
|
| 140 |
+
white-space: nowrap;
|
| 141 |
+
overflow: hidden;
|
| 142 |
+
text-overflow: ellipsis;
|
| 143 |
+
margin-bottom: 4px;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.mindMapDate {
|
| 147 |
+
font-size: 12px;
|
| 148 |
+
color: #6c757d;
|
| 149 |
+
display: flex;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.collapsed .mindMapListTitle,
|
| 153 |
+
.collapsed .mindMapName,
|
| 154 |
+
.collapsed .mindMapDate {
|
| 155 |
+
display: none;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.collapsed .mindMapItem {
|
| 159 |
+
padding: 12px;
|
| 160 |
+
justify-content: center;
|
| 161 |
+
display: flex;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.collapsed .mindMapIcon {
|
| 165 |
+
margin-right: 0;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.newMindMapButton {
|
| 169 |
+
margin: 10px 20px;
|
| 170 |
+
padding: 8px 16px;
|
| 171 |
+
background: #007bff;
|
| 172 |
+
color: white;
|
| 173 |
+
border: none;
|
| 174 |
+
border-radius: 4px;
|
| 175 |
+
cursor: pointer;
|
| 176 |
+
display: flex;
|
| 177 |
+
align-items: center;
|
| 178 |
+
justify-content: center;
|
| 179 |
+
font-size: 14px;
|
| 180 |
+
transition: background-color 0.2s;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.newMindMapButton:hover {
|
| 184 |
+
background: #0056b3;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.newMindMapButton .icon {
|
| 188 |
+
margin-right: 8px;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.collapsed .newMindMapButton {
|
| 192 |
+
margin: 10px;
|
| 193 |
+
padding: 8px;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.collapsed .newMindMapButton span {
|
| 197 |
+
display: none;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.retryButton {
|
| 201 |
+
margin: 10px 20px;
|
| 202 |
+
padding: 8px 16px;
|
| 203 |
+
background: #6c757d;
|
| 204 |
+
color: white;
|
| 205 |
+
border: none;
|
| 206 |
+
border-radius: 4px;
|
| 207 |
+
cursor: pointer;
|
| 208 |
+
font-size: 14px;
|
| 209 |
+
transition: background-color 0.2s;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.retryButton:hover {
|
| 213 |
+
background: #5a6268;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
@media (max-width: 600px) {
|
| 217 |
+
.mindMapList {
|
| 218 |
+
padding: 10px 0;
|
| 219 |
+
}
|
| 220 |
+
.header {
|
| 221 |
+
flex-direction: column;
|
| 222 |
+
gap: 8px;
|
| 223 |
+
}
|
| 224 |
+
.list {
|
| 225 |
+
max-height: 60vh;
|
| 226 |
+
}
|
| 227 |
+
}
|
frontend/src/components/MindMapList.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import axios from '../utils/axios';
|
| 3 |
+
import { MindMapData } from '../utils/jsonUtils';
|
| 4 |
+
import styles from './MindMapList.module.css';
|
| 5 |
+
import MindMapItem from './MindMapItem';
|
| 6 |
+
|
| 7 |
+
interface MindMap {
|
| 8 |
+
id: string;
|
| 9 |
+
name: string;
|
| 10 |
+
updatedAt: string;
|
| 11 |
+
nodes: any[];
|
| 12 |
+
edges: any[];
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
interface MindMapListProps {
|
| 16 |
+
isCollapsed: boolean;
|
| 17 |
+
onSelectMindMap: (mindMap: MindMapData) => void;
|
| 18 |
+
onNewMindMap: () => void;
|
| 19 |
+
onDeleteMindMap: (id: string) => void;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const MindMapList: React.FC<MindMapListProps> = ({
|
| 23 |
+
isCollapsed,
|
| 24 |
+
onSelectMindMap,
|
| 25 |
+
onNewMindMap,
|
| 26 |
+
onDeleteMindMap,
|
| 27 |
+
}) => {
|
| 28 |
+
const [mindMaps, setMindMaps] = useState<MindMapData[]>([]);
|
| 29 |
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
| 30 |
+
const [loading, setLoading] = useState(true);
|
| 31 |
+
const [error, setError] = useState<string | null>(null);
|
| 32 |
+
|
| 33 |
+
const fetchMindMaps = async () => {
|
| 34 |
+
setLoading(true);
|
| 35 |
+
setError(null);
|
| 36 |
+
try {
|
| 37 |
+
const res = await axios.get('/api/workflows');
|
| 38 |
+
setMindMaps(res.data);
|
| 39 |
+
} catch (err: any) {
|
| 40 |
+
setError('Failed to load mind maps');
|
| 41 |
+
} finally {
|
| 42 |
+
setLoading(false);
|
| 43 |
+
}
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
fetchMindMaps();
|
| 48 |
+
}, []);
|
| 49 |
+
|
| 50 |
+
const handleDelete = async (id: string) => {
|
| 51 |
+
try {
|
| 52 |
+
await axios.delete(`/api/workflows/${id}`);
|
| 53 |
+
await fetchMindMaps();
|
| 54 |
+
if (selectedId === id) setSelectedId(null);
|
| 55 |
+
onDeleteMindMap(id);
|
| 56 |
+
} catch {
|
| 57 |
+
setError('Failed to delete mind map');
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const handleRename = async (id: string, newName: string) => {
|
| 62 |
+
try {
|
| 63 |
+
await axios.put(`/api/workflows/${id}`, { name: newName });
|
| 64 |
+
await fetchMindMaps();
|
| 65 |
+
} catch {
|
| 66 |
+
setError('Failed to rename mind map');
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<div className={styles.mindMapList}>
|
| 72 |
+
<div className={styles.header}>
|
| 73 |
+
<h3 className={styles.title}>Your Mind Maps</h3>
|
| 74 |
+
<button
|
| 75 |
+
onClick={onNewMindMap}
|
| 76 |
+
className={styles.newButton}
|
| 77 |
+
title="Create new mind map"
|
| 78 |
+
>
|
| 79 |
+
<svg
|
| 80 |
+
width="18"
|
| 81 |
+
height="18"
|
| 82 |
+
viewBox="0 0 24 24"
|
| 83 |
+
fill="none"
|
| 84 |
+
stroke="currentColor"
|
| 85 |
+
strokeWidth="2"
|
| 86 |
+
strokeLinecap="round"
|
| 87 |
+
strokeLinejoin="round"
|
| 88 |
+
style={{ marginRight: isCollapsed ? 0 : 4 }}
|
| 89 |
+
>
|
| 90 |
+
<circle cx="12" cy="12" r="10" />
|
| 91 |
+
<line x1="12" y1="8" x2="12" y2="16" />
|
| 92 |
+
<line x1="8" y1="12" x2="16" y2="12" />
|
| 93 |
+
</svg>
|
| 94 |
+
{!isCollapsed && <span>New</span>}
|
| 95 |
+
</button>
|
| 96 |
+
</div>
|
| 97 |
+
<div className={styles.list}>
|
| 98 |
+
{loading && <div className={styles.loading}>Loading...</div>}
|
| 99 |
+
{error && <div className={styles.error}>{error}</div>}
|
| 100 |
+
{!loading && !error && mindMaps.length === 0 && (
|
| 101 |
+
<div className={styles.empty}>No mind maps found.</div>
|
| 102 |
+
)}
|
| 103 |
+
{!loading && !error && mindMaps.map((mindMap) => (
|
| 104 |
+
<MindMapItem
|
| 105 |
+
key={mindMap.id}
|
| 106 |
+
id={mindMap.id}
|
| 107 |
+
name={mindMap.name}
|
| 108 |
+
updatedAt={mindMap.updatedAt}
|
| 109 |
+
isActive={selectedId === mindMap.id}
|
| 110 |
+
onSelect={() => {
|
| 111 |
+
setSelectedId(mindMap.id);
|
| 112 |
+
onSelectMindMap(mindMap);
|
| 113 |
+
}}
|
| 114 |
+
onDelete={() => handleDelete(mindMap.id)}
|
| 115 |
+
onRename={(newName) => handleRename(mindMap.id, newName)}
|
| 116 |
+
onShare={() => {}}
|
| 117 |
+
/>
|
| 118 |
+
))}
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
);
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
export default MindMapList;
|
frontend/src/components/SidePane.module.css
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.sidePane {
|
| 2 |
+
position: fixed;
|
| 3 |
+
left: 0;
|
| 4 |
+
top: 0;
|
| 5 |
+
height: 100vh;
|
| 6 |
+
background: linear-gradient(180deg, #ffffff 0%, #f8f9fa 100%);
|
| 7 |
+
box-shadow: 0 0 20px rgba(0, 0, 0, 0.08);
|
| 8 |
+
z-index: 1000;
|
| 9 |
+
display: flex;
|
| 10 |
+
flex-direction: column;
|
| 11 |
+
border-right: 1px solid rgba(0, 0, 0, 0.05);
|
| 12 |
+
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.expanded {
|
| 16 |
+
width: 260px;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.collapsed {
|
| 20 |
+
width: 70px;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.toggleButton {
|
| 24 |
+
position: absolute;
|
| 25 |
+
right: -12px;
|
| 26 |
+
top: 20px;
|
| 27 |
+
width: 28px;
|
| 28 |
+
height: 28px;
|
| 29 |
+
background: #fff;
|
| 30 |
+
border: 1px solid #e0e0e0;
|
| 31 |
+
border-radius: 50%;
|
| 32 |
+
cursor: pointer;
|
| 33 |
+
display: flex;
|
| 34 |
+
align-items: center;
|
| 35 |
+
justify-content: center;
|
| 36 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
| 37 |
+
z-index: 1001;
|
| 38 |
+
transition: background 0.2s, transform 0.2s;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.toggleButton:hover {
|
| 42 |
+
background: #f0f4fa;
|
| 43 |
+
transform: scale(1.08);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.menuItems {
|
| 47 |
+
padding: 24px 0 0 0;
|
| 48 |
+
flex-grow: 1;
|
| 49 |
+
overflow-y: auto;
|
| 50 |
+
display: flex;
|
| 51 |
+
flex-direction: column;
|
| 52 |
+
gap: 4px;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.menuItem {
|
| 56 |
+
display: flex;
|
| 57 |
+
align-items: center;
|
| 58 |
+
padding: 12px 20px;
|
| 59 |
+
color: #2c3e50;
|
| 60 |
+
text-decoration: none;
|
| 61 |
+
border-radius: 0 12px 12px 0;
|
| 62 |
+
margin-right: 12px;
|
| 63 |
+
transition: background 0.2s, color 0.2s;
|
| 64 |
+
font-size: 1rem;
|
| 65 |
+
font-weight: 500;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.menuItem:hover {
|
| 69 |
+
background: #f0f4fa;
|
| 70 |
+
color: #007bff;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.menuItem.active {
|
| 74 |
+
background: #eaf4ff;
|
| 75 |
+
color: #007bff;
|
| 76 |
+
border-left: 3px solid #007bff;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.icon {
|
| 80 |
+
width: 24px;
|
| 81 |
+
height: 24px;
|
| 82 |
+
display: flex;
|
| 83 |
+
align-items: center;
|
| 84 |
+
justify-content: center;
|
| 85 |
+
margin-right: 16px;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.label {
|
| 89 |
+
white-space: nowrap;
|
| 90 |
+
overflow: hidden;
|
| 91 |
+
font-weight: 500;
|
| 92 |
+
font-size: 1rem;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.collapsed .label {
|
| 96 |
+
display: none;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.logoutButton {
|
| 100 |
+
margin-top: auto;
|
| 101 |
+
padding: 20px 16px;
|
| 102 |
+
border-top: 1px solid #f0f0f0;
|
| 103 |
+
background: #fff;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.logoutButton button {
|
| 107 |
+
width: 100%;
|
| 108 |
+
padding: 12px;
|
| 109 |
+
background: #dc3545;
|
| 110 |
+
color: white;
|
| 111 |
+
border: none;
|
| 112 |
+
border-radius: 8px;
|
| 113 |
+
cursor: pointer;
|
| 114 |
+
font-weight: 500;
|
| 115 |
+
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.12);
|
| 116 |
+
transition: background 0.2s, transform 0.2s;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.logoutButton button:hover {
|
| 120 |
+
background: #c82333;
|
| 121 |
+
transform: translateY(-1px);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.logoutButton.collapsed button {
|
| 125 |
+
padding: 12px;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.logoutButton.collapsed span {
|
| 129 |
+
display: none;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
@media (max-width: 600px) {
|
| 133 |
+
.sidePane {
|
| 134 |
+
width: 100vw !important;
|
| 135 |
+
min-width: 0;
|
| 136 |
+
max-width: 100vw;
|
| 137 |
+
border-radius: 0;
|
| 138 |
+
left: 0;
|
| 139 |
+
top: 0;
|
| 140 |
+
height: 60px;
|
| 141 |
+
flex-direction: row;
|
| 142 |
+
align-items: center;
|
| 143 |
+
border-right: none;
|
| 144 |
+
border-bottom: 1px solid #e0e0e0;
|
| 145 |
+
}
|
| 146 |
+
.expanded, .collapsed {
|
| 147 |
+
width: 100vw !important;
|
| 148 |
+
}
|
| 149 |
+
.menuItems {
|
| 150 |
+
flex-direction: row;
|
| 151 |
+
padding: 0 8px;
|
| 152 |
+
gap: 0;
|
| 153 |
+
}
|
| 154 |
+
.logoutButton {
|
| 155 |
+
padding: 8px;
|
| 156 |
+
border-top: none;
|
| 157 |
+
border-left: 1px solid #e0e0e0;
|
| 158 |
+
margin-top: 0;
|
| 159 |
+
}
|
| 160 |
+
}
|
frontend/src/components/SidePane.tsx
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { useNavigate, useLocation } from 'react-router-dom';
|
| 3 |
+
import { Node, Edge } from 'reactflow';
|
| 4 |
+
import styles from './SidePane.module.css';
|
| 5 |
+
import MindMapList from './MindMapList';
|
| 6 |
+
import axios from '../utils/axios';
|
| 7 |
+
import { MindMapData } from '../utils/jsonUtils';
|
| 8 |
+
|
| 9 |
+
// Icons with improved styling
|
| 10 |
+
const HomeIcon = () => (
|
| 11 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 12 |
+
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
| 13 |
+
<polyline points="9 22 9 12 15 12 15 22" />
|
| 14 |
+
</svg>
|
| 15 |
+
);
|
| 16 |
+
|
| 17 |
+
const MindMapIcon = () => (
|
| 18 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 19 |
+
<circle cx="12" cy="12" r="10" />
|
| 20 |
+
<path d="M12 2v20M2 12h20" />
|
| 21 |
+
<path d="M12 2a10 10 0 0 1 10 10M12 22a10 10 0 0 1-10-10" />
|
| 22 |
+
</svg>
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
const LogoutIcon = () => (
|
| 26 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 27 |
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
| 28 |
+
<polyline points="16 17 21 12 16 7" />
|
| 29 |
+
<line x1="21" y1="12" x2="9" y2="12" />
|
| 30 |
+
</svg>
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
const ChevronIcon = ({ isCollapsed }: { isCollapsed: boolean }) => (
|
| 34 |
+
<svg
|
| 35 |
+
width="16"
|
| 36 |
+
height="16"
|
| 37 |
+
viewBox="0 0 24 24"
|
| 38 |
+
fill="none"
|
| 39 |
+
stroke="currentColor"
|
| 40 |
+
strokeWidth="2"
|
| 41 |
+
strokeLinecap="round"
|
| 42 |
+
strokeLinejoin="round"
|
| 43 |
+
style={{
|
| 44 |
+
transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)',
|
| 45 |
+
transition: 'transform 0.3s ease',
|
| 46 |
+
}}
|
| 47 |
+
>
|
| 48 |
+
<polyline points="15 18 9 12 15 6" />
|
| 49 |
+
</svg>
|
| 50 |
+
);
|
| 51 |
+
|
| 52 |
+
interface SidePaneProps {
|
| 53 |
+
onSelectMindMap: (mindMap: MindMapData) => void;
|
| 54 |
+
onNewMindMap: () => void;
|
| 55 |
+
isCollapsed: boolean;
|
| 56 |
+
setIsCollapsed: (collapsed: boolean) => void;
|
| 57 |
+
onDeleteMindMap: (id: string) => void;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const SidePane: React.FC<SidePaneProps> = ({ onSelectMindMap, onNewMindMap, isCollapsed, setIsCollapsed, onDeleteMindMap }) => {
|
| 61 |
+
const navigate = useNavigate();
|
| 62 |
+
const location = useLocation();
|
| 63 |
+
const isEditor = location.pathname === '/editor';
|
| 64 |
+
|
| 65 |
+
const handleLogout = async () => {
|
| 66 |
+
try {
|
| 67 |
+
await axios.post('/logout');
|
| 68 |
+
} catch (error) {
|
| 69 |
+
// Ignore error, proceed to clear token and redirect
|
| 70 |
+
}
|
| 71 |
+
localStorage.removeItem('token');
|
| 72 |
+
navigate('/');
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const handleNavigation = (path: string) => (e: React.MouseEvent) => {
|
| 76 |
+
e.preventDefault();
|
| 77 |
+
try {
|
| 78 |
+
navigate(path);
|
| 79 |
+
} catch (error) {
|
| 80 |
+
console.error('Navigation error:', error);
|
| 81 |
+
// Fallback to window.location if navigation fails
|
| 82 |
+
window.location.href = path;
|
| 83 |
+
}
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<div className={`${styles.sidePane} ${isCollapsed ? styles.collapsed : styles.expanded}`}>
|
| 88 |
+
<button
|
| 89 |
+
className={styles.toggleButton}
|
| 90 |
+
onClick={() => setIsCollapsed(!isCollapsed)}
|
| 91 |
+
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
| 92 |
+
>
|
| 93 |
+
<ChevronIcon isCollapsed={isCollapsed} />
|
| 94 |
+
</button>
|
| 95 |
+
|
| 96 |
+
<div className={styles.menuItems}>
|
| 97 |
+
<a
|
| 98 |
+
href="/"
|
| 99 |
+
className={`${styles.menuItem} ${location.pathname === '/' ? styles.active : ''}`}
|
| 100 |
+
onClick={handleNavigation('/')}
|
| 101 |
+
>
|
| 102 |
+
<span className={styles.icon}>
|
| 103 |
+
<HomeIcon />
|
| 104 |
+
</span>
|
| 105 |
+
<span className={styles.label}>Home</span>
|
| 106 |
+
</a>
|
| 107 |
+
|
| 108 |
+
<a
|
| 109 |
+
href="/editor"
|
| 110 |
+
className={`${styles.menuItem} ${location.pathname === '/editor' ? styles.active : ''}`}
|
| 111 |
+
onClick={handleNavigation('/editor')}
|
| 112 |
+
>
|
| 113 |
+
<span className={styles.icon}>
|
| 114 |
+
<MindMapIcon />
|
| 115 |
+
</span>
|
| 116 |
+
<span className={styles.label}>Mind Map</span>
|
| 117 |
+
</a>
|
| 118 |
+
|
| 119 |
+
{isEditor && (
|
| 120 |
+
<MindMapList
|
| 121 |
+
isCollapsed={isCollapsed}
|
| 122 |
+
onSelectMindMap={onSelectMindMap}
|
| 123 |
+
onNewMindMap={onNewMindMap}
|
| 124 |
+
onDeleteMindMap={onDeleteMindMap}
|
| 125 |
+
/>
|
| 126 |
+
)}
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div className={`${styles.logoutButton} ${isCollapsed ? styles.collapsed : ''}`}>
|
| 130 |
+
<button onClick={handleLogout} aria-label="Logout">
|
| 131 |
+
<span className={styles.icon}>
|
| 132 |
+
<LogoutIcon />
|
| 133 |
+
</span>
|
| 134 |
+
<span className={styles.label}>Logout</span>
|
| 135 |
+
</button>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
);
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
export default SidePane;
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* {
|
| 2 |
+
box-sizing: border-box;
|
| 3 |
+
margin: 0;
|
| 4 |
+
padding: 0;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
body {
|
| 8 |
+
margin: 0;
|
| 9 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
| 10 |
+
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
| 11 |
+
sans-serif;
|
| 12 |
+
-webkit-font-smoothing: antialiased;
|
| 13 |
+
-moz-osx-font-smoothing: grayscale;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
code {
|
| 17 |
+
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
| 18 |
+
monospace;
|
| 19 |
+
}
|
frontend/src/index.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactDOM from 'react-dom/client';
|
| 3 |
+
import './index.css';
|
| 4 |
+
import App from './App';
|
| 5 |
+
import './App.css';
|
| 6 |
+
import reportWebVitals from './reportWebVitals';
|
| 7 |
+
|
| 8 |
+
const root = ReactDOM.createRoot(
|
| 9 |
+
document.getElementById('root') as HTMLElement
|
| 10 |
+
);
|
| 11 |
+
|
| 12 |
+
root.render(
|
| 13 |
+
<React.StrictMode>
|
| 14 |
+
<App />
|
| 15 |
+
</React.StrictMode>
|
| 16 |
+
);
|
| 17 |
+
|
| 18 |
+
// If you want to start measuring performance in your app, pass a function
|
| 19 |
+
// to log results (for example: reportWebVitals(console.log))
|
| 20 |
+
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
| 21 |
+
reportWebVitals();
|
frontend/src/logo.svg
ADDED
|
|
frontend/src/react-app-env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="react-scripts" />
|
frontend/src/reportWebVitals.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ReportHandler } from 'web-vitals';
|
| 2 |
+
|
| 3 |
+
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
| 4 |
+
if (onPerfEntry && onPerfEntry instanceof Function) {
|
| 5 |
+
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
| 6 |
+
getCLS(onPerfEntry);
|
| 7 |
+
getFID(onPerfEntry);
|
| 8 |
+
getFCP(onPerfEntry);
|
| 9 |
+
getLCP(onPerfEntry);
|
| 10 |
+
getTTFB(onPerfEntry);
|
| 11 |
+
});
|
| 12 |
+
}
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export default reportWebVitals;
|
frontend/src/setupTests.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
| 2 |
+
// allows you to do things like:
|
| 3 |
+
// expect(element).toHaveTextContent(/react/i)
|
| 4 |
+
// learn more: https://github.com/testing-library/jest-dom
|
| 5 |
+
import '@testing-library/jest-dom';
|
frontend/src/utils/apiUtils.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios, { AxiosError } from 'axios';
|
| 2 |
+
|
| 3 |
+
export interface ApiError {
|
| 4 |
+
message: string;
|
| 5 |
+
details?: string;
|
| 6 |
+
code?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
interface ApiErrorResponse {
|
| 10 |
+
message?: string;
|
| 11 |
+
error?: string;
|
| 12 |
+
details?: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export const handleApiError = (error: unknown): ApiError => {
|
| 16 |
+
if (axios.isAxiosError(error)) {
|
| 17 |
+
const axiosError = error as AxiosError<ApiErrorResponse>;
|
| 18 |
+
const responseData = axiosError.response?.data;
|
| 19 |
+
return {
|
| 20 |
+
message: axiosError.message,
|
| 21 |
+
details: responseData?.message || responseData?.error || 'Unknown error occurred',
|
| 22 |
+
code: axiosError.code,
|
| 23 |
+
};
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
if (error instanceof Error) {
|
| 27 |
+
return {
|
| 28 |
+
message: error.message,
|
| 29 |
+
details: error.stack,
|
| 30 |
+
};
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
return {
|
| 34 |
+
message: 'An unexpected error occurred',
|
| 35 |
+
details: String(error),
|
| 36 |
+
};
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
export const withRetry = async <T>(
|
| 40 |
+
operation: () => Promise<T>,
|
| 41 |
+
maxRetries: number = 3,
|
| 42 |
+
delay: number = 1000
|
| 43 |
+
): Promise<T> => {
|
| 44 |
+
let lastError: unknown;
|
| 45 |
+
|
| 46 |
+
for (let i = 0; i < maxRetries; i++) {
|
| 47 |
+
try {
|
| 48 |
+
return await operation();
|
| 49 |
+
} catch (error) {
|
| 50 |
+
lastError = error;
|
| 51 |
+
if (i < maxRetries - 1) {
|
| 52 |
+
await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i)));
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
throw lastError;
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
export const validateInput = (input: string, maxLength: number = 100): boolean => {
|
| 61 |
+
if (!input || typeof input !== 'string') return false;
|
| 62 |
+
if (input.trim().length === 0) return false;
|
| 63 |
+
if (input.length > maxLength) return false;
|
| 64 |
+
return true;
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
export const sanitizeInput = (input: string): string => {
|
| 68 |
+
return input
|
| 69 |
+
.trim()
|
| 70 |
+
.replace(/[<>]/g, '') // Remove potential HTML tags
|
| 71 |
+
.replace(/javascript:/gi, '') // Remove potential JavaScript protocol
|
| 72 |
+
.slice(0, 100); // Limit length
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
// Rate limiting utility
|
| 76 |
+
export class RateLimiter {
|
| 77 |
+
private timestamps: number[] = [];
|
| 78 |
+
private readonly windowMs: number;
|
| 79 |
+
private readonly maxRequests: number;
|
| 80 |
+
|
| 81 |
+
constructor(windowMs: number = 60000, maxRequests: number = 100) {
|
| 82 |
+
this.windowMs = windowMs;
|
| 83 |
+
this.maxRequests = maxRequests;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
canMakeRequest(): boolean {
|
| 87 |
+
const now = Date.now();
|
| 88 |
+
this.timestamps = this.timestamps.filter(time => now - time < this.windowMs);
|
| 89 |
+
|
| 90 |
+
if (this.timestamps.length >= this.maxRequests) {
|
| 91 |
+
return false;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
this.timestamps.push(now);
|
| 95 |
+
return true;
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Create a rate limiter instance
|
| 100 |
+
export const rateLimiter = new RateLimiter();
|
| 101 |
+
|
| 102 |
+
// Wrapper for API calls with rate limiting
|
| 103 |
+
export const rateLimitedApiCall = async <T>(
|
| 104 |
+
apiCall: () => Promise<T>,
|
| 105 |
+
retryCount: number = 3
|
| 106 |
+
): Promise<T> => {
|
| 107 |
+
if (!rateLimiter.canMakeRequest()) {
|
| 108 |
+
throw new Error('Rate limit exceeded. Please try again later.');
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
return withRetry(apiCall, retryCount);
|
| 112 |
+
};
|
frontend/src/utils/axios.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios';
|
| 2 |
+
import qs from 'qs';
|
| 3 |
+
|
| 4 |
+
const instance = axios.create({
|
| 5 |
+
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:8000',
|
| 6 |
+
headers: {
|
| 7 |
+
'Content-Type': 'application/json',
|
| 8 |
+
},
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
// Token refresh mechanism
|
| 12 |
+
let isRefreshing = false;
|
| 13 |
+
let failedQueue: any[] = [];
|
| 14 |
+
|
| 15 |
+
const processQueue = (error: any, token: string | null = null) => {
|
| 16 |
+
failedQueue.forEach(prom => {
|
| 17 |
+
if (error) {
|
| 18 |
+
prom.reject(error);
|
| 19 |
+
} else {
|
| 20 |
+
prom.resolve(token);
|
| 21 |
+
}
|
| 22 |
+
});
|
| 23 |
+
failedQueue = [];
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
// Add a request interceptor
|
| 27 |
+
instance.interceptors.request.use(
|
| 28 |
+
(config) => {
|
| 29 |
+
const token = localStorage.getItem('token');
|
| 30 |
+
if (token) {
|
| 31 |
+
config.headers.Authorization = `Bearer ${token}`;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Handle form data
|
| 35 |
+
if (config.data instanceof FormData) {
|
| 36 |
+
config.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
| 37 |
+
config.data = qs.stringify(Object.fromEntries(config.data));
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return config;
|
| 41 |
+
},
|
| 42 |
+
(error) => {
|
| 43 |
+
return Promise.reject(error);
|
| 44 |
+
}
|
| 45 |
+
);
|
| 46 |
+
|
| 47 |
+
// Add a response interceptor
|
| 48 |
+
instance.interceptors.response.use(
|
| 49 |
+
(response) => response,
|
| 50 |
+
async (error) => {
|
| 51 |
+
const originalRequest = error.config;
|
| 52 |
+
|
| 53 |
+
if (error.response?.status === 401 && !originalRequest._retry) {
|
| 54 |
+
if (isRefreshing) {
|
| 55 |
+
return new Promise((resolve, reject) => {
|
| 56 |
+
failedQueue.push({ resolve, reject });
|
| 57 |
+
})
|
| 58 |
+
.then(token => {
|
| 59 |
+
originalRequest.headers.Authorization = `Bearer ${token}`;
|
| 60 |
+
return instance(originalRequest);
|
| 61 |
+
})
|
| 62 |
+
.catch(err => Promise.reject(err));
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
originalRequest._retry = true;
|
| 66 |
+
isRefreshing = true;
|
| 67 |
+
|
| 68 |
+
try {
|
| 69 |
+
const refreshToken = localStorage.getItem('refreshToken');
|
| 70 |
+
if (!refreshToken) {
|
| 71 |
+
throw new Error('No refresh token available');
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const response = await axios.post('/api/auth/refresh', {
|
| 75 |
+
refresh_token: refreshToken,
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
const { access_token, refresh_token } = response.data;
|
| 79 |
+
localStorage.setItem('token', access_token);
|
| 80 |
+
localStorage.setItem('refreshToken', refresh_token);
|
| 81 |
+
|
| 82 |
+
instance.defaults.headers.common.Authorization = `Bearer ${access_token}`;
|
| 83 |
+
processQueue(null, access_token);
|
| 84 |
+
return instance(originalRequest);
|
| 85 |
+
} catch (refreshError) {
|
| 86 |
+
processQueue(refreshError, null);
|
| 87 |
+
localStorage.removeItem('token');
|
| 88 |
+
localStorage.removeItem('refreshToken');
|
| 89 |
+
window.location.href = '/login';
|
| 90 |
+
return Promise.reject(refreshError);
|
| 91 |
+
} finally {
|
| 92 |
+
isRefreshing = false;
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return Promise.reject(error);
|
| 97 |
+
}
|
| 98 |
+
);
|
| 99 |
+
|
| 100 |
+
// Error handling utility
|
| 101 |
+
export const handleApiError = (error: any): string => {
|
| 102 |
+
if (error.response) {
|
| 103 |
+
// The request was made and the server responded with a status code
|
| 104 |
+
// that falls out of the range of 2xx
|
| 105 |
+
return error.response.data.message || 'An error occurred while processing your request';
|
| 106 |
+
} else if (error.request) {
|
| 107 |
+
// The request was made but no response was received
|
| 108 |
+
return 'No response received from server. Please check your internet connection';
|
| 109 |
+
} else {
|
| 110 |
+
// Something happened in setting up the request that triggered an Error
|
| 111 |
+
return error.message || 'An unexpected error occurred';
|
| 112 |
+
}
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
export default instance;
|
frontend/src/utils/jsonUtils.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Node, Edge } from 'reactflow';
|
| 2 |
+
|
| 3 |
+
export interface MindMapConfig {
|
| 4 |
+
nodeBgColor: string;
|
| 5 |
+
nodeTextColor: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export interface MindMapData {
|
| 9 |
+
id: string;
|
| 10 |
+
name: string;
|
| 11 |
+
updatedAt: string;
|
| 12 |
+
nodes: Node[];
|
| 13 |
+
edges: Edge[];
|
| 14 |
+
config: {
|
| 15 |
+
nodeBgColor: string;
|
| 16 |
+
nodeTextColor: string;
|
| 17 |
+
};
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export const saveMindMapToJSON = (data: MindMapData): void => {
|
| 21 |
+
const jsonString = JSON.stringify(data, null, 2);
|
| 22 |
+
const blob = new Blob([jsonString], { type: 'application/json' });
|
| 23 |
+
const url = URL.createObjectURL(blob);
|
| 24 |
+
const link = document.createElement('a');
|
| 25 |
+
link.href = url;
|
| 26 |
+
link.download = `${data.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`;
|
| 27 |
+
document.body.appendChild(link);
|
| 28 |
+
link.click();
|
| 29 |
+
document.body.removeChild(link);
|
| 30 |
+
URL.revokeObjectURL(url);
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
export const loadMindMapFromJSON = async (file: File): Promise<MindMapData> => {
|
| 34 |
+
return new Promise((resolve, reject) => {
|
| 35 |
+
const reader = new FileReader();
|
| 36 |
+
reader.onload = (event) => {
|
| 37 |
+
try {
|
| 38 |
+
const data = JSON.parse(event.target?.result as string) as MindMapData;
|
| 39 |
+
if (!data.nodes || !data.edges || !data.config) {
|
| 40 |
+
throw new Error('Invalid mind map data format');
|
| 41 |
+
}
|
| 42 |
+
resolve(data);
|
| 43 |
+
} catch (error) {
|
| 44 |
+
reject(new Error('Failed to parse mind map data'));
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
reader.onerror = () => reject(new Error('Failed to read file'));
|
| 48 |
+
reader.readAsText(file);
|
| 49 |
+
});
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const validateMindMapData = (data: any): data is MindMapData => {
|
| 53 |
+
return (
|
| 54 |
+
typeof data === 'object' &&
|
| 55 |
+
typeof data.id === 'string' &&
|
| 56 |
+
typeof data.name === 'string' &&
|
| 57 |
+
typeof data.updatedAt === 'string' &&
|
| 58 |
+
Array.isArray(data.nodes) &&
|
| 59 |
+
Array.isArray(data.edges) &&
|
| 60 |
+
typeof data.config === 'object' &&
|
| 61 |
+
typeof data.config.nodeBgColor === 'string' &&
|
| 62 |
+
typeof data.config.nodeTextColor === 'string'
|
| 63 |
+
);
|
| 64 |
+
};
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "es5",
|
| 4 |
+
"lib": [
|
| 5 |
+
"dom",
|
| 6 |
+
"dom.iterable",
|
| 7 |
+
"esnext"
|
| 8 |
+
],
|
| 9 |
+
"allowJs": true,
|
| 10 |
+
"skipLibCheck": true,
|
| 11 |
+
"esModuleInterop": true,
|
| 12 |
+
"allowSyntheticDefaultImports": true,
|
| 13 |
+
"strict": true,
|
| 14 |
+
"forceConsistentCasingInFileNames": true,
|
| 15 |
+
"noFallthroughCasesInSwitch": true,
|
| 16 |
+
"module": "esnext",
|
| 17 |
+
"moduleResolution": "node",
|
| 18 |
+
"resolveJsonModule": true,
|
| 19 |
+
"isolatedModules": true,
|
| 20 |
+
"noEmit": true,
|
| 21 |
+
"jsx": "preserve"
|
| 22 |
+
},
|
| 23 |
+
"include": [
|
| 24 |
+
"src"
|
| 25 |
+
]
|
| 26 |
+
}
|
supervisord.conf
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[supervisord]
|
| 2 |
+
nodaemon=true
|
| 3 |
+
|
| 4 |
+
[program:flask]
|
| 5 |
+
directory=/home/user/app
|
| 6 |
+
command=flask run --host=0.0.0.0 --port=7860
|
| 7 |
+
autostart=true
|
| 8 |
+
autorestart=true
|
| 9 |
+
stdout_logfile=/dev/stdout
|
| 10 |
+
stderr_logfile=/dev/stderr
|
| 11 |
+
user=user
|
| 12 |
+
environment=HOME="/home/user",FLASK_APP="main.py",FLASK_ENV="development",PYTHONUNBUFFERED="1"
|
| 13 |
+
|
| 14 |
+
[program:react]
|
| 15 |
+
directory=/home/user/app/frontend
|
| 16 |
+
command=npm start
|
| 17 |
+
autostart=true
|
| 18 |
+
autorestart=true
|
| 19 |
+
stdout_logfile=/dev/stdout
|
| 20 |
+
stderr_logfile=/dev/stderr
|
| 21 |
+
user=user
|
| 22 |
+
environment=HOST="0.0.0.0",PORT="3000"
|