Rsnarsna commited on
Commit
b0c3c39
·
verified ·
1 Parent(s): 0ad4209

Upload 44 files

Browse files
Files changed (44) hide show
  1. .gitignore +46 -0
  2. Dockerfile +62 -0
  3. README +170 -0
  4. backend/.env +5 -0
  5. backend/main.py +394 -0
  6. backend/requirements.txt +11 -0
  7. frontend/.gitignore +1 -0
  8. frontend/README.md +46 -0
  9. frontend/package.json +75 -0
  10. frontend/public/favicon.ico +0 -0
  11. frontend/public/index.html +43 -0
  12. frontend/public/logo192.png +0 -0
  13. frontend/public/logo512.png +0 -0
  14. frontend/public/manifest.json +25 -0
  15. frontend/public/robots.txt +3 -0
  16. frontend/src/App.css +167 -0
  17. frontend/src/App.tsx +43 -0
  18. frontend/src/components/Auth.module.css +126 -0
  19. frontend/src/components/Auth.tsx +141 -0
  20. frontend/src/components/ChatModal.d.ts +18 -0
  21. frontend/src/components/ChatModal.tsx +185 -0
  22. frontend/src/components/EditableColorNode.css +47 -0
  23. frontend/src/components/EditableColorNode.tsx +75 -0
  24. frontend/src/components/LandingPage.module.css +136 -0
  25. frontend/src/components/LandingPage.tsx +80 -0
  26. frontend/src/components/MindMapEditor.module.css +460 -0
  27. frontend/src/components/MindMapEditor.tsx +801 -0
  28. frontend/src/components/MindMapItem.module.css +159 -0
  29. frontend/src/components/MindMapItem.tsx +114 -0
  30. frontend/src/components/MindMapList.module.css +227 -0
  31. frontend/src/components/MindMapList.tsx +124 -0
  32. frontend/src/components/SidePane.module.css +160 -0
  33. frontend/src/components/SidePane.tsx +141 -0
  34. frontend/src/index.css +19 -0
  35. frontend/src/index.tsx +21 -0
  36. frontend/src/logo.svg +1 -0
  37. frontend/src/react-app-env.d.ts +1 -0
  38. frontend/src/reportWebVitals.ts +15 -0
  39. frontend/src/setupTests.ts +5 -0
  40. frontend/src/utils/apiUtils.ts +112 -0
  41. frontend/src/utils/axios.ts +115 -0
  42. frontend/src/utils/jsonUtils.ts +64 -0
  43. frontend/tsconfig.json +26 -0
  44. 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
+ &times;
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"