Initial commit
This commit is contained in:
commit
8a7ab3ac24
14
.env
Normal file
14
.env
Normal file
@ -0,0 +1,14 @@
|
||||
VITE_APP_URL=app.classroomcopilot.ai
|
||||
VITE_FRONTEND_SITE_URL=classroomcopilot.ai
|
||||
VITE_APP_PROTOCOL=https
|
||||
VITE_APP_NAME=Classroom Copilot
|
||||
VITE_SUPER_ADMIN_EMAIL=kcar@kevlarai.com
|
||||
VITE_DEV=false
|
||||
VITE_SUPABASE_URL=https://supa.classroomcopilot.ai
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiaWF0IjoxNzM0OTg4MzkxLCJpc3MiOiJzdXBhYmFzZSIsImV4cCI6MTc2NjUyNDM5MSwicm9sZSI6ImFub24ifQ.utdDZzVlhYIc-cSXuC2kyZz7HN59YfyMH4eaOw1hRlk
|
||||
VITE_APP_API_URL=https://api.classroomcopilot.ai
|
||||
VITE_STRICT_MODE=false
|
||||
|
||||
APP_PROTOCOL=https
|
||||
APP_URL=app.classroomcopilot.ai
|
||||
PORT_FRONTEND=3000
|
||||
15
.env.development
Normal file
15
.env.development
Normal file
@ -0,0 +1,15 @@
|
||||
# Production environment configuration
|
||||
# These values override .env for production mode
|
||||
|
||||
# Disable development features
|
||||
VITE_DEV=true
|
||||
VITE_STRICT_MODE=true
|
||||
|
||||
# App environment
|
||||
VITE_SUPER_ADMIN_EMAIL=kcar@kevlarai.com
|
||||
VITE_FRONTEND_SITE_URL=classroomcopilot.test
|
||||
VITE_APP_PROTOCOL=http
|
||||
VITE_SUPABASE_URL=supa.classroomcopilot.test
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiaWF0IjoxNzM0OTg4MzkxLCJpc3MiOiJzdXBhYmFzZSIsImV4cCI6MTc2NjUyNDM5MSwicm9sZSI6ImFub24ifQ.utdDZzVlhYIc-cSXuC2kyZz7HN59YfyMH4eaOw1hRlk
|
||||
VITE_WHISPERLIVE_URL=whisperlive.classroomcopilot.test
|
||||
VITE_APP_API_URL=api.classroomcopilot.test
|
||||
0
.env.example
Normal file
0
.env.example
Normal file
15
.env.production
Normal file
15
.env.production
Normal file
@ -0,0 +1,15 @@
|
||||
# Production environment configuration
|
||||
# These values override .env for production mode
|
||||
|
||||
# Disable development features
|
||||
VITE_DEV=false
|
||||
VITE_STRICT_MODE=false
|
||||
|
||||
# App environment
|
||||
VITE_SUPER_ADMIN_EMAIL=kcar@kevlarai.com
|
||||
VITE_FRONTEND_SITE_URL=classroomcopilot.ai
|
||||
VITE_APP_PROTOCOL=https
|
||||
VITE_SUPABASE_URL=supa.classroomcopilot.ai
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiaWF0IjoxNzM0OTg4MzkxLCJpc3MiOiJzdXBhYmFzZSIsImV4cCI6MTc2NjUyNDM5MSwicm9sZSI6ImFub24ifQ.utdDZzVlhYIc-cSXuC2kyZz7HN59YfyMH4eaOw1hRlk
|
||||
VITE_WHISPERLIVE_URL=whisperlive.classroomcopilot.ai
|
||||
VITE_APP_API_URL=api.classroomcopilot.ai
|
||||
33
App.test.tsx
Normal file
33
App.test.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { describe, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { WrappedApp, App } from '../src/App';
|
||||
|
||||
describe('App', () => {
|
||||
it('Renders hello world', () => {
|
||||
// ARRANGE
|
||||
render(<WrappedApp />);
|
||||
// ACT
|
||||
// EXPECT
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
level: 1,
|
||||
}
|
||||
)).toHaveTextContent('Hello World')
|
||||
});
|
||||
it('Renders not found if invalid path', () => {
|
||||
// ARRANGE
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/this-route-does-not-exist']}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
// ACT
|
||||
// EXPECT
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
level: 1,
|
||||
}
|
||||
)).toHaveTextContent('Not Found')
|
||||
});
|
||||
});
|
||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@ -0,0 +1,42 @@
|
||||
FROM node:20 as builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
# TODO: Remove this or review embedded variables
|
||||
COPY .env .env
|
||||
|
||||
# First generate package-lock.json if it doesn't exist, then do clean install
|
||||
RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && npm ci
|
||||
COPY . .
|
||||
# Run build with production mode
|
||||
RUN npm run build -- --mode production
|
||||
|
||||
FROM nginx:alpine
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Create a simple nginx configuration
|
||||
RUN echo 'server { \
|
||||
listen 3000; \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
location / { \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
expires 30d; \
|
||||
add_header Cache-Control "public, no-transform"; \
|
||||
} \
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { \
|
||||
expires 30d; \
|
||||
add_header Cache-Control "public, no-transform"; \
|
||||
} \
|
||||
location ~ /\. { \
|
||||
deny all; \
|
||||
} \
|
||||
error_page 404 /index.html; \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Set up permissions
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html \
|
||||
&& chown -R nginx:nginx /var/log/nginx
|
||||
|
||||
# Expose HTTP port (NPM will handle HTTPS)
|
||||
EXPOSE 3000
|
||||
41
Dockerfile.dev
Normal file
41
Dockerfile.dev
Normal file
@ -0,0 +1,41 @@
|
||||
FROM node:20 as builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY .env.development .env.development
|
||||
|
||||
# First generate package-lock.json if it doesn't exist, then do clean install
|
||||
RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && npm ci
|
||||
COPY . .
|
||||
# Run build with development mode
|
||||
RUN npm run build -- --mode development
|
||||
|
||||
FROM nginx:alpine
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Create a simple nginx configuration
|
||||
RUN echo 'server { \
|
||||
listen 3003; \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
location / { \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
expires 30d; \
|
||||
add_header Cache-Control "public, no-transform"; \
|
||||
} \
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { \
|
||||
expires 30d; \
|
||||
add_header Cache-Control "public, no-transform"; \
|
||||
} \
|
||||
location ~ /\. { \
|
||||
deny all; \
|
||||
} \
|
||||
error_page 404 /index.html; \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Set up permissions
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html \
|
||||
&& chown -R nginx:nginx /var/log/nginx
|
||||
|
||||
# Expose HTTP port (NPM will handle HTTPS)
|
||||
EXPOSE 3003
|
||||
28
Dockerfile.storybook.macos.dev
Normal file
28
Dockerfile.storybook.macos.dev
Normal file
@ -0,0 +1,28 @@
|
||||
# Dockerfile.storybook
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install basic dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
xdg-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Copy yarn.lock if it exists
|
||||
COPY yarn.lock* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
# Expose port Storybook runs on
|
||||
EXPOSE 6006
|
||||
|
||||
# Start Storybook in development mode with host configuration
|
||||
ENV BROWSER=none
|
||||
CMD ["yarn", "storybook", "dev", "-p", "6006", "--host", "0.0.0.0"]
|
||||
51
Dockerfile.storybook.macos.prod
Normal file
51
Dockerfile.storybook.macos.prod
Normal file
@ -0,0 +1,51 @@
|
||||
# Build stage
|
||||
FROM node:20 as builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Copy yarn.lock if it exists
|
||||
COPY yarn.lock* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
# Build Storybook
|
||||
RUN yarn build-storybook
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
# Copy built Storybook files
|
||||
COPY --from=builder /app/storybook-static .
|
||||
|
||||
# Create nginx configuration
|
||||
RUN echo 'server { \
|
||||
listen 6006; \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
location / { \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
expires 30d; \
|
||||
add_header Cache-Control "public, no-transform"; \
|
||||
} \
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { \
|
||||
expires 30d; \
|
||||
add_header Cache-Control "public, no-transform"; \
|
||||
} \
|
||||
location ~ /\. { \
|
||||
deny all; \
|
||||
} \
|
||||
error_page 404 /index.html; \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Set up permissions
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html \
|
||||
&& chown -R nginx:nginx /var/log/nginx
|
||||
|
||||
EXPOSE 6006
|
||||
107
README.md
Normal file
107
README.md
Normal file
@ -0,0 +1,107 @@
|
||||
# Frontend Service
|
||||
|
||||
This directory contains the frontend service for ClassroomCopilot, including the web application and static file serving configuration.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/ # Frontend application source code
|
||||
└── Dockerfile.dev # Development container configuration
|
||||
└── Dockerfile.prod # Production container configuration
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The frontend service uses the following environment variables:
|
||||
|
||||
- `VITE_FRONTEND_SITE_URL`: The base URL of the frontend application
|
||||
- `VITE_APP_NAME`: The name of the application
|
||||
- `VITE_SUPER_ADMIN_EMAIL`: Email address of the super admin
|
||||
- `VITE_DEV`: Development mode flag
|
||||
- `VITE_SUPABASE_URL`: Supabase API URL
|
||||
- `VITE_SUPABASE_ANON_KEY`: Supabase anonymous key
|
||||
- `VITE_STRICT_MODE`: Strict mode flag
|
||||
- Other environment variables are defined in the root `.env` file
|
||||
|
||||
### Server Configuration
|
||||
|
||||
The frontend container uses a simple nginx configuration that:
|
||||
- Serves static files on port 80
|
||||
- Handles SPA routing
|
||||
- Manages caching headers
|
||||
- Denies access to hidden files
|
||||
|
||||
SSL termination and domain routing are handled by Nginx Proxy Manager (NPM).
|
||||
|
||||
## Usage
|
||||
|
||||
### Development
|
||||
|
||||
1. Start the development environment:
|
||||
```bash
|
||||
NGINX_MODE=dev ./init_macos_dev.sh up
|
||||
```
|
||||
|
||||
2. Configure NPM:
|
||||
- Create a new proxy host for app.localhost
|
||||
- Forward to http://frontend:80
|
||||
- Enable SSL with a self-signed certificate
|
||||
- Add custom locations for SPA routing
|
||||
|
||||
3. Access the application:
|
||||
- HTTPS: https://app.localhost
|
||||
|
||||
### Production
|
||||
|
||||
1. Set environment variables:
|
||||
```bash
|
||||
NGINX_MODE=prod
|
||||
```
|
||||
|
||||
2. Start the production environment:
|
||||
```bash
|
||||
./init_macos_dev.sh up
|
||||
```
|
||||
|
||||
3. Configure NPM:
|
||||
- Create a new proxy host for app.classroomcopilot.ai
|
||||
- Forward to http://frontend:80
|
||||
- Enable SSL with Cloudflare certificates
|
||||
- Add custom locations for SPA routing
|
||||
|
||||
## Security
|
||||
|
||||
- SSL termination handled by NPM
|
||||
- Static file serving with proper caching headers
|
||||
- Hidden file access denied
|
||||
- SPA routing with fallback to index.html
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
- Check NPM logs in the admin interface
|
||||
- Verify frontend container is running
|
||||
- Ensure NPM proxy host is properly configured
|
||||
- Check network connectivity between NPM and frontend
|
||||
|
||||
### SPA Routing Issues
|
||||
- Verify NPM custom locations are properly configured
|
||||
- Check frontend container logs
|
||||
- Ensure all routes fall back to index.html
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Log Files
|
||||
Located in `/var/log/nginx/`:
|
||||
- `access.log`: General access logs
|
||||
- `error.log`: Error logs
|
||||
|
||||
### Configuration Updates
|
||||
1. Modify Dockerfile.dev or Dockerfile.prod as needed
|
||||
2. Rebuild and restart the container:
|
||||
```bash
|
||||
docker compose up -d --build frontend
|
||||
```
|
||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@ -0,0 +1,19 @@
|
||||
services:
|
||||
classroom-copilot:
|
||||
container_name: classroom-copilot
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- ./:/app
|
||||
networks:
|
||||
- kevlarai-network
|
||||
|
||||
networks:
|
||||
kevlarai-network:
|
||||
name: kevlarai-network
|
||||
driver: bridge
|
||||
52
eslint.config.js
Normal file
52
eslint.config.js
Normal file
@ -0,0 +1,52 @@
|
||||
import globals from 'globals';
|
||||
import pluginJs from '@eslint/js';
|
||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
|
||||
export default [
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2021,
|
||||
...globals.node,
|
||||
...globals.serviceworker
|
||||
},
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
import: importPlugin
|
||||
}
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules
|
||||
}
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooksPlugin
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react/react-in-jsx-scope': 'off'
|
||||
}
|
||||
}
|
||||
];
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Classroom Copilot</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
98
package.json
Normal file
98
package.json
Normal file
@ -0,0 +1,98 @@
|
||||
{
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"start": "vite --host",
|
||||
"build": "vite build",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@emotion/react": "11.11.3",
|
||||
"@emotion/styled": "11.11.0",
|
||||
"@fullcalendar/core": "^6.1.15",
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/list": "^6.1.15",
|
||||
"@fullcalendar/multimonth": "^6.1.15",
|
||||
"@fullcalendar/react": "^6.1.15",
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@mui/icons-material": "5.15.0",
|
||||
"@mui/material": "5.15.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.4",
|
||||
"@radix-ui/react-toast": "^1.2.5",
|
||||
"@supabase/gotrue-js": "^2.66.1",
|
||||
"@supabase/supabase-js": "^2.46.1",
|
||||
"@tldraw/store": "3.6.1",
|
||||
"@tldraw/sync": "3.6.1",
|
||||
"@tldraw/sync-core": "3.6.1",
|
||||
"@tldraw/tldraw": "3.6.1",
|
||||
"@tldraw/tlschema": "3.6.1",
|
||||
"@types/pdfjs-dist": "^2.10.378",
|
||||
"@types/styled-components": "^5.1.34",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vercel/analytics": "^1.3.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@xyflow/react": "^12.3.1",
|
||||
"axios": "^1.7.7",
|
||||
"cmdk": "^1.0.4",
|
||||
"dotenv": "^16.4.5",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"postcss-import": "^16.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-modal": "^3.16.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"react-use": "^17.3.1",
|
||||
"styled-components": "^6.1.13",
|
||||
"uuid": "^11.1.0",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-mkcert": "^1.17.5",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@storybook/addon-actions": "^8.6.12",
|
||||
"@storybook/addon-essentials": "^8.6.12",
|
||||
"@storybook/addon-interactions": "^8.6.12",
|
||||
"@storybook/addon-links": "^8.6.12",
|
||||
"@storybook/addon-onboarding": "^8.6.12",
|
||||
"@storybook/react": "^8.6.12",
|
||||
"@storybook/react-vite": "^8.6.12",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@testing-library/jest-dom": "^6.4.5",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"@types/serviceworker": "^0.0.119",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.2.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"globals": "^15.3.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"react-refresh": "^0.14.2",
|
||||
"storybook": "^8.6.12",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
7
postcss.config.js
Normal file
7
postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'postcss-import': {},
|
||||
},
|
||||
};
|
||||
12
public/audioWorklet.js
Normal file
12
public/audioWorklet.js
Normal file
@ -0,0 +1,12 @@
|
||||
class AudioProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (input.length > 0) {
|
||||
const audioData = input[0];
|
||||
this.port.postMessage(audioData);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('audio-processor', AudioProcessor);
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/icons/icon-192x192-maskable.png
Normal file
BIN
public/icons/icon-192x192-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
public/icons/icon-192x192.png
Normal file
BIN
public/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
public/icons/icon-512x512-maskable.png
Normal file
BIN
public/icons/icon-512x512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
BIN
public/icons/icon-512x512.png
Normal file
BIN
public/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
21
public/icons/sticker-tool.svg
Normal file
21
public/icons/sticker-tool.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Sticker outline -->
|
||||
<path d="M20 12C20 16.4183 16.4183 20 12 20C7.58172 20 4 16.4183 4 12C4 7.58172 7.58172 4 12 4C16.4183 4 20 7.58172 20 12Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<!-- Peeling corner effect -->
|
||||
<path d="M16 8C16 10.2091 14.2091 12 12 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<!-- Star decoration -->
|
||||
<path d="M12 8L13 9L12 10L11 9L12 8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 689 B |
70
public/offline.html
Normal file
70
public/offline.html
Normal file
@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline - Classroom Copilot</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #1a73e8;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
p {
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.retry-button {
|
||||
background-color: #1a73e8;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.retry-button:hover {
|
||||
background-color: #1557b0;
|
||||
}
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">📡</div>
|
||||
<h1>You're Offline</h1>
|
||||
<p>It looks like you've lost your internet connection. Don't worry - any work you've done has been saved locally.</p>
|
||||
<p>Please check your connection and try again.</p>
|
||||
<button class="retry-button" onclick="window.location.reload()">Try Again</button>
|
||||
</div>
|
||||
<script>
|
||||
// Check if we're back online
|
||||
window.addEventListener('online', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
src/.DS_Store
vendored
Normal file
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
34
src/App.tsx
Normal file
34
src/App.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { theme } from './services/themeService';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { TLDrawProvider } from './contexts/TLDrawContext';
|
||||
import { UserProvider } from './contexts/UserContext';
|
||||
import { NeoUserProvider } from './contexts/NeoUserContext';
|
||||
import { NeoInstituteProvider } from './contexts/NeoInstituteContext';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import React from 'react';
|
||||
|
||||
// Wrap the entire app in a memo to prevent unnecessary re-renders
|
||||
const App = React.memo(() => (
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<AuthProvider>
|
||||
<UserProvider>
|
||||
<NeoUserProvider>
|
||||
<NeoInstituteProvider>
|
||||
<TLDrawProvider>
|
||||
<AppRoutes />
|
||||
</TLDrawProvider>
|
||||
</NeoInstituteProvider>
|
||||
</NeoUserProvider>
|
||||
</UserProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
));
|
||||
|
||||
// Add display name for better debugging
|
||||
App.displayName = import.meta.env.VITE_APP_NAME;
|
||||
|
||||
export default App;
|
||||
133
src/AppRoutes.tsx
Normal file
133
src/AppRoutes.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import { useUser } from './contexts/UserContext';
|
||||
import { useNeoUser } from './contexts/NeoUserContext';
|
||||
import { useNeoInstitute } from './contexts/NeoInstituteContext';
|
||||
import Layout from './pages/Layout';
|
||||
import LoginPage from './pages/auth/loginPage';
|
||||
import SignupPage from './pages/auth/signupPage';
|
||||
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
||||
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
||||
import { CCExamMarker } from './pages/tldraw/CCExamMarker/CCExamMarker';
|
||||
import CalendarPage from './pages/user/calendarPage';
|
||||
import SettingsPage from './pages/user/settingsPage';
|
||||
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
|
||||
import AdminDashboard from './pages/auth/adminPage';
|
||||
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
|
||||
import DevPage from './pages/tldraw/devPage';
|
||||
import TeacherPlanner from './pages/react-flow/teacherPlanner';
|
||||
import MorphicPage from './pages/morphicPage';
|
||||
import NotFound from './pages/user/NotFound';
|
||||
import NotFoundPublic from './pages/NotFoundPublic';
|
||||
import ShareHandler from './pages/tldraw/ShareHandler';
|
||||
import SearxngPage from './pages/searxngPage';
|
||||
import { logger } from './debugConfig';
|
||||
import { CircularProgress } from '@mui/material';
|
||||
|
||||
const AppRoutes: React.FC = () => {
|
||||
const { user, loading: isAuthLoading } = useAuth();
|
||||
const { isInitialized: isUserInitialized } =
|
||||
useUser();
|
||||
const { isLoading: isNeoUserLoading, isInitialized: isNeoUserInitialized } =
|
||||
useNeoUser();
|
||||
const {
|
||||
isLoading: isNeoInstituteLoading,
|
||||
isInitialized: isNeoInstituteInitialized,
|
||||
} = useNeoInstitute();
|
||||
const location = useLocation();
|
||||
|
||||
// Debug log for routing
|
||||
logger.debug('routing', '🔄 Rendering routes', {
|
||||
hasUser: !!user,
|
||||
userId: user?.id,
|
||||
userEmail: user?.email,
|
||||
currentPath: location.pathname,
|
||||
authStatus: {
|
||||
isLoading: isAuthLoading,
|
||||
},
|
||||
userStatus: {
|
||||
isInitialized: isUserInitialized,
|
||||
},
|
||||
neoUserStatus: {
|
||||
isLoading: isNeoUserLoading,
|
||||
isInitialized: isNeoUserInitialized,
|
||||
},
|
||||
neoInstituteStatus: {
|
||||
isLoading: isNeoInstituteLoading,
|
||||
isInitialized: isNeoInstituteInitialized,
|
||||
},
|
||||
});
|
||||
|
||||
// Show loading state while initializing
|
||||
if (
|
||||
isAuthLoading ||
|
||||
(user &&
|
||||
(!isUserInitialized ||
|
||||
!isNeoUserInitialized ||
|
||||
!isNeoInstituteInitialized))
|
||||
) {
|
||||
return (
|
||||
<Layout>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={user ? <SinglePlayerPage /> : <TLDrawCanvas />}
|
||||
/>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/share" element={<ShareHandler />} />
|
||||
|
||||
{/* Super Admin only routes */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
user?.user_type === 'admin' ? <AdminDashboard /> : <NotFound />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Authentication only routes - only render if all contexts are initialized */}
|
||||
{user &&
|
||||
isUserInitialized &&
|
||||
isNeoUserInitialized &&
|
||||
isNeoInstituteInitialized && (
|
||||
<>
|
||||
<Route path="/search" element={<SearxngPage />} />
|
||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||
<Route path="/exam-marker" element={<CCExamMarker />} />
|
||||
<Route path="/morphic" element={<MorphicPage />} />
|
||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||
<Route path="/dev" element={<DevPage />} />
|
||||
<Route path="/single-player" element={<SinglePlayerPage />} />
|
||||
<Route path="/multiplayer" element={<MultiplayerUser />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fallback route - use different NotFound pages based on auth state */}
|
||||
<Route path="*" element={user ? <NotFound /> : <NotFoundPublic />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRoutes;
|
||||
|
||||
68
src/axiosConfig.ts
Normal file
68
src/axiosConfig.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import axios from 'axios';
|
||||
import { logger } from './debugConfig';
|
||||
|
||||
// Use development backend URL if no custom URL is provided
|
||||
const appProtocol = import.meta.env.VITE_APP_PROTOCOL;
|
||||
const baseURL = `${appProtocol}://${import.meta.env.VITE_APP_API_URL}`;
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
timeout: 120000, // Increase timeout to 120 seconds for large files
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Add request interceptor for logging
|
||||
instance.interceptors.request.use(
|
||||
(config) => {
|
||||
// Don't override Content-Type if it's already set (e.g., for multipart/form-data)
|
||||
if (config.headers['Content-Type'] === 'application/json' && config.data instanceof FormData) {
|
||||
delete config.headers['Content-Type'];
|
||||
}
|
||||
|
||||
logger.debug('axios', '🔄 Outgoing request', {
|
||||
method: config.method,
|
||||
url: config.url,
|
||||
baseURL: config.baseURL
|
||||
});
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
logger.error('axios', '❌ Request error', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add response interceptor for logging
|
||||
instance.interceptors.response.use(
|
||||
(response) => {
|
||||
logger.debug('axios', '✅ Response received', {
|
||||
status: response.status,
|
||||
url: response.config.url
|
||||
});
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
logger.error('axios', '❌ Response error', {
|
||||
status: error.response.status,
|
||||
url: error.config.url,
|
||||
data: error.response.data
|
||||
});
|
||||
} else if (error.request) {
|
||||
logger.error('axios', '❌ No response received', {
|
||||
url: error.config.url
|
||||
});
|
||||
} else {
|
||||
logger.error('axios', '❌ Request setup error', error.message);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add type guard for Axios errors
|
||||
export const {isAxiosError} = axios;
|
||||
|
||||
// Export the axios instance with the type guard
|
||||
export default Object.assign(instance, { isAxiosError });
|
||||
458
src/components/navigation/GraphNavigator.tsx
Normal file
458
src/components/navigation/GraphNavigator.tsx
Normal file
@ -0,0 +1,458 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Box,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Button,
|
||||
styled
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack as ArrowBackIcon,
|
||||
ArrowForward as ArrowForwardIcon,
|
||||
History as HistoryIcon,
|
||||
School as SchoolIcon,
|
||||
Person as PersonIcon,
|
||||
AccountCircle as AccountCircleIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
School as TeachingIcon,
|
||||
Business as BusinessIcon,
|
||||
AccountTree as DepartmentIcon,
|
||||
Class as ClassIcon,
|
||||
ExpandMore as ExpandMoreIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigationStore } from '../../stores/navigationStore';
|
||||
import { useNeoUser } from '../../contexts/NeoUserContext';
|
||||
import { NAVIGATION_CONTEXTS } from '../../config/navigationContexts';
|
||||
import {
|
||||
BaseContext,
|
||||
ViewContext
|
||||
} from '../../types/navigation';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const NavigationRoot = styled(Box)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const NavigationControls = styled(Box)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const ContextToggleContainer = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(0.5),
|
||||
gap: theme.spacing(0.5),
|
||||
'& .button-label': {
|
||||
'@media (max-width: 500px)': {
|
||||
display: 'none'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const ContextToggleButton = styled(Button, {
|
||||
shouldForwardProp: (prop) => prop !== 'active'
|
||||
})<{ active?: boolean }>(({ theme, active }) => ({
|
||||
minWidth: 0,
|
||||
padding: theme.spacing(0.5, 1.5),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: active ? theme.palette.primary.main : 'transparent',
|
||||
color: active ? theme.palette.primary.contrastText : theme.palette.text.primary,
|
||||
textTransform: 'none',
|
||||
transition: theme.transitions.create(['background-color', 'color'], {
|
||||
duration: theme.transitions.duration.shorter,
|
||||
}),
|
||||
'&:hover': {
|
||||
backgroundColor: active ? theme.palette.primary.dark : theme.palette.action.hover,
|
||||
},
|
||||
'@media (max-width: 500px)': {
|
||||
padding: theme.spacing(0.5),
|
||||
}
|
||||
}));
|
||||
|
||||
export const GraphNavigator: React.FC = () => {
|
||||
const {
|
||||
context,
|
||||
switchContext,
|
||||
goBack,
|
||||
goForward,
|
||||
isLoading
|
||||
} = useNavigationStore();
|
||||
|
||||
const { userDbName, workerDbName, isInitialized: isNeoUserInitialized } = useNeoUser();
|
||||
|
||||
const [contextMenuAnchor, setContextMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const [historyMenuAnchor, setHistoryMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [availableWidth, setAvailableWidth] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const calculateAvailableSpace = () => {
|
||||
if (!rootRef.current) return;
|
||||
|
||||
// Get the header element
|
||||
const header = rootRef.current.closest('.MuiToolbar-root');
|
||||
if (!header) return;
|
||||
|
||||
// Get the title and menu elements
|
||||
const title = header.querySelector('.app-title');
|
||||
const menu = header.querySelector('.menu-button');
|
||||
|
||||
if (!title || !menu) return;
|
||||
|
||||
// Calculate available width
|
||||
const headerWidth = header.clientWidth;
|
||||
const titleWidth = title.clientWidth;
|
||||
const menuWidth = menu.clientWidth;
|
||||
const padding = 48; // Increased buffer space
|
||||
|
||||
const newAvailableWidth = headerWidth - titleWidth - menuWidth - padding;
|
||||
console.log('Available width:', newAvailableWidth); // Debug log
|
||||
setAvailableWidth(newAvailableWidth);
|
||||
};
|
||||
|
||||
// Set up ResizeObserver
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
// Use requestAnimationFrame to debounce calculations
|
||||
window.requestAnimationFrame(calculateAvailableSpace);
|
||||
});
|
||||
|
||||
// Observe both the root element and the header
|
||||
if (rootRef.current) {
|
||||
const header = rootRef.current.closest('.MuiToolbar-root');
|
||||
if (header) {
|
||||
resizeObserver.observe(header);
|
||||
resizeObserver.observe(rootRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial calculation
|
||||
calculateAvailableSpace();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Helper function to determine what should be visible
|
||||
const getVisibility = () => {
|
||||
// Adjusted thresholds and collapse order:
|
||||
// 1. Navigation controls (back/forward/history) collapse first
|
||||
// 2. Toggle labels collapse second
|
||||
// 3. Context label collapses last
|
||||
if (availableWidth < 300) {
|
||||
return {
|
||||
navigation: false,
|
||||
contextLabel: true, // Keep context label visible longer
|
||||
toggleLabels: false
|
||||
};
|
||||
} else if (availableWidth < 450) {
|
||||
return {
|
||||
navigation: false,
|
||||
contextLabel: true, // Keep context label visible
|
||||
toggleLabels: true
|
||||
};
|
||||
} else if (availableWidth < 600) {
|
||||
return {
|
||||
navigation: true,
|
||||
contextLabel: true,
|
||||
toggleLabels: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
navigation: true,
|
||||
contextLabel: true,
|
||||
toggleLabels: true
|
||||
};
|
||||
};
|
||||
|
||||
const visibility = getVisibility();
|
||||
|
||||
const handleHistoryClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setHistoryMenuAnchor(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleHistoryClose = () => {
|
||||
setHistoryMenuAnchor(null);
|
||||
};
|
||||
|
||||
const handleHistoryItemClick = (index: number) => {
|
||||
const {currentIndex} = context.history;
|
||||
const steps = index - currentIndex;
|
||||
|
||||
if (steps < 0) {
|
||||
for (let i = 0; i < -steps; i++) {
|
||||
goBack();
|
||||
}
|
||||
} else if (steps > 0) {
|
||||
for (let i = 0; i < steps; i++) {
|
||||
goForward();
|
||||
}
|
||||
}
|
||||
|
||||
handleHistoryClose();
|
||||
};
|
||||
|
||||
const handleContextChange = useCallback(async (newContext: BaseContext) => {
|
||||
try {
|
||||
// Check if trying to access institute contexts without worker database
|
||||
if (['school', 'department', 'class'].includes(newContext) && !workerDbName) {
|
||||
logger.error('navigation', '❌ Cannot switch to institute context: missing worker database');
|
||||
return;
|
||||
}
|
||||
// Check if trying to access profile contexts without user database
|
||||
if (['profile', 'calendar', 'teaching'].includes(newContext) && !userDbName) {
|
||||
logger.error('navigation', '❌ Cannot switch to profile context: missing user database');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('navigation', '🔄 Changing main context', {
|
||||
from: context.main,
|
||||
to: newContext,
|
||||
userDbName,
|
||||
workerDbName
|
||||
});
|
||||
|
||||
// Get default view for new context
|
||||
const defaultView = getDefaultViewForContext(newContext);
|
||||
|
||||
// Use unified context switch with both base and extended contexts
|
||||
await switchContext({
|
||||
main: ['profile', 'calendar', 'teaching'].includes(newContext) ? 'profile' : 'institute',
|
||||
base: newContext,
|
||||
extended: defaultView,
|
||||
skipBaseContextLoad: false
|
||||
}, userDbName, workerDbName);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to change context:', error);
|
||||
}
|
||||
}, [context.main, switchContext, userDbName, workerDbName]);
|
||||
|
||||
// Helper function to get default view for a context
|
||||
const getDefaultViewForContext = (context: BaseContext): ViewContext => {
|
||||
switch (context) {
|
||||
case 'calendar':
|
||||
return 'overview';
|
||||
case 'teaching':
|
||||
return 'overview';
|
||||
case 'school':
|
||||
return 'overview';
|
||||
case 'department':
|
||||
return 'overview';
|
||||
case 'class':
|
||||
return 'overview';
|
||||
default:
|
||||
return 'overview';
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setContextMenuAnchor(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleContextSelect = useCallback(async (context: BaseContext) => {
|
||||
setContextMenuAnchor(null);
|
||||
try {
|
||||
// Use unified context switch with both base and extended contexts
|
||||
const contextDef = NAVIGATION_CONTEXTS[context];
|
||||
const defaultExtended = contextDef?.views[0]?.id;
|
||||
|
||||
await switchContext({
|
||||
base: context,
|
||||
extended: defaultExtended
|
||||
}, userDbName, workerDbName);
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to select context:', error);
|
||||
}
|
||||
}, [switchContext, userDbName, workerDbName]);
|
||||
|
||||
const getContextItems = useCallback(() => {
|
||||
if (context.main === 'profile') {
|
||||
return [
|
||||
{ id: 'profile', label: 'Profile', icon: AccountCircleIcon },
|
||||
{ id: 'calendar', label: 'Calendar', icon: CalendarIcon },
|
||||
{ id: 'teaching', label: 'Teaching', icon: TeachingIcon },
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
{ id: 'school', label: 'School', icon: BusinessIcon },
|
||||
{ id: 'department', label: 'Department', icon: DepartmentIcon },
|
||||
{ id: 'class', label: 'Class', icon: ClassIcon },
|
||||
];
|
||||
}
|
||||
}, [context.main]);
|
||||
|
||||
const getContextIcon = useCallback((contextType: string) => {
|
||||
switch (contextType) {
|
||||
case 'profile':
|
||||
return <AccountCircleIcon />;
|
||||
case 'calendar':
|
||||
return <CalendarIcon />;
|
||||
case 'teaching':
|
||||
return <TeachingIcon />;
|
||||
case 'school':
|
||||
return <BusinessIcon />;
|
||||
case 'department':
|
||||
return <DepartmentIcon />;
|
||||
case 'class':
|
||||
return <ClassIcon />;
|
||||
default:
|
||||
return <AccountCircleIcon />;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isDisabled = !isNeoUserInitialized || isLoading;
|
||||
const { history } = context;
|
||||
const canGoBack = history.currentIndex > 0;
|
||||
const canGoForward = history.currentIndex < history.nodes.length - 1;
|
||||
|
||||
return (
|
||||
<NavigationRoot ref={rootRef}>
|
||||
<NavigationControls sx={{ display: visibility.navigation ? 'flex' : 'none' }}>
|
||||
<Tooltip title="Back">
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack || isDisabled}
|
||||
size="small"
|
||||
>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="History">
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={handleHistoryClick}
|
||||
disabled={!history.nodes.length || isDisabled}
|
||||
size="small"
|
||||
>
|
||||
<HistoryIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Forward">
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward || isDisabled}
|
||||
size="small"
|
||||
>
|
||||
<ArrowForwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</NavigationControls>
|
||||
|
||||
{/* History Menu */}
|
||||
<Menu
|
||||
anchorEl={historyMenuAnchor}
|
||||
open={Boolean(historyMenuAnchor)}
|
||||
onClose={handleHistoryClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
>
|
||||
{history.nodes.map((node, index) => (
|
||||
<MenuItem
|
||||
key={`${node.id}-${index}`}
|
||||
onClick={() => handleHistoryItemClick(index)}
|
||||
selected={index === history.currentIndex}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{getContextIcon(node.type)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={node.label || node.id}
|
||||
secondary={node.type}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
<ContextToggleContainer>
|
||||
<ContextToggleButton
|
||||
active={context.main === 'profile'}
|
||||
onClick={() => handleContextChange('profile' as BaseContext)}
|
||||
startIcon={<PersonIcon />}
|
||||
disabled={isDisabled || !userDbName}
|
||||
>
|
||||
{visibility.toggleLabels && <span className="button-label">Profile</span>}
|
||||
</ContextToggleButton>
|
||||
<ContextToggleButton
|
||||
active={context.main === 'institute'}
|
||||
onClick={() => handleContextChange('school' as BaseContext)}
|
||||
startIcon={<SchoolIcon />}
|
||||
disabled={isDisabled || !workerDbName}
|
||||
>
|
||||
{visibility.toggleLabels && <span className="button-label">Institute</span>}
|
||||
</ContextToggleButton>
|
||||
</ContextToggleContainer>
|
||||
|
||||
<Box>
|
||||
<Tooltip title={context.base}>
|
||||
<span>
|
||||
<Button
|
||||
onClick={handleContextMenu}
|
||||
disabled={isDisabled}
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
p: 0.5,
|
||||
color: 'text.primary',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getContextIcon(context.base)}
|
||||
{visibility.contextLabel && (
|
||||
<Box sx={{ ml: 1 }}>
|
||||
{context.base}
|
||||
</Box>
|
||||
)}
|
||||
<ExpandMoreIcon sx={{ ml: visibility.contextLabel ? 0.5 : 0 }} />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
anchorEl={contextMenuAnchor}
|
||||
open={Boolean(contextMenuAnchor)}
|
||||
onClose={() => setContextMenuAnchor(null)}
|
||||
>
|
||||
{getContextItems().map(item => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleContextSelect(item.id as BaseContext)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<item.icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</NavigationRoot>
|
||||
);
|
||||
};
|
||||
371
src/components/navigation/extended/CalendarNavigation.tsx
Normal file
371
src/components/navigation/extended/CalendarNavigation.tsx
Normal file
@ -0,0 +1,371 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, IconButton, Button, Typography, styled, ThemeProvider, createTheme, useMediaQuery } from '@mui/material';
|
||||
import {
|
||||
NavigateBefore as NavigateBeforeIcon,
|
||||
NavigateNext as NavigateNextIcon,
|
||||
Today as TodayIcon,
|
||||
ViewWeek as ViewWeekIcon,
|
||||
DateRange as DateRangeIcon,
|
||||
Event as EventIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useNeoUser } from '../../../contexts/NeoUserContext';
|
||||
import { CalendarExtendedContext } from '../../../types/navigation';
|
||||
import { logger } from '../../../debugConfig';
|
||||
import { useTLDraw } from '../../../contexts/TLDrawContext';
|
||||
|
||||
const NavigationContainer = styled(Box)(() => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '0 8px',
|
||||
minHeight: '48px',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
'@media (max-width: 600px)': {
|
||||
flexWrap: 'wrap',
|
||||
padding: '4px',
|
||||
gap: '4px',
|
||||
},
|
||||
}));
|
||||
|
||||
const ViewControls = styled(Box)(() => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
flexShrink: 0,
|
||||
}));
|
||||
|
||||
const NavigationSection = styled(Box)(() => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px',
|
||||
flex: 1,
|
||||
minWidth: 0, // Allows the container to shrink below its content size
|
||||
'@media (max-width: 600px)': {
|
||||
order: -1,
|
||||
flex: '1 1 100%',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
}));
|
||||
|
||||
const TitleTypography = styled(Typography)(() => ({
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
margin: '0 8px',
|
||||
}));
|
||||
|
||||
const ActionButtonContainer = styled(Box)(() => ({
|
||||
flexShrink: 0,
|
||||
'@media (max-width: 600px)': {
|
||||
width: 'auto',
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledIconButton = styled(IconButton)(() => ({
|
||||
color: 'var(--color-text)',
|
||||
transition: 'background-color 200ms ease, color 200ms ease, transform 200ms ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-hover)',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
color: 'var(--color-text-disabled)',
|
||||
},
|
||||
'&.active': {
|
||||
color: 'var(--color-selected)',
|
||||
backgroundColor: 'var(--color-selected-background)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-selected-hover)',
|
||||
transform: 'scale(1.05)',
|
||||
}
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.25rem',
|
||||
transition: 'transform 150ms ease',
|
||||
},
|
||||
}));
|
||||
|
||||
const ActionButton = styled(Button)(() => ({
|
||||
textTransform: 'none',
|
||||
padding: '6px 16px',
|
||||
gap: '8px',
|
||||
color: 'var(--color-text)',
|
||||
transition: 'background-color 200ms ease, transform 200ms ease, box-shadow 200ms ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-hover)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
color: 'var(--color-text-disabled)',
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.25rem',
|
||||
color: 'inherit',
|
||||
transition: 'transform 150ms ease',
|
||||
},
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
activeView: CalendarExtendedContext;
|
||||
onViewChange: (view: CalendarExtendedContext) => void;
|
||||
}
|
||||
|
||||
export const CalendarNavigation: React.FC<Props> = ({ activeView, onViewChange }) => {
|
||||
const { tldrawPreferences } = useTLDraw();
|
||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||
|
||||
// Create a dynamic theme based on TLDraw preferences
|
||||
const theme = useMemo(() => {
|
||||
let mode: 'light' | 'dark';
|
||||
|
||||
// Determine mode based on TLDraw preferences
|
||||
if (tldrawPreferences?.colorScheme === 'system') {
|
||||
mode = prefersDarkMode ? 'dark' : 'light';
|
||||
} else {
|
||||
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
divider: 'var(--color-divider)',
|
||||
},
|
||||
});
|
||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||
|
||||
const {
|
||||
navigateToDay,
|
||||
navigateToWeek,
|
||||
navigateToMonth,
|
||||
navigateToYear,
|
||||
currentCalendarNode,
|
||||
calendarStructure
|
||||
} = useNeoUser();
|
||||
|
||||
const handlePrevious = async () => {
|
||||
if (!currentCalendarNode || !calendarStructure) return;
|
||||
|
||||
try {
|
||||
switch (activeView) {
|
||||
case 'day': {
|
||||
// Find current day and get previous
|
||||
const days = Object.values(calendarStructure.days);
|
||||
const currentIndex = days.findIndex(d => d.id === currentCalendarNode.id);
|
||||
if (currentIndex > 0) {
|
||||
await navigateToDay(days[currentIndex - 1].id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'week': {
|
||||
// Find current week and get previous
|
||||
const weeks = Object.values(calendarStructure.weeks);
|
||||
const currentIndex = weeks.findIndex(w => w.id === currentCalendarNode.id);
|
||||
if (currentIndex > 0) {
|
||||
await navigateToWeek(weeks[currentIndex - 1].id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'month': {
|
||||
// Find current month and get previous
|
||||
const months = Object.values(calendarStructure.months);
|
||||
const currentIndex = months.findIndex(m => m.id === currentCalendarNode.id);
|
||||
if (currentIndex > 0) {
|
||||
await navigateToMonth(months[currentIndex - 1].id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'year': {
|
||||
// Find current year and get previous
|
||||
const years = calendarStructure.years;
|
||||
const currentIndex = years.findIndex(y => y.id === currentCalendarNode.id);
|
||||
if (currentIndex > 0) {
|
||||
await navigateToYear(years[currentIndex - 1].id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to navigate to previous:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (!currentCalendarNode || !calendarStructure) return;
|
||||
|
||||
try {
|
||||
switch (activeView) {
|
||||
case 'day': {
|
||||
// Find current day and get next
|
||||
const days = Object.values(calendarStructure.days);
|
||||
const currentIndex = days.findIndex(d => d.id === currentCalendarNode.id);
|
||||
if (currentIndex < days.length - 1) {
|
||||
await navigateToDay(days[currentIndex + 1].id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'week': {
|
||||
// Find current week and get next
|
||||
const weeks = Object.values(calendarStructure.weeks);
|
||||
const currentIndex = weeks.findIndex(w => w.id === currentCalendarNode.id);
|
||||
if (currentIndex < weeks.length - 1) {
|
||||
await navigateToWeek(weeks[currentIndex + 1].id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'month': {
|
||||
// Find current month and get next
|
||||
const months = Object.values(calendarStructure.months);
|
||||
const currentIndex = months.findIndex(m => m.id === currentCalendarNode.id);
|
||||
if (currentIndex < months.length - 1) {
|
||||
await navigateToMonth(months[currentIndex + 1].id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'year': {
|
||||
// Find current year and get next
|
||||
const years = calendarStructure.years;
|
||||
const currentIndex = years.findIndex(y => y.id === currentCalendarNode.id);
|
||||
if (currentIndex < years.length - 1) {
|
||||
await navigateToYear(years[currentIndex + 1].id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to navigate to next:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToday = async () => {
|
||||
if (!calendarStructure) return;
|
||||
|
||||
try {
|
||||
// Navigate to current day based on active view
|
||||
switch (activeView) {
|
||||
case 'day':
|
||||
await navigateToDay(calendarStructure.currentDay);
|
||||
break;
|
||||
case 'week': {
|
||||
const currentDay = calendarStructure.days[calendarStructure.currentDay];
|
||||
if (currentDay) {
|
||||
const week = Object.values(calendarStructure.weeks)
|
||||
.find(w => w.days.includes(currentDay));
|
||||
if (week) {
|
||||
await navigateToWeek(week.id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'month': {
|
||||
const currentDay = calendarStructure.days[calendarStructure.currentDay];
|
||||
if (currentDay) {
|
||||
const month = Object.values(calendarStructure.months)
|
||||
.find(m => m.days.includes(currentDay));
|
||||
if (month) {
|
||||
await navigateToMonth(month.id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'year': {
|
||||
const currentDay = calendarStructure.days[calendarStructure.currentDay];
|
||||
if (currentDay) {
|
||||
const month = Object.values(calendarStructure.months)
|
||||
.find(m => m.days.includes(currentDay));
|
||||
if (month) {
|
||||
const year = calendarStructure.years
|
||||
.find(y => y.months.includes(month));
|
||||
if (year) {
|
||||
await navigateToYear(year.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to navigate to today:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<NavigationContainer>
|
||||
<NavigationSection>
|
||||
<StyledIconButton
|
||||
size="small"
|
||||
onClick={handlePrevious}
|
||||
disabled={!currentCalendarNode || !calendarStructure}
|
||||
>
|
||||
<NavigateBeforeIcon />
|
||||
</StyledIconButton>
|
||||
|
||||
{currentCalendarNode && (
|
||||
<TitleTypography
|
||||
variant="subtitle2"
|
||||
>
|
||||
{currentCalendarNode.title}
|
||||
</TitleTypography>
|
||||
)}
|
||||
|
||||
<StyledIconButton
|
||||
size="small"
|
||||
onClick={handleNext}
|
||||
disabled={!currentCalendarNode || !calendarStructure}
|
||||
>
|
||||
<NavigateNextIcon />
|
||||
</StyledIconButton>
|
||||
</NavigationSection>
|
||||
|
||||
<ViewControls>
|
||||
<StyledIconButton
|
||||
size="small"
|
||||
onClick={() => onViewChange('day')}
|
||||
className={activeView === 'day' ? 'active' : ''}
|
||||
>
|
||||
<TodayIcon />
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
size="small"
|
||||
onClick={() => onViewChange('week')}
|
||||
className={activeView === 'week' ? 'active' : ''}
|
||||
>
|
||||
<ViewWeekIcon />
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
size="small"
|
||||
onClick={() => onViewChange('month')}
|
||||
className={activeView === 'month' ? 'active' : ''}
|
||||
>
|
||||
<DateRangeIcon />
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
size="small"
|
||||
onClick={() => onViewChange('year')}
|
||||
className={activeView === 'year' ? 'active' : ''}
|
||||
>
|
||||
<EventIcon />
|
||||
</StyledIconButton>
|
||||
</ViewControls>
|
||||
|
||||
<ActionButtonContainer>
|
||||
<ActionButton
|
||||
size="small"
|
||||
startIcon={<TodayIcon />}
|
||||
onClick={handleToday}
|
||||
disabled={!calendarStructure}
|
||||
>
|
||||
Today
|
||||
</ActionButton>
|
||||
</ActionButtonContainer>
|
||||
</NavigationContainer>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
361
src/components/navigation/extended/TeacherNavigation.tsx
Normal file
361
src/components/navigation/extended/TeacherNavigation.tsx
Normal file
@ -0,0 +1,361 @@
|
||||
import React from 'react';
|
||||
import { Box, IconButton, Typography, styled, Tabs, Tab } from '@mui/material';
|
||||
import {
|
||||
Schedule as ScheduleIcon,
|
||||
Book as JournalIcon,
|
||||
EventNote as PlannerIcon,
|
||||
Class as ClassIcon,
|
||||
MenuBook as LessonIcon,
|
||||
NavigateBefore as NavigateBeforeIcon,
|
||||
NavigateNext as NavigateNextIcon,
|
||||
Dashboard as DashboardIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useNeoUser } from '../../../contexts/NeoUserContext';
|
||||
import { TeacherExtendedContext } from '../../../types/navigation';
|
||||
import { logger } from '../../../debugConfig';
|
||||
import { useTLDraw } from '../../../contexts/TLDrawContext';
|
||||
|
||||
const NavigationContainer = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(0, 2),
|
||||
}));
|
||||
|
||||
const ViewControls = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.5),
|
||||
}));
|
||||
|
||||
const StyledIconButton = styled(IconButton, {
|
||||
shouldForwardProp: prop => prop !== 'isDarkMode'
|
||||
})<{ isDarkMode?: boolean }>(({ theme, isDarkMode }) => ({
|
||||
color: isDarkMode ? theme.palette.text.primary : theme.palette.text.secondary,
|
||||
transition: theme.transitions.create(['background-color', 'color', 'transform'], {
|
||||
duration: theme.transitions.duration.shorter,
|
||||
}),
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
color: theme.palette.action.disabled,
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.25rem',
|
||||
transition: theme.transitions.create('transform', {
|
||||
duration: theme.transitions.duration.shortest,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledTabs = styled(Tabs, {
|
||||
shouldForwardProp: prop => prop !== 'isDarkMode'
|
||||
})<{ isDarkMode?: boolean }>(({ theme, isDarkMode }) => ({
|
||||
minHeight: 'unset',
|
||||
'& .MuiTab-root': {
|
||||
minHeight: 'unset',
|
||||
padding: theme.spacing(1),
|
||||
textTransform: 'none',
|
||||
fontSize: '0.875rem',
|
||||
color: isDarkMode ? theme.palette.text.primary : theme.palette.text.secondary,
|
||||
transition: theme.transitions.create(['color', 'background-color', 'box-shadow'], {
|
||||
duration: theme.transitions.duration.shorter,
|
||||
}),
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
color: theme.palette.primary.main,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.selected,
|
||||
},
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.25rem',
|
||||
marginBottom: theme.spacing(0.5),
|
||||
transition: theme.transitions.create('transform', {
|
||||
duration: theme.transitions.duration.shortest,
|
||||
}),
|
||||
},
|
||||
'&:hover .MuiSvgIcon-root': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
transition: theme.transitions.create(['width', 'left'], {
|
||||
duration: theme.transitions.duration.standard,
|
||||
easing: theme.transitions.easing.easeInOut,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
activeView: TeacherExtendedContext;
|
||||
onViewChange: (view: TeacherExtendedContext) => void;
|
||||
}
|
||||
|
||||
export const TeacherNavigation: React.FC<Props> = ({ activeView, onViewChange }) => {
|
||||
const { tldrawPreferences } = useTLDraw();
|
||||
const isDarkMode = tldrawPreferences?.colorScheme === 'dark';
|
||||
const {
|
||||
navigateToTimetable,
|
||||
navigateToClass,
|
||||
navigateToLesson,
|
||||
navigateToJournal,
|
||||
navigateToPlanner,
|
||||
currentWorkerNode,
|
||||
workerStructure
|
||||
} = useNeoUser();
|
||||
|
||||
const handlePrevious = async () => {
|
||||
if (!currentWorkerNode || !workerStructure) return;
|
||||
|
||||
try {
|
||||
switch (activeView) {
|
||||
case 'overview': {
|
||||
// Overview doesn't have navigation
|
||||
break;
|
||||
}
|
||||
case 'timetable': {
|
||||
// Find current timetable and get previous
|
||||
const deptId = Object.keys(workerStructure.timetables).find(
|
||||
deptId => workerStructure.timetables[deptId].some(t => t.id === currentWorkerNode.id)
|
||||
);
|
||||
if (deptId) {
|
||||
const timetables = workerStructure.timetables[deptId];
|
||||
const currentIndex = timetables.findIndex(t => t.id === currentWorkerNode.id);
|
||||
if (currentIndex > 0) {
|
||||
await navigateToTimetable(timetables[currentIndex - 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'classes': {
|
||||
// Find current class and get previous
|
||||
const deptId = Object.keys(workerStructure.classes).find(
|
||||
deptId => workerStructure.classes[deptId].some(c => c.id === currentWorkerNode.id)
|
||||
);
|
||||
if (deptId) {
|
||||
const classes = workerStructure.classes[deptId];
|
||||
const currentIndex = classes.findIndex(c => c.id === currentWorkerNode.id);
|
||||
if (currentIndex > 0) {
|
||||
await navigateToClass(classes[currentIndex - 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'lessons': {
|
||||
// Find current lesson and get previous
|
||||
const deptId = Object.keys(workerStructure.lessons).find(
|
||||
deptId => workerStructure.lessons[deptId].some(l => l.id === currentWorkerNode.id)
|
||||
);
|
||||
if (deptId) {
|
||||
const lessons = workerStructure.lessons[deptId];
|
||||
const currentIndex = lessons.findIndex(l => l.id === currentWorkerNode.id);
|
||||
if (currentIndex > 0) {
|
||||
await navigateToLesson(lessons[currentIndex - 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'journal': {
|
||||
// Find current journal and get previous
|
||||
const deptId = Object.keys(workerStructure.journals).find(
|
||||
deptId => workerStructure.journals[deptId].some(j => j.id === currentWorkerNode.id)
|
||||
);
|
||||
if (deptId) {
|
||||
const journals = workerStructure.journals[deptId];
|
||||
const currentIndex = journals.findIndex(j => j.id === currentWorkerNode.id);
|
||||
if (currentIndex > 0) {
|
||||
await navigateToJournal(journals[currentIndex - 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'planner': {
|
||||
// Find current planner and get previous
|
||||
const deptId = Object.keys(workerStructure.planners).find(
|
||||
deptId => workerStructure.planners[deptId].some(p => p.id === currentWorkerNode.id)
|
||||
);
|
||||
if (deptId) {
|
||||
const planners = workerStructure.planners[deptId];
|
||||
const currentIndex = planners.findIndex(p => p.id === currentWorkerNode.id);
|
||||
if (currentIndex > 0) {
|
||||
await navigateToPlanner(planners[currentIndex - 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to navigate to previous:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (!currentWorkerNode || !workerStructure) return;
|
||||
|
||||
try {
|
||||
switch (activeView) {
|
||||
case 'overview': {
|
||||
// Overview doesn't have navigation
|
||||
break;
|
||||
}
|
||||
case 'timetable': {
|
||||
// Find current timetable and get next
|
||||
const deptId = Object.keys(workerStructure.timetables).find(
|
||||
deptId => workerStructure.timetables[deptId].some(t => t.id === currentWorkerNode.id)
|
||||
);
|
||||
if (deptId) {
|
||||
const timetables = workerStructure.timetables[deptId];
|
||||
const currentIndex = timetables.findIndex(t => t.id === currentWorkerNode.id);
|
||||
if (currentIndex < timetables.length - 1) {
|
||||
await navigateToTimetable(timetables[currentIndex + 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'classes': {
|
||||
// Find current class and get next
|
||||
const deptId = Object.keys(workerStructure.classes).find(
|
||||
deptId => workerStructure.classes[deptId].some(c => c.id === currentWorkerNode.id)
|
||||
);
|
||||
if (deptId) {
|
||||
const classes = workerStructure.classes[deptId];
|
||||
const currentIndex = classes.findIndex(c => c.id === currentWorkerNode.id);
|
||||
if (currentIndex < classes.length - 1) {
|
||||
await navigateToClass(classes[currentIndex + 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'lessons': {
|
||||
// Find current lesson and get next
|
||||
const deptId = Object.keys(workerStructure.lessons).find(
|
||||
deptId => workerStructure.lessons[deptId].some(l => l.id === currentWorkerNode.id)
|
||||
);
|
||||
if (deptId) {
|
||||
const lessons = workerStructure.lessons[deptId];
|
||||
const currentIndex = lessons.findIndex(l => l.id === currentWorkerNode.id);
|
||||
if (currentIndex < lessons.length - 1) {
|
||||
await navigateToLesson(lessons[currentIndex + 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'journal': {
|
||||
// Find current journal and get next
|
||||
const deptId = Object.keys(workerStructure.journals).find(
|
||||
deptId => workerStructure.journals[deptId].some(j => j.id === currentWorkerNode.id)
|
||||
);
|
||||
if (deptId) {
|
||||
const journals = workerStructure.journals[deptId];
|
||||
const currentIndex = journals.findIndex(j => j.id === currentWorkerNode.id);
|
||||
if (currentIndex < journals.length - 1) {
|
||||
await navigateToJournal(journals[currentIndex + 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'planner': {
|
||||
// Find current planner and get next
|
||||
const deptId = Object.keys(workerStructure.planners).find(
|
||||
deptId => workerStructure.planners[deptId].some(p => p.id === currentWorkerNode.id)
|
||||
);
|
||||
if (deptId) {
|
||||
const planners = workerStructure.planners[deptId];
|
||||
const currentIndex = planners.findIndex(p => p.id === currentWorkerNode.id);
|
||||
if (currentIndex < planners.length - 1) {
|
||||
await navigateToPlanner(planners[currentIndex + 1].id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to navigate to next:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<StyledTabs
|
||||
value={activeView}
|
||||
onChange={(_, value) => onViewChange(value as TeacherExtendedContext)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
isDarkMode={isDarkMode}
|
||||
>
|
||||
<Tab
|
||||
value="overview"
|
||||
icon={<DashboardIcon />}
|
||||
label="Overview"
|
||||
/>
|
||||
<Tab
|
||||
value="timetable"
|
||||
icon={<ScheduleIcon />}
|
||||
label="Timetable"
|
||||
/>
|
||||
<Tab
|
||||
value="classes"
|
||||
icon={<ClassIcon />}
|
||||
label="Classes"
|
||||
/>
|
||||
<Tab
|
||||
value="lessons"
|
||||
icon={<LessonIcon />}
|
||||
label="Lessons"
|
||||
/>
|
||||
<Tab
|
||||
value="journal"
|
||||
icon={<JournalIcon />}
|
||||
label="Journal"
|
||||
/>
|
||||
<Tab
|
||||
value="planner"
|
||||
icon={<PlannerIcon />}
|
||||
label="Planner"
|
||||
/>
|
||||
</StyledTabs>
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<ViewControls>
|
||||
<StyledIconButton
|
||||
size="small"
|
||||
onClick={handlePrevious}
|
||||
disabled={!currentWorkerNode || !workerStructure}
|
||||
isDarkMode={isDarkMode}
|
||||
>
|
||||
<NavigateBeforeIcon />
|
||||
</StyledIconButton>
|
||||
|
||||
{currentWorkerNode && (
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
component="span"
|
||||
sx={{
|
||||
mx: 2,
|
||||
color: 'text.primary',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
{currentWorkerNode.title}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<StyledIconButton
|
||||
size="small"
|
||||
onClick={handleNext}
|
||||
disabled={!currentWorkerNode || !workerStructure}
|
||||
isDarkMode={isDarkMode}
|
||||
>
|
||||
<NavigateNextIcon />
|
||||
</StyledIconButton>
|
||||
</ViewControls>
|
||||
</NavigationContainer>
|
||||
);
|
||||
};
|
||||
60
src/components/navigation/extended/UserNavigation.tsx
Normal file
60
src/components/navigation/extended/UserNavigation.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, styled, Tabs, Tab } from '@mui/material';
|
||||
import {
|
||||
AccountCircle as ProfileIcon,
|
||||
Book as JournalIcon,
|
||||
EventNote as PlannerIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useNeoUser } from '../../../contexts/NeoUserContext';
|
||||
import { UserExtendedContext } from '../../../types/navigation';
|
||||
|
||||
const NavigationContainer = styled(Box)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
activeView: UserExtendedContext;
|
||||
onViewChange: (view: UserExtendedContext) => void;
|
||||
}
|
||||
|
||||
export const UserNavigation: React.FC<Props> = ({ activeView, onViewChange }) => {
|
||||
const { currentWorkerNode } = useNeoUser();
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<Tabs
|
||||
value={activeView}
|
||||
onChange={(_, value) => onViewChange(value as UserExtendedContext)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab
|
||||
value="profile"
|
||||
icon={<ProfileIcon />}
|
||||
label="Profile"
|
||||
/>
|
||||
<Tab
|
||||
value="journal"
|
||||
icon={<JournalIcon />}
|
||||
label="Journal"
|
||||
/>
|
||||
<Tab
|
||||
value="planner"
|
||||
icon={<PlannerIcon />}
|
||||
label="Planner"
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
{currentWorkerNode && (
|
||||
<Typography variant="subtitle2" sx={{ px: 2 }}>
|
||||
{currentWorkerNode.label}
|
||||
</Typography>
|
||||
)}
|
||||
</NavigationContainer>
|
||||
);
|
||||
};
|
||||
3
src/components/navigation/extended/index.ts
Normal file
3
src/components/navigation/extended/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { CalendarNavigation } from './CalendarNavigation';
|
||||
export { TeacherNavigation } from './TeacherNavigation';
|
||||
export { UserNavigation } from './UserNavigation';
|
||||
211
src/config/navigationContexts.ts
Normal file
211
src/config/navigationContexts.ts
Normal file
@ -0,0 +1,211 @@
|
||||
import { ContextDefinition } from '../types/navigation';
|
||||
|
||||
export const NAVIGATION_CONTEXTS: Record<string, ContextDefinition> = {
|
||||
// Personal Contexts
|
||||
profile: {
|
||||
id: 'profile',
|
||||
icon: 'Person',
|
||||
label: 'User Profile',
|
||||
description: 'Personal workspace and settings',
|
||||
defaultNodeId: 'user-root',
|
||||
views: [
|
||||
{
|
||||
id: 'overview',
|
||||
icon: 'Dashboard',
|
||||
label: 'Overview',
|
||||
description: 'View your profile overview'
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
icon: 'Settings',
|
||||
label: 'Settings',
|
||||
description: 'Manage your preferences'
|
||||
},
|
||||
{
|
||||
id: 'history',
|
||||
icon: 'History',
|
||||
label: 'History',
|
||||
description: 'View your activity history'
|
||||
},
|
||||
{
|
||||
id: 'journal',
|
||||
icon: 'Book',
|
||||
label: 'Journal',
|
||||
description: 'Your personal journal'
|
||||
},
|
||||
{
|
||||
id: 'planner',
|
||||
icon: 'Event',
|
||||
label: 'Planner',
|
||||
description: 'Plan your activities'
|
||||
}
|
||||
]
|
||||
},
|
||||
calendar: {
|
||||
id: 'calendar',
|
||||
icon: 'CalendarToday',
|
||||
label: 'Calendar',
|
||||
description: 'Calendar navigation and events',
|
||||
defaultNodeId: 'calendar-root',
|
||||
views: [
|
||||
{
|
||||
id: 'overview',
|
||||
icon: 'Dashboard',
|
||||
label: 'Overview',
|
||||
description: 'Calendar overview'
|
||||
},
|
||||
{
|
||||
id: 'day',
|
||||
icon: 'Today',
|
||||
label: 'Day View',
|
||||
description: 'Navigate by day'
|
||||
},
|
||||
{
|
||||
id: 'week',
|
||||
icon: 'ViewWeek',
|
||||
label: 'Week View',
|
||||
description: 'Navigate by week'
|
||||
},
|
||||
{
|
||||
id: 'month',
|
||||
icon: 'DateRange',
|
||||
label: 'Month View',
|
||||
description: 'Navigate by month'
|
||||
},
|
||||
{
|
||||
id: 'year',
|
||||
icon: 'Event',
|
||||
label: 'Year View',
|
||||
description: 'Navigate by year'
|
||||
}
|
||||
]
|
||||
},
|
||||
teaching: {
|
||||
id: 'teaching',
|
||||
icon: 'School',
|
||||
label: 'Teaching',
|
||||
description: 'Teaching workspace',
|
||||
defaultNodeId: 'teacher-root',
|
||||
views: [
|
||||
{
|
||||
id: 'overview',
|
||||
icon: 'Dashboard',
|
||||
label: 'Overview',
|
||||
description: 'Teaching overview'
|
||||
},
|
||||
{
|
||||
id: 'timetable',
|
||||
icon: 'Schedule',
|
||||
label: 'Timetable',
|
||||
description: 'View your teaching schedule'
|
||||
},
|
||||
{
|
||||
id: 'classes',
|
||||
icon: 'Class',
|
||||
label: 'Classes',
|
||||
description: 'Manage your classes'
|
||||
},
|
||||
{
|
||||
id: 'lessons',
|
||||
icon: 'Book',
|
||||
label: 'Lessons',
|
||||
description: 'Plan and view lessons'
|
||||
},
|
||||
{
|
||||
id: 'journal',
|
||||
icon: 'Book',
|
||||
label: 'Journal',
|
||||
description: 'Your teaching journal'
|
||||
},
|
||||
{
|
||||
id: 'planner',
|
||||
icon: 'Event',
|
||||
label: 'Planner',
|
||||
description: 'Plan your teaching activities'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// Institutional Contexts
|
||||
school: {
|
||||
id: 'school',
|
||||
icon: 'Business',
|
||||
label: 'School',
|
||||
description: 'School management',
|
||||
defaultNodeId: 'school-root',
|
||||
views: [
|
||||
{
|
||||
id: 'overview',
|
||||
icon: 'Dashboard',
|
||||
label: 'Overview',
|
||||
description: 'School overview'
|
||||
},
|
||||
{
|
||||
id: 'departments',
|
||||
icon: 'AccountTree',
|
||||
label: 'Departments',
|
||||
description: 'View departments'
|
||||
},
|
||||
{
|
||||
id: 'staff',
|
||||
icon: 'People',
|
||||
label: 'Staff',
|
||||
description: 'Staff directory'
|
||||
}
|
||||
]
|
||||
},
|
||||
department: {
|
||||
id: 'department',
|
||||
icon: 'AccountTree',
|
||||
label: 'Department',
|
||||
description: 'Department management',
|
||||
defaultNodeId: 'department-root',
|
||||
views: [
|
||||
{
|
||||
id: 'overview',
|
||||
icon: 'Dashboard',
|
||||
label: 'Overview',
|
||||
description: 'Department overview'
|
||||
},
|
||||
{
|
||||
id: 'teachers',
|
||||
icon: 'People',
|
||||
label: 'Teachers',
|
||||
description: 'Department teachers'
|
||||
},
|
||||
{
|
||||
id: 'subjects',
|
||||
icon: 'Subject',
|
||||
label: 'Subjects',
|
||||
description: 'Department subjects'
|
||||
}
|
||||
]
|
||||
},
|
||||
class: {
|
||||
id: 'class',
|
||||
icon: 'Class',
|
||||
label: 'Class',
|
||||
description: 'Class management',
|
||||
defaultNodeId: 'class-root',
|
||||
views: [
|
||||
{
|
||||
id: 'overview',
|
||||
icon: 'Dashboard',
|
||||
label: 'Overview',
|
||||
description: 'Class overview'
|
||||
},
|
||||
{
|
||||
id: 'students',
|
||||
icon: 'People',
|
||||
label: 'Students',
|
||||
description: 'Class students'
|
||||
},
|
||||
{
|
||||
id: 'timetable',
|
||||
icon: 'Schedule',
|
||||
label: 'Timetable',
|
||||
description: 'Class schedule'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
157
src/contexts/AuthContext.tsx
Normal file
157
src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
|
||||
import { logger } from '../debugConfig';
|
||||
import { supabase } from '../supabaseClient';
|
||||
|
||||
export interface AuthContextType {
|
||||
user: CCUser | null;
|
||||
user_role: string | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
signIn: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
user_role: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
signIn: async () => {},
|
||||
signOut: async () => {},
|
||||
clearError: () => {}
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState<CCUser | null>(null);
|
||||
const [user_role, setUserRole] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (user) {
|
||||
const metadata = user.user_metadata as CCUserMetadata;
|
||||
setUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
user_type: metadata.user_type || '',
|
||||
username: metadata.username || '',
|
||||
display_name: metadata.display_name || '',
|
||||
user_db_name: `cc.users.${metadata.user_type}.${metadata.username}`,
|
||||
school_db_name: 'cc.institutes.development.default',
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at
|
||||
});
|
||||
setUserRole(metadata.user_role || null);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('auth-context', '❌ Failed to load user', { error });
|
||||
setError(error instanceof Error ? error : new Error('Failed to load user'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadUser();
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
if (event === 'SIGNED_IN' && session?.user) {
|
||||
const metadata = session.user.user_metadata as CCUserMetadata;
|
||||
setUser({
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
user_type: metadata.user_type || '',
|
||||
username: metadata.username || '',
|
||||
display_name: metadata.display_name || '',
|
||||
user_db_name: `cc.users.${metadata.user_type}.${metadata.username}`,
|
||||
school_db_name: 'cc.institutes.development.default',
|
||||
created_at: session.user.created_at,
|
||||
updated_at: session.user.updated_at
|
||||
});
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
setUser(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
});
|
||||
|
||||
if (signInError) throw signInError;
|
||||
|
||||
if (data.user) {
|
||||
const metadata = data.user.user_metadata as CCUserMetadata;
|
||||
setUser({
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
user_type: metadata.user_type || '',
|
||||
username: metadata.username || '',
|
||||
display_name: metadata.display_name || '',
|
||||
user_db_name: `cc.users.${metadata.user_type}.${metadata.username}`,
|
||||
school_db_name: 'cc.institutes.development.default',
|
||||
created_at: data.user.created_at,
|
||||
updated_at: data.user.updated_at
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('auth-context', '❌ Sign in failed', { error });
|
||||
setError(error instanceof Error ? error : new Error('Sign in failed'));
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await authService.logout();
|
||||
setUser(null);
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
logger.error('auth-context', '❌ Sign out failed', { error });
|
||||
setError(error instanceof Error ? error : new Error('Sign out failed'));
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = () => setError(null);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
user_role,
|
||||
loading,
|
||||
error,
|
||||
signIn,
|
||||
signOut,
|
||||
clearError
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
92
src/contexts/NeoInstituteContext.tsx
Normal file
92
src/contexts/NeoInstituteContext.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useUser } from './UserContext';
|
||||
import { SchoolNeoDBService } from '../services/graph/schoolNeoDBService';
|
||||
import { CCSchoolNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
import { logger } from '../debugConfig';
|
||||
|
||||
export interface NeoInstituteContextType {
|
||||
schoolNode: CCSchoolNodeProps | null;
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const NeoInstituteContext = createContext<NeoInstituteContextType>({
|
||||
schoolNode: null,
|
||||
isLoading: true,
|
||||
isInitialized: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const { profile, isInitialized: isUserInitialized } = useUser();
|
||||
|
||||
const [schoolNode, setSchoolNode] = useState<CCSchoolNodeProps | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for user profile to be ready
|
||||
if (!isUserInitialized) {
|
||||
logger.debug('neo-institute-context', '⏳ Waiting for user initialization...');
|
||||
return;
|
||||
}
|
||||
|
||||
// If no profile or no worker database, mark as initialized with no data
|
||||
if (!profile || !profile.school_db_name) {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadSchoolNode = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
logger.debug('neo-institute-context', '🔄 Loading school node', {
|
||||
schoolDbName: profile.school_db_name,
|
||||
userEmail: user?.email
|
||||
});
|
||||
|
||||
const node = await SchoolNeoDBService.getSchoolNode(profile.school_db_name);
|
||||
if (node) {
|
||||
setSchoolNode(node);
|
||||
logger.debug('neo-institute-context', '✅ School node loaded', {
|
||||
schoolId: node.unique_id,
|
||||
dbName: profile.school_db_name
|
||||
});
|
||||
} else {
|
||||
logger.warn('neo-institute-context', '⚠️ No school node found');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to load school node';
|
||||
logger.error('neo-institute-context', '❌ Failed to load school node', {
|
||||
error: errorMessage,
|
||||
schoolDbName: profile.school_db_name
|
||||
});
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadSchoolNode();
|
||||
}, [user?.email, profile, isUserInitialized]);
|
||||
|
||||
return (
|
||||
<NeoInstituteContext.Provider value={{
|
||||
schoolNode,
|
||||
isLoading,
|
||||
isInitialized,
|
||||
error
|
||||
}}>
|
||||
{children}
|
||||
</NeoInstituteContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNeoInstitute = () => useContext(NeoInstituteContext);
|
||||
624
src/contexts/NeoUserContext.tsx
Normal file
624
src/contexts/NeoUserContext.tsx
Normal file
@ -0,0 +1,624 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useUser } from './UserContext';
|
||||
import { logger } from '../debugConfig';
|
||||
import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
import { CalendarStructure, WorkerStructure } from '../types/navigation';
|
||||
import { useNavigationStore } from '../stores/navigationStore';
|
||||
|
||||
// Core Node Types
|
||||
export interface CalendarNode {
|
||||
id: string;
|
||||
label: string;
|
||||
title: string;
|
||||
tldraw_snapshot: string;
|
||||
type?: CCCalendarNodeProps['__primarylabel__'];
|
||||
nodeData?: CCCalendarNodeProps;
|
||||
}
|
||||
|
||||
export interface WorkerNode {
|
||||
id: string;
|
||||
label: string;
|
||||
title: string;
|
||||
tldraw_snapshot: string;
|
||||
type?: CCUserTeacherTimetableNodeProps['__primarylabel__'];
|
||||
nodeData?: CCUserTeacherTimetableNodeProps;
|
||||
}
|
||||
|
||||
// Calendar Structure Types
|
||||
export interface CalendarDay {
|
||||
id: string;
|
||||
date: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CalendarWeek {
|
||||
id: string;
|
||||
title: string;
|
||||
days: { id: string }[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export interface CalendarMonth {
|
||||
id: string;
|
||||
title: string;
|
||||
days: { id: string }[];
|
||||
weeks: { id: string }[];
|
||||
year: string;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface CalendarYear {
|
||||
id: string;
|
||||
title: string;
|
||||
months: { id: string }[];
|
||||
year: string;
|
||||
}
|
||||
|
||||
// Worker Structure Types
|
||||
export interface TimetableEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
export interface ClassEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface LessonEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface NeoUserContextType {
|
||||
userNode: CCUserNodeProps | null;
|
||||
calendarNode: CalendarNode | null;
|
||||
workerNode: WorkerNode | null;
|
||||
userDbName: string | null;
|
||||
workerDbName: string | null;
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Calendar Navigation
|
||||
navigateToDay: (id: string) => Promise<void>;
|
||||
navigateToWeek: (id: string) => Promise<void>;
|
||||
navigateToMonth: (id: string) => Promise<void>;
|
||||
navigateToYear: (id: string) => Promise<void>;
|
||||
currentCalendarNode: CalendarNode | null;
|
||||
calendarStructure: CalendarStructure | null;
|
||||
|
||||
// Worker Navigation
|
||||
navigateToTimetable: (id: string) => Promise<void>;
|
||||
navigateToJournal: (id: string) => Promise<void>;
|
||||
navigateToPlanner: (id: string) => Promise<void>;
|
||||
navigateToClass: (id: string) => Promise<void>;
|
||||
navigateToLesson: (id: string) => Promise<void>;
|
||||
currentWorkerNode: WorkerNode | null;
|
||||
workerStructure: WorkerStructure | null;
|
||||
}
|
||||
|
||||
const NeoUserContext = createContext<NeoUserContextType>({
|
||||
userNode: null,
|
||||
calendarNode: null,
|
||||
workerNode: null,
|
||||
userDbName: null,
|
||||
workerDbName: null,
|
||||
isLoading: false,
|
||||
isInitialized: false,
|
||||
error: null,
|
||||
navigateToDay: async () => {},
|
||||
navigateToWeek: async () => {},
|
||||
navigateToMonth: async () => {},
|
||||
navigateToYear: async () => {},
|
||||
navigateToTimetable: async () => {},
|
||||
navigateToJournal: async () => {},
|
||||
navigateToPlanner: async () => {},
|
||||
navigateToClass: async () => {},
|
||||
navigateToLesson: async () => {},
|
||||
currentCalendarNode: null,
|
||||
currentWorkerNode: null,
|
||||
calendarStructure: null,
|
||||
workerStructure: null
|
||||
});
|
||||
|
||||
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const { profile, isInitialized: isUserInitialized } = useUser();
|
||||
const navigationStore = useNavigationStore();
|
||||
|
||||
const [userNode, setUserNode] = useState<CCUserNodeProps | null>(null);
|
||||
const [calendarNode] = useState<CalendarNode | null>(null);
|
||||
const [workerNode] = useState<WorkerNode | null>(null);
|
||||
const [currentCalendarNode, setCurrentCalendarNode] = useState<CalendarNode | null>(null);
|
||||
const [currentWorkerNode, setCurrentWorkerNode] = useState<WorkerNode | null>(null);
|
||||
const [calendarStructure] = useState<CalendarStructure | null>(null);
|
||||
const [workerStructure] = useState<WorkerStructure | null>(null);
|
||||
const [userDbName, setUserDbName] = useState<string | null>(null);
|
||||
const [workerDbName, setWorkerDbName] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Use ref for initialization tracking to prevent re-renders
|
||||
const initializationRef = React.useRef({
|
||||
hasStarted: false,
|
||||
isComplete: false
|
||||
});
|
||||
|
||||
// Add base properties for node data
|
||||
const getBaseNodeProps = () => ({
|
||||
title: '',
|
||||
w: 200,
|
||||
h: 200,
|
||||
headerColor: '#000000',
|
||||
backgroundColor: '#ffffff',
|
||||
isLocked: false,
|
||||
__primarylabel__: 'UserTeacherTimetable',
|
||||
unique_id: '',
|
||||
tldraw_snapshot: '',
|
||||
created: new Date().toISOString(),
|
||||
merged: new Date().toISOString(),
|
||||
state: {
|
||||
parentId: null,
|
||||
isPageChild: false,
|
||||
hasChildren: false,
|
||||
bindings: [],
|
||||
},
|
||||
defaultComponent: true,
|
||||
});
|
||||
|
||||
// Initialize context when dependencies are ready
|
||||
useEffect(() => {
|
||||
if (!isUserInitialized || !profile || isInitialized || initializationRef.current.hasStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initializeContext = async () => {
|
||||
try {
|
||||
initializationRef.current.hasStarted = true;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Set database names
|
||||
const userDb = profile.user_db_name || (user?.email ?
|
||||
`cc.users.${user.email.replace('@', 'at').replace(/\./g, 'dot')}` : null);
|
||||
|
||||
if (!userDb) {
|
||||
throw new Error('No user database name available');
|
||||
}
|
||||
|
||||
// Initialize user node in profile context
|
||||
logger.debug('neo-user-context', '🔄 Starting context initialization');
|
||||
|
||||
// Initialize user node
|
||||
await navigationStore.switchContext({
|
||||
main: 'profile',
|
||||
base: 'profile',
|
||||
extended: 'overview'
|
||||
}, userDb, profile.school_db_name);
|
||||
|
||||
const userNavigationNode = navigationStore.context.node;
|
||||
if (userNavigationNode?.data) {
|
||||
const userNodeData: CCUserNodeProps = {
|
||||
...getBaseNodeProps(),
|
||||
__primarylabel__: 'User',
|
||||
unique_id: userNavigationNode.id,
|
||||
tldraw_snapshot: userNavigationNode.tldraw_snapshot || '',
|
||||
title: String(userNavigationNode.data?.user_name || 'User'),
|
||||
user_name: String(userNavigationNode.data?.user_name || 'User'),
|
||||
user_email: user?.email || '',
|
||||
user_type: 'User',
|
||||
user_id: userNavigationNode.id,
|
||||
worker_node_data: JSON.stringify(userNavigationNode.data || {})
|
||||
};
|
||||
setUserNode(userNodeData);
|
||||
}
|
||||
|
||||
// Set final state
|
||||
setUserDbName(userDb);
|
||||
setWorkerDbName(profile.school_db_name);
|
||||
setIsInitialized(true);
|
||||
setIsLoading(false);
|
||||
initializationRef.current.isComplete = true;
|
||||
|
||||
logger.debug('neo-user-context', '✅ Context initialization complete');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize user context';
|
||||
logger.error('neo-user-context', '❌ Failed to initialize context', { error: errorMessage });
|
||||
setError(errorMessage);
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
initializationRef.current.isComplete = true;
|
||||
}
|
||||
};
|
||||
|
||||
initializeContext();
|
||||
}, [user?.email, profile, isUserInitialized, navigationStore, isInitialized]);
|
||||
|
||||
// Calendar Navigation Functions
|
||||
const navigateToDay = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'calendar',
|
||||
extended: 'day'
|
||||
}, userDbName, workerDbName);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
const nodeData: CCCalendarNodeProps = {
|
||||
...getBaseNodeProps(),
|
||||
__primarylabel__: 'CalendarDay',
|
||||
unique_id: id || node.id,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
title: node.label,
|
||||
name: node.label,
|
||||
calendar_type: 'day',
|
||||
calendar_name: node.label,
|
||||
start_date: new Date().toISOString(),
|
||||
end_date: new Date().toISOString()
|
||||
};
|
||||
|
||||
setCurrentCalendarNode({
|
||||
id: id || node.id,
|
||||
label: node.label,
|
||||
title: node.label,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
type: 'CalendarDay',
|
||||
nodeData
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to navigate to day');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToWeek = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'calendar',
|
||||
extended: 'week'
|
||||
}, userDbName, workerDbName);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
const nodeData: CCCalendarNodeProps = {
|
||||
...getBaseNodeProps(),
|
||||
__primarylabel__: 'CalendarWeek',
|
||||
unique_id: id || node.id,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
title: node.label,
|
||||
name: node.label,
|
||||
calendar_type: 'week',
|
||||
calendar_name: node.label,
|
||||
start_date: new Date().toISOString(),
|
||||
end_date: new Date().toISOString()
|
||||
};
|
||||
|
||||
setCurrentCalendarNode({
|
||||
id: id || node.id,
|
||||
label: node.label,
|
||||
title: node.label,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
type: 'CalendarWeek',
|
||||
nodeData
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to navigate to week');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToMonth = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'calendar',
|
||||
extended: 'month'
|
||||
}, userDbName, workerDbName);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
const nodeData: CCCalendarNodeProps = {
|
||||
...getBaseNodeProps(),
|
||||
__primarylabel__: 'CalendarMonth',
|
||||
unique_id: id || node.id,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
title: node.label,
|
||||
name: node.label,
|
||||
calendar_type: 'month',
|
||||
calendar_name: node.label,
|
||||
start_date: new Date().toISOString(),
|
||||
end_date: new Date().toISOString()
|
||||
};
|
||||
|
||||
setCurrentCalendarNode({
|
||||
id: id || node.id,
|
||||
label: node.label,
|
||||
title: node.label,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
type: 'CalendarMonth',
|
||||
nodeData
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to navigate to month');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToYear = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'calendar',
|
||||
extended: 'year'
|
||||
}, userDbName, workerDbName);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
const nodeData: CCCalendarNodeProps = {
|
||||
...getBaseNodeProps(),
|
||||
__primarylabel__: 'CalendarYear',
|
||||
unique_id: id || node.id,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
title: node.label,
|
||||
name: node.label,
|
||||
calendar_type: 'year',
|
||||
calendar_name: node.label,
|
||||
start_date: new Date().toISOString(),
|
||||
end_date: new Date().toISOString()
|
||||
};
|
||||
|
||||
setCurrentCalendarNode({
|
||||
id: id || node.id,
|
||||
label: node.label,
|
||||
title: node.label,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
type: 'CalendarYear',
|
||||
nodeData
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to navigate to year');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Worker Navigation Functions
|
||||
const navigateToTimetable = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'teaching',
|
||||
extended: 'timetable'
|
||||
}, userDbName, workerDbName);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||||
...getBaseNodeProps(),
|
||||
__primarylabel__: 'UserTeacherTimetable',
|
||||
unique_id: id || node.id,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
title: node.label,
|
||||
school_db_name: workerDbName || '',
|
||||
school_timetable_id: id || node.id
|
||||
};
|
||||
|
||||
setCurrentWorkerNode({
|
||||
id: id || node.id,
|
||||
label: node.label,
|
||||
title: node.label,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
type: 'UserTeacherTimetable',
|
||||
nodeData
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to navigate to timetable');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToJournal = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'teaching',
|
||||
extended: 'journal'
|
||||
}, userDbName, workerDbName);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||||
...getBaseNodeProps(),
|
||||
__primarylabel__: 'UserTeacherTimetable',
|
||||
unique_id: id || node.id,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
title: node.label,
|
||||
school_db_name: workerDbName || '',
|
||||
school_timetable_id: id || node.id
|
||||
};
|
||||
|
||||
setCurrentWorkerNode({
|
||||
id: id || node.id,
|
||||
label: node.label,
|
||||
title: node.label,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
type: 'UserTeacherTimetable',
|
||||
nodeData
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to navigate to journal');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToPlanner = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'teaching',
|
||||
extended: 'planner'
|
||||
}, userDbName, workerDbName);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||||
...getBaseNodeProps(),
|
||||
__primarylabel__: 'UserTeacherTimetable',
|
||||
unique_id: id || node.id,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
title: node.label,
|
||||
school_db_name: workerDbName || '',
|
||||
school_timetable_id: id || node.id
|
||||
};
|
||||
|
||||
setCurrentWorkerNode({
|
||||
id: id || node.id,
|
||||
label: node.label,
|
||||
title: node.label,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
type: 'UserTeacherTimetable',
|
||||
nodeData
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to navigate to planner');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToClass = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'teaching',
|
||||
extended: 'classes'
|
||||
}, userDbName, workerDbName);
|
||||
await navigationStore.navigate(id, userDbName);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||||
...getBaseNodeProps(),
|
||||
__primarylabel__: 'UserTeacherTimetable',
|
||||
unique_id: node.id,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
title: node.label,
|
||||
school_db_name: workerDbName || '',
|
||||
school_timetable_id: node.id
|
||||
};
|
||||
|
||||
setCurrentWorkerNode({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
title: node.label,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
type: 'UserTeacherTimetable',
|
||||
nodeData
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to navigate to class');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToLesson = async (id: string) => {
|
||||
if (!userDbName) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await navigationStore.switchContext({
|
||||
base: 'teaching',
|
||||
extended: 'lessons'
|
||||
}, userDbName, workerDbName);
|
||||
await navigationStore.navigate(id, userDbName);
|
||||
|
||||
const node = navigationStore.context.node;
|
||||
if (node?.data) {
|
||||
const nodeData: CCUserTeacherTimetableNodeProps = {
|
||||
...getBaseNodeProps(),
|
||||
__primarylabel__: 'UserTeacherTimetable',
|
||||
unique_id: node.id,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
title: node.label,
|
||||
school_db_name: workerDbName || '',
|
||||
school_timetable_id: node.id
|
||||
};
|
||||
|
||||
setCurrentWorkerNode({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
title: node.label,
|
||||
tldraw_snapshot: node.tldraw_snapshot || '',
|
||||
type: 'UserTeacherTimetable',
|
||||
nodeData
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to navigate to lesson');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NeoUserContext.Provider value={{
|
||||
userNode,
|
||||
calendarNode,
|
||||
workerNode,
|
||||
userDbName,
|
||||
workerDbName,
|
||||
isLoading,
|
||||
isInitialized,
|
||||
error,
|
||||
navigateToDay,
|
||||
navigateToWeek,
|
||||
navigateToMonth,
|
||||
navigateToYear,
|
||||
navigateToTimetable,
|
||||
navigateToJournal,
|
||||
navigateToPlanner,
|
||||
navigateToClass,
|
||||
navigateToLesson,
|
||||
currentCalendarNode,
|
||||
currentWorkerNode,
|
||||
calendarStructure,
|
||||
workerStructure
|
||||
}}>
|
||||
{children}
|
||||
</NeoUserContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNeoUser = () => useContext(NeoUserContext);
|
||||
221
src/contexts/TLDrawContext.tsx
Normal file
221
src/contexts/TLDrawContext.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import React, { ReactNode, createContext, useContext, useState, useCallback } from 'react';
|
||||
import { TLUserPreferences, TLEditorSnapshot, TLStore, getSnapshot, loadSnapshot, Editor } from '@tldraw/tldraw';
|
||||
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
||||
import { LoadingState } from '../services/tldraw/snapshotService';
|
||||
import { SharedStoreService } from '../services/tldraw/sharedStoreService';
|
||||
import { logger } from '../debugConfig';
|
||||
import { PresentationService } from '../services/tldraw/presentationService';
|
||||
|
||||
interface TLDrawContextType {
|
||||
tldrawPreferences: TLUserPreferences | null;
|
||||
tldrawUserFilePath: string | null;
|
||||
localSnapshot: Partial<TLEditorSnapshot> | null;
|
||||
presentationMode: boolean;
|
||||
sharedStore: SharedStoreService | null;
|
||||
connectionStatus: 'online' | 'offline' | 'error';
|
||||
presentationService: PresentationService | null;
|
||||
setTldrawPreferences: (preferences: TLUserPreferences | null) => void;
|
||||
setTldrawUserFilePath: (path: string | null) => void;
|
||||
handleLocalSnapshot: (
|
||||
action: string,
|
||||
store: TLStore,
|
||||
setLoadingState: (state: LoadingState) => void
|
||||
) => Promise<void>;
|
||||
togglePresentationMode: (editor?: Editor) => void;
|
||||
initializePreferences: (userId: string) => void;
|
||||
setSharedStore: (store: SharedStoreService | null) => void;
|
||||
setConnectionStatus: (status: 'online' | 'offline' | 'error') => void;
|
||||
}
|
||||
|
||||
const TLDrawContext = createContext<TLDrawContextType>({
|
||||
tldrawPreferences: null,
|
||||
tldrawUserFilePath: null,
|
||||
localSnapshot: null,
|
||||
presentationMode: false,
|
||||
sharedStore: null,
|
||||
connectionStatus: 'online',
|
||||
presentationService: null,
|
||||
setTldrawPreferences: () => {},
|
||||
setTldrawUserFilePath: () => {},
|
||||
handleLocalSnapshot: async () => {},
|
||||
togglePresentationMode: () => {},
|
||||
initializePreferences: () => {},
|
||||
setSharedStore: () => {},
|
||||
setConnectionStatus: () => {}
|
||||
});
|
||||
|
||||
export const TLDrawProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [tldrawPreferences, setTldrawPreferencesState] = useState<TLUserPreferences | null>(
|
||||
storageService.get(StorageKeys.TLDRAW_PREFERENCES)
|
||||
);
|
||||
const [tldrawUserFilePath, setTldrawUserFilePathState] = useState<string | null>(
|
||||
storageService.get(StorageKeys.TLDRAW_FILE_PATH)
|
||||
);
|
||||
const [localSnapshot, setLocalSnapshot] = useState<Partial<TLEditorSnapshot> | null>(
|
||||
storageService.get(StorageKeys.LOCAL_SNAPSHOT)
|
||||
);
|
||||
const [presentationMode, setPresentationMode] = useState<boolean>(
|
||||
storageService.get(StorageKeys.PRESENTATION_MODE) || false
|
||||
);
|
||||
const [sharedStore, setSharedStore] = useState<SharedStoreService | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState<'online' | 'offline' | 'error'>('online');
|
||||
const [presentationService, setPresentationService] = useState<PresentationService | null>(null);
|
||||
|
||||
const initializePreferences = useCallback((userId: string) => {
|
||||
logger.debug('tldraw-context', '🔄 Initializing TLDraw preferences');
|
||||
const storedPrefs = storageService.get(StorageKeys.TLDRAW_PREFERENCES);
|
||||
|
||||
if (storedPrefs) {
|
||||
logger.debug('tldraw-context', '📥 Found stored preferences');
|
||||
setTldrawPreferencesState(storedPrefs);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create default preferences if none exist
|
||||
const defaultPrefs: TLUserPreferences = {
|
||||
id: userId,
|
||||
name: 'User',
|
||||
color: `hsl(${Math.random() * 360}, 70%, 50%)`,
|
||||
locale: 'en',
|
||||
colorScheme: 'system',
|
||||
isSnapMode: false,
|
||||
isWrapMode: false,
|
||||
isDynamicSizeMode: false,
|
||||
isPasteAtCursorMode: false,
|
||||
animationSpeed: 1,
|
||||
edgeScrollSpeed: 1
|
||||
};
|
||||
|
||||
logger.debug('tldraw-context', '📝 Creating default preferences');
|
||||
storageService.set(StorageKeys.TLDRAW_PREFERENCES, defaultPrefs);
|
||||
setTldrawPreferencesState(defaultPrefs);
|
||||
}, []);
|
||||
|
||||
const setTldrawPreferences = useCallback((preferences: TLUserPreferences | null) => {
|
||||
logger.debug('tldraw-context', '🔄 Setting TLDraw preferences', { preferences });
|
||||
if (preferences) {
|
||||
storageService.set(StorageKeys.TLDRAW_PREFERENCES, preferences);
|
||||
} else {
|
||||
storageService.remove(StorageKeys.TLDRAW_PREFERENCES);
|
||||
}
|
||||
setTldrawPreferencesState(preferences);
|
||||
}, []);
|
||||
|
||||
const setTldrawUserFilePath = (path: string | null) => {
|
||||
logger.debug('tldraw-context', '🔄 Setting TLDraw user file path');
|
||||
if (path) {
|
||||
storageService.set(StorageKeys.TLDRAW_FILE_PATH, path);
|
||||
} else {
|
||||
storageService.remove(StorageKeys.TLDRAW_FILE_PATH);
|
||||
}
|
||||
setTldrawUserFilePathState(path);
|
||||
};
|
||||
|
||||
const handleLocalSnapshot = useCallback(async (
|
||||
action: string,
|
||||
store: TLStore,
|
||||
setLoadingState: (state: LoadingState) => void
|
||||
): Promise<void> => {
|
||||
if (!store) {
|
||||
setLoadingState({ status: 'error', error: 'Store not initialized' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (sharedStore) {
|
||||
if (action === 'put') {
|
||||
const snapshot = getSnapshot(store);
|
||||
await sharedStore.saveSnapshot(snapshot, setLoadingState);
|
||||
} else if (action === 'get') {
|
||||
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
|
||||
if (savedSnapshot) {
|
||||
await sharedStore.loadSnapshot(savedSnapshot, setLoadingState);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (action === 'put') {
|
||||
logger.debug('tldraw-context', '💾 Putting snapshot into local storage');
|
||||
const snapshot = getSnapshot(store);
|
||||
logger.debug('tldraw-context', '📦 Snapshot:', snapshot);
|
||||
setLocalSnapshot(snapshot);
|
||||
storageService.set(StorageKeys.LOCAL_SNAPSHOT, snapshot);
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
}
|
||||
else if (action === 'get') {
|
||||
logger.debug('tldraw-context', '📂 Getting snapshot from local storage');
|
||||
setLoadingState({ status: 'loading', error: '' });
|
||||
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
|
||||
|
||||
if (savedSnapshot && savedSnapshot.document && savedSnapshot.session) {
|
||||
try {
|
||||
logger.debug('tldraw-context', '📥 Loading snapshot into editor');
|
||||
loadSnapshot(store, savedSnapshot);
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
} catch (error) {
|
||||
logger.error('tldraw-context', '❌ Failed to load snapshot:', error);
|
||||
store.clear();
|
||||
setLoadingState({ status: 'error', error: 'Failed to load snapshot' });
|
||||
}
|
||||
} else {
|
||||
logger.debug('tldraw-context', '⚠️ No valid snapshot found in local storage');
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('tldraw-context', '❌ Error handling local snapshot:', error);
|
||||
setLoadingState({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}, [sharedStore]);
|
||||
|
||||
const togglePresentationMode = useCallback((editor?: Editor) => {
|
||||
logger.debug('tldraw-context', '🔄 Toggling presentation mode');
|
||||
|
||||
setPresentationMode(prev => {
|
||||
const newValue = !prev;
|
||||
storageService.set(StorageKeys.PRESENTATION_MODE, newValue);
|
||||
|
||||
if (newValue && editor) {
|
||||
// Starting presentation mode
|
||||
logger.info('presentation', '🎥 Initializing presentation service');
|
||||
const service = new PresentationService(editor);
|
||||
setPresentationService(service);
|
||||
service.startPresentationMode();
|
||||
} else if (!newValue && presentationService) {
|
||||
// Stopping presentation mode
|
||||
logger.info('presentation', '🛑 Stopping presentation service');
|
||||
presentationService.stopPresentationMode();
|
||||
setPresentationService(null);
|
||||
}
|
||||
|
||||
return newValue;
|
||||
});
|
||||
}, [presentationService]);
|
||||
|
||||
return (
|
||||
<TLDrawContext.Provider
|
||||
value={{
|
||||
tldrawPreferences,
|
||||
tldrawUserFilePath,
|
||||
localSnapshot,
|
||||
presentationMode,
|
||||
sharedStore,
|
||||
connectionStatus,
|
||||
presentationService,
|
||||
setTldrawPreferences,
|
||||
setTldrawUserFilePath,
|
||||
handleLocalSnapshot,
|
||||
togglePresentationMode,
|
||||
initializePreferences,
|
||||
setSharedStore,
|
||||
setConnectionStatus
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TLDrawContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTLDraw = () => useContext(TLDrawContext);
|
||||
191
src/contexts/UserContext.tsx
Normal file
191
src/contexts/UserContext.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { supabase } from '../supabaseClient';
|
||||
import { logger } from '../debugConfig';
|
||||
import { CCUser, CCUserMetadata } from '../services/auth/authService';
|
||||
import { UserPreferences } from '../services/auth/profileService';
|
||||
import { DatabaseNameService } from '../services/graph/databaseNameService';
|
||||
|
||||
export interface UserContextType {
|
||||
user: CCUser | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
profile: CCUser | null;
|
||||
preferences: UserPreferences;
|
||||
isMobile: boolean;
|
||||
isInitialized: boolean;
|
||||
updateProfile: (updates: Partial<CCUser>) => Promise<void>;
|
||||
updatePreferences: (updates: Partial<UserPreferences>) => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const UserContext = createContext<UserContextType>({
|
||||
user: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
profile: null,
|
||||
preferences: {},
|
||||
isMobile: false,
|
||||
isInitialized: false,
|
||||
updateProfile: async () => {},
|
||||
updatePreferences: async () => {},
|
||||
clearError: () => {}
|
||||
});
|
||||
|
||||
export function UserProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user] = useState<CCUser | null>(null);
|
||||
const [profile, setProfile] = useState<CCUser | null>(null);
|
||||
const [preferences, setPreferences] = useState<UserPreferences>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isMobile] = useState(window.innerWidth <= 768);
|
||||
|
||||
useEffect(() => {
|
||||
const loadUserProfile = async () => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
setProfile(null);
|
||||
setLoading(false);
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const metadata = user.user_metadata as CCUserMetadata;
|
||||
const userDbName = DatabaseNameService.getUserPrivateDB(metadata.user_type || '', metadata.username || '');
|
||||
const schoolDbName = DatabaseNameService.getDevelopmentSchoolDB();
|
||||
|
||||
const userProfile: CCUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
user_type: metadata.user_type || '',
|
||||
username: metadata.username || '',
|
||||
display_name: metadata.display_name || '',
|
||||
user_db_name: userDbName,
|
||||
school_db_name: schoolDbName,
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at
|
||||
};
|
||||
|
||||
setProfile(userProfile);
|
||||
|
||||
logger.debug('user-context', '✅ User profile loaded', {
|
||||
userId: userProfile.id,
|
||||
userType: userProfile.user_type,
|
||||
username: userProfile.username,
|
||||
userDbName: userProfile.user_db_name,
|
||||
schoolDbName: userProfile.school_db_name
|
||||
});
|
||||
|
||||
// Load preferences from profile data
|
||||
setPreferences({
|
||||
theme: data.theme || 'system',
|
||||
notifications: data.notifications_enabled || false
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('user-context', '❌ Failed to load user profile', { error });
|
||||
setError(error instanceof Error ? error : new Error('Failed to load user profile'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadUserProfile();
|
||||
}, []);
|
||||
|
||||
const updateProfile = async (updates: Partial<CCUser>) => {
|
||||
if (!user?.id || !profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
...updates,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', user.id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setProfile(prev => prev ? { ...prev, ...updates } : null);
|
||||
logger.info('user-context', '✅ Profile updated successfully');
|
||||
} catch (error) {
|
||||
logger.error('user-context', '❌ Failed to update profile', { error });
|
||||
setError(error instanceof Error ? error : new Error('Failed to update profile'));
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePreferences = async (updates: Partial<UserPreferences>) => {
|
||||
if (!user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const newPreferences = { ...preferences, ...updates };
|
||||
setPreferences(newPreferences);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
preferences: newPreferences,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', user.id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info('user-context', '✅ Preferences updated successfully');
|
||||
} catch (error) {
|
||||
logger.error('user-context', '❌ Failed to update preferences', { error });
|
||||
setError(error instanceof Error ? error : new Error('Failed to update preferences'));
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UserContext.Provider
|
||||
value={{
|
||||
user: profile,
|
||||
loading,
|
||||
error,
|
||||
profile,
|
||||
preferences,
|
||||
isMobile,
|
||||
isInitialized,
|
||||
updateProfile,
|
||||
updatePreferences,
|
||||
clearError: () => setError(null)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useUser = () => useContext(UserContext);
|
||||
342
src/debugConfig.ts
Normal file
342
src/debugConfig.ts
Normal file
@ -0,0 +1,342 @@
|
||||
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
||||
export type LogCategory =
|
||||
| 'app'
|
||||
| 'header'
|
||||
| 'not-found'
|
||||
| 'routing'
|
||||
| 'neo4j-service'
|
||||
| 'site-page'
|
||||
| 'supabase-client'
|
||||
| 'user-page'
|
||||
| 'auth-page'
|
||||
| 'supabase-profile-service'
|
||||
| 'email-signup-form'
|
||||
| 'routes'
|
||||
| 'super-admin-section'
|
||||
| 'tldraw-context'
|
||||
| 'user-context'
|
||||
| 'super-admin-auth-route'
|
||||
| 'admin-page'
|
||||
| 'neo4j-context'
|
||||
| 'auth-context'
|
||||
| 'neo-user-context'
|
||||
| 'neo-institute-context'
|
||||
| 'auth-service'
|
||||
| 'graph-service'
|
||||
| 'registration-service'
|
||||
| 'snapshot-service'
|
||||
| 'shared-store-service'
|
||||
| 'sync-service'
|
||||
| 'state-management'
|
||||
| 'local-store-service'
|
||||
| 'storage-service'
|
||||
| 'school-service'
|
||||
| 'timetable-service'
|
||||
| 'local-storage'
|
||||
| 'single-player-page'
|
||||
| 'multiplayer-page'
|
||||
| 'login-page'
|
||||
| 'signup-page'
|
||||
| 'login-form'
|
||||
| 'dev-page'
|
||||
| 'axios'
|
||||
| 'tldraw-events'
|
||||
| 'user-toolbar'
|
||||
| 'snapshot-toolbar'
|
||||
| 'microphone-state-tool'
|
||||
| 'graph-shape'
|
||||
| 'graph-panel'
|
||||
| 'graph-shape-shared' // For shared graph shape functionality
|
||||
| 'graph-shape-user' // For user node specific functionality
|
||||
| 'graph-shape-teacher' // For teacher node specific functionality
|
||||
| 'graph-shape-student' // For student node specific functionality
|
||||
| 'calendar-shape'
|
||||
| 'calendar'
|
||||
| 'supabase'
|
||||
| 'binding'
|
||||
| 'translation'
|
||||
| 'position'
|
||||
| 'array'
|
||||
| 'shape'
|
||||
| 'baseNodeShapeUtil'
|
||||
| 'general'
|
||||
| 'system'
|
||||
| 'slides-panel'
|
||||
| 'graphStateUtil'
|
||||
| 'navigation' // For slide navigation
|
||||
| 'presentation' // For presentation mode
|
||||
| 'selection' // For slide/slideshow selection
|
||||
| 'camera' // For camera movements
|
||||
| 'tldraw-service' // For tldraw related logs
|
||||
| 'store-service' // For store related logs
|
||||
| 'morphic-page' // For Morphic page related logs
|
||||
| 'share-handler' // For share handler related logs
|
||||
| 'transcription-service' // For transcription service related logs
|
||||
| 'slideshow-helpers' // For slideshow helpers related logs
|
||||
| 'slide-shape' // For slide shape util related logs
|
||||
| 'cc-base-shape-util' // For cc base shape util related logs
|
||||
| 'cc-user-node-shape-util' // For cc user node shape util related logs
|
||||
| 'node-canvas' // For node canvas related logs
|
||||
| 'navigation-service' // For navigation service related logs
|
||||
| 'autosave' // For autosave service related logs
|
||||
| 'cc-exam-marker' // For cc exam marker related logs
|
||||
| 'cc-search' // For cc search related logs
|
||||
| 'cc-web-browser' // For cc web browser related logs
|
||||
| 'cc-node-snapshot-panel' // For cc node snapshot related logs
|
||||
| 'user-neo-db'
|
||||
| 'navigation-queue-service' // For navigation queue service related logs
|
||||
| 'editor-state' // For editor state related logs
|
||||
| 'neo-shape-service' // For neo shape service related logs
|
||||
// New navigation-specific categories
|
||||
| 'navigation-context' // Context switching and state
|
||||
| 'navigation-history' // History management
|
||||
| 'navigation-ui' // UI interactions in navigation
|
||||
| 'navigation-store' // Navigation store updates
|
||||
| 'navigation-queue' // Navigation queue operations
|
||||
| 'navigation-state' // Navigation state changes
|
||||
| 'context-switch' // Context switching operations
|
||||
| 'history-management' // History stack operations
|
||||
| 'node-navigation' // Node-specific navigation
|
||||
| 'navigation-panel' // Navigation panel related logs
|
||||
| 'auth'
|
||||
| 'school-context'
|
||||
| 'database-name-service'
|
||||
| 'tldraw'
|
||||
| 'websocket'
|
||||
| 'app'
|
||||
| 'storage-service'
|
||||
| 'routing'
|
||||
| 'auth-service'
|
||||
| 'user-context'
|
||||
| 'neo-user-context'
|
||||
| 'neo-institute-context';
|
||||
|
||||
interface LogConfig {
|
||||
enabled: boolean; // Master switch to turn logging on/off
|
||||
level: LogLevel; // Current log level
|
||||
categories: LogCategory[]; // Which categories to show
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
error: 0, // Always shown if enabled
|
||||
warn: 1, // Shows warns and errors
|
||||
info: 2, // Shows info, warns, and errors
|
||||
debug: 3, // Shows debug and above
|
||||
trace: 4, // Shows everything
|
||||
};
|
||||
|
||||
class DebugLogger {
|
||||
private config: LogConfig = {
|
||||
enabled: true,
|
||||
level: 'debug',
|
||||
categories: [
|
||||
'system',
|
||||
'navigation',
|
||||
'presentation',
|
||||
'selection',
|
||||
'camera',
|
||||
'binding',
|
||||
'shape',
|
||||
'tldraw-service',
|
||||
],
|
||||
};
|
||||
|
||||
setConfig(config: Partial<LogConfig>) {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel, category: LogCategory): boolean {
|
||||
return (
|
||||
this.config.enabled &&
|
||||
LOG_LEVELS[level] <= LOG_LEVELS[this.config.level] &&
|
||||
this.config.categories.includes(category)
|
||||
);
|
||||
}
|
||||
|
||||
log(level: LogLevel, category: LogCategory, message: string, data?: unknown) {
|
||||
if (!this.shouldLog(level, category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const levelEmojis: Record<LogLevel, string> = {
|
||||
error: '🔴', // Red circle for errors
|
||||
warn: '⚠️', // Warning symbol
|
||||
info: 'ℹ️', // Information symbol
|
||||
debug: '🔧', // Wrench for debug
|
||||
trace: '🔍', // Magnifying glass for trace
|
||||
};
|
||||
|
||||
const prefix = `${levelEmojis[level]} [${category}]`;
|
||||
|
||||
// Use appropriate console method based on level
|
||||
switch (level) {
|
||||
case 'error':
|
||||
if (data) {
|
||||
console.error(`${prefix} ${message}`, data);
|
||||
} else {
|
||||
console.error(`${prefix} ${message}`);
|
||||
}
|
||||
break;
|
||||
case 'warn':
|
||||
if (data) {
|
||||
console.warn(`${prefix} ${message}`, data);
|
||||
} else {
|
||||
console.warn(`${prefix} ${message}`);
|
||||
}
|
||||
break;
|
||||
case 'info':
|
||||
if (data) {
|
||||
console.info(`${prefix} ${message}`, data);
|
||||
} else {
|
||||
console.info(`${prefix} ${message}`);
|
||||
}
|
||||
break;
|
||||
case 'debug':
|
||||
if (data) {
|
||||
console.debug(`${prefix} ${message}`, data);
|
||||
} else {
|
||||
console.debug(`${prefix} ${message}`);
|
||||
}
|
||||
break;
|
||||
case 'trace':
|
||||
if (data) {
|
||||
console.trace(`${prefix} ${message}`, data);
|
||||
} else {
|
||||
console.trace(`${prefix} ${message}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
error(category: LogCategory, message: string, data?: unknown) {
|
||||
this.log('error', category, message, data);
|
||||
}
|
||||
|
||||
warn(category: LogCategory, message: string, data?: unknown) {
|
||||
this.log('warn', category, message, data);
|
||||
}
|
||||
|
||||
info(category: LogCategory, message: string, data?: unknown) {
|
||||
this.log('info', category, message, data);
|
||||
}
|
||||
|
||||
debug(category: LogCategory, message: string, data?: unknown) {
|
||||
this.log('debug', category, message, data);
|
||||
}
|
||||
|
||||
trace(category: LogCategory, message: string, data?: unknown) {
|
||||
this.log('trace', category, message, data);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new DebugLogger();
|
||||
|
||||
logger.setConfig({
|
||||
enabled: true,
|
||||
level: 'debug',
|
||||
categories: [
|
||||
'app',
|
||||
'header',
|
||||
'routing',
|
||||
'neo4j-context',
|
||||
'auth-context',
|
||||
'auth-service',
|
||||
'state-management',
|
||||
'local-storage',
|
||||
'axios',
|
||||
'system',
|
||||
'navigation',
|
||||
'calendar',
|
||||
'presentation',
|
||||
'selection',
|
||||
'camera',
|
||||
'binding',
|
||||
'shape',
|
||||
'tldraw-service',
|
||||
'tldraw-events',
|
||||
'signup-page',
|
||||
'timetable-service',
|
||||
'dev-page',
|
||||
'super-admin-auth-route',
|
||||
'admin-page',
|
||||
'storage-service',
|
||||
'user-context',
|
||||
'login-form',
|
||||
'super-admin-section',
|
||||
'routes',
|
||||
'neo4j-service',
|
||||
'supabase-client',
|
||||
'user-page',
|
||||
'site-page',
|
||||
'auth-page',
|
||||
'email-signup-form',
|
||||
'supabase-profile-service',
|
||||
'multiplayer-page',
|
||||
'snapshot-service',
|
||||
'sync-service',
|
||||
'slides-panel',
|
||||
'local-store-service',
|
||||
'shared-store-service',
|
||||
'single-player-page',
|
||||
'user-toolbar',
|
||||
'registration-service',
|
||||
'graph-service',
|
||||
'graph-shape',
|
||||
'calendar-shape',
|
||||
'snapshot-toolbar',
|
||||
'graphStateUtil',
|
||||
'baseNodeShapeUtil',
|
||||
'school-service',
|
||||
'microphone-state-tool',
|
||||
'store-service',
|
||||
'morphic-page',
|
||||
'not-found',
|
||||
'share-handler',
|
||||
'transcription-service',
|
||||
'slideshow-helpers',
|
||||
'slide-shape',
|
||||
'graph-panel',
|
||||
'cc-user-node-shape-util',
|
||||
'cc-base-shape-util',
|
||||
'node-canvas',
|
||||
'navigation-service',
|
||||
'autosave',
|
||||
'cc-exam-marker',
|
||||
'cc-search',
|
||||
'cc-web-browser',
|
||||
'neo-user-context',
|
||||
'neo-institute-context',
|
||||
'cc-node-snapshot-panel',
|
||||
'user-neo-db',
|
||||
'navigation-queue-service',
|
||||
'editor-state',
|
||||
'neo-shape-service',
|
||||
// Add new navigation categories
|
||||
'navigation-context',
|
||||
'navigation-history',
|
||||
'navigation-ui',
|
||||
'navigation-store',
|
||||
'navigation-queue',
|
||||
'navigation-state',
|
||||
'context-switch',
|
||||
'history-management',
|
||||
'node-navigation',
|
||||
'navigation-panel',
|
||||
'auth',
|
||||
'school-context',
|
||||
'database-name-service',
|
||||
'tldraw',
|
||||
'websocket',
|
||||
'app',
|
||||
'auth-service',
|
||||
'storage-service',
|
||||
'routing',
|
||||
'auth-service',
|
||||
'user-context',
|
||||
'neo-user-context',
|
||||
'neo-institute-context',
|
||||
],
|
||||
});
|
||||
|
||||
export default logger;
|
||||
507
src/index.css
Normal file
507
src/index.css
Normal file
@ -0,0 +1,507 @@
|
||||
/* src/index.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Base styles */
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overscroll-behavior: none;
|
||||
touch-action: none;
|
||||
min-height: 100vh;
|
||||
/* mobile viewport bug fix */
|
||||
min-height: -webkit-fill-available;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Container styles */
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Typography styles */
|
||||
@media (max-width: 600px) {
|
||||
.MuiTypography-h2 {
|
||||
font-size: 2rem !important;
|
||||
line-height: 2.5rem !important;
|
||||
margin-bottom: 16px !important;
|
||||
padding: 0 16px !important;
|
||||
word-break: break-word !important;
|
||||
}
|
||||
|
||||
.MuiTypography-h5 {
|
||||
font-size: 1.25rem !important;
|
||||
line-height: 1.75rem !important;
|
||||
margin-bottom: 16px !important;
|
||||
padding: 0 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form and input styles */
|
||||
@media (max-width: 600px) {
|
||||
.MuiContainer-root {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
.MuiGrid-container {
|
||||
gap: 16px !important;
|
||||
padding: 0 16px !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.MuiGrid-item {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
flex-basis: 100% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.MuiTextField-root {
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.MuiButton-root {
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add styles for wider screens */
|
||||
@media (min-width: 601px) {
|
||||
.MuiTextField-root {
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.MuiButton-root {
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add this after your existing styles */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.login-buttons-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.login-buttons-container {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-form-container .MuiTextField-root,
|
||||
.login-form-container .MuiButton-root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Adjust spacing for mobile */
|
||||
@media (max-width: 600px) {
|
||||
.login-form-container {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-section {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-section-header {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.login-role-header {
|
||||
font-weight: 600 !important;
|
||||
color: #1976d2 !important;
|
||||
margin-bottom: 24px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-bottom: 2px solid #1976d2 !important;
|
||||
width: fit-content !important;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.login-role-header {
|
||||
font-size: 1.75rem !important;
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Calendar styles */
|
||||
.fc-timegrid-slot {
|
||||
height: 2em !important;
|
||||
}
|
||||
|
||||
.fc-timegrid-event {
|
||||
min-height: 2.5em !important;
|
||||
}
|
||||
|
||||
.fc-timegrid-slot-label {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.fc-event {
|
||||
cursor: pointer;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.fc-event:hover {
|
||||
filter: brightness(90%);
|
||||
}
|
||||
|
||||
.fc-event-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.custom-event-content > div {
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
max-height: 1.5em;
|
||||
}
|
||||
|
||||
.custom-event-content > div[style*="display: none"] {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Custom button styling */
|
||||
.fc-filterClassesButton-button {
|
||||
background-color: #4CAF50;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
margin: 4px 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Modal styling */
|
||||
.class-filter-modal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 9999;
|
||||
max-width: 90%;
|
||||
width: 400px;
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
|
||||
.class-filter-modal h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.class-filter-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.class-filter-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.class-filter-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.class-filter-button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.class-filter-button .checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid currentColor;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 10px;
|
||||
/* Add this to ensure the checkbox is visible */
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.class-filter-button .checkbox svg {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.class-filter-button span {
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background-color: #2C3E50;
|
||||
border: 1px solid #2C3E50;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
margin: 4px 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background-color: #34495E;
|
||||
border-color: #34495E;
|
||||
}
|
||||
|
||||
.close-button-container {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.event-details-modal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 9999;
|
||||
max-width: 90%;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.event-details-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.open-tldraw-button {
|
||||
background-color: #4CAF50;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
margin: 4px 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.open-tldraw-button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
.open-tldraw-button svg {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.event-dropdown {
|
||||
position: absolute;
|
||||
z-index: 1100; /* Higher value to ensure it appears above events */
|
||||
right: -5px;
|
||||
top: 25px;
|
||||
background-color: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
min-width: 180px;
|
||||
max-width: 250px;
|
||||
min-height: 185px; /* Ensure a minimum height */
|
||||
max-height: 200px;
|
||||
padding: 5px;
|
||||
overflow-y: scroll; /* Always show scrollbar */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.event-dropdown div {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: #000;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-dropdown div:not(:last-child) {
|
||||
border-bottom: 1px solid #eee; /* Add separators between items */
|
||||
}
|
||||
|
||||
.event-dropdown div:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
/* Styling for webkit browsers */
|
||||
.event-dropdown::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.event-dropdown::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.event-dropdown::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.event-dropdown::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Styling for Firefox */
|
||||
.event-dropdown {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #888 #f1f1f1;
|
||||
}
|
||||
|
||||
/* Ensure the dropdown is on top of other elements */
|
||||
.fc-event-main {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Style for the ellipsis icon */
|
||||
.custom-event-content .fa-ellipsis-v {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.3s ease;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.custom-event-content .fa-ellipsis-v:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Add this new style to ensure the event content doesn't overflow */
|
||||
.fc-event-main-frame {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* View Toggle Modal styles */
|
||||
.view-toggle-modal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 9999;
|
||||
max-width: 90%;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.view-toggle-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.view-toggle-modal h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.view-toggle-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.view-toggle-button {
|
||||
background-color: #4CAF50;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
margin: 4px 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.view-toggle-button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
32
src/main.tsx
Normal file
32
src/main.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { initializeApp } from './services/initService';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const isDevMode = import.meta.env.VITE_DEV === 'true';
|
||||
|
||||
// Initialize the app before rendering
|
||||
initializeApp();
|
||||
|
||||
// In development, React.StrictMode causes components to render twice
|
||||
// This is intentional and helps catch certain bugs, but can be disabled
|
||||
// if double-mounting is causing issues with initialization
|
||||
const AppWithStrictMode = isDevMode ? (
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
) : (
|
||||
<App />
|
||||
);
|
||||
|
||||
// Register SW only if we're on the app subdomain
|
||||
if ('serviceWorker' in navigator && window.location.hostname.startsWith('app.')) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(console.error);
|
||||
});
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
AppWithStrictMode
|
||||
);
|
||||
BIN
src/pages/.DS_Store
vendored
Normal file
BIN
src/pages/.DS_Store
vendored
Normal file
Binary file not shown.
305
src/pages/Header.tsx
Normal file
305
src/pages/Header.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
IconButton,
|
||||
Box,
|
||||
useTheme,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import {
|
||||
Login as LoginIcon,
|
||||
Logout as LogoutIcon,
|
||||
School as TeacherIcon,
|
||||
Person as StudentIcon,
|
||||
Dashboard as TLDrawDevIcon,
|
||||
Build as DevToolsIcon,
|
||||
Groups as MultiplayerIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
Assignment as TeacherPlannerIcon,
|
||||
AssignmentTurnedIn as ExamMarkerIcon,
|
||||
Settings as SettingsIcon,
|
||||
Search as SearchIcon,
|
||||
AdminPanelSettings as AdminIcon
|
||||
} from '@mui/icons-material';
|
||||
import { HEADER_HEIGHT } from './Layout';
|
||||
import { logger } from '../debugConfig';
|
||||
import { GraphNavigator } from '../components/navigation/GraphNavigator';
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, signOut } = useAuth();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(!!user);
|
||||
const isAdmin = user?.email === import.meta.env.VITE_SUPER_ADMIN_EMAIL;
|
||||
const showGraphNavigation = location.pathname === '/single-player';
|
||||
|
||||
// Update authentication state whenever user changes
|
||||
useEffect(() => {
|
||||
const newAuthState = !!user;
|
||||
setIsAuthenticated(newAuthState);
|
||||
logger.debug('user-context', '🔄 User state changed in header', {
|
||||
hasUser: newAuthState,
|
||||
userId: user?.id,
|
||||
userEmail: user?.email,
|
||||
userState: newAuthState ? 'logged-in' : 'logged-out',
|
||||
isAdmin
|
||||
});
|
||||
}, [user, isAdmin]);
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleNavigation = (path: string) => {
|
||||
navigate(path);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleSignupNavigation = (role: 'teacher' | 'student') => {
|
||||
navigate('/signup', { state: { role } });
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
logger.debug('auth-service', '🔄 Signing out user', { userId: user?.id });
|
||||
await signOut();
|
||||
// Clear local state immediately
|
||||
setIsAuthenticated(false);
|
||||
setAnchorEl(null);
|
||||
logger.debug('auth-service', '✅ User signed out');
|
||||
} catch (error) {
|
||||
logger.error('auth-service', '❌ Error signing out:', error);
|
||||
console.error('Error signing out:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
height: `${HEADER_HEIGHT}px`,
|
||||
bgcolor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
boxShadow: 1
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: `${HEADER_HEIGHT}px !important`,
|
||||
height: `${HEADER_HEIGHT}px`,
|
||||
gap: 2,
|
||||
px: { xs: 1, sm: 2 }
|
||||
}}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
minWidth: { xs: 'auto', sm: '200px' }
|
||||
}}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="div"
|
||||
className="app-title"
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
color: theme.palette.text.primary,
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main
|
||||
},
|
||||
fontSize: { xs: '1rem', sm: '1.25rem' }
|
||||
}}
|
||||
onClick={() => navigate(isAuthenticated ? '/single-player' : '/')}
|
||||
>
|
||||
ClassroomCopilot
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
visibility: showGraphNavigation ? 'visible' : 'hidden',
|
||||
width: {
|
||||
xs: 'calc(100% - 160px)', // More space for menu and title
|
||||
sm: 'calc(100% - 200px)', // Standard spacing
|
||||
md: 'auto' // Full width on medium and up
|
||||
},
|
||||
maxWidth: '800px',
|
||||
'& .navigation-controls': {
|
||||
display: { xs: 'none', sm: 'flex' }
|
||||
},
|
||||
'& .context-section': {
|
||||
display: { xs: 'none', md: 'flex' }
|
||||
},
|
||||
'& .context-toggle': {
|
||||
display: 'flex' // Always show the profile/institute toggle
|
||||
}
|
||||
}}>
|
||||
<GraphNavigator />
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
minWidth: { xs: 'auto', sm: '200px' },
|
||||
ml: 'auto'
|
||||
}}>
|
||||
<IconButton
|
||||
className="menu-button"
|
||||
color="inherit"
|
||||
onClick={handleMenuOpen}
|
||||
edge="end"
|
||||
sx={{
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.action.hover
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
slotProps={{
|
||||
paper: {
|
||||
elevation: 3,
|
||||
sx: {
|
||||
bgcolor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
minWidth: '240px'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isAuthenticated ? [
|
||||
// Development Tools Section
|
||||
<MenuItem key="tldraw" onClick={() => handleNavigation('/tldraw-dev')}>
|
||||
<ListItemIcon>
|
||||
<TLDrawDevIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="TLDraw Dev" />
|
||||
</MenuItem>,
|
||||
<MenuItem key="dev" onClick={() => handleNavigation('/dev')}>
|
||||
<ListItemIcon>
|
||||
<DevToolsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Dev Tools" />
|
||||
</MenuItem>,
|
||||
<Divider key="dev-divider" />,
|
||||
|
||||
// Main Features Section
|
||||
<MenuItem key="multiplayer" onClick={() => handleNavigation('/multiplayer')}>
|
||||
<ListItemIcon>
|
||||
<MultiplayerIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Multiplayer" />
|
||||
</MenuItem>,
|
||||
<MenuItem key="calendar" onClick={() => handleNavigation('/calendar')}>
|
||||
<ListItemIcon>
|
||||
<CalendarIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Calendar" />
|
||||
</MenuItem>,
|
||||
<MenuItem key="planner" onClick={() => handleNavigation('/teacher-planner')}>
|
||||
<ListItemIcon>
|
||||
<TeacherPlannerIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Teacher Planner" />
|
||||
</MenuItem>,
|
||||
<MenuItem key="exam" onClick={() => handleNavigation('/exam-marker')}>
|
||||
<ListItemIcon>
|
||||
<ExamMarkerIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Exam Marker" />
|
||||
</MenuItem>,
|
||||
<Divider key="features-divider" />,
|
||||
|
||||
// Utilities Section
|
||||
<MenuItem key="settings" onClick={() => handleNavigation('/settings')}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Settings" />
|
||||
</MenuItem>,
|
||||
<MenuItem key="search" onClick={() => handleNavigation('/search')}>
|
||||
<ListItemIcon>
|
||||
<SearchIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Search" />
|
||||
</MenuItem>,
|
||||
|
||||
// Admin Section
|
||||
...(isAdmin ? [
|
||||
<Divider key="admin-divider" />,
|
||||
<MenuItem key="admin" onClick={() => handleNavigation('/admin')}>
|
||||
<ListItemIcon>
|
||||
<AdminIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Admin Dashboard" />
|
||||
</MenuItem>
|
||||
] : []),
|
||||
|
||||
// Authentication Section
|
||||
<Divider key="auth-divider" />,
|
||||
<MenuItem key="signout" onClick={handleSignOut}>
|
||||
<ListItemIcon>
|
||||
<LogoutIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Sign Out" />
|
||||
</MenuItem>
|
||||
] : [
|
||||
// Authentication Section for Non-authenticated Users
|
||||
<MenuItem key="signin" onClick={() => handleNavigation('/login')}>
|
||||
<ListItemIcon>
|
||||
<LoginIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Sign In" />
|
||||
</MenuItem>,
|
||||
<Divider key="signup-divider" />,
|
||||
<MenuItem key="teacher-signup" onClick={() => handleSignupNavigation('teacher')}>
|
||||
<ListItemIcon>
|
||||
<TeacherIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Sign up as Teacher"
|
||||
secondary="Create a teacher account"
|
||||
/>
|
||||
</MenuItem>,
|
||||
<MenuItem key="student-signup" onClick={() => handleSignupNavigation('student')}>
|
||||
<ListItemIcon>
|
||||
<StudentIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Sign up as Student"
|
||||
secondary="Create a student account"
|
||||
/>
|
||||
</MenuItem>
|
||||
]}
|
||||
</Menu>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
25
src/pages/Layout.tsx
Normal file
25
src/pages/Layout.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import Header from './Header';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const HEADER_HEIGHT = 40; // in pixels
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<main className="main-content" style={{
|
||||
paddingTop: `${HEADER_HEIGHT}px`,
|
||||
height: '100vh',
|
||||
width: '100%'
|
||||
}}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
51
src/pages/NotFoundPublic.tsx
Normal file
51
src/pages/NotFoundPublic.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Box, Typography, Button, Container, useTheme } from "@mui/material";
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import { logger } from '../debugConfig';
|
||||
|
||||
function NotFoundPublic() {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleReturn = () => {
|
||||
logger.debug('not-found', '🔄 Public user navigating to home');
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
textAlign: 'center',
|
||||
gap: 3
|
||||
}}
|
||||
>
|
||||
<ErrorOutlineIcon sx={{ fontSize: 60, color: theme.palette.error.main }} />
|
||||
<Typography variant="h2" component="h1" gutterBottom>
|
||||
404
|
||||
</Typography>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Page Not Found
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
onClick={handleReturn}
|
||||
>
|
||||
Return to Canvas
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFoundPublic;
|
||||
23
src/pages/auth/AuthCallback.tsx
Normal file
23
src/pages/auth/AuthCallback.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect to login page since we're no longer supporting external authentication
|
||||
navigate('/login');
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-md space-y-8 rounded-lg bg-white p-6 shadow-lg">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold text-gray-900">Redirecting...</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">Please wait while we redirect you to the login page...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
70
src/pages/auth/EmailLoginForm.tsx
Normal file
70
src/pages/auth/EmailLoginForm.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TextField, Button, Box, Alert } from '@mui/material';
|
||||
import { EmailCredentials } from '../../services/auth/authService';
|
||||
|
||||
interface EmailLoginFormProps {
|
||||
role: 'email_teacher' | 'email_student';
|
||||
onSubmit: (credentials: EmailCredentials) => Promise<void>;
|
||||
}
|
||||
|
||||
export const EmailLoginForm: React.FC<EmailLoginFormProps> = ({ role, onSubmit }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await onSubmit({ email, password, role });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to login');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ width: '100%' }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
margin="normal"
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
margin="normal"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
139
src/pages/auth/EmailSignupForm.tsx
Normal file
139
src/pages/auth/EmailSignupForm.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TextField, Button, Box, Alert, Stack } from '@mui/material';
|
||||
import { EmailCredentials } from '../../services/auth/authService';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
interface EmailSignupFormProps {
|
||||
role: 'email_teacher' | 'email_student';
|
||||
onSubmit: (
|
||||
credentials: EmailCredentials,
|
||||
displayName: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export const EmailSignupForm: React.FC<EmailSignupFormProps> = ({
|
||||
role,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const validateForm = () => {
|
||||
if (!email || !password || !confirmPassword || !displayName) {
|
||||
return 'All fields are required';
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return 'Password must be at least 6 characters';
|
||||
}
|
||||
if (!email.includes('@')) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
logger.debug('email-signup-form', '🔄 Submitting signup form', {
|
||||
email,
|
||||
role,
|
||||
hasDisplayName: !!displayName,
|
||||
});
|
||||
|
||||
await onSubmit({ email, password, role }, displayName);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'An error occurred during signup'
|
||||
);
|
||||
logger.error(
|
||||
'email-signup-form',
|
||||
'❌ Signup form submission failed',
|
||||
err
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="displayName"
|
||||
label="Display Name"
|
||||
name="displayName"
|
||||
autoComplete="name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label="Email Address"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
name="confirmPassword"
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
disabled={
|
||||
isLoading || !email || !password || !confirmPassword || !displayName
|
||||
}
|
||||
>
|
||||
{isLoading ? 'Signing up...' : 'Sign Up'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
113
src/pages/auth/adminPage.tsx
Normal file
113
src/pages/auth/adminPage.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Container, Box, Typography, Tabs, Tab, Paper, Button } from '@mui/material';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { SchoolUploadSection } from '../components/admin/SchoolUploadSection';
|
||||
import { TimetableUploadSection } from '../components/admin/TimetableUploadSection';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const SUPER_ADMIN_EMAIL = import.meta.env.VITE_SUPER_ADMIN_EMAIL;
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function TabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
return (
|
||||
<div hidden={value !== index} {...other}>
|
||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
const isSuperAdmin = user?.email === SUPER_ADMIN_EMAIL;
|
||||
|
||||
logger.debug('admin-page', '🔍 Super admin check', {
|
||||
userEmail: user?.email,
|
||||
superAdminEmail: SUPER_ADMIN_EMAIL,
|
||||
isMatch: isSuperAdmin
|
||||
});
|
||||
|
||||
const handleReturn = () => {
|
||||
logger.info('admin-page', '🏠 Returning to single player page');
|
||||
navigate('/single-player');
|
||||
};
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
logger.error('admin-page', '🚫 Unauthorized access attempt', {
|
||||
userEmail: user?.email,
|
||||
requiredEmail: SUPER_ADMIN_EMAIL
|
||||
});
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h4" color="error">Unauthorized Access</Typography>
|
||||
<Button
|
||||
onClick={handleReturn}
|
||||
variant="contained"
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Return to User Page
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2
|
||||
}}>
|
||||
<Typography variant="h4">Admin Dashboard</Typography>
|
||||
<Button
|
||||
onClick={handleReturn}
|
||||
variant="outlined"
|
||||
>
|
||||
Return to User Page
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ width: '100%', mb: 2 }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange}>
|
||||
<Tab label="System Settings" />
|
||||
<Tab label="Database Management" />
|
||||
<Tab label="User Management" />
|
||||
<Tab label="Schools" />
|
||||
<Tab label="Timetables" />
|
||||
</Tabs>
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<Typography variant="h6">System Settings</Typography>
|
||||
{/* Add system settings components here */}
|
||||
</TabPanel>
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Typography variant="h6">Database Management</Typography>
|
||||
{/* Add database management components here */}
|
||||
</TabPanel>
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<Typography variant="h6">User Management</Typography>
|
||||
{/* Add user management components here */}
|
||||
</TabPanel>
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<SchoolUploadSection />
|
||||
</TabPanel>
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
<TimetableUploadSection />
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
75
src/pages/auth/loginPage.tsx
Normal file
75
src/pages/auth/loginPage.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Container, Typography, Box, Alert } from '@mui/material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { EmailLoginForm } from './EmailLoginForm';
|
||||
import { EmailCredentials } from '../../services/auth/authService';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user, signIn } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
logger.debug('login-page', '🔍 Login page loaded', {
|
||||
hasUser: !!user
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
navigate('/single-player');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
const handleLogin = async (credentials: EmailCredentials) => {
|
||||
try {
|
||||
setError(null);
|
||||
await signIn(credentials.email, credentials.password);
|
||||
navigate('/single-player');
|
||||
} catch (error) {
|
||||
logger.error('login-page', '❌ Login failed', error);
|
||||
setError(error instanceof Error ? error.message : 'Login failed');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
gap: 4
|
||||
}}
|
||||
>
|
||||
<Typography variant="h2" component="h1" gutterBottom>
|
||||
ClassroomCopilot.ai
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Login
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ width: '100%', maxWidth: 400 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ width: '100%', maxWidth: 400 }}>
|
||||
<EmailLoginForm
|
||||
role="email_teacher"
|
||||
onSubmit={handleLogin}
|
||||
/>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
120
src/pages/auth/signupPage.tsx
Normal file
120
src/pages/auth/signupPage.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Button,
|
||||
Stack,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { EmailSignupForm } from './EmailSignupForm';
|
||||
import { EmailCredentials } from '../../services/auth/authService';
|
||||
import { RegistrationService } from '../../services/auth/registrationService';
|
||||
import { logger } from '../../debugConfig';
|
||||
import MicrosoftIcon from '@mui/icons-material/Microsoft';
|
||||
|
||||
const SignupPage: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const registrationService = RegistrationService.getInstance();
|
||||
|
||||
// Get role from location state, default to teacher
|
||||
const { role = 'teacher' } = location.state || {};
|
||||
const roleDisplay = role === 'teacher' ? 'Teacher' : 'Student';
|
||||
|
||||
logger.debug('signup-page', '🔍 Signup page loaded', {
|
||||
role,
|
||||
hasUser: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
navigate('/single-player');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
const handleSignup = async (
|
||||
credentials: EmailCredentials,
|
||||
displayName: string
|
||||
) => {
|
||||
try {
|
||||
const result = await registrationService.register(
|
||||
credentials,
|
||||
displayName
|
||||
);
|
||||
if (result.user) {
|
||||
navigate('/single-player');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('signup-page', '❌ Registration failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const switchRole = () => {
|
||||
navigate('/signup', {
|
||||
state: { role: role === 'teacher' ? 'student' : 'teacher' },
|
||||
});
|
||||
};
|
||||
|
||||
if (user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h2" component="h1" gutterBottom>
|
||||
ClassroomCopilot.ai
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{roleDisplay} Sign Up
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ width: '100%', maxWidth: 400 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<MicrosoftIcon />}
|
||||
onClick={() => {}}
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
Sign up with Microsoft
|
||||
</Button>
|
||||
|
||||
<Divider sx={{ my: 2 }}>OR</Divider>
|
||||
|
||||
<EmailSignupForm
|
||||
role={`email_${role}` as 'email_teacher' | 'email_student'}
|
||||
onSubmit={handleSignup}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
justifyContent="center"
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
<Button variant="text" onClick={switchRole}>
|
||||
Switch to {role === 'teacher' ? 'Student' : 'Teacher'} Sign Up
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignupPage;
|
||||
|
||||
55
src/pages/components/admin/SchoolUploadSection.tsx
Normal file
55
src/pages/components/admin/SchoolUploadSection.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Box, Typography, Alert } from '@mui/material';
|
||||
import { logger } from '../../../debugConfig';
|
||||
import { SchoolNeoDBService } from '../../../services/graph/schoolNeoDBService';
|
||||
|
||||
export const SchoolUploadSection = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleSchoolUpload = async () => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
const result = await SchoolNeoDBService.createSchools();
|
||||
setSuccess(result.message);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to upload school';
|
||||
logger.error('admin-page', '❌ School upload failed:', error);
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Create Schools
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSchoolUpload}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create Schools'}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
78
src/pages/components/admin/TimetableUploadSection.tsx
Normal file
78
src/pages/components/admin/TimetableUploadSection.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Box, Typography, Alert } from '@mui/material';
|
||||
import { useNeoUser } from '../../../contexts/NeoUserContext';
|
||||
import { TimetableNeoDBService } from '../../../services/graph/timetableNeoDBService';
|
||||
import { CCTeacherNodeProps } from '../../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
|
||||
export const TimetableUploadSection = () => {
|
||||
const { userNode, workerNode } = useNeoUser();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleTimetableUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
const result = await TimetableNeoDBService.handleTimetableUpload(
|
||||
event.target.files?.[0],
|
||||
userNode || undefined,
|
||||
workerNode?.nodeData as CCTeacherNodeProps | undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(result.message);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (event.target) {
|
||||
event.target.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Upload Teacher Timetable
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
disabled={isUploading || !workerNode}
|
||||
>
|
||||
{isUploading ? 'Uploading...' : 'Upload Timetable'}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".xlsx"
|
||||
onChange={handleTimetableUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{!workerNode && (
|
||||
<Typography color="error" sx={{ mt: 1 }}>
|
||||
No teacher node found. Please ensure you have the correct permissions.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
45
src/pages/components/auth/LoginForm.tsx
Normal file
45
src/pages/components/auth/LoginForm.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, TextField } from '@mui/material';
|
||||
import { EmailCredentials } from '../../../services/auth/authService';
|
||||
import { logger } from '../../../debugConfig';
|
||||
|
||||
interface LoginFormProps {
|
||||
role: 'email_teacher' | 'email_student';
|
||||
onSubmit: (credentials: EmailCredentials) => Promise<void>;
|
||||
}
|
||||
|
||||
export const LoginForm: React.FC<LoginFormProps> = ({ role, onSubmit }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
logger.debug('login-form', '🔄 Submitting login form', { role });
|
||||
await onSubmit({ email, password, role });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<TextField
|
||||
label="Email"
|
||||
variant="outlined"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
fullWidth
|
||||
autoComplete="new-username"
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
fullWidth
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Button type="submit" variant="contained" fullWidth>
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
23
src/pages/components/auth/SuperAdminSection.tsx
Normal file
23
src/pages/components/auth/SuperAdminSection.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Button } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { logger } from '../../../debugConfig';
|
||||
|
||||
export const SuperAdminSection = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleAdminClick = () => {
|
||||
logger.info('super-admin-section', '🔑 Navigating to admin page');
|
||||
navigate('/admin');
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleAdminClick}
|
||||
variant="contained"
|
||||
color="warning"
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Admin Dashboard
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
12
src/pages/components/common/LoadingSpinner.tsx
Normal file
12
src/pages/components/common/LoadingSpinner.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { CircularProgress, Box } from '@mui/material';
|
||||
|
||||
export const LoadingSpinner = () => (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight="100vh"
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
28
src/pages/morphicPage.tsx
Normal file
28
src/pages/morphicPage.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Container, Typography, CircularProgress } from '@mui/material';
|
||||
import { logger } from '../debugConfig';
|
||||
|
||||
const MorphicPage: React.FC = () => {
|
||||
useEffect(() => {
|
||||
// Redirect to the nginx-handled /morphic URL
|
||||
window.location.href = '/morphic';
|
||||
logger.debug('morphic-page', '🔄 Redirecting to Morphic');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh'
|
||||
}}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Redirecting to Morphic...
|
||||
</Typography>
|
||||
<CircularProgress />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default MorphicPage;
|
||||
67
src/pages/react-flow/teacherPlanner.tsx
Normal file
67
src/pages/react-flow/teacherPlanner.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
MiniMap,
|
||||
Controls,
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Node,
|
||||
Edge,
|
||||
Connection,
|
||||
addEdge,
|
||||
BackgroundVariant
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
const initialNodes: Node[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'input',
|
||||
data: { label: 'Teacher Node' },
|
||||
position: { x: 250, y: 25 },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
data: { label: 'Class Node' },
|
||||
position: { x: 100, y: 125 },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'output',
|
||||
data: { label: 'Student Node' },
|
||||
position: { x: 400, y: 125 },
|
||||
},
|
||||
];
|
||||
|
||||
const initialEdges: Edge[] = [
|
||||
{ id: 'e1-2', source: '1', target: '2' },
|
||||
{ id: 'e1-3', source: '1', target: '3' },
|
||||
];
|
||||
|
||||
export default function TeacherPlanner() {
|
||||
const [nodes, , onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
||||
[setEdges],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100vw', height: '100vh' }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
fitView
|
||||
>
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/pages/searxngPage.tsx
Normal file
30
src/pages/searxngPage.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { HEADER_HEIGHT } from './Layout';
|
||||
|
||||
const SearxngPage: React.FC = () => {
|
||||
return (
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
top: HEADER_HEIGHT,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.default'
|
||||
}}>
|
||||
<iframe
|
||||
src={`${import.meta.env.VITE_FRONTEND_SITE_URL}/searxng-api/`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
display: 'block'
|
||||
}}
|
||||
title="SearXNG Search"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearxngPage;
|
||||
92
src/pages/tldraw/CCExamMarker/AnnotationManager.ts
Normal file
92
src/pages/tldraw/CCExamMarker/AnnotationManager.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { TLShapeId } from '@tldraw/tldraw';
|
||||
|
||||
export interface AnnotationData {
|
||||
studentIndex?: number; // undefined for exam/markscheme annotations
|
||||
pageIndex: number;
|
||||
shapeId: TLShapeId;
|
||||
bounds: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class AnnotationManager {
|
||||
private examAnnotations: Set<TLShapeId> = new Set();
|
||||
private markSchemeAnnotations: Set<TLShapeId> = new Set();
|
||||
private studentAnnotations: Map<number, Set<TLShapeId>> = new Map();
|
||||
private annotationData: Map<TLShapeId, AnnotationData> = new Map();
|
||||
|
||||
addAnnotation(shapeId: TLShapeId, data: AnnotationData) {
|
||||
this.annotationData.set(shapeId, data);
|
||||
|
||||
if (data.studentIndex !== undefined) {
|
||||
// Student response annotation
|
||||
let studentSet = this.studentAnnotations.get(data.studentIndex);
|
||||
if (!studentSet) {
|
||||
studentSet = new Set();
|
||||
this.studentAnnotations.set(data.studentIndex, studentSet);
|
||||
}
|
||||
studentSet.add(shapeId);
|
||||
} else {
|
||||
// Exam or mark scheme annotation
|
||||
if (data.pageIndex < 0) {
|
||||
this.examAnnotations.add(shapeId);
|
||||
} else {
|
||||
this.markSchemeAnnotations.add(shapeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeAnnotation(shapeId: TLShapeId) {
|
||||
const data = this.annotationData.get(shapeId);
|
||||
if (!data) return;
|
||||
|
||||
if (data.studentIndex !== undefined) {
|
||||
const studentSet = this.studentAnnotations.get(data.studentIndex);
|
||||
studentSet?.delete(shapeId);
|
||||
} else {
|
||||
if (data.pageIndex < 0) {
|
||||
this.examAnnotations.delete(shapeId);
|
||||
} else {
|
||||
this.markSchemeAnnotations.delete(shapeId);
|
||||
}
|
||||
}
|
||||
this.annotationData.delete(shapeId);
|
||||
}
|
||||
|
||||
getAnnotationsForStudent(studentIndex: number): TLShapeId[] {
|
||||
return Array.from(this.studentAnnotations.get(studentIndex) || []);
|
||||
}
|
||||
|
||||
getAnnotationsForExam(): TLShapeId[] {
|
||||
return Array.from(this.examAnnotations);
|
||||
}
|
||||
|
||||
getAnnotationsForMarkScheme(): TLShapeId[] {
|
||||
return Array.from(this.markSchemeAnnotations);
|
||||
}
|
||||
|
||||
getAnnotationData(shapeId: TLShapeId): AnnotationData | undefined {
|
||||
return this.annotationData.get(shapeId);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.examAnnotations.clear();
|
||||
this.markSchemeAnnotations.clear();
|
||||
this.studentAnnotations.clear();
|
||||
this.annotationData.clear();
|
||||
}
|
||||
|
||||
// Future transcription support
|
||||
addTranscriptionToAnnotation(shapeId: TLShapeId) {
|
||||
const data = this.annotationData.get(shapeId);
|
||||
if (data) {
|
||||
this.annotationData.set(shapeId, {
|
||||
...data
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
108
src/pages/tldraw/CCExamMarker/CCExamMarker.tsx
Normal file
108
src/pages/tldraw/CCExamMarker/CCExamMarker.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import 'tldraw/tldraw.css';
|
||||
import { CCPdfEditor } from './CCPdfEditor';
|
||||
import { CCPdfPicker } from './CCPdfPicker';
|
||||
import { ExamPdfState } from './types';
|
||||
import './cc-exam-marker.css';
|
||||
import { HEADER_HEIGHT } from '../../Layout';
|
||||
import { CCPanel } from '../../../utils/tldraw/ui-overrides/components/CCPanel';
|
||||
|
||||
export const CCExamMarker = () => {
|
||||
const [state, setState] = useState<ExamPdfState>({ phase: 'pick' });
|
||||
const [view, setView] = useState<'exam-and-markscheme' | 'student-responses'>('exam-and-markscheme');
|
||||
const [currentStudentIndex, setCurrentStudentIndex] = useState(0);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
|
||||
const handleViewChange = (newView: 'exam-and-markscheme' | 'student-responses') => {
|
||||
setView(newView);
|
||||
};
|
||||
|
||||
const handleNextStudent = () => {
|
||||
if (state.phase === 'edit' && 'studentResponses' in state && 'examPaper' in state) {
|
||||
const totalStudents = Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length);
|
||||
if (currentStudentIndex < totalStudents - 1) {
|
||||
setCurrentStudentIndex(prev => prev + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousStudent = () => {
|
||||
if (currentStudentIndex > 0) {
|
||||
setCurrentStudentIndex(prev => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: 'background.default',
|
||||
color: 'text.primary',
|
||||
}}>
|
||||
{state.phase === 'pick' ? (
|
||||
<CCPdfPicker
|
||||
onOpenPdfs={(pdfs) =>
|
||||
setState({
|
||||
phase: 'edit',
|
||||
examPaper: pdfs.examPaper,
|
||||
markScheme: pdfs.markScheme,
|
||||
studentResponses: pdfs.studentResponses,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ flex: 1, position: 'relative' }}>
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
bgcolor: 'background.paper',
|
||||
}}>
|
||||
<CCPdfEditor
|
||||
examPaper={state.examPaper}
|
||||
markScheme={state.markScheme}
|
||||
studentResponses={state.studentResponses}
|
||||
currentView={view}
|
||||
currentStudentIndex={currentStudentIndex}
|
||||
onEditorMount={(editor) => {
|
||||
if (!editor) return null;
|
||||
const examMarkerProps = {
|
||||
editor,
|
||||
currentView: view,
|
||||
onViewChange: handleViewChange,
|
||||
currentStudentIndex,
|
||||
totalStudents: Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length),
|
||||
onPreviousStudent: handlePreviousStudent,
|
||||
onNextStudent: handleNextStudent,
|
||||
getCurrentPdf: () => {
|
||||
if (!editor) return null;
|
||||
const currentPageId = editor.getCurrentPageId();
|
||||
if (currentPageId.includes('exam-page')) {
|
||||
return state.examPaper;
|
||||
} else if (currentPageId.includes('mark-scheme-page')) {
|
||||
return state.markScheme;
|
||||
} else if (currentPageId.includes('student-response')) {
|
||||
return state.studentResponses;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return <CCPanel
|
||||
examMarkerProps={examMarkerProps}
|
||||
isExpanded={isExpanded}
|
||||
isPinned={isPinned}
|
||||
onExpandedChange={setIsExpanded}
|
||||
onPinnedChange={setIsPinned}
|
||||
/>;
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
113
src/pages/tldraw/CCExamMarker/CCExportPdfButton.tsx
Normal file
113
src/pages/tldraw/CCExamMarker/CCExportPdfButton.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { useState } from 'react';
|
||||
import { Editor, exportToBlob } from '@tldraw/tldraw';
|
||||
import { Button } from '@mui/material';
|
||||
import { Pdf } from './types';
|
||||
|
||||
interface CCExportPdfButtonProps {
|
||||
editor: Editor;
|
||||
pdf: Pdf;
|
||||
}
|
||||
|
||||
export function CCExportPdfButton({ editor, pdf }: CCExportPdfButtonProps) {
|
||||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
||||
|
||||
const exportPdf = async (
|
||||
editor: Editor,
|
||||
{ name, source, pages }: Pdf,
|
||||
onProgress: (progress: number) => void
|
||||
) => {
|
||||
const totalThings = pages.length * 2 + 2;
|
||||
let progressCount = 0;
|
||||
const tickProgress = () => {
|
||||
progressCount++;
|
||||
onProgress(progressCount / totalThings);
|
||||
};
|
||||
|
||||
const pdf = await PDFDocument.load(source);
|
||||
tickProgress();
|
||||
const pdfPages = pdf.getPages();
|
||||
|
||||
if (pdfPages.length !== pages.length) {
|
||||
throw new Error('PDF page count mismatch');
|
||||
}
|
||||
|
||||
const pageShapeIds = new Set(pages.map((page) => page.shapeId));
|
||||
const allIds = Array.from(editor.getCurrentPageShapeIds()).filter(
|
||||
(id) => !pageShapeIds.has(id)
|
||||
);
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
const pdfPage = pdfPages[i];
|
||||
const {bounds} = page;
|
||||
|
||||
const shapesInBounds = allIds.filter((id) => {
|
||||
const shapePageBounds = editor.getShapePageBounds(id);
|
||||
if (!shapePageBounds) return false;
|
||||
return shapePageBounds.collides(bounds);
|
||||
});
|
||||
|
||||
if (shapesInBounds.length === 0) {
|
||||
tickProgress();
|
||||
tickProgress();
|
||||
continue;
|
||||
}
|
||||
|
||||
const exportedPng = await exportToBlob({
|
||||
editor,
|
||||
ids: allIds,
|
||||
format: 'png',
|
||||
opts: { background: false, bounds: page.bounds, padding: 0, scale: 1 },
|
||||
});
|
||||
|
||||
tickProgress();
|
||||
|
||||
pdfPage.drawImage(await pdf.embedPng(await exportedPng.arrayBuffer()), {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pdfPage.getWidth(),
|
||||
height: pdfPage.getHeight(),
|
||||
});
|
||||
|
||||
tickProgress();
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([await pdf.save()], { type: 'application/pdf' })
|
||||
);
|
||||
tickProgress();
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="CCExportPdfButton"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
setExportProgress(0);
|
||||
try {
|
||||
await exportPdf(editor, pdf, setExportProgress);
|
||||
} finally {
|
||||
setExportProgress(null);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{exportProgress
|
||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
||||
: 'Export PDF'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
385
src/pages/tldraw/CCExamMarker/CCPdfEditor.tsx
Normal file
385
src/pages/tldraw/CCExamMarker/CCPdfEditor.tsx
Normal file
@ -0,0 +1,385 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Editor, TLPageId, Box as TLBox } from '@tldraw/editor';
|
||||
import { Tldraw } from '@tldraw/tldraw';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { ExamPdfs } from './types';
|
||||
import { AnnotationManager, AnnotationData } from './AnnotationManager';
|
||||
import { logger } from '../../../debugConfig';
|
||||
|
||||
const PAGE_SPACING = 32; // Same spacing as the example
|
||||
|
||||
interface CCPdfEditorProps extends ExamPdfs {
|
||||
currentView: 'exam-and-markscheme' | 'student-responses';
|
||||
currentStudentIndex: number;
|
||||
onEditorMount: (editor: Editor) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function CCPdfEditor({
|
||||
examPaper,
|
||||
markScheme,
|
||||
studentResponses,
|
||||
currentView,
|
||||
currentStudentIndex,
|
||||
onEditorMount,
|
||||
}: CCPdfEditorProps) {
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [pagesInitialized, setPagesInitialized] = useState(false);
|
||||
const annotationManager = useRef(new AnnotationManager());
|
||||
|
||||
const handleMount = useCallback((editor: Editor) => {
|
||||
setEditor(editor);
|
||||
onEditorMount(editor);
|
||||
|
||||
// Subscribe to shape changes
|
||||
editor.on('change', () => {
|
||||
const shapes = editor.getCurrentPageShapeIds();
|
||||
logger.debug('cc-exam-marker', '🔄 Shape change detected', {
|
||||
totalShapes: shapes.size,
|
||||
currentPage: editor.getCurrentPageId()
|
||||
});
|
||||
|
||||
shapes.forEach(shapeId => {
|
||||
const shape = editor.getShape(shapeId);
|
||||
if (shape && !shape.isLocked) { // Only track non-locked shapes (annotations)
|
||||
const bounds = editor.getShapePageBounds(shapeId);
|
||||
if (bounds) {
|
||||
const currentPageId = editor.getCurrentPageId();
|
||||
let annotationData: AnnotationData;
|
||||
|
||||
if (currentPageId.includes('student-response')) {
|
||||
const studentIndex = parseInt(currentPageId.split('-').pop() || '0', 10);
|
||||
|
||||
// Find which page this annotation belongs to by checking collision with page bounds
|
||||
const pageShapes = Array.from(shapes).filter(id => {
|
||||
const s = editor.getShape(id);
|
||||
return s?.isLocked; // Locked shapes are our PDF pages
|
||||
});
|
||||
|
||||
let pageIndex = -1; // Default to -1 if no collision found
|
||||
for (let i = 0; i < pageShapes.length; i++) {
|
||||
const pageShape = editor.getShape(pageShapes[i]);
|
||||
if (!pageShape) continue;
|
||||
|
||||
const pageBounds = editor.getShapePageBounds(pageShapes[i]);
|
||||
if (!pageBounds) continue;
|
||||
|
||||
// Check if the annotation's center point is within the page bounds
|
||||
const annotationCenter = {
|
||||
x: bounds.x + bounds.width / 2,
|
||||
y: bounds.y + bounds.height / 2
|
||||
};
|
||||
|
||||
if (annotationCenter.x >= pageBounds.x &&
|
||||
annotationCenter.x <= pageBounds.x + pageBounds.width &&
|
||||
annotationCenter.y >= pageBounds.y &&
|
||||
annotationCenter.y <= pageBounds.y + pageBounds.height) {
|
||||
pageIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('cc-exam-marker', '📏 Calculated page index', {
|
||||
shapeId,
|
||||
shapeBounds: bounds,
|
||||
pageIndex,
|
||||
studentIndex
|
||||
});
|
||||
|
||||
annotationData = {
|
||||
studentIndex,
|
||||
pageIndex,
|
||||
shapeId,
|
||||
bounds: {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// For exam/mark scheme, use current page type as index
|
||||
const pageIndex = currentPageId.includes('exam') ? -1 : 1;
|
||||
annotationData = {
|
||||
pageIndex,
|
||||
shapeId,
|
||||
bounds: {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug('cc-exam-marker', '📝 Adding/updating annotation', {
|
||||
shapeId,
|
||||
annotationData,
|
||||
currentPage: currentPageId
|
||||
});
|
||||
|
||||
annotationManager.current.addAnnotation(shapeId, annotationData);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [onEditorMount]);
|
||||
|
||||
// Initial setup effect - runs only once when editor is mounted
|
||||
useEffect(() => {
|
||||
if (!editor || pagesInitialized) return;
|
||||
|
||||
const setupExamAndMarkScheme = async () => {
|
||||
const examPageId = 'page:exam-page' as TLPageId;
|
||||
const markSchemePageId = 'page:mark-scheme-page' as TLPageId;
|
||||
|
||||
// Calculate vertical layout for exam pages
|
||||
let top = 0;
|
||||
let widest = 0;
|
||||
const examPages = examPaper.pages.map(page => {
|
||||
const width = page.bounds.width;
|
||||
const height = page.bounds.height;
|
||||
const currentTop = top;
|
||||
top += height + PAGE_SPACING;
|
||||
widest = Math.max(widest, width);
|
||||
return { ...page, top: currentTop, width, height };
|
||||
});
|
||||
|
||||
// Center pages horizontally
|
||||
examPages.forEach(page => {
|
||||
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
|
||||
});
|
||||
|
||||
// Create exam paper page
|
||||
editor.createPage({
|
||||
id: examPageId,
|
||||
name: 'Exam Paper',
|
||||
});
|
||||
editor.setCurrentPage(examPageId);
|
||||
|
||||
// Create assets and shapes for exam pages
|
||||
examPages.forEach((page) => {
|
||||
editor.createAssets([{
|
||||
id: page.assetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
name: 'PDF Page',
|
||||
src: page.src,
|
||||
isAnimated: false,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
meta: {},
|
||||
}]);
|
||||
|
||||
editor.createShape({
|
||||
id: page.shapeId,
|
||||
type: 'image',
|
||||
x: page.bounds.x,
|
||||
y: page.bounds.y,
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
assetId: page.assetId,
|
||||
},
|
||||
isLocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Similar process for mark scheme pages
|
||||
let markSchemeTop = 0;
|
||||
const markSchemePages = markScheme.pages.map(page => {
|
||||
const width = page.bounds.width;
|
||||
const height = page.bounds.height;
|
||||
const currentTop = markSchemeTop;
|
||||
markSchemeTop += height + PAGE_SPACING;
|
||||
return {
|
||||
...page,
|
||||
bounds: new TLBox((widest - width) / 2, currentTop, width, height)
|
||||
};
|
||||
});
|
||||
|
||||
// Create mark scheme page
|
||||
editor.createPage({
|
||||
id: markSchemePageId,
|
||||
name: 'Mark Scheme',
|
||||
});
|
||||
editor.setCurrentPage(markSchemePageId);
|
||||
|
||||
// Create assets and shapes for mark scheme pages
|
||||
markSchemePages.forEach((page) => {
|
||||
editor.createAssets([{
|
||||
id: page.assetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
name: 'PDF Page',
|
||||
src: page.src,
|
||||
isAnimated: false,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
meta: {},
|
||||
}]);
|
||||
|
||||
editor.createShape({
|
||||
id: page.shapeId,
|
||||
type: 'image',
|
||||
x: page.bounds.x,
|
||||
y: page.bounds.y,
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
assetId: page.assetId,
|
||||
},
|
||||
isLocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Go back to exam page
|
||||
editor.setCurrentPage(examPageId);
|
||||
};
|
||||
|
||||
const setupStudentResponses = async () => {
|
||||
const pagesPerStudent = examPaper.pages.length;
|
||||
const totalStudents = Math.floor(studentResponses.pages.length / pagesPerStudent);
|
||||
|
||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
||||
const startPage = studentIndex * pagesPerStudent;
|
||||
const endPage = startPage + pagesPerStudent;
|
||||
const studentPageId = `page:student-response-${studentIndex}` as TLPageId;
|
||||
|
||||
// Calculate vertical layout
|
||||
let top = 0;
|
||||
let widest = 0;
|
||||
const studentPages = studentResponses.pages
|
||||
.slice(startPage, endPage)
|
||||
.map(page => {
|
||||
const width = page.bounds.width;
|
||||
const height = page.bounds.height;
|
||||
const currentTop = top;
|
||||
top += height + PAGE_SPACING;
|
||||
widest = Math.max(widest, width);
|
||||
return { ...page, top: currentTop, width, height };
|
||||
});
|
||||
|
||||
// Center pages horizontally
|
||||
studentPages.forEach(page => {
|
||||
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
|
||||
});
|
||||
|
||||
// Create page for this student
|
||||
editor.createPage({
|
||||
id: studentPageId,
|
||||
name: `Student ${studentIndex + 1}`,
|
||||
});
|
||||
editor.setCurrentPage(studentPageId);
|
||||
|
||||
// Create assets and shapes
|
||||
studentPages.forEach((page) => {
|
||||
editor.createAssets([{
|
||||
id: page.assetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
name: 'PDF Page',
|
||||
src: page.src,
|
||||
isAnimated: false,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
meta: {},
|
||||
}]);
|
||||
|
||||
editor.createShape({
|
||||
id: page.shapeId,
|
||||
type: 'image',
|
||||
x: page.bounds.x,
|
||||
y: page.bounds.y,
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
assetId: page.assetId,
|
||||
},
|
||||
isLocked: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initial setup of all pages
|
||||
const setup = async () => {
|
||||
await setupExamAndMarkScheme();
|
||||
await setupStudentResponses();
|
||||
setPagesInitialized(true);
|
||||
};
|
||||
|
||||
setup();
|
||||
}, [editor, pagesInitialized, examPaper, markScheme, studentResponses]);
|
||||
|
||||
// Effect to handle view changes and navigation
|
||||
useEffect(() => {
|
||||
if (!editor || !pagesInitialized) return;
|
||||
|
||||
// Switch to appropriate page based on current view
|
||||
const targetPageId = currentView === 'exam-and-markscheme'
|
||||
? ('page:exam-page' as TLPageId)
|
||||
: (`page:student-response-${currentStudentIndex}` as TLPageId);
|
||||
|
||||
logger.debug('cc-exam-marker', '🔄 Switching view', {
|
||||
currentView,
|
||||
currentStudentIndex,
|
||||
targetPageId
|
||||
});
|
||||
|
||||
editor.setCurrentPage(targetPageId);
|
||||
|
||||
// Update camera constraints for current page
|
||||
const currentPageBounds = Array.from(editor.getCurrentPageShapeIds()).reduce(
|
||||
(acc: TLBox | null, shapeId) => {
|
||||
const bounds = editor.getShapePageBounds(shapeId);
|
||||
return bounds ? (acc ? acc.union(bounds) : bounds) : acc;
|
||||
},
|
||||
null as TLBox | null
|
||||
);
|
||||
|
||||
if (currentPageBounds) {
|
||||
const isMobile = editor.getViewportScreenBounds().width < 840;
|
||||
editor.setCameraOptions({
|
||||
constraints: {
|
||||
bounds: currentPageBounds,
|
||||
padding: { x: isMobile ? 16 : 164, y: 64 },
|
||||
origin: { x: 0.5, y: 0 },
|
||||
initialZoom: 'fit-x-100',
|
||||
baseZoom: 'default',
|
||||
behavior: 'contain',
|
||||
},
|
||||
});
|
||||
editor.setCamera(editor.getCamera(), { reset: true });
|
||||
}
|
||||
}, [editor, pagesInitialized, currentView, currentStudentIndex]);
|
||||
|
||||
// Expose annotationManager to parent through onEditorMount
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
onEditorMount(editor);
|
||||
// @ts-expect-error - Adding custom property to editor for CCExamMarkerPanel access
|
||||
editor.annotationManager = annotationManager.current;
|
||||
}
|
||||
}, [editor, onEditorMount]);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<Tldraw
|
||||
onMount={handleMount}
|
||||
components={{
|
||||
InFrontOfTheCanvas: () => onEditorMount(editor!)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
210
src/pages/tldraw/CCExamMarker/CCPdfPicker.tsx
Normal file
210
src/pages/tldraw/CCExamMarker/CCPdfPicker.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
import { AssetRecordType, Box as TLBox, createShapeId } from '@tldraw/editor';
|
||||
import { ExamPdfs, Pdf, PdfPage } from './types';
|
||||
|
||||
interface CCPdfPickerProps {
|
||||
onOpenPdfs: (pdfs: ExamPdfs) => void;
|
||||
}
|
||||
|
||||
const pageSpacing = 32;
|
||||
|
||||
export function CCPdfPicker({ onOpenPdfs }: CCPdfPickerProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedPdfs, setSelectedPdfs] = useState<Partial<ExamPdfs>>({});
|
||||
|
||||
async function loadPdf(name: string, source: ArrayBuffer): Promise<Pdf> {
|
||||
const PdfJS = await import('pdfjs-dist');
|
||||
PdfJS.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
const pdf = await PdfJS.getDocument(source.slice()).promise;
|
||||
const pages: PdfPage[] = [];
|
||||
const canvas = window.document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Failed to create canvas context');
|
||||
|
||||
const visualScale = 1.5;
|
||||
const scale = window.devicePixelRatio;
|
||||
let top = 0;
|
||||
let widest = 0;
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: scale * visualScale });
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
};
|
||||
|
||||
await page.render(renderContext).promise;
|
||||
const width = viewport.width / scale;
|
||||
const height = viewport.height / scale;
|
||||
|
||||
pages.push({
|
||||
src: canvas.toDataURL(),
|
||||
bounds: new TLBox(0, top, width, height),
|
||||
assetId: AssetRecordType.createId(),
|
||||
shapeId: createShapeId(),
|
||||
});
|
||||
|
||||
top += height + pageSpacing;
|
||||
widest = Math.max(widest, width);
|
||||
}
|
||||
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
|
||||
for (const page of pages) {
|
||||
page.bounds.x = (widest - page.bounds.width) / 2;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
pages,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
const handleFileSelect = async (type: keyof ExamPdfs, file: File) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const pdf = await loadPdf(file.name, await file.arrayBuffer());
|
||||
|
||||
// Validate student responses page count
|
||||
if (type === 'studentResponses' && selectedPdfs.examPaper) {
|
||||
const examPageCount = selectedPdfs.examPaper.pages.length;
|
||||
if (pdf.pages.length % examPageCount !== 0) {
|
||||
alert(`Student responses PDF must have a number of pages that is a multiple of the exam paper's ${examPageCount} pages.\n\nStudent responses PDF has ${pdf.pages.length} pages, which is not a multiple of ${examPageCount}.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedPdfs((prev) => ({ ...prev, [type]: pdf }));
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
alert('Error loading PDF (mismatch between responses and exam paper). Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createFileInput = (type: keyof ExamPdfs) => {
|
||||
const input = window.document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'application/pdf';
|
||||
input.addEventListener('change', async (e) => {
|
||||
const fileList = (e.target as HTMLInputElement).files;
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
await handleFileSelect(type, fileList[0]);
|
||||
});
|
||||
input.click();
|
||||
};
|
||||
|
||||
const allPdfsSelected = () => {
|
||||
return (
|
||||
selectedPdfs.examPaper &&
|
||||
selectedPdfs.markScheme &&
|
||||
selectedPdfs.studentResponses
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className="CCPdfPicker" sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}>
|
||||
<Typography>Loading...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="CCPdfPicker" sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}>
|
||||
<Stack
|
||||
spacing={4}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
p: 3
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">Select PDF Files</Typography>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
gap: 4 // Using MUI's spacing unit (1 unit = 8px, so 4 = 32px)
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant={selectedPdfs.examPaper ? 'contained' : 'outlined'}
|
||||
onClick={() => createFileInput('examPaper')}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
{selectedPdfs.examPaper ? '✓ Exam Paper' : 'Select Exam Paper'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={selectedPdfs.markScheme ? 'contained' : 'outlined'}
|
||||
onClick={() => createFileInput('markScheme')}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
{selectedPdfs.markScheme ? '✓ Mark Scheme' : 'Select Mark Scheme'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={selectedPdfs.studentResponses ? 'contained' : 'outlined'}
|
||||
onClick={() => createFileInput('studentResponses')}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
{selectedPdfs.studentResponses ? '✓ Student Responses' : 'Select Student Responses'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{allPdfsSelected() && (
|
||||
<Box sx={{ mt: 4, width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => onOpenPdfs(selectedPdfs as ExamPdfs)}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
61
src/pages/tldraw/CCExamMarker/cc-exam-marker.css
Normal file
61
src/pages/tldraw/CCExamMarker/cc-exam-marker.css
Normal file
@ -0,0 +1,61 @@
|
||||
.CCExamMarker {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.CCExamMarker .CCPdfPicker {
|
||||
position: absolute;
|
||||
inset: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.CCExamMarker .CCPdfBgRenderer {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.CCExamMarker .CCPdfBgRenderer img {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.CCExamMarker .PageOverlayScreen-screen {
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
fill: var(--color-background);
|
||||
fill-opacity: 0.8;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.CCExamMarker .PageOverlayScreen-outline {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.CCExamMarker .CCExportPdfButton {
|
||||
font: inherit;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
color: var(--color-selected-contrast);
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin: 6px;
|
||||
margin-bottom: 0;
|
||||
pointer-events: all;
|
||||
z-index: var(--layer-panels);
|
||||
border: 2px solid var(--color-background);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.CCExamMarker .CCExportPdfButton:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
44
src/pages/tldraw/CCExamMarker/types.ts
Normal file
44
src/pages/tldraw/CCExamMarker/types.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { Box, TLAssetId, TLShapeId } from '@tldraw/tldraw';
|
||||
|
||||
export interface PdfPage {
|
||||
src: string;
|
||||
bounds: Box;
|
||||
assetId: TLAssetId;
|
||||
shapeId: TLShapeId;
|
||||
}
|
||||
|
||||
export interface Pdf {
|
||||
name: string;
|
||||
pages: PdfPage[];
|
||||
source: string | ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface ExamPdfs {
|
||||
examPaper: Pdf;
|
||||
markScheme: Pdf;
|
||||
studentResponses: Pdf;
|
||||
}
|
||||
|
||||
export type ExamPdfState =
|
||||
| {
|
||||
phase: 'pick';
|
||||
}
|
||||
| {
|
||||
phase: 'edit';
|
||||
examPaper: Pdf;
|
||||
markScheme: Pdf;
|
||||
studentResponses: Pdf;
|
||||
};
|
||||
|
||||
export interface StudentResponse {
|
||||
studentId: string;
|
||||
pageStart: number;
|
||||
pageEnd: number;
|
||||
}
|
||||
|
||||
export interface ExamMetadata {
|
||||
totalPages: number;
|
||||
pagesPerStudent: number;
|
||||
totalStudents: number;
|
||||
studentResponses: StudentResponse[];
|
||||
}
|
||||
90
src/pages/tldraw/ShareHandler.tsx
Normal file
90
src/pages/tldraw/ShareHandler.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
interface LaunchParams {
|
||||
files: FileSystemFileHandle[];
|
||||
}
|
||||
|
||||
interface LaunchQueue {
|
||||
setConsumer(callback: (params: LaunchParams) => Promise<void>): void;
|
||||
}
|
||||
|
||||
interface WindowWithLaunchQueue extends Window {
|
||||
launchQueue: LaunchQueue;
|
||||
}
|
||||
|
||||
const ShareHandler = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const processSharedData = async () => {
|
||||
try {
|
||||
// Handle files shared through Web Share Target API
|
||||
if ('launchQueue' in window) {
|
||||
(window as WindowWithLaunchQueue).launchQueue.setConsumer(async (launchParams: LaunchParams) => {
|
||||
if (!launchParams.files.length) {
|
||||
logger.debug('share-handler', 'No files shared');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const fileHandle of launchParams.files) {
|
||||
const file = await fileHandle.getFile();
|
||||
logger.info('share-handler', 'Processing shared file', {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size
|
||||
});
|
||||
|
||||
// Navigate to single player with the shared file
|
||||
// You might want to modify this based on your needs
|
||||
navigate('/single-player', {
|
||||
state: {
|
||||
sharedFile: file
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle URL parameters for text/url sharing
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const title = urlParams.get('title');
|
||||
const text = urlParams.get('text');
|
||||
const url = urlParams.get('url');
|
||||
|
||||
if (title || text || url) {
|
||||
logger.info('share-handler', 'Processing shared content', {
|
||||
title,
|
||||
text,
|
||||
url
|
||||
});
|
||||
|
||||
// Navigate to single player with the shared content
|
||||
navigate('/single-player', {
|
||||
state: {
|
||||
sharedContent: { title, text, url }
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('share-handler', 'Error processing shared content', { error });
|
||||
}
|
||||
};
|
||||
|
||||
processSharedData();
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh'
|
||||
}}>
|
||||
Processing shared content...
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareHandler;
|
||||
13
src/pages/tldraw/TLDrawCanvas.tsx
Normal file
13
src/pages/tldraw/TLDrawCanvas.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Tldraw } from '@tldraw/tldraw';
|
||||
import '@tldraw/tldraw/tldraw.css';
|
||||
|
||||
const TLDrawCanvas: React.FC = () => {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<Tldraw persistenceKey="classroom-copilot-landing-page" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TLDrawCanvas;
|
||||
469
src/pages/tldraw/devPage.tsx
Normal file
469
src/pages/tldraw/devPage.tsx
Normal file
@ -0,0 +1,469 @@
|
||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Tldraw,
|
||||
Editor,
|
||||
useTldrawUser,
|
||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||
} from '@tldraw/tldraw';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||
// Tldraw services
|
||||
import { localStoreService } from '../../services/tldraw/localStoreService';
|
||||
// Tldraw utils
|
||||
import { customAssets } from '../../utils/tldraw/assets';
|
||||
import { devEmbeds } from '../../utils/tldraw/embeds';
|
||||
import { allShapeUtils } from '../../utils/tldraw/shapes';
|
||||
import { allBindingUtils } from '../../utils/tldraw/bindings';
|
||||
import { devTools } from '../../utils/tldraw/tools';
|
||||
import { customSchema } from '../../utils/tldraw/schemas';
|
||||
// Layout
|
||||
import { HEADER_HEIGHT } from '../Layout';
|
||||
// Styles
|
||||
import '../../utils/tldraw/tldraw.css';
|
||||
// App debug
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
interface EventFilter {
|
||||
type: 'all' | 'ui' | 'store' | 'canvas';
|
||||
subType?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface EventFilters {
|
||||
mode: 'all' | 'specific';
|
||||
filters: {
|
||||
[key: string]: EventFilter;
|
||||
};
|
||||
}
|
||||
|
||||
const EventMonitoringControls: React.FC<{
|
||||
filters: EventFilters;
|
||||
setFilters: (filters: EventFilters) => void;
|
||||
onClear: () => void;
|
||||
}> = ({ filters, setFilters, onClear }) => {
|
||||
const handleModeChange = (mode: 'all' | 'specific') => {
|
||||
setFilters({ ...filters, mode });
|
||||
};
|
||||
|
||||
const handleFilterChange = (key: string, enabled: boolean) => {
|
||||
setFilters({
|
||||
...filters,
|
||||
filters: {
|
||||
...filters.filters,
|
||||
[key]: { ...filters.filters[key], enabled }
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="event-monitor-controls">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="mode-selector">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
checked={filters.mode === 'all'}
|
||||
onChange={() => handleModeChange('all')}
|
||||
/>
|
||||
Monitor All Events
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
checked={filters.mode === 'specific'}
|
||||
onChange={() => handleModeChange('specific')}
|
||||
/>
|
||||
Monitor Specific Events
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClear}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#f44336',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Clear Logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filters.mode === 'specific' && (
|
||||
<div className="specific-filters">
|
||||
<select
|
||||
onChange={(e) => handleFilterChange(e.target.value, true)}
|
||||
value=""
|
||||
>
|
||||
<option value="" disabled>Add Event Filter</option>
|
||||
<optgroup label="UI Events">
|
||||
<option value="ui-selection">Selection Changes</option>
|
||||
<option value="ui-tool">Tool Changes</option>
|
||||
<option value="ui-viewport">Viewport Changes</option>
|
||||
</optgroup>
|
||||
<optgroup label="Store Events">
|
||||
<option value="store-shapes">Shape Updates</option>
|
||||
<option value="store-bindings">Binding Updates</option>
|
||||
<option value="store-assets">Asset Updates</option>
|
||||
</optgroup>
|
||||
<optgroup label="Canvas Events">
|
||||
<option value="canvas-pointer">Pointer Events</option>
|
||||
<option value="canvas-camera">Camera Events</option>
|
||||
<option value="canvas-selection">Selection Events</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
<div className="active-filters">
|
||||
{Object.entries(filters.filters)
|
||||
.filter(([, filter]) => filter.enabled)
|
||||
.map(([key]) => (
|
||||
<div key={key} className="filter-tag">
|
||||
{key}
|
||||
<button onClick={() => handleFilterChange(key, false)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MAX_EVENTS = 100; // Limit visible events to last 100
|
||||
|
||||
const EventDisplay: React.FC<{ events: Array<{ type: string; data: string; timestamp: string }> }> =
|
||||
({ events }) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
// Only show the last MAX_EVENTS events
|
||||
const visibleEvents = useMemo(() =>
|
||||
events.slice(-MAX_EVENTS),
|
||||
[events]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="event-display"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 8,
|
||||
background: '#ddd',
|
||||
borderLeft: 'solid 2px #333',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
overflow: 'auto',
|
||||
scrollBehavior: 'smooth',
|
||||
}}
|
||||
>
|
||||
{visibleEvents.length === MAX_EVENTS && (
|
||||
<div style={{
|
||||
padding: '4px 8px',
|
||||
marginBottom: 8,
|
||||
backgroundColor: '#fff3cd',
|
||||
color: '#856404',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
}}>
|
||||
Showing last {MAX_EVENTS} events only
|
||||
</div>
|
||||
)}
|
||||
{visibleEvents.map((event, i) => (
|
||||
<pre
|
||||
key={event.timestamp + i}
|
||||
style={{
|
||||
borderBottom: '1px solid #000',
|
||||
marginBottom: 0,
|
||||
paddingBottom: '12px',
|
||||
backgroundColor: getEventTypeColor(event.type),
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
<span className="event-timestamp">{event.timestamp}</span>
|
||||
<span className="event-type">[{event.type}]</span>
|
||||
{event.data}
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getEventTypeColor = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'ui':
|
||||
return '#e8f0fe'; // Light blue
|
||||
case 'store':
|
||||
return '#fef3e8'; // Light orange
|
||||
case 'canvas':
|
||||
return '#f0fee8'; // Light green
|
||||
default:
|
||||
return 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
export default function DevPage() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { tldrawPreferences, initializePreferences, setTldrawPreferences } = useTLDraw();
|
||||
const [events, setEvents] = useState<Array<{ type: 'ui' | 'store' | 'canvas'; data: string; timestamp: string; }>>([]);
|
||||
const [eventFilters, setEventFilters] = useState<EventFilters>({ mode: 'all', filters: {} });
|
||||
const [logPanelWidth, setLogPanelWidth] = useState(30); // Width in percentage
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
const handleDragMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current) return;
|
||||
|
||||
const windowWidth = window.innerWidth;
|
||||
const newWidth = (e.clientX / windowWidth) * 100;
|
||||
|
||||
// Limit the range to between 20% and 80%
|
||||
const clampedWidth = Math.min(Math.max(newWidth, 20), 80);
|
||||
setLogPanelWidth(100 - clampedWidth);
|
||||
};
|
||||
|
||||
const handleDragUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
document.body.style.cursor = 'default';
|
||||
window.removeEventListener('mousemove', handleDragMove);
|
||||
window.removeEventListener('mouseup', handleDragUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleDragMove);
|
||||
window.addEventListener('mouseup', handleDragUp);
|
||||
}, []);
|
||||
|
||||
// Create tldraw user
|
||||
const tldrawUser = useTldrawUser({
|
||||
userPreferences: {
|
||||
id: user?.id ?? 'dev-user',
|
||||
name: user?.display_name ?? 'Unknown User',
|
||||
color: tldrawPreferences?.color,
|
||||
locale: tldrawPreferences?.locale,
|
||||
colorScheme: tldrawPreferences?.colorScheme,
|
||||
animationSpeed: tldrawPreferences?.animationSpeed,
|
||||
isSnapMode: tldrawPreferences?.isSnapMode
|
||||
},
|
||||
setUserPreferences: setTldrawPreferences
|
||||
});
|
||||
|
||||
// Create store
|
||||
const store = useMemo(() => localStoreService.getStore({
|
||||
schema: customSchema,
|
||||
shapeUtils: allShapeUtils,
|
||||
bindingUtils: allBindingUtils
|
||||
}), []);
|
||||
|
||||
// Initialize preferences when user is available
|
||||
useEffect(() => {
|
||||
if (user?.id && !tldrawPreferences) {
|
||||
logger.debug('dev-page', '🔄 Initializing preferences for user', { userId: user.id });
|
||||
initializePreferences(user.id);
|
||||
}
|
||||
}, [user?.id, tldrawPreferences, initializePreferences]);
|
||||
|
||||
// Redirect if no user
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
logger.info('dev-page', '🚪 Redirecting to home - no user logged in');
|
||||
navigate('/');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
const shouldCaptureEvent = useCallback((type: 'ui' | 'store' | 'canvas', data: string) => {
|
||||
if (eventFilters.mode === 'all') return true;
|
||||
|
||||
// Check specific filters
|
||||
return Object.entries(eventFilters.filters)
|
||||
.some(([key, filter]) => {
|
||||
if (!filter.enabled) return false;
|
||||
|
||||
const [filterType, filterSubType] = key.split('-');
|
||||
if (filterType !== type) return false;
|
||||
|
||||
// Match specific event subtypes
|
||||
switch (filterType) {
|
||||
case 'ui':
|
||||
return data.includes(filterSubType);
|
||||
case 'store':
|
||||
return data.includes(`"type":"${filterSubType}"`);
|
||||
case 'canvas':
|
||||
return data.includes(`Canvas Event: ${filterSubType}`);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, [eventFilters]);
|
||||
|
||||
const addEvent = useCallback((type: 'ui' | 'store' | 'canvas', data: string) => {
|
||||
if (!shouldCaptureEvent(type, data)) return;
|
||||
|
||||
setEvents(prevEvents => {
|
||||
const newEvents = [...prevEvents, {
|
||||
type,
|
||||
data,
|
||||
timestamp: new Date().toISOString()
|
||||
}];
|
||||
// Keep last 2 * MAX_EVENTS in state to allow some scrollback
|
||||
return newEvents.slice(-(MAX_EVENTS * 2));
|
||||
});
|
||||
}, [shouldCaptureEvent]);
|
||||
|
||||
const handleUiEvent = useCallback((name: string, data: unknown) => {
|
||||
const eventString = `UI Event: ${name} ${JSON.stringify(data)}`;
|
||||
addEvent('ui', eventString);
|
||||
console.log(eventString);
|
||||
}, [addEvent]);
|
||||
|
||||
const handleCanvasEvent = useCallback((editor: Editor) => {
|
||||
logger.trace('dev-page', '🎨 Canvas editor mounted');
|
||||
|
||||
editor.on('change', () => {
|
||||
const camera = editor.getCamera();
|
||||
logger.trace('dev-page', '🎥 Camera changed', { camera });
|
||||
addEvent('canvas', `Canvas Event: camera ${JSON.stringify(camera)}`);
|
||||
});
|
||||
|
||||
editor.on('change', () => {
|
||||
const selectedIds = editor.getSelectedShapeIds();
|
||||
if (selectedIds.length > 0) {
|
||||
logger.trace('dev-page', '🔍 Selection changed', { selectedIds });
|
||||
addEvent('canvas', `Canvas Event: selection ${JSON.stringify(selectedIds)}`);
|
||||
}
|
||||
});
|
||||
|
||||
editor.on('event', (info) => {
|
||||
if (info.type === 'pointer') {
|
||||
const point = editor.inputs.currentPagePoint;
|
||||
logger.trace('dev-page', '👆 Pointer event', { point });
|
||||
addEvent('canvas', `Canvas Event: pointer ${JSON.stringify(point)}`);
|
||||
}
|
||||
});
|
||||
}, [addEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (store) {
|
||||
const cleanupFn = store.listen((info) => {
|
||||
const eventString = `Store Event: ${info.source} ${JSON.stringify(info.changes)}`;
|
||||
addEvent('store', eventString);
|
||||
console.log(eventString);
|
||||
});
|
||||
return () => cleanupFn();
|
||||
}
|
||||
}, [store, addEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
const clearEvents = useCallback(() => {
|
||||
setEvents([]);
|
||||
}, []);
|
||||
|
||||
if (!user) {
|
||||
logger.info('dev-page', '🚫 Rendering null - no user');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: `calc(100vh - ${HEADER_HEIGHT}px)`,
|
||||
position: 'fixed',
|
||||
top: `${HEADER_HEIGHT}px`
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${100 - logPanelWidth}%`,
|
||||
height: '100%',
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<Tldraw
|
||||
user={tldrawUser}
|
||||
store={store}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor;
|
||||
handleCanvasEvent(editor);
|
||||
logger.info('system', '🎨 Tldraw mounted', {
|
||||
editorId: editor.store.id
|
||||
});
|
||||
}}
|
||||
onUiEvent={handleUiEvent}
|
||||
tools={devTools}
|
||||
shapeUtils={allShapeUtils}
|
||||
bindingUtils={allBindingUtils}
|
||||
embeds={devEmbeds}
|
||||
assetUrls={customAssets}
|
||||
autoFocus={true}
|
||||
hideUi={false}
|
||||
inferDarkMode={false}
|
||||
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
|
||||
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
|
||||
maxImageDimension={Infinity}
|
||||
maxAssetSize={100 * 1024 * 1024}
|
||||
renderDebugMenuItems={() => []}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '5px',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: `${100 - logPanelWidth}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
cursor: 'col-resize',
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
<div style={{
|
||||
width: '1px',
|
||||
height: '100%',
|
||||
backgroundColor: '#333',
|
||||
margin: '0 auto',
|
||||
}} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: `${logPanelWidth}%`,
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<EventMonitoringControls
|
||||
filters={eventFilters}
|
||||
setFilters={setEventFilters}
|
||||
onClear={clearEvents}
|
||||
/>
|
||||
<EventDisplay events={events} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/pages/tldraw/devPlayerPage.tsx
Normal file
140
src/pages/tldraw/devPlayerPage.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Tldraw,
|
||||
Editor,
|
||||
useTldrawUser,
|
||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||
TLAnyShapeUtilConstructor
|
||||
} from '@tldraw/tldraw';
|
||||
// App context
|
||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||
// Tldraw services
|
||||
import { localStoreService } from '../../services/tldraw/localStoreService';
|
||||
import { PresentationService } from '../../services/tldraw/presentationService';
|
||||
// Tldraw utils
|
||||
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
||||
import { customAssets } from '../../utils/tldraw/assets';
|
||||
import { devEmbeds } from '../../utils/tldraw/embeds';
|
||||
import { allShapeUtils } from '../../utils/tldraw/shapes';
|
||||
import { allBindingUtils } from '../../utils/tldraw/bindings';
|
||||
import { devTools } from '../../utils/tldraw/tools';
|
||||
import { customSchema } from '../../utils/tldraw/schemas';
|
||||
// Layout
|
||||
import { HEADER_HEIGHT } from '../../pages/Layout';
|
||||
// Styles
|
||||
import '../../utils/tldraw/tldraw.css';
|
||||
// App debug
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const devUserId = 'dev-user';
|
||||
|
||||
export default function TLDrawDevPage() {
|
||||
// 1. All context hooks first
|
||||
const {
|
||||
tldrawPreferences,
|
||||
initializePreferences,
|
||||
presentationMode,
|
||||
setTldrawPreferences
|
||||
} = useTLDraw();
|
||||
|
||||
// 2. All refs
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
// 4. All memos
|
||||
const tldrawUser = useTldrawUser({
|
||||
userPreferences: {
|
||||
id: devUserId,
|
||||
name: 'Dev User',
|
||||
color: tldrawPreferences?.color,
|
||||
locale: tldrawPreferences?.locale,
|
||||
colorScheme: tldrawPreferences?.colorScheme,
|
||||
animationSpeed: tldrawPreferences?.animationSpeed,
|
||||
isSnapMode: tldrawPreferences?.isSnapMode
|
||||
},
|
||||
setUserPreferences: setTldrawPreferences
|
||||
});
|
||||
|
||||
const store = useMemo(() => localStoreService.getStore({
|
||||
schema: customSchema,
|
||||
shapeUtils: [...allShapeUtils] as TLAnyShapeUtilConstructor[],
|
||||
bindingUtils: allBindingUtils
|
||||
}), []);
|
||||
|
||||
// Initialize preferences when user is available
|
||||
useEffect(() => {
|
||||
if (!tldrawPreferences) {
|
||||
logger.debug('single-player-page', '🔄 Initializing preferences');
|
||||
initializePreferences(devUserId);
|
||||
}
|
||||
}, [tldrawPreferences, initializePreferences]);
|
||||
|
||||
// Load initial data when user node is available
|
||||
useEffect(() => {
|
||||
if (!tldrawUser) {
|
||||
return;
|
||||
}
|
||||
}, [tldrawUser, store]);
|
||||
|
||||
// Handle presentation mode
|
||||
useEffect(() => {
|
||||
if (presentationMode && editorRef.current) {
|
||||
logger.info('presentation', '🔄 Presentation mode changed', {
|
||||
presentationMode,
|
||||
editorExists: !!editorRef.current
|
||||
});
|
||||
|
||||
const editor = editorRef.current;
|
||||
const presentationService = new PresentationService(editor);
|
||||
const cleanup = presentationService.startPresentationMode();
|
||||
|
||||
return () => {
|
||||
logger.info('presentation', '🧹 Cleaning up presentation mode');
|
||||
presentationService.stopPresentationMode();
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
}, [presentationMode]);
|
||||
|
||||
// Modify the render logic to use presentationMode
|
||||
const uiOverrides = getUiOverrides(presentationMode);
|
||||
const uiComponents = getUiComponents(presentationMode);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<Tldraw
|
||||
user={tldrawUser}
|
||||
store={store}
|
||||
tools={devTools}
|
||||
shapeUtils={allShapeUtils as TLAnyShapeUtilConstructor[]}
|
||||
bindingUtils={allBindingUtils}
|
||||
components={uiComponents}
|
||||
overrides={uiOverrides}
|
||||
embeds={devEmbeds}
|
||||
assetUrls={customAssets}
|
||||
autoFocus={true}
|
||||
hideUi={false}
|
||||
inferDarkMode={false}
|
||||
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
|
||||
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
|
||||
maxImageDimension={Infinity}
|
||||
maxAssetSize={100 * 1024 * 1024}
|
||||
renderDebugMenuItems={() => []}
|
||||
onMount={(editor) => {
|
||||
logger.info('system', '🎨 Tldraw mounted', {
|
||||
editorId: editor.store.id,
|
||||
presentationMode
|
||||
});
|
||||
editorRef.current = editor;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
src/pages/tldraw/multiplayerUser.tsx
Normal file
200
src/pages/tldraw/multiplayerUser.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Tldraw,
|
||||
Editor,
|
||||
useTldrawUser,
|
||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||
} from '@tldraw/tldraw';
|
||||
import { useSync } from '@tldraw/sync';
|
||||
// App context
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
|
||||
// Tldraw services
|
||||
import { multiplayerOptions } from '../../services/tldraw/optionsService';
|
||||
import { PresentationService } from '../../services/tldraw/presentationService';
|
||||
import { createSyncConnectionOptions, handleExternalAsset } from '../../services/tldraw/syncService';
|
||||
// Tldraw utils
|
||||
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
||||
import { customAssets } from '../../utils/tldraw/assets';
|
||||
import { multiplayerTools } from '../../utils/tldraw/tools';
|
||||
import { allShapeUtils } from '../../utils/tldraw/shapes';
|
||||
import { customSchema } from '../../utils/tldraw/schemas';
|
||||
import { allBindingUtils } from '../../utils/tldraw/bindings';
|
||||
import { multiplayerEmbeds } from '../../utils/tldraw/embeds';
|
||||
// Layout
|
||||
import { HEADER_HEIGHT } from '../../pages/Layout';
|
||||
// Styles
|
||||
import '../../utils/tldraw/tldraw.css';
|
||||
// App debug
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const SYNC_WORKER_URL = import.meta.env.VITE_FRONTEND_SITE_URL.startsWith('http')
|
||||
? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`
|
||||
: `https://${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`;
|
||||
|
||||
export default function TldrawMultiUser() {
|
||||
const { user } = useAuth();
|
||||
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
|
||||
const {
|
||||
tldrawPreferences,
|
||||
setTldrawPreferences,
|
||||
initializePreferences,
|
||||
presentationMode
|
||||
} = useTLDraw();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
// Get room ID from URL params
|
||||
const roomId = searchParams.get('room') || 'multiplayer';
|
||||
|
||||
// Memoize user information to ensure consistency
|
||||
const userInfo = useMemo(() => ({
|
||||
id: user?.id ?? '',
|
||||
name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User',
|
||||
color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`
|
||||
}), [user?.id, user?.display_name, user?.email, tldrawPreferences?.color]);
|
||||
|
||||
// Create editor user with memoization
|
||||
const editorUser = useTldrawUser({
|
||||
userPreferences: {
|
||||
id: userInfo.id,
|
||||
name: userInfo.name,
|
||||
color: userInfo.color,
|
||||
locale: tldrawPreferences?.locale,
|
||||
colorScheme: tldrawPreferences?.colorScheme,
|
||||
animationSpeed: tldrawPreferences?.animationSpeed,
|
||||
isSnapMode: tldrawPreferences?.isSnapMode
|
||||
},
|
||||
setUserPreferences: setTldrawPreferences
|
||||
});
|
||||
|
||||
const connectionOptions = useMemo(() => createSyncConnectionOptions({
|
||||
userId: userInfo.id,
|
||||
displayName: userInfo.name,
|
||||
color: userInfo.color,
|
||||
roomId,
|
||||
baseUrl: SYNC_WORKER_URL
|
||||
}), [userInfo, roomId]);
|
||||
|
||||
const store = useSync({
|
||||
...connectionOptions,
|
||||
schema: customSchema,
|
||||
shapeUtils: allShapeUtils,
|
||||
bindingUtils: allBindingUtils,
|
||||
userInfo: {
|
||||
id: userInfo.id,
|
||||
name: userInfo.name,
|
||||
color: userInfo.color
|
||||
}
|
||||
});
|
||||
|
||||
// Log connection status changes
|
||||
useEffect(() => {
|
||||
logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, {
|
||||
status: store.status,
|
||||
connectionOptions
|
||||
});
|
||||
}, [store.status, connectionOptions]);
|
||||
|
||||
// Effect for initializing preferences
|
||||
useEffect(() => {
|
||||
if (user?.id && !tldrawPreferences) {
|
||||
logger.info('multiplayer-page', '🔄 Initializing preferences');
|
||||
initializePreferences(user.id);
|
||||
}
|
||||
}, [user?.id, tldrawPreferences, initializePreferences]);
|
||||
|
||||
// Effect for redirecting if user is not authenticated
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
// Effect for presentation mode
|
||||
useEffect(() => {
|
||||
if (presentationMode && editorRef.current) {
|
||||
const editor = editorRef.current;
|
||||
const presentationService = new PresentationService(editor);
|
||||
const cleanup = presentationService.startPresentationMode();
|
||||
|
||||
return () => {
|
||||
presentationService.stopPresentationMode();
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
}, [presentationMode]);
|
||||
|
||||
// Memoize UI overrides and components
|
||||
const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]);
|
||||
const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]);
|
||||
|
||||
// Render conditionally to avoid unnecessary rerenders
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
{isInstituteLoading ? 'Loading institute data...' : `Connecting to room: ${roomId}...`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<Tldraw
|
||||
user={editorUser}
|
||||
store={store.store}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor;
|
||||
editor.registerExternalAssetHandler('url', async ({ url }: { url: string }) => {
|
||||
return handleExternalAsset(SYNC_WORKER_URL, url);
|
||||
});
|
||||
}}
|
||||
options={multiplayerOptions}
|
||||
embeds={multiplayerEmbeds}
|
||||
tools={multiplayerTools}
|
||||
shapeUtils={allShapeUtils}
|
||||
bindingUtils={allBindingUtils}
|
||||
overrides={uiOverrides}
|
||||
components={uiComponents}
|
||||
assetUrls={customAssets}
|
||||
autoFocus={true}
|
||||
hideUi={false}
|
||||
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
|
||||
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
|
||||
maxImageDimension={Infinity}
|
||||
maxAssetSize={100 * 1024 * 1024}
|
||||
renderDebugMenuItems={() => []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
514
src/pages/tldraw/singlePlayerPage.tsx
Normal file
514
src/pages/tldraw/singlePlayerPage.tsx
Normal file
@ -0,0 +1,514 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import {
|
||||
Tldraw,
|
||||
Editor,
|
||||
useTldrawUser,
|
||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||
TLStore,
|
||||
TLStoreWithStatus
|
||||
} from '@tldraw/tldraw';
|
||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||
import { useUser } from '../../contexts/UserContext';
|
||||
// Tldraw services
|
||||
import { localStoreService } from '../../services/tldraw/localStoreService';
|
||||
import { PresentationService } from '../../services/tldraw/presentationService';
|
||||
import { UserNeoDBService } from '../../services/graph/userNeoDBService';
|
||||
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
|
||||
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
||||
// Tldraw utils
|
||||
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
||||
import { customAssets } from '../../utils/tldraw/assets';
|
||||
import { singlePlayerTools } from '../../utils/tldraw/tools';
|
||||
import { allShapeUtils } from '../../utils/tldraw/shapes';
|
||||
import { allBindingUtils } from '../../utils/tldraw/bindings';
|
||||
import { singlePlayerEmbeds } from '../../utils/tldraw/embeds';
|
||||
import { customSchema } from '../../utils/tldraw/schemas';
|
||||
// Navigation
|
||||
import { useNavigationStore } from '../../stores/navigationStore';
|
||||
// Layout
|
||||
import { HEADER_HEIGHT } from '../../pages/Layout';
|
||||
// Styles
|
||||
import '../../utils/tldraw/tldraw.css';
|
||||
// App debug
|
||||
import { logger } from '../../debugConfig';
|
||||
import { CircularProgress, Alert, Snackbar } from '@mui/material';
|
||||
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
|
||||
import { NodeData } from '../../types/graph-shape';
|
||||
import { NavigationNode } from '../../types/navigation';
|
||||
|
||||
interface LoadingState {
|
||||
status: 'ready' | 'loading' | 'error';
|
||||
error: string;
|
||||
}
|
||||
|
||||
export default function SinglePlayerPage() {
|
||||
// Context hooks with initialization states
|
||||
const { user, loading: userLoading } = useUser();
|
||||
const {
|
||||
tldrawPreferences,
|
||||
initializePreferences,
|
||||
presentationMode,
|
||||
setTldrawPreferences
|
||||
} = useTLDraw();
|
||||
const routerNavigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Navigation store
|
||||
const { context } = useNavigationStore();
|
||||
|
||||
// Refs
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const snapshotServiceRef = useRef<NavigationSnapshotService | null>(null);
|
||||
|
||||
// State
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>({
|
||||
status: 'ready',
|
||||
error: ''
|
||||
});
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
|
||||
|
||||
// TLDraw user preferences
|
||||
const tldrawUser = useTldrawUser({
|
||||
userPreferences: {
|
||||
id: user?.id ?? '',
|
||||
name: user?.display_name,
|
||||
color: tldrawPreferences?.color,
|
||||
locale: tldrawPreferences?.locale,
|
||||
colorScheme: tldrawPreferences?.colorScheme,
|
||||
animationSpeed: tldrawPreferences?.animationSpeed,
|
||||
isSnapMode: tldrawPreferences?.isSnapMode
|
||||
},
|
||||
setUserPreferences: setTldrawPreferences
|
||||
});
|
||||
|
||||
// Initialize store
|
||||
useEffect(() => {
|
||||
if (!isEditorReady) {
|
||||
logger.debug('single-player-page', '⏳ Waiting for editor to be ready');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
logger.debug('single-player-page', '⏳ Waiting for user data');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editorRef.current) {
|
||||
logger.debug('single-player-page', '⏳ Waiting for editor ref');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('single-player-page', '🔄 Starting store initialization', {
|
||||
isEditorReady,
|
||||
hasUser: !!user,
|
||||
userType: user.user_type,
|
||||
username: user.username
|
||||
});
|
||||
|
||||
const initializeStoreAndSnapshot = async () => {
|
||||
try {
|
||||
setLoadingState({ status: 'loading', error: '' });
|
||||
|
||||
// 1. Create store
|
||||
logger.debug('single-player-page', '🔄 Creating TLStore');
|
||||
const newStore = localStoreService.getStore({
|
||||
schema: customSchema,
|
||||
shapeUtils: allShapeUtils,
|
||||
bindingUtils: allBindingUtils
|
||||
});
|
||||
logger.debug('single-player-page', '✅ TLStore created');
|
||||
|
||||
// 2. Initialize snapshot service
|
||||
const snapshotService = new NavigationSnapshotService(newStore);
|
||||
snapshotServiceRef.current = snapshotService;
|
||||
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
|
||||
|
||||
// 3. Load initial snapshot if we have a node
|
||||
if (context.node) {
|
||||
logger.debug('single-player-page', '📥 Loading snapshot from database', {
|
||||
dbName: user.user_db_name,
|
||||
tldraw_snapshot: context.node.tldraw_snapshot,
|
||||
user_type: user.user_type,
|
||||
username: user.username
|
||||
});
|
||||
|
||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||
context.node.tldraw_snapshot,
|
||||
user.user_db_name,
|
||||
newStore,
|
||||
setLoadingState
|
||||
);
|
||||
logger.debug('single-player-page', '✅ Snapshot loaded from database');
|
||||
} else {
|
||||
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
|
||||
}
|
||||
|
||||
// 4. Set up auto-save
|
||||
newStore.listen(() => {
|
||||
if (snapshotServiceRef.current && context.node) {
|
||||
logger.debug('single-player-page', '💾 Auto-saving changes');
|
||||
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
|
||||
logger.error('single-player-page', '❌ Auto-save failed', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Update store state
|
||||
setStore(newStore);
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
logger.info('single-player-page', '✅ Store initialization complete');
|
||||
|
||||
// 6. Handle cleanup
|
||||
return () => {
|
||||
logger.debug('single-player-page', '🧹 Starting cleanup');
|
||||
if (snapshotServiceRef.current) {
|
||||
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
|
||||
logger.error('single-player-page', '❌ Final save failed', error);
|
||||
});
|
||||
snapshotServiceRef.current.clearCurrentNode();
|
||||
snapshotServiceRef.current = null;
|
||||
}
|
||||
newStore.dispose();
|
||||
setStore(undefined);
|
||||
logger.debug('single-player-page', '🧹 Cleanup complete');
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize store';
|
||||
logger.error('single-player-page', '❌ Store initialization failed', error);
|
||||
setLoadingState({ status: 'error', error: errorMessage });
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
initializeStoreAndSnapshot();
|
||||
}, [isEditorReady, user, context.node, editorRef.current]);
|
||||
|
||||
// Handle initial node placement
|
||||
useEffect(() => {
|
||||
const placeInitialNode = async () => {
|
||||
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingState({ status: 'loading', error: '' });
|
||||
|
||||
// Center the node
|
||||
const nodeData = await loadNodeData(context.node);
|
||||
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
|
||||
|
||||
setIsInitialLoad(false);
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
} catch (error) {
|
||||
logger.error('single-player-page', '❌ Failed to place initial node', error);
|
||||
setLoadingState({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Failed to place initial node'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
placeInitialNode();
|
||||
}, [context.node, store, isInitialLoad]);
|
||||
|
||||
// Handle navigation changes
|
||||
useEffect(() => {
|
||||
const handleNodeChange = async () => {
|
||||
if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We can safely assert these types because we've checked for null above
|
||||
const editor = editorRef.current as Editor;
|
||||
const snapshotService = snapshotServiceRef.current;
|
||||
const currentNode = context.node;
|
||||
|
||||
try {
|
||||
setLoadingState({ status: 'loading', error: '' });
|
||||
logger.debug('single-player-page', '🔄 Loading node data', {
|
||||
nodeId: currentNode.id,
|
||||
tldraw_snapshot: currentNode.tldraw_snapshot,
|
||||
isInitialLoad
|
||||
});
|
||||
|
||||
// Get the previous node from navigation history
|
||||
const previousNode = context.history.currentIndex > 0
|
||||
? context.history.nodes[context.history.currentIndex - 1]
|
||||
: null;
|
||||
|
||||
// Handle navigation in snapshot service
|
||||
await snapshotService.handleNavigationStart(previousNode, currentNode);
|
||||
|
||||
// Center the node on canvas
|
||||
const nodeData = await loadNodeData(currentNode);
|
||||
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
|
||||
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
} catch (error) {
|
||||
logger.error('single-player-page', '❌ Failed to load node data', error);
|
||||
setLoadingState({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Failed to load node data'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleNodeChange();
|
||||
}, [context.node?.id, context.history, store]);
|
||||
|
||||
// Initialize preferences when user is available
|
||||
useEffect(() => {
|
||||
if (user?.id && !tldrawPreferences) {
|
||||
logger.debug('single-player-page', '🔄 Initializing preferences for user', { userId: user.id });
|
||||
initializePreferences(user.id);
|
||||
}
|
||||
}, [user?.id, tldrawPreferences, initializePreferences]);
|
||||
|
||||
// Redirect if no user or incorrect role
|
||||
useEffect(() => {
|
||||
if (!user || user.user_type !== 'admin') {
|
||||
logger.info('single-player-page', '🚪 Redirecting to home - no user or incorrect role', {
|
||||
hasUser: !!user,
|
||||
userType: user?.user_type
|
||||
});
|
||||
routerNavigate('/', { replace: true });
|
||||
}
|
||||
}, [user, routerNavigate]);
|
||||
|
||||
// Handle presentation mode
|
||||
useEffect(() => {
|
||||
if (presentationMode && editorRef.current) {
|
||||
logger.info('presentation', '🔄 Presentation mode changed', {
|
||||
presentationMode,
|
||||
editorExists: !!editorRef.current
|
||||
});
|
||||
|
||||
const editor = editorRef.current;
|
||||
const presentationService = new PresentationService(editor);
|
||||
const cleanup = presentationService.startPresentationMode();
|
||||
|
||||
return () => {
|
||||
logger.info('presentation', '🧹 Cleaning up presentation mode');
|
||||
presentationService.stopPresentationMode();
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
}, [presentationMode]);
|
||||
|
||||
// Handle shared content
|
||||
useEffect(() => {
|
||||
const handleSharedContent = async () => {
|
||||
if (!editorRef.current || !location.state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = editorRef.current;
|
||||
const { sharedFile, sharedContent } = location.state as {
|
||||
sharedFile?: File;
|
||||
sharedContent?: {
|
||||
title?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
if (sharedFile) {
|
||||
logger.info('single-player-page', '📤 Processing shared file', {
|
||||
name: sharedFile.name,
|
||||
type: sharedFile.type
|
||||
});
|
||||
|
||||
try {
|
||||
// Handle different file types
|
||||
if (sharedFile.type.startsWith('image/')) {
|
||||
const imageUrl = URL.createObjectURL(sharedFile);
|
||||
await editor.createShape({
|
||||
type: 'image',
|
||||
props: {
|
||||
url: imageUrl,
|
||||
w: 320,
|
||||
h: 240,
|
||||
name: sharedFile.name
|
||||
}
|
||||
});
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
} else if (sharedFile.type === 'application/pdf') {
|
||||
// Handle PDF (you might want to implement PDF handling)
|
||||
logger.info('single-player-page', '📄 PDF handling not implemented yet');
|
||||
} else if (sharedFile.type === 'text/plain') {
|
||||
const text = await sharedFile.text();
|
||||
editor.createShape({
|
||||
type: 'text',
|
||||
props: { text }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('single-player-page', '❌ Error processing shared file', { error });
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedContent) {
|
||||
logger.info('single-player-page', '📤 Processing shared content', { sharedContent });
|
||||
|
||||
const { title, text, url } = sharedContent;
|
||||
let contentText = '';
|
||||
|
||||
if (title) {
|
||||
contentText += `${title}\n`;
|
||||
}
|
||||
if (text) {
|
||||
contentText += `${text}\n`;
|
||||
}
|
||||
if (url) {
|
||||
contentText += url;
|
||||
}
|
||||
|
||||
if (contentText) {
|
||||
editor.createShape({
|
||||
type: 'text',
|
||||
props: { text: contentText }
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleSharedContent();
|
||||
}, [location.state]);
|
||||
|
||||
// Modify the render logic to use presentationMode
|
||||
const uiOverrides = getUiOverrides(presentationMode);
|
||||
const uiComponents = getUiComponents(presentationMode);
|
||||
|
||||
// Show loading state if user context is still loading
|
||||
if (userLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'var(--color-background)'
|
||||
}}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Loading overlay - show when loading or contexts not initialized */}
|
||||
{(loadingState.status === 'loading' || !store) && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
zIndex: 1000,
|
||||
}}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error snackbar */}
|
||||
<Snackbar
|
||||
open={loadingState.status === 'error'}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setLoadingState({ status: 'ready', error: '' })}
|
||||
>
|
||||
<Alert severity="error" onClose={() => setLoadingState({ status: 'ready', error: '' })}>
|
||||
{loadingState.error}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
<Tldraw
|
||||
user={tldrawUser}
|
||||
store={store}
|
||||
tools={singlePlayerTools}
|
||||
shapeUtils={allShapeUtils}
|
||||
bindingUtils={allBindingUtils}
|
||||
components={uiComponents}
|
||||
overrides={uiOverrides}
|
||||
embeds={singlePlayerEmbeds}
|
||||
assetUrls={customAssets}
|
||||
autoFocus={true}
|
||||
hideUi={false}
|
||||
inferDarkMode={false}
|
||||
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
|
||||
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
|
||||
maxImageDimension={Infinity}
|
||||
maxAssetSize={100 * 1024 * 1024}
|
||||
renderDebugMenuItems={() => []}
|
||||
onMount={(editor) => {
|
||||
logger.info('single-player-page', '🎨 Starting Tldraw mount');
|
||||
try {
|
||||
if (!editor) {
|
||||
logger.error('single-player-page', '❌ Editor is null in onMount');
|
||||
return;
|
||||
}
|
||||
|
||||
editorRef.current = editor;
|
||||
logger.debug('single-player-page', '✅ Editor ref set');
|
||||
|
||||
setIsEditorReady(true);
|
||||
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
||||
editorId: editor.store.id,
|
||||
presentationMode,
|
||||
isEditorReady: true
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('single-player-page', '❌ Error in onMount', error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
|
||||
// 1. Always fetch fresh data
|
||||
const dbName = UserNeoDBService.getNodeDatabaseName(node);
|
||||
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
|
||||
|
||||
if (!fetchedData?.node_data) {
|
||||
throw new Error('Failed to fetch node data');
|
||||
}
|
||||
|
||||
// 2. Process the data into the correct shape
|
||||
const theme = getThemeFromLabel(node.type);
|
||||
return {
|
||||
...fetchedData.node_data,
|
||||
title: fetchedData.node_data.title || node.label,
|
||||
w: 500,
|
||||
h: 350,
|
||||
state: {
|
||||
parentId: null,
|
||||
isPageChild: true,
|
||||
hasChildren: null,
|
||||
bindings: null
|
||||
},
|
||||
headerColor: theme.headerColor,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
isLocked: false,
|
||||
__primarylabel__: node.type,
|
||||
unique_id: node.id,
|
||||
tldraw_snapshot: node.tldraw_snapshot
|
||||
};
|
||||
};
|
||||
62
src/pages/user/NotFound.tsx
Normal file
62
src/pages/user/NotFound.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Box, Typography, Button, Container, useTheme } from "@mui/material";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
function NotFound() {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
logger.debug('not-found', '🔄 Not Found page rendered', {
|
||||
hasUser: !!user,
|
||||
userId: user?.id
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
const handleReturn = () => {
|
||||
const returnPath = user ? '/single-player' : '/';
|
||||
logger.debug('not-found', '🔄 Navigating to return path', { returnPath });
|
||||
navigate(returnPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
textAlign: 'center',
|
||||
gap: 3
|
||||
}}
|
||||
>
|
||||
<ErrorOutlineIcon sx={{ fontSize: 60, color: theme.palette.error.main }} />
|
||||
<Typography variant="h2" component="h1" gutterBottom>
|
||||
404
|
||||
</Typography>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Page Not Found
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
onClick={handleReturn}
|
||||
>
|
||||
Return to {user ? 'Canvas' : 'Home'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFound;
|
||||
530
src/pages/user/calendarPage.tsx
Normal file
530
src/pages/user/calendarPage.tsx
Normal file
@ -0,0 +1,530 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { EventContentArg, EventClickArg, CalendarOptions } from '@fullcalendar/core';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import multiMonthPlugin from '@fullcalendar/multimonth'; // Import the multiMonth plugin for year view
|
||||
import listPlugin from '@fullcalendar/list';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNeoUser } from '../../contexts/NeoUserContext';
|
||||
import { FaEllipsisV } from 'react-icons/fa';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { TimetableNeoDBService } from '../../services/graph/timetableNeoDBService';
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
groupId?: string;
|
||||
extendedProps?: {
|
||||
subjectClass: string;
|
||||
color: string;
|
||||
periodCode: string;
|
||||
tldraw_snapshot?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function lightenColor(color: string, amount: number): string {
|
||||
// Remove the '#' if it exists
|
||||
color = color.replace(/^#/, '');
|
||||
|
||||
// Parse the color
|
||||
let r = parseInt(color.slice(0, 2), 16);
|
||||
let g = parseInt(color.slice(2, 4), 16);
|
||||
let b = parseInt(color.slice(4, 6), 16);
|
||||
|
||||
// Convert to HSL
|
||||
const [h, s, l] = rgbToHsl(r, g, b);
|
||||
|
||||
// Adjust the lightness based on the current lightness
|
||||
const newL = l < 0.5 ? l + (1 - l) * amount : l + (1 - l) * amount * 0.5;
|
||||
|
||||
// Convert back to RGB
|
||||
[r, g, b] = hslToRgb(h, s, newL);
|
||||
|
||||
// Convert to hex and return
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0, s, l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0; // achromatic
|
||||
} else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
case b: h = (r - g) / d + 4; break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
let r, g, b;
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l; // achromatic
|
||||
} else {
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) {
|
||||
t += 1;
|
||||
}
|
||||
if (t > 1) {
|
||||
t -= 1;
|
||||
}
|
||||
if (t < 1/6) {
|
||||
return p + (q - p) * 6 * t;
|
||||
}
|
||||
if (t < 1/2) {
|
||||
return q;
|
||||
}
|
||||
if (t < 2/3) {
|
||||
return p + (q - p) * (2/3 - t) * 6;
|
||||
}
|
||||
return p;
|
||||
};
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1/3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1/3);
|
||||
}
|
||||
|
||||
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
||||
}
|
||||
|
||||
const CalendarPage: React.FC = () => {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
|
||||
const { user } = useAuth();
|
||||
const calendarRef = useRef<FullCalendar>(null);
|
||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||
const [hiddenSubjectClassDivs, setHiddenSubjectClassDivs] = useState<string[]>([]);
|
||||
const [hiddenPeriodCodeDivs, setHiddenPeriodCodeDivs] = useState<string[]>([]);
|
||||
const [hiddenTimeDivs, setHiddenTimeDivs] = useState<string[]>([]);
|
||||
const [eventRange, setEventRange] = useState<{ start: Date | null; end: Date | null }>({ start: null, end: null });
|
||||
const { workerNode, isLoading, error, workerDbName } = useNeoUser();
|
||||
|
||||
const getEventRange = useCallback((events: Event[]) => {
|
||||
if (events.length === 0) {
|
||||
return { start: null, end: null };
|
||||
}
|
||||
|
||||
let start = new Date(events[0].start);
|
||||
let end = new Date(events[0].end);
|
||||
|
||||
events.forEach(event => {
|
||||
const eventStart = new Date(event.start);
|
||||
const eventEnd = new Date(event.end);
|
||||
if (eventStart < start) {
|
||||
start = eventStart;
|
||||
}
|
||||
if (eventEnd > end) {
|
||||
end = eventEnd;
|
||||
}
|
||||
});
|
||||
|
||||
// Adjust start to the beginning of its month and end to the end of its month
|
||||
start.setDate(1);
|
||||
end.setMonth(end.getMonth() + 1, 0);
|
||||
|
||||
return { start, end };
|
||||
}, []);
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
if (!user || isLoading || error || !workerNode?.nodeData) {
|
||||
if (error) {
|
||||
logger.error('calendar', 'NeoUser context error', { error });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug('calendar', 'Fetching events', {
|
||||
unique_id: workerNode.nodeData.unique_id,
|
||||
school_db_name: workerDbName
|
||||
});
|
||||
|
||||
const events = await TimetableNeoDBService.fetchTeacherTimetableEvents(
|
||||
workerNode.nodeData.unique_id,
|
||||
workerDbName || ''
|
||||
);
|
||||
|
||||
const transformedEvents = events.map(event => ({
|
||||
...event,
|
||||
extendedProps: {
|
||||
...event.extendedProps,
|
||||
tldraw_snapshot: workerNode?.nodeData?.tldraw_snapshot
|
||||
}
|
||||
}));
|
||||
|
||||
setEvents(transformedEvents);
|
||||
|
||||
const classes: string[] = [];
|
||||
transformedEvents.forEach((event: Event) => {
|
||||
if (event.extendedProps?.subjectClass && !classes.includes(event.extendedProps.subjectClass)) {
|
||||
classes.push(event.extendedProps.subjectClass);
|
||||
}
|
||||
});
|
||||
|
||||
setSelectedClasses(classes);
|
||||
|
||||
const range = getEventRange(transformedEvents);
|
||||
setEventRange(range);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('calendar', 'Error fetching events', { error });
|
||||
}
|
||||
}, [user, workerNode, workerDbName, isLoading, error, getEventRange]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, [fetchEvents]);
|
||||
|
||||
const handleEventClick = useCallback((clickInfo: EventClickArg) => {
|
||||
const tldraw_snapshot = clickInfo.event.extendedProps?.tldraw_snapshot;
|
||||
if (tldraw_snapshot) {
|
||||
// TODO: Implement tldraw_snapshot retrieval from storage API
|
||||
// For now, we'll just log it
|
||||
console.log('TLDraw snapshot:', tldraw_snapshot);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredEvents = useMemo(() =>
|
||||
events.filter(event =>
|
||||
selectedClasses.includes(event.extendedProps?.subjectClass || '')
|
||||
), [events, selectedClasses]
|
||||
);
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (calendarRef.current) {
|
||||
calendarRef.current.getApi().updateSize();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
const toggleDropdown = useCallback((eventId: string) => {
|
||||
setOpenDropdownId(openDropdownId === eventId ? null : eventId);
|
||||
}, [openDropdownId]);
|
||||
|
||||
const toggleSubjectClassDivVisibility = useCallback((subjectClass: string) => {
|
||||
setHiddenSubjectClassDivs(prev =>
|
||||
prev.includes(subjectClass)
|
||||
? prev.filter(c => c !== subjectClass)
|
||||
: [...prev, subjectClass]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const togglePeriodCodeDivVisibility = useCallback((subjectClass: string) => {
|
||||
setHiddenPeriodCodeDivs(prev =>
|
||||
prev.includes(subjectClass)
|
||||
? prev.filter(c => c !== subjectClass)
|
||||
: [...prev, subjectClass]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleTimeDivVisibility = useCallback((subjectClass: string) => {
|
||||
setHiddenTimeDivs(prev =>
|
||||
prev.includes(subjectClass)
|
||||
? prev.filter(c => c !== subjectClass)
|
||||
: [...prev, subjectClass]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const hideSubjectClassFromView = useCallback((subjectClass: string) => {
|
||||
setSelectedClasses(prev => prev.filter(c => c !== subjectClass));
|
||||
}, []);
|
||||
|
||||
const toggleAllDivs = useCallback((subjectClass: string, hide: boolean) => {
|
||||
const updateHiddenDivs = (prev: string[]) =>
|
||||
hide ? [...prev, subjectClass] : prev.filter(c => c !== subjectClass);
|
||||
|
||||
setHiddenSubjectClassDivs(updateHiddenDivs);
|
||||
setHiddenPeriodCodeDivs(updateHiddenDivs);
|
||||
setHiddenTimeDivs(updateHiddenDivs);
|
||||
}, []);
|
||||
|
||||
const areAllDivsHidden = useCallback((subjectClass: string) => {
|
||||
return hiddenSubjectClassDivs.includes(subjectClass) &&
|
||||
hiddenPeriodCodeDivs.includes(subjectClass) &&
|
||||
hiddenTimeDivs.includes(subjectClass);
|
||||
}, [hiddenSubjectClassDivs, hiddenPeriodCodeDivs, hiddenTimeDivs]);
|
||||
|
||||
const renderEventContent = useCallback((eventInfo: EventContentArg) => {
|
||||
const { event } = eventInfo;
|
||||
const subjectClass = event.extendedProps?.subjectClass || 'Subject Class';
|
||||
const originalColor = event.extendedProps?.color || '#ffffff';
|
||||
const lightenedColor = lightenColor(originalColor, 0.9);
|
||||
|
||||
const eventStyle = {
|
||||
backgroundColor: lightenedColor,
|
||||
color: '#000',
|
||||
padding: '4px 6px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '1.0em',
|
||||
overflow: 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
height: '100%',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
border: `2px solid ${originalColor}`,
|
||||
position: 'relative' as const,
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
fontWeight: 'bold' as const,
|
||||
whiteSpace: 'nowrap' as const,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
paddingRight: '20px',
|
||||
};
|
||||
|
||||
const contentStyle = {
|
||||
fontSize: '0.8em',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
};
|
||||
|
||||
const ellipsisStyle = {
|
||||
position: 'absolute' as const,
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
cursor: 'pointer',
|
||||
zIndex: 10,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`custom-event-content ${openDropdownId === event.id ? 'event-with-dropdown' : ''}`} style={eventStyle}>
|
||||
<div style={titleStyle}>{event.title}</div>
|
||||
<div style={ellipsisStyle}>
|
||||
<FaEllipsisV onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleDropdown(event.id);
|
||||
}} />
|
||||
</div>
|
||||
{openDropdownId === event.id && (
|
||||
<div className="event-dropdown" style={{ position: 'absolute'}}>
|
||||
<div onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
hideSubjectClassFromView(subjectClass);
|
||||
setOpenDropdownId(null);
|
||||
}}>
|
||||
Hide this class from view
|
||||
</div>
|
||||
<div onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleAllDivs(subjectClass, !areAllDivsHidden(subjectClass));
|
||||
setOpenDropdownId(null);
|
||||
}}>
|
||||
{areAllDivsHidden(subjectClass) ? 'Show' : 'Hide'} all divs
|
||||
</div>
|
||||
<div onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSubjectClassDivVisibility(subjectClass);
|
||||
setOpenDropdownId(null);
|
||||
}}>
|
||||
{hiddenSubjectClassDivs.includes(subjectClass) ? 'Show' : 'Hide'} subject class
|
||||
</div>
|
||||
<div onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePeriodCodeDivVisibility(subjectClass);
|
||||
setOpenDropdownId(null);
|
||||
}}>
|
||||
{hiddenPeriodCodeDivs.includes(subjectClass) ? 'Show' : 'Hide'} period code
|
||||
</div>
|
||||
<div onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTimeDivVisibility(subjectClass);
|
||||
setOpenDropdownId(null);
|
||||
}}>
|
||||
{hiddenTimeDivs.includes(subjectClass) ? 'Show' : 'Hide'} time
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hiddenSubjectClassDivs.includes(subjectClass) && (
|
||||
<div style={contentStyle} className="event-subject-class">{subjectClass}</div>
|
||||
)}
|
||||
{!hiddenPeriodCodeDivs.includes(subjectClass) && (
|
||||
<div style={contentStyle} className="event-period">{event.extendedProps?.periodCode || 'Period Code'}</div>
|
||||
)}
|
||||
{!hiddenTimeDivs.includes(subjectClass) && (
|
||||
<div style={contentStyle} className="event-time">
|
||||
{event.start?.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} -
|
||||
{event.end?.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [openDropdownId, hiddenSubjectClassDivs, hiddenPeriodCodeDivs, hiddenTimeDivs, toggleDropdown, hideSubjectClassFromView, toggleAllDivs, areAllDivsHidden, toggleSubjectClassDivVisibility, togglePeriodCodeDivVisibility, toggleTimeDivVisibility]);
|
||||
|
||||
const calendarOptions: CalendarOptions = useMemo(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, multiMonthPlugin, listPlugin],
|
||||
initialView: "timeGridWeek",
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'viewToggle filterClassesButton'
|
||||
},
|
||||
customButtons: {
|
||||
filterClassesButton: {
|
||||
text: 'Filter Classes',
|
||||
click: () => {} // We'll implement this differently later
|
||||
},
|
||||
viewToggle: {
|
||||
text: 'Change View',
|
||||
click: () => {} // We'll implement this differently later
|
||||
}
|
||||
},
|
||||
views: {
|
||||
dayGridYear: {
|
||||
type: 'dayGrid',
|
||||
duration: { years: 1 },
|
||||
buttonText: 'Year Grid',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
dayGridMonth: {
|
||||
buttonText: 'Month',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
timeGridWeek: {
|
||||
buttonText: 'Week',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
timeGridDay: {
|
||||
buttonText: 'Day',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
listYear: {
|
||||
buttonText: 'List Year',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
listMonth: {
|
||||
buttonText: 'List Month',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
listWeek: {
|
||||
buttonText: 'List Week',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
listDay: {
|
||||
buttonText: 'List Day',
|
||||
visibleRange: (currentDate: Date) => ({
|
||||
start: eventRange.start || currentDate,
|
||||
end: eventRange.end || currentDate
|
||||
}),
|
||||
},
|
||||
},
|
||||
validRange: eventRange.start && eventRange.end ? {
|
||||
start: eventRange.start,
|
||||
end: eventRange.end
|
||||
} : undefined,
|
||||
events: filteredEvents,
|
||||
height: "100%",
|
||||
slotMinTime: "08:00:00",
|
||||
slotMaxTime: "17:00:00",
|
||||
allDaySlot: false,
|
||||
expandRows: true,
|
||||
slotEventOverlap: false,
|
||||
slotDuration: "00:30:00",
|
||||
slotLabelInterval: "01:00",
|
||||
eventContent: renderEventContent,
|
||||
eventClassNames: (arg: { event: { extendedProps?: { subjectClass?: string } } }) =>
|
||||
[arg.event.extendedProps?.subjectClass || ''],
|
||||
eventDidMount: (arg: { event: { extendedProps?: { color?: string }; id: string }; el: HTMLElement }) => {
|
||||
if (arg.event.extendedProps?.color) {
|
||||
const originalColor = arg.event.extendedProps.color;
|
||||
const lightenedColor = lightenColor(originalColor, 0.4);
|
||||
arg.el.style.backgroundColor = lightenedColor;
|
||||
arg.el.style.borderColor = originalColor;
|
||||
}
|
||||
|
||||
const updateEventContent = () => {
|
||||
const height = arg.el.offsetHeight;
|
||||
const contentElements = arg.el.querySelectorAll('.custom-event-content > div:not(.event-dropdown)');
|
||||
|
||||
contentElements.forEach((el, index) => {
|
||||
const element = el as HTMLElement;
|
||||
if (index === 0 || index === 1) {
|
||||
element.style.display = 'block';
|
||||
} else if (height >= 40 && index === 2) {
|
||||
element.style.display = 'block';
|
||||
} else if (height >= 60 && index === 3) {
|
||||
element.style.display = 'block';
|
||||
} else if (height >= 80 && index === 4) {
|
||||
element.style.display = 'block';
|
||||
} else {
|
||||
element.style.display = 'none';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
updateEventContent();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateEventContent);
|
||||
resizeObserver.observe(arg.el);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
},
|
||||
eventClick: handleEventClick,
|
||||
}), [eventRange.start, eventRange.end, filteredEvents, renderEventContent, handleEventClick]);
|
||||
|
||||
if (!user) {
|
||||
console.log('User not logged in');
|
||||
return <div>Please log in to view your calendar.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="calendar-page">
|
||||
<div className="calendar-container" style={{ height: '100vh', position: 'relative' }}>
|
||||
<FullCalendar
|
||||
{...calendarOptions}
|
||||
ref={calendarRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarPage;
|
||||
123
src/pages/user/settingsPage.tsx
Normal file
123
src/pages/user/settingsPage.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Paper,
|
||||
Box,
|
||||
Button,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNeoUser } from '../../contexts/NeoUserContext';
|
||||
import { TimetableNeoDBService } from '../../services/graph/timetableNeoDBService';
|
||||
import { CCTeacherNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { user, user_role } = useAuth();
|
||||
const { userNode, workerNode } = useNeoUser();
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// Check if user is a teacher (includes both email and MS teachers)
|
||||
const isTeacher = user_role?.includes('teacher');
|
||||
|
||||
const handleTimetableUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
setUploadSuccess(null);
|
||||
const result = await TimetableNeoDBService.handleTimetableUpload(
|
||||
event.target.files?.[0],
|
||||
userNode || undefined,
|
||||
workerNode?.nodeData as CCTeacherNodeProps | undefined
|
||||
);
|
||||
if (result.success) {
|
||||
setUploadSuccess(result.message);
|
||||
} else {
|
||||
setUploadError(result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (event.target) {
|
||||
event.target.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Settings
|
||||
</Typography>
|
||||
|
||||
{/* User Info Section */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
User Information
|
||||
</Typography>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body1">
|
||||
Email: {user?.email}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
Role: {user_role}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Timetable Upload Section - Only visible for teachers */}
|
||||
{isTeacher && (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Timetable Management
|
||||
</Typography>
|
||||
|
||||
{!userNode && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
Your workspace is being set up. Some features may be limited until setup is complete.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{uploadError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadSuccess && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{uploadSuccess}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
disabled={isUploading || !workerNode}
|
||||
color="secondary"
|
||||
fullWidth
|
||||
>
|
||||
{isUploading ? 'Uploading...' : 'Upload Timetable'}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".xlsx"
|
||||
onChange={handleTimetableUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Upload your timetable in Excel (.xlsx) format
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Additional settings sections can be added here */}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
BIN
src/services/.DS_Store
vendored
Normal file
BIN
src/services/.DS_Store
vendored
Normal file
Binary file not shown.
265
src/services/auth/authService.ts
Normal file
265
src/services/auth/authService.ts
Normal file
@ -0,0 +1,265 @@
|
||||
import { User, AuthChangeEvent, Session } from '@supabase/supabase-js';
|
||||
import { TLUserPreferences } from '@tldraw/tldraw';
|
||||
import { supabase } from '../../supabaseClient';
|
||||
import { storageService, StorageKeys } from './localStorageService';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { DatabaseNameService } from '../graph/databaseNameService';
|
||||
|
||||
export interface CCUser {
|
||||
id: string;
|
||||
email?: string;
|
||||
user_type: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
user_db_name: string;
|
||||
school_db_name: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CCUserMetadata {
|
||||
username?: string;
|
||||
user_type?: string;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export function convertToCCUser(user: User, metadata: CCUserMetadata): CCUser {
|
||||
// Extract username from various possible sources
|
||||
const username = metadata.username ||
|
||||
metadata.preferred_username ||
|
||||
metadata.email?.split('@')[0] ||
|
||||
user.email?.split('@')[0] ||
|
||||
'user';
|
||||
|
||||
// Extract display name from various possible sources
|
||||
const displayName = metadata.display_name ||
|
||||
metadata.name ||
|
||||
metadata.preferred_username ||
|
||||
username;
|
||||
|
||||
// Default to student if no user type specified
|
||||
const userType = metadata.user_type || 'student';
|
||||
|
||||
const userDbName = DatabaseNameService.getUserPrivateDB(
|
||||
userType,
|
||||
username
|
||||
);
|
||||
const schoolDbName = DatabaseNameService.getDevelopmentSchoolDB();
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
user_type: userType,
|
||||
username: username,
|
||||
display_name: displayName,
|
||||
user_db_name: userDbName,
|
||||
school_db_name: schoolDbName,
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export type UserRole =
|
||||
| 'email_teacher'
|
||||
| 'email_student'
|
||||
| 'cc_admin'
|
||||
| 'cc_developer'
|
||||
| 'super_admin';
|
||||
|
||||
// Login response
|
||||
export interface LoginResponse {
|
||||
user: CCUser | null;
|
||||
accessToken: string | null;
|
||||
userRole: string;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
// Session response
|
||||
export interface SessionResponse {
|
||||
user: CCUser | null;
|
||||
accessToken: string | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
// Registration response
|
||||
export interface RegistrationResponse extends LoginResponse {
|
||||
user: CCUser;
|
||||
accessToken: string | null;
|
||||
userRole: UserRole;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface EmailCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
role: 'email_teacher' | 'email_student';
|
||||
}
|
||||
|
||||
export type AuthCredentials = EmailCredentials;
|
||||
|
||||
export const getTldrawPreferences = (user: CCUser): TLUserPreferences => {
|
||||
return {
|
||||
id: user.id,
|
||||
colorScheme: 'system',
|
||||
};
|
||||
};
|
||||
|
||||
class AuthService {
|
||||
private static instance: AuthService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
onAuthStateChange(
|
||||
callback: (event: AuthChangeEvent, session: Session | null) => void
|
||||
) {
|
||||
return supabase.auth.onAuthStateChange((event, session) => {
|
||||
logger.info('auth-service', '🔄 Auth state changed', {
|
||||
event,
|
||||
hasSession: !!session,
|
||||
userId: session?.user?.id,
|
||||
eventType: event,
|
||||
});
|
||||
|
||||
// Ensure we clear storage on signout
|
||||
if (event === 'SIGNED_OUT') {
|
||||
storageService.clearAll();
|
||||
}
|
||||
|
||||
callback(event, session);
|
||||
});
|
||||
}
|
||||
|
||||
static getInstance(): AuthService {
|
||||
if (!AuthService.instance) {
|
||||
AuthService.instance = new AuthService();
|
||||
}
|
||||
return AuthService.instance;
|
||||
}
|
||||
|
||||
async getCurrentSession(): Promise<SessionResponse> {
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await supabase.auth.getSession();
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return { user: null, accessToken: null, message: 'No active session' };
|
||||
}
|
||||
|
||||
return {
|
||||
user: convertToCCUser(
|
||||
session.user,
|
||||
session.user.user_metadata as CCUserMetadata
|
||||
),
|
||||
accessToken: session.access_token,
|
||||
message: 'Session retrieved',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('auth-service', 'Failed to get current session:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentUser(): Promise<CCUser | null> {
|
||||
try {
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser();
|
||||
if (error || !user) {
|
||||
return null;
|
||||
}
|
||||
return convertToCCUser(user, user.user_metadata as CCUserMetadata);
|
||||
} catch (error) {
|
||||
logger.error('auth-service', 'Failed to get current user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async login({
|
||||
email,
|
||||
password,
|
||||
role,
|
||||
}: EmailCredentials): Promise<LoginResponse> {
|
||||
try {
|
||||
logger.info('auth-service', '🔄 Attempting login', {
|
||||
email,
|
||||
role,
|
||||
});
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error('auth-service', '❌ Supabase auth error', {
|
||||
error: error.message,
|
||||
status: error.status,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data.session) {
|
||||
logger.error('auth-service', '❌ No session after login');
|
||||
throw new Error('No session after login');
|
||||
}
|
||||
|
||||
const ccUser = convertToCCUser(
|
||||
data.user,
|
||||
data.user.user_metadata as CCUserMetadata
|
||||
);
|
||||
|
||||
// Store auth session in storage
|
||||
storageService.set(StorageKeys.USER_ROLE, ccUser.user_type);
|
||||
storageService.set(StorageKeys.USER, ccUser);
|
||||
storageService.set(StorageKeys.SUPABASE_TOKEN, data.session.access_token);
|
||||
|
||||
logger.info('auth-service', '✅ Login successful', {
|
||||
userId: ccUser.id,
|
||||
role: ccUser.user_type,
|
||||
username: ccUser.username,
|
||||
});
|
||||
|
||||
return {
|
||||
user: ccUser,
|
||||
accessToken: data.session.access_token,
|
||||
userRole: ccUser.user_type,
|
||||
message: 'Login successful',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('auth-service', '❌ Login failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
logger.debug('auth-service', '🔄 Attempting logout');
|
||||
const { error } = await supabase.auth.signOut({ scope: 'local' });
|
||||
if (error) {
|
||||
logger.error('auth-service', '❌ Logout failed:', error);
|
||||
throw error;
|
||||
}
|
||||
// Clear all stored data
|
||||
storageService.clearAll();
|
||||
// Force a refresh of the auth state
|
||||
await supabase.auth.refreshSession();
|
||||
logger.debug('auth-service', '✅ Logout successful');
|
||||
} catch (error) {
|
||||
logger.error('auth-service', '❌ Logout failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = AuthService.getInstance();
|
||||
|
||||
111
src/services/auth/localStorageService.ts
Normal file
111
src/services/auth/localStorageService.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { TLUserPreferences, TLUser } from '@tldraw/tldraw';
|
||||
import { CCUser } from '../../services/auth/authService';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
// Type-safe storage keys
|
||||
export enum StorageKeys {
|
||||
USER = 'user',
|
||||
USER_ROLE = 'user_role',
|
||||
SUPABASE_TOKEN = 'supabase_token',
|
||||
MS_TOKEN = 'msAccessToken',
|
||||
NEO4J_USER_DB = 'neo4jUserDbName',
|
||||
NEO4J_WORKER_DB = 'neo4jWorkerDbName',
|
||||
USER_NODES = 'userNodes',
|
||||
CALENDAR_DATA = 'calendarData',
|
||||
IS_NEW_REGISTRATION = 'isNewRegistration',
|
||||
TLDRAW_PREFERENCES = 'tldrawUserPreferences',
|
||||
TLDRAW_FILE_PATH = 'tldrawUserFilePath',
|
||||
LOCAL_SNAPSHOT = 'localSnapshot',
|
||||
NODE_FILE_PATH = 'nodeFilePath',
|
||||
ONENOTE_NOTEBOOK = 'oneNoteNotebook',
|
||||
PRESENTATION_MODE = 'presentationMode',
|
||||
TLDRAW_USER = 'tldrawUser'
|
||||
}
|
||||
|
||||
interface StorageValueTypes {
|
||||
[StorageKeys.USER]: CCUser;
|
||||
[StorageKeys.USER_ROLE]: string;
|
||||
[StorageKeys.SUPABASE_TOKEN]: string;
|
||||
[StorageKeys.MS_TOKEN]: string;
|
||||
[StorageKeys.NEO4J_USER_DB]: string;
|
||||
[StorageKeys.NEO4J_WORKER_DB]: string;
|
||||
[StorageKeys.USER_NODES]: any[];
|
||||
[StorageKeys.CALENDAR_DATA]: any;
|
||||
[StorageKeys.IS_NEW_REGISTRATION]: boolean;
|
||||
[StorageKeys.TLDRAW_PREFERENCES]: TLUserPreferences;
|
||||
[StorageKeys.TLDRAW_FILE_PATH]: string;
|
||||
[StorageKeys.LOCAL_SNAPSHOT]: any;
|
||||
[StorageKeys.NODE_FILE_PATH]: string;
|
||||
[StorageKeys.ONENOTE_NOTEBOOK]: any;
|
||||
[StorageKeys.PRESENTATION_MODE]: boolean;
|
||||
[StorageKeys.TLDRAW_USER]: TLUser;
|
||||
}
|
||||
|
||||
type StorageKey = keyof StorageValueTypes;
|
||||
|
||||
class StorageService {
|
||||
private static instance: StorageService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): StorageService {
|
||||
if (!StorageService.instance) {
|
||||
StorageService.instance = new StorageService();
|
||||
}
|
||||
return StorageService.instance;
|
||||
}
|
||||
|
||||
get<K extends StorageKey>(key: K): StorageValueTypes[K] | null {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : null;
|
||||
} catch (error) {
|
||||
logger.error('storage-service', `Error retrieving ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
set<K extends StorageKey>(key: K, value: StorageValueTypes[K]): void {
|
||||
try {
|
||||
const serializedValue = JSON.stringify(value);
|
||||
localStorage.setItem(key, serializedValue);
|
||||
logger.debug('storage-service', `Stored ${key} in localStorage`);
|
||||
} catch (error) {
|
||||
logger.error('storage-service', `Error storing ${key}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
remove(key: StorageKey): void {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
logger.debug('storage-service', `Removed ${key} from localStorage`);
|
||||
} catch (error) {
|
||||
logger.error('storage-service', `Error removing ${key}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
try {
|
||||
// Only clear app-specific storage, not Supabase's internal keys
|
||||
Object.values(StorageKeys).forEach(key => {
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
logger.debug('storage-service', 'Cleared all app items from localStorage');
|
||||
} catch (error) {
|
||||
logger.error('storage-service', 'Error clearing storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to update state and storage together
|
||||
setStateAndStorage<K extends StorageKey>(
|
||||
setter: React.Dispatch<React.SetStateAction<StorageValueTypes[K]>>,
|
||||
key: K,
|
||||
value: StorageValueTypes[K]
|
||||
): void {
|
||||
setter(value);
|
||||
this.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export const storageService = StorageService.getInstance();
|
||||
0
src/services/auth/microsoft/oneDriveService.ts
Normal file
0
src/services/auth/microsoft/oneDriveService.ts
Normal file
107
src/services/auth/microsoft/oneNoteService.ts
Normal file
107
src/services/auth/microsoft/oneNoteService.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { supabase } from '../../../supabaseClient';
|
||||
import axios from '../../../axiosConfig';
|
||||
|
||||
export interface StandardizedOneNoteDetails {
|
||||
id: string;
|
||||
displayName: string;
|
||||
createdDateTime: string;
|
||||
lastModifiedDateTime: string;
|
||||
links: {
|
||||
oneNoteClientUrl: string;
|
||||
oneNoteWebUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateUserOneNoteDetails(userId: string, oneNoteDetails: StandardizedOneNoteDetails) {
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
one_note_details: oneNoteDetails,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', userId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating OneNote details:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOneNoteNotebooks(msAccessToken: string) {
|
||||
try {
|
||||
const response = await axios.get(`/msgraph/onenote/get-onenote-notebooks`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${msAccessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting notebooks:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOneNoteNotebook(msAccessToken: string, uid: string): Promise<StandardizedOneNoteDetails> {
|
||||
if (!msAccessToken) {
|
||||
throw new Error('Microsoft token not found');
|
||||
}
|
||||
|
||||
try {
|
||||
const notebooks = await getOneNoteNotebooks(msAccessToken);
|
||||
const existingNotebook = notebooks.value.find((notebook: any) =>
|
||||
notebook.displayName === 'Classroom Copilot'
|
||||
);
|
||||
|
||||
if (existingNotebook) {
|
||||
const standardizedNotebook = standardizeNotebookDetails(existingNotebook);
|
||||
await updateUserOneNoteDetails(uid, standardizedNotebook);
|
||||
return standardizedNotebook;
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`/msgraph/onenote/create-onenote-notebook?notebook_name=${encodeURIComponent('Classroom Copilot')}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${msAccessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const standardizedNotebook = standardizeNotebookDetails(response.data);
|
||||
await updateUserOneNoteDetails(uid, standardizedNotebook);
|
||||
return standardizedNotebook;
|
||||
} catch (error) {
|
||||
console.error('Error creating notebook:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerOneNoteUser(msAccessToken: string, uid: string) {
|
||||
try {
|
||||
return await createOneNoteNotebook(msAccessToken, uid);
|
||||
} catch (error) {
|
||||
console.error('Error registering Microsoft user:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function standardizeNotebookDetails(notebook: any): StandardizedOneNoteDetails {
|
||||
const notebookData = notebook.data || notebook;
|
||||
return {
|
||||
id: notebookData.id || '',
|
||||
displayName: notebookData.displayName || '',
|
||||
createdDateTime: notebookData.createdDateTime || '',
|
||||
lastModifiedDateTime: notebookData.lastModifiedDateTime || '',
|
||||
links: {
|
||||
oneNoteClientUrl: notebookData.links?.oneNoteClientUrl?.href || '',
|
||||
oneNoteWebUrl: notebookData.links?.oneNoteWebUrl?.href || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
86
src/services/auth/profileService.ts
Normal file
86
src/services/auth/profileService.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { TLUserPreferences } from '@tldraw/tldraw';
|
||||
import { supabase } from '../../supabaseClient';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { CCUser } from './authService';
|
||||
|
||||
export type UserProfile = CCUser;
|
||||
|
||||
export interface UserProfileUpdate extends Partial<UserProfile> {
|
||||
id: string; // ID is always required for updates
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
tldraw?: TLUserPreferences;
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
notifications?: boolean;
|
||||
}
|
||||
|
||||
export async function createUserProfile(profile: UserProfile): Promise<UserProfile | null> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.insert([profile])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error('supabase-profile-service', '❌ Failed to create user profile', {
|
||||
userId: profile.id,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('supabase-profile-service', '❌ Error in createUserProfile', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserProfile(userId: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error('supabase-profile-service', '❌ Failed to fetch user profile', {
|
||||
userId,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('supabase-profile-service', '❌ Error in getUserProfile', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserProfile(update: UserProfileUpdate): Promise<UserProfile | null> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.update(update)
|
||||
.eq('id', update.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error('supabase-profile-service', '❌ Failed to update user profile', {
|
||||
userId: update.id,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('supabase-profile-service', '❌ Error in updateUserProfile', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
118
src/services/auth/registrationService.ts
Normal file
118
src/services/auth/registrationService.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { supabase } from '../../supabaseClient';
|
||||
import { CCUser, convertToCCUser } from '../../services/auth/authService';
|
||||
import { EmailCredentials } from '../../services/auth/authService';
|
||||
import { formatEmailForDatabase } from '../graph/neoDBService';
|
||||
import { RegistrationResponse } from '../../services/auth/authService';
|
||||
import { neoRegistrationService } from '../graph/neoRegistrationService';
|
||||
import { storageService, StorageKeys } from './localStorageService';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const REGISTRATION_SERVICE = 'registration-service';
|
||||
|
||||
export class RegistrationService {
|
||||
private static instance: RegistrationService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): RegistrationService {
|
||||
if (!RegistrationService.instance) {
|
||||
RegistrationService.instance = new RegistrationService();
|
||||
}
|
||||
return RegistrationService.instance;
|
||||
}
|
||||
|
||||
async register(credentials: EmailCredentials, displayName: string): Promise<RegistrationResponse> {
|
||||
try {
|
||||
logger.debug(REGISTRATION_SERVICE, '🔄 Starting registration', {
|
||||
email: credentials.email,
|
||||
role: credentials.role,
|
||||
hasDisplayName: !!displayName
|
||||
});
|
||||
|
||||
// Generate username from email (or use another method)
|
||||
const username = formatEmailForDatabase(credentials.email);
|
||||
|
||||
// 1. First sign up the user in auth
|
||||
const { data: authData, error: signUpError } = await supabase.auth.signUp({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
options: {
|
||||
data: {
|
||||
user_type: credentials.role,
|
||||
username: username,
|
||||
display_name: displayName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (signUpError) {
|
||||
logger.error(REGISTRATION_SERVICE, '❌ Supabase signup error', { error: signUpError });
|
||||
throw signUpError;
|
||||
}
|
||||
|
||||
if (!authData.user) {
|
||||
logger.error(REGISTRATION_SERVICE, '❌ No user data after registration');
|
||||
throw new Error('No user data after registration');
|
||||
}
|
||||
|
||||
const ccUser: CCUser = convertToCCUser(authData.user, authData.user.user_metadata);
|
||||
|
||||
// 2. Update the profile with the correct user type
|
||||
const { error: updateError } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
user_type: credentials.role,
|
||||
username: username,
|
||||
display_name: displayName
|
||||
})
|
||||
.eq('id', authData.user.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
logger.error(REGISTRATION_SERVICE, '❌ Failed to update profile', updateError);
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
storageService.set(StorageKeys.IS_NEW_REGISTRATION, true);
|
||||
|
||||
// 3. Create Neo4j nodes
|
||||
try {
|
||||
const userNode = await neoRegistrationService.registerNeo4JUser(
|
||||
ccUser,
|
||||
username, // Pass username for database operations
|
||||
credentials.role
|
||||
);
|
||||
|
||||
logger.info(REGISTRATION_SERVICE, '✅ Registration successful with Neo4j setup', {
|
||||
userId: ccUser.id,
|
||||
hasUserNode: !!userNode
|
||||
});
|
||||
|
||||
return {
|
||||
user: ccUser,
|
||||
accessToken: authData.session?.access_token || null,
|
||||
userRole: credentials.role,
|
||||
message: 'Registration successful'
|
||||
};
|
||||
} catch (neo4jError) {
|
||||
logger.warn(REGISTRATION_SERVICE, '⚠️ Neo4j setup problem', {
|
||||
userId: ccUser.id,
|
||||
error: neo4jError
|
||||
});
|
||||
// Return success even if Neo4j setup is pending
|
||||
return {
|
||||
user: ccUser,
|
||||
accessToken: authData.session?.access_token || null,
|
||||
userRole: credentials.role,
|
||||
message: 'Registration successful - Neo4j setup pending'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(REGISTRATION_SERVICE, '❌ Registration failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const registrationService = RegistrationService.getInstance();
|
||||
0
src/services/graph/calendarNeoDBService.ts
Normal file
0
src/services/graph/calendarNeoDBService.ts
Normal file
45
src/services/graph/curriculumNeoDBService.ts
Normal file
45
src/services/graph/curriculumNeoDBService.ts
Normal file
@ -0,0 +1,45 @@
|
||||
export async function uploadCurriculum(file: File, backendUrl: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/database/curriculum/upload-subject-curriculum`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const result = await response.json();
|
||||
console.log(result);
|
||||
alert('Upload Successful!');
|
||||
} else {
|
||||
alert('Upload failed!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading curriculum:', error);
|
||||
alert('Upload failed!');
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadSubjectCurriculum(file: File, backendUrl: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/database/curriculum/upload-subject-curriculum`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const result = await response.json();
|
||||
console.log(result);
|
||||
alert('Upload Successful!');
|
||||
} else {
|
||||
alert('Upload failed!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading curriculum:', error);
|
||||
alert('Upload failed!');
|
||||
}
|
||||
}
|
||||
58
src/services/graph/databaseNameService.ts
Normal file
58
src/services/graph/databaseNameService.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
export class DatabaseNameService {
|
||||
static readonly CC_USERS = 'cc.users';
|
||||
static readonly CC_SCHOOLS = 'cc.institutes';
|
||||
|
||||
static getUserPrivateDB(userType: string, username: string): string {
|
||||
const dbName = `${this.CC_USERS}.${userType}.${username}`;
|
||||
logger.debug('database-name-service', '📥 Generating user private DB name', {
|
||||
userType,
|
||||
username,
|
||||
dbName
|
||||
});
|
||||
return dbName;
|
||||
}
|
||||
|
||||
static getSchoolPrivateDB(schoolId: string): string {
|
||||
const dbName = `${this.CC_SCHOOLS}.${schoolId}`;
|
||||
logger.debug('database-name-service', '📥 Generating school private DB name', {
|
||||
schoolId,
|
||||
dbName
|
||||
});
|
||||
return dbName;
|
||||
}
|
||||
|
||||
static getDevelopmentSchoolDB(): string {
|
||||
const dbName = `${this.CC_SCHOOLS}.development.default`;
|
||||
logger.debug('database-name-service', '📥 Getting default school DB name', {
|
||||
dbName
|
||||
});
|
||||
return dbName;
|
||||
}
|
||||
|
||||
static getContextDatabase(context: string, userType: string, username: string): string {
|
||||
logger.debug('database-name-service', '📥 Resolving context database', {
|
||||
context,
|
||||
userType,
|
||||
username
|
||||
});
|
||||
|
||||
// For school-related contexts, use the schools database
|
||||
if (['school', 'department', 'class'].includes(context)) {
|
||||
logger.debug('database-name-service', '✅ Using schools database for context', {
|
||||
context,
|
||||
dbName: this.CC_SCHOOLS
|
||||
});
|
||||
return this.CC_SCHOOLS;
|
||||
}
|
||||
|
||||
// For user-specific contexts, use their private database
|
||||
const userDb = this.getUserPrivateDB(userType, username);
|
||||
logger.debug('database-name-service', '✅ Using user private database for context', {
|
||||
context,
|
||||
dbName: userDb
|
||||
});
|
||||
return userDb;
|
||||
}
|
||||
}
|
||||
159
src/services/graph/graphNeoDBService.ts
Normal file
159
src/services/graph/graphNeoDBService.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { Editor, createShapeId, IndexKey } from '@tldraw/tldraw';
|
||||
import axios from '../../axiosConfig';
|
||||
import { getShapeType, isValidNodeType, CCNodeTypes } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
import { AllNodeShapes, NodeShapeType, ShapeUtils } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-shapes';
|
||||
import { graphState } from '../../utils/tldraw/cc-base/cc-graph/graphStateUtil';
|
||||
import { NodeResponse, ConnectedNodesResponse } from '../../types/api';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
export class GraphNeoDBService {
|
||||
static async fetchConnectedNodesAndEdges(
|
||||
unique_id: string,
|
||||
db_name: string,
|
||||
editor: Editor
|
||||
) {
|
||||
try {
|
||||
logger.debug('graph-service', '📤 Fetching connected nodes', {
|
||||
unique_id,
|
||||
db_name
|
||||
});
|
||||
|
||||
const response = await axios.get<ConnectedNodesResponse>(
|
||||
'/database/tools/get-connected-nodes-and-edges', {
|
||||
params: {
|
||||
unique_id,
|
||||
db_name
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.status === "success") {
|
||||
// Make sure editor is set in graphState
|
||||
graphState.setEditor(editor);
|
||||
await this.processConnectedNodesResponse(response.data);
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error('Failed to fetch connected nodes');
|
||||
} catch (error) {
|
||||
logger.error('graph-service', '❌ Failed to fetch connected nodes', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async processConnectedNodesResponse(
|
||||
data: ConnectedNodesResponse
|
||||
) {
|
||||
try {
|
||||
// Log the incoming data
|
||||
logger.debug('graph-service', '📥 Processing nodes response', {
|
||||
mainNode: data.main_node,
|
||||
connectedNodesCount: data.connected_nodes?.length,
|
||||
relationshipsCount: data.relationships?.length
|
||||
});
|
||||
|
||||
// Create a batch of nodes to process
|
||||
const nodesToProcess: NodeResponse['node_data'][] = [];
|
||||
|
||||
// Add connected nodes first
|
||||
if (data.connected_nodes) {
|
||||
data.connected_nodes.forEach(connectedNode => {
|
||||
if (isValidNodeType(connectedNode.type)) {
|
||||
// Convert the simplified node structure to node_data format
|
||||
const nodeData = {
|
||||
unique_id: connectedNode.id,
|
||||
tldraw_snapshot: connectedNode.tldraw_snapshot,
|
||||
name: connectedNode.label,
|
||||
__primarylabel__: connectedNode.type as keyof CCNodeTypes,
|
||||
created: new Date().toISOString(),
|
||||
merged: new Date().toISOString(),
|
||||
};
|
||||
nodesToProcess.push(nodeData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add main node last (if it exists) to ensure it's processed after connected nodes
|
||||
if (data.main_node) {
|
||||
nodesToProcess.push(data.main_node.node_data);
|
||||
}
|
||||
|
||||
// Process all nodes in batch
|
||||
for (const nodeData of nodesToProcess) {
|
||||
await this.createOrUpdateNode(nodeData);
|
||||
logger.debug('graph-service', '📝 Processed node', {
|
||||
nodeId: nodeData.unique_id,
|
||||
nodeType: nodeData.__primarylabel__
|
||||
});
|
||||
}
|
||||
|
||||
// After all nodes are processed, arrange them in grid
|
||||
graphState.arrangeNodesInGrid();
|
||||
|
||||
logger.debug('graph-service', '✅ Processed nodes batch', {
|
||||
processedCount: nodesToProcess.length,
|
||||
totalNodes: graphState.getAllNodes().length,
|
||||
nodesInState: Array.from(graphState.nodeData.keys())
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('graph-service', '❌ Failed to process connected nodes response', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async createOrUpdateNode(
|
||||
nodeData: NodeResponse['node_data']
|
||||
) {
|
||||
const uniqueId = nodeData.unique_id;
|
||||
const nodeType = nodeData.__primarylabel__;
|
||||
|
||||
if (!isValidNodeType(nodeType)) {
|
||||
logger.warn('graph-service', '⚠️ Unknown node type', { data: nodeData });
|
||||
return;
|
||||
}
|
||||
|
||||
const shapeType = getShapeType(nodeType) as NodeShapeType;
|
||||
|
||||
// Get the shape util for this node type
|
||||
const shapeUtil = ShapeUtils[shapeType];
|
||||
if (!shapeUtil) {
|
||||
logger.warn('graph-service', '⚠️ No shape util found for type', { type: shapeType });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get default props from the shape util's prototype
|
||||
const defaultProps = shapeUtil.prototype.getDefaultProps();
|
||||
|
||||
// Create the shape with proper typing based on the node type
|
||||
const shape = {
|
||||
id: createShapeId(uniqueId),
|
||||
type: shapeType,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
index: 'a1' as IndexKey,
|
||||
parentId: createShapeId('page:page'),
|
||||
isLocked: false,
|
||||
opacity: 1,
|
||||
meta: {},
|
||||
props: {
|
||||
...defaultProps,
|
||||
...nodeData,
|
||||
__primarylabel__: nodeData.__primarylabel__,
|
||||
unique_id: nodeData.unique_id,
|
||||
tldraw_snapshot: nodeData.path as string || '',
|
||||
}
|
||||
};
|
||||
|
||||
// Add to graphState
|
||||
graphState.addNode(shape as AllNodeShapes);
|
||||
|
||||
logger.debug('graph-service', '📝 Node processed', {
|
||||
uniqueId,
|
||||
nodeType,
|
||||
shapeId: shape.id,
|
||||
shapeType: shape.type
|
||||
});
|
||||
}
|
||||
}
|
||||
118
src/services/graph/neoDBService.ts
Normal file
118
src/services/graph/neoDBService.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { CCNodeTypes } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
export interface BaseNodeData {
|
||||
unique_id: string;
|
||||
path: string;
|
||||
__primarylabel__: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CalendarNodeData extends BaseNodeData {
|
||||
__primarylabel__: 'Calendar' | 'CalendarYear' | 'CalendarMonth' | 'CalendarWeek' | 'CalendarDay';
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export interface WorkerNodeData extends BaseNodeData {
|
||||
__primarylabel__: 'School' | 'Department' | 'Teacher' | 'UserTeacherTimetable' | 'Class' | 'TimetableLesson';
|
||||
name?: string;
|
||||
teacher_code?: string;
|
||||
teacher_name_formal?: string;
|
||||
class_code?: string;
|
||||
department_code?: string;
|
||||
school_name?: string;
|
||||
}
|
||||
|
||||
export type NodeType = keyof CCNodeTypes | 'User' | 'Calendar' | 'CalendarYear' | 'CalendarMonth' | 'CalendarWeek' | 'CalendarDay' | 'Teacher' | 'UserTeacherTimetable' | 'Student' | 'Class' | 'TimetableLesson';
|
||||
|
||||
export function formatEmailForDatabase(email: string): string {
|
||||
// Convert to lowercase and replace special characters
|
||||
const sanitized = email.toLowerCase()
|
||||
.replace('@', 'at')
|
||||
.replace(/\./g, 'dot')
|
||||
.replace(/_/g, 'underscore')
|
||||
.replace(/-/g, 'dash');
|
||||
|
||||
// Add prefix and ensure no consecutive dashes
|
||||
return `${sanitized}`;
|
||||
}
|
||||
|
||||
export function generateNodeTitle(nodeData: BaseNodeData): string {
|
||||
try {
|
||||
const calendarData = nodeData as CalendarNodeData;
|
||||
const workerData = nodeData as WorkerNodeData;
|
||||
|
||||
switch (nodeData.__primarylabel__ as NodeType) {
|
||||
// Calendar nodes
|
||||
case 'Calendar':
|
||||
return 'Calendar';
|
||||
case 'CalendarYear':
|
||||
if (!calendarData.date) return 'Unknown Year';
|
||||
return `Year ${new Date(calendarData.date).getFullYear()}`;
|
||||
case 'CalendarMonth':
|
||||
if (!calendarData.date) return 'Unknown Month';
|
||||
return new Date(calendarData.date).toLocaleString('default', { month: 'long' });
|
||||
case 'CalendarWeek':
|
||||
if (!calendarData.date) return 'Unknown Week';
|
||||
return `Week ${new Date(calendarData.date).getDate()}`;
|
||||
case 'CalendarDay':
|
||||
if (!calendarData.date) return 'Unknown Day';
|
||||
return new Date(calendarData.date).toLocaleDateString();
|
||||
|
||||
// Worker/School nodes
|
||||
case 'School':
|
||||
return workerData.school_name || 'School';
|
||||
case 'Department':
|
||||
return workerData.department_code || 'Department';
|
||||
case 'Teacher':
|
||||
return workerData.teacher_name_formal || workerData.teacher_code || 'Teacher';
|
||||
case 'UserTeacherTimetable':
|
||||
return 'Timetable';
|
||||
case 'Class':
|
||||
return workerData.class_code || 'Class';
|
||||
case 'TimetableLesson':
|
||||
return 'Lesson';
|
||||
|
||||
default:
|
||||
logger.warn('neo4j-service', `⚠️ Unknown node type for title generation: ${nodeData.__primarylabel__}`);
|
||||
return 'Unknown Node';
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('neo4j-service', '❌ Failed to generate node title', { error, nodeData });
|
||||
return 'Error: Invalid Node Data';
|
||||
}
|
||||
}
|
||||
|
||||
export function getMonthFromWeek(weekDate: string): string {
|
||||
// Get the month that contains the most days of this week
|
||||
const weekStart = new Date(weekDate);
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
|
||||
// If week spans two months, use the month that contains more days of the week
|
||||
if (weekStart.getMonth() !== weekEnd.getMonth()) {
|
||||
const daysInFirstMonth = new Date(weekStart.getFullYear(), weekStart.getMonth() + 1, 0).getDate() - weekStart.getDate() + 1;
|
||||
const daysInSecondMonth = 7 - daysInFirstMonth;
|
||||
|
||||
return daysInFirstMonth >= daysInSecondMonth ?
|
||||
weekStart.toLocaleString('default', { month: 'long' }) :
|
||||
weekEnd.toLocaleString('default', { month: 'long' });
|
||||
}
|
||||
|
||||
return weekStart.toLocaleString('default', { month: 'long' });
|
||||
}
|
||||
|
||||
export function getDatabaseName(path: string, defaultSchoolUuid = 'kevlarai'): string {
|
||||
// If the path starts with /node_filesystem/users/, it's in a user database
|
||||
if (path.startsWith('/node_filesystem/users/')) {
|
||||
const parts = path.split('/');
|
||||
// parts[3] should be the database name (e.g., cc.users.surfacedashdev3atkevlaraidotcom)
|
||||
return parts[3];
|
||||
}
|
||||
// For school/worker nodes, extract from the path or use default
|
||||
if (path.includes('/schools/')) {
|
||||
return `cc.institutes.${defaultSchoolUuid}`;
|
||||
}
|
||||
// Default to user database if we can't determine
|
||||
return path.split('/')[3];
|
||||
}
|
||||
0
src/services/graph/neoNavigationService.ts
Normal file
0
src/services/graph/neoNavigationService.ts
Normal file
155
src/services/graph/neoRegistrationService.ts
Normal file
155
src/services/graph/neoRegistrationService.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { supabase } from '../../supabaseClient';
|
||||
import { CCUser } from '../auth/authService';
|
||||
import { CCSchoolNodeProps, CCUserNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
import { storageService, StorageKeys } from '../auth/localStorageService';
|
||||
import axiosInstance from '../../axiosConfig';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
// Dev configuration - only hardcoded value we need
|
||||
const DEV_SCHOOL_UUID = 'kevlarai';
|
||||
|
||||
class NeoRegistrationService {
|
||||
private static instance: NeoRegistrationService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): NeoRegistrationService {
|
||||
if (!NeoRegistrationService.instance) {
|
||||
NeoRegistrationService.instance = new NeoRegistrationService();
|
||||
}
|
||||
return NeoRegistrationService.instance;
|
||||
}
|
||||
|
||||
async registerNeo4JUser(
|
||||
user: CCUser,
|
||||
username: string,
|
||||
role: string
|
||||
): Promise<CCUserNodeProps> {
|
||||
try {
|
||||
// For teachers and students, fetch school node first
|
||||
let schoolNode = null;
|
||||
if (role.includes('teacher') || role.includes('student')) {
|
||||
schoolNode = await this.fetchSchoolNode(DEV_SCHOOL_UUID);
|
||||
if (!schoolNode) {
|
||||
throw new Error('Failed to fetch required school node');
|
||||
}
|
||||
}
|
||||
|
||||
// Create FormData with proper headers
|
||||
const formData = new FormData();
|
||||
|
||||
// Required fields
|
||||
formData.append('user_id', user.id);
|
||||
formData.append('user_type', role);
|
||||
formData.append('user_name', username);
|
||||
formData.append('user_email', user.email || '');
|
||||
|
||||
// Add school data if we have a school node
|
||||
if (schoolNode) {
|
||||
formData.append('school_uuid', schoolNode.school_uuid);
|
||||
formData.append('school_name', schoolNode.school_name);
|
||||
formData.append('school_website', schoolNode.school_website);
|
||||
formData.append('school_tldraw_snapshot', schoolNode.tldraw_snapshot);
|
||||
|
||||
// Add worker data based on role
|
||||
const workerData = role.includes('teacher') ? {
|
||||
teacher_code: username,
|
||||
teacher_name_formal: username,
|
||||
teacher_email: user.email,
|
||||
} : {
|
||||
student_code: username,
|
||||
student_name_formal: username,
|
||||
student_email: user.email,
|
||||
};
|
||||
|
||||
formData.append('worker_data', JSON.stringify(workerData));
|
||||
}
|
||||
|
||||
// Debug log the form data
|
||||
logger.debug('neo4j-service', '🔄 Sending form data', {
|
||||
userId: user.id,
|
||||
userType: role,
|
||||
userName: username,
|
||||
userEmail: user.email,
|
||||
schoolNode: schoolNode ? {
|
||||
uuid: schoolNode.school_uuid,
|
||||
name: schoolNode.school_name
|
||||
} : null
|
||||
});
|
||||
|
||||
const response = await axiosInstance.post('/database/entity/create-user', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.status !== 'success') {
|
||||
throw new Error(`Failed to create user: ${JSON.stringify(response.data)}`);
|
||||
}
|
||||
|
||||
const userNode = response.data.data.user_node;
|
||||
const workerNode = response.data.data.worker_node;
|
||||
|
||||
// Store calendar data if needed
|
||||
if (response.data.data.calendar_nodes) {
|
||||
logger.debug('neo4j-service', '🔄 Storing calendar data', {
|
||||
calendarNodes: response.data.data.calendar_nodes
|
||||
});
|
||||
storageService.set(StorageKeys.CALENDAR_DATA, response.data.data.calendar_nodes);
|
||||
}
|
||||
|
||||
// Update user node with worker data
|
||||
userNode.worker_node_data = JSON.stringify(workerNode);
|
||||
|
||||
await this.updateUserNeo4jDetails(user.id, userNode);
|
||||
|
||||
logger.info('neo4j-service', '✅ Neo4j user registration successful', {
|
||||
userId: user.id,
|
||||
nodeId: userNode.unique_id,
|
||||
hasCalendar: !!response.data.data.calendar_nodes
|
||||
});
|
||||
|
||||
return userNode;
|
||||
} catch (error) {
|
||||
logger.error('neo4j-service', '❌ Neo4j user registration failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserNeo4jDetails(userId: string, userNode: CCUserNodeProps) {
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
metadata: {
|
||||
...userNode
|
||||
},
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', userId);
|
||||
|
||||
if (error) {
|
||||
logger.error('neo4j-service', '❌ Failed to update Neo4j details:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchSchoolNode(schoolUuid: string): Promise<CCSchoolNodeProps> {
|
||||
logger.debug('neo4j-service', '🔄 Fetching school node', { schoolUuid });
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.get(`/database/tools/get-school-node?school_uuid=${schoolUuid}`);
|
||||
|
||||
if (response.data?.status === 'success' && response.data.school_node) {
|
||||
logger.info('neo4j-service', '✅ School node fetched successfully');
|
||||
return response.data.school_node;
|
||||
}
|
||||
|
||||
throw new Error('Failed to fetch school node: ' + JSON.stringify(response.data));
|
||||
} catch (error) {
|
||||
logger.error('neo4j-service', '❌ Failed to fetch school node:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const neoRegistrationService = NeoRegistrationService.getInstance();
|
||||
78
src/services/graph/neoShapeService.ts
Normal file
78
src/services/graph/neoShapeService.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { NavigationNode } from '../../types/navigation';
|
||||
import { getShapeType, CCNodeTypes } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { NodeData } from '../../types/graph-shape';
|
||||
|
||||
export class NeoShapeService {
|
||||
private static readonly DATE_TIME_FIELDS = [
|
||||
'merged', 'created', 'start_date', 'end_date', 'start_time', 'end_time'
|
||||
] as const;
|
||||
|
||||
private static processDateTimeFields(data: Record<string, unknown>): Record<string, unknown> {
|
||||
const processed = { ...data };
|
||||
for (const key of Object.keys(processed)) {
|
||||
if (this.DATE_TIME_FIELDS.includes(key as typeof this.DATE_TIME_FIELDS[number]) &&
|
||||
processed[key] &&
|
||||
typeof processed[key] === 'object') {
|
||||
processed[key] = processed[key].toString();
|
||||
}
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
|
||||
static getShapeConfig(node: NavigationNode, nodeData: NodeData, centerX: number, centerY: number) {
|
||||
try {
|
||||
// Get the shape type based on the node type
|
||||
const shapeType = getShapeType(node.type as keyof CCNodeTypes);
|
||||
|
||||
// Get theme colors based on the node type
|
||||
const theme = getThemeFromLabel(node.type);
|
||||
|
||||
// Default dimensions
|
||||
const width = 500;
|
||||
const height = 350;
|
||||
|
||||
// Process the node data
|
||||
const processedProps = {
|
||||
...this.processDateTimeFields(nodeData),
|
||||
title: nodeData.title || node.label,
|
||||
w: width,
|
||||
h: height,
|
||||
state: {
|
||||
parentId: null,
|
||||
isPageChild: true,
|
||||
hasChildren: null,
|
||||
bindings: null
|
||||
},
|
||||
headerColor: theme.headerColor,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
isLocked: false,
|
||||
__primarylabel__: node.type,
|
||||
unique_id: node.id,
|
||||
tldraw_snapshot: node.tldraw_snapshot
|
||||
};
|
||||
|
||||
logger.debug('neo-shape-service', '📄 Created shape configuration', {
|
||||
nodeId: node.id,
|
||||
shapeType,
|
||||
theme,
|
||||
props: processedProps
|
||||
});
|
||||
|
||||
return {
|
||||
type: shapeType,
|
||||
x: centerX - (width / 2),
|
||||
y: centerY - (height / 2),
|
||||
props: processedProps
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('neo-shape-service', '❌ Failed to create shape configuration', {
|
||||
nodeId: node.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
src/services/graph/schoolNeoDBService.ts
Normal file
68
src/services/graph/schoolNeoDBService.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import axiosInstance from '../../axiosConfig';
|
||||
import { CCSchoolNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
interface CreateSchoolResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class SchoolNeoDBService {
|
||||
static async createSchools(
|
||||
): Promise<CreateSchoolResponse> {
|
||||
logger.warn('school-service', '📤 Creating schools using default config.yaml');
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.post(
|
||||
'/database/entity/create-schools',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.status === 'success' || response.data.status === 'Accepted') {
|
||||
logger.info('school-service', '✅ Schools successfully');
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Schools created successfully'
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(response.data.message || 'Creation failed');
|
||||
} catch (err: unknown) {
|
||||
const error = err as AxiosError;
|
||||
logger.error('school-service', '❌ Failed to create school', {
|
||||
error: error.message,
|
||||
details: error.response?.data
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getSchoolNode(schoolDbName: string): Promise<CCSchoolNodeProps | null> {
|
||||
logger.debug('school-service', '🔄 Fetching school node', { schoolDbName });
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.get(`/database/tools/get-default-node/school?db_name=${schoolDbName}`);
|
||||
|
||||
if (response.data?.status === 'success' && response.data.node) {
|
||||
logger.info('school-service', '✅ School node fetched successfully');
|
||||
return response.data.node;
|
||||
}
|
||||
|
||||
logger.warn('school-service', '⚠️ No school node found');
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError && error.response?.status === 404) {
|
||||
logger.warn('school-service', '⚠️ School node not found (404)', { schoolDbName });
|
||||
return null;
|
||||
}
|
||||
logger.error('school-service', '❌ Failed to fetch school node:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
256
src/services/graph/timetableNeoDBService.ts
Normal file
256
src/services/graph/timetableNeoDBService.ts
Normal file
@ -0,0 +1,256 @@
|
||||
import axios from '../../axiosConfig';
|
||||
import { CCTeacherNodeProps, CCUserNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
interface UploadTimetableResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TeacherTimetableEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
extendedProps: {
|
||||
subjectClass: string;
|
||||
color: string;
|
||||
periodCode: string;
|
||||
tldraw_snapshot?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class TimetableNeoDBService {
|
||||
static async uploadWorkerTimetable(
|
||||
file: File,
|
||||
userNode: CCUserNodeProps,
|
||||
workerNode: CCTeacherNodeProps
|
||||
): Promise<UploadTimetableResponse> {
|
||||
logger.debug('timetable-service', '📤 Uploading timetable', {
|
||||
fileName: file.name,
|
||||
schoolDbName: workerNode.school_db_name,
|
||||
userDbName: workerNode.user_db_name,
|
||||
teacherCode: workerNode.teacher_code
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('user_node', JSON.stringify({
|
||||
unique_id: userNode.unique_id,
|
||||
user_id: userNode.user_id,
|
||||
user_type: userNode.user_type,
|
||||
user_name: userNode.user_name,
|
||||
user_email: userNode.user_email,
|
||||
tldraw_snapshot: userNode.tldraw_snapshot,
|
||||
worker_node_data: userNode.worker_node_data
|
||||
|
||||
}));
|
||||
formData.append('worker_node', JSON.stringify({
|
||||
unique_id: workerNode.unique_id,
|
||||
teacher_code: workerNode.teacher_code,
|
||||
teacher_name_formal: workerNode.teacher_name_formal,
|
||||
teacher_email: workerNode.teacher_email,
|
||||
tldraw_snapshot: workerNode.tldraw_snapshot,
|
||||
worker_db_name: workerNode.school_db_name,
|
||||
user_db_name: workerNode.user_db_name
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'/database/timetables/upload-worker-timetable',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.status === 'success' || response.data.status === 'Accepted') {
|
||||
logger.info('timetable-service', '✅ Timetable upload successful');
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Timetable uploaded successfully'
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(response.data.message || 'Upload failed');
|
||||
} catch (err: unknown) {
|
||||
const error = err as AxiosError;
|
||||
logger.error('timetable-service', '❌ Failed to upload timetable', {
|
||||
error: error.message,
|
||||
details: error.response?.data
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async fetchTeacherTimetableEvents(
|
||||
unique_id: string,
|
||||
school_db_name: string
|
||||
): Promise<TeacherTimetableEvent[]> {
|
||||
try {
|
||||
logger.debug('timetable-service', '📤 Fetching timetable events', {
|
||||
unique_id,
|
||||
school_db_name
|
||||
});
|
||||
|
||||
const response = await axios.get('/calendar/get_teacher_timetable_events', {
|
||||
params: {
|
||||
unique_id,
|
||||
school_db_name
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('timetable-service', '📥 Received response', {
|
||||
status: response.status,
|
||||
data: response.data
|
||||
});
|
||||
|
||||
if (response.data.status === "success") {
|
||||
return response.data.events;
|
||||
}
|
||||
throw new Error(response.data.message || 'Failed to fetch events');
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
logger.error('timetable-service', '❌ Failed to fetch timetable events', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message
|
||||
});
|
||||
} else {
|
||||
logger.error('timetable-service', '❌ Failed to fetch timetable events', { error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static lightenColor(color: string, amount: number): string {
|
||||
color = color.replace(/^#/, '');
|
||||
const num = parseInt(color, 16);
|
||||
const r = Math.min(255, (num >> 16) + amount);
|
||||
const g = Math.min(255, ((num >> 8) & 0x00FF) + amount);
|
||||
const b = Math.min(255, (num & 0x0000FF) + amount);
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
static getContrastColor(hexColor: string): string {
|
||||
hexColor = hexColor.replace(/^#/, '');
|
||||
const r = parseInt(hexColor.slice(0, 2), 16);
|
||||
const g = parseInt(hexColor.slice(2, 4), 16);
|
||||
const b = parseInt(hexColor.slice(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.7 ? '#000000' : '#FFFFFF';
|
||||
}
|
||||
|
||||
static getEventRange(events: TeacherTimetableEvent[]) {
|
||||
if (events.length === 0) {
|
||||
return { start: null, end: null };
|
||||
}
|
||||
|
||||
let start = new Date(events[0].start);
|
||||
let end = new Date(events[0].end);
|
||||
|
||||
events.forEach(event => {
|
||||
const eventStart = new Date(event.start);
|
||||
const eventEnd = new Date(event.end);
|
||||
if (eventStart < start) {
|
||||
start = eventStart;
|
||||
}
|
||||
if (eventEnd > end) {
|
||||
end = eventEnd;
|
||||
}
|
||||
});
|
||||
|
||||
start.setDate(1);
|
||||
end.setMonth(end.getMonth() + 1, 0);
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
static getSubjectClassColor(subjectClass: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < subjectClass.length; i++) {
|
||||
hash = subjectClass.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return `hsl(${hash % 360}, 70%, 50%)`;
|
||||
}
|
||||
|
||||
static async handleTimetableUpload(
|
||||
file: File | undefined,
|
||||
userNode: CCUserNodeProps | undefined,
|
||||
workerNode: CCTeacherNodeProps | undefined
|
||||
): Promise<UploadResult> {
|
||||
if (!file) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No file selected'
|
||||
};
|
||||
}
|
||||
|
||||
if (!file.name.endsWith('.xlsx')) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Please upload an Excel (.xlsx) file'
|
||||
};
|
||||
}
|
||||
|
||||
if (!userNode) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User information not found. Please ensure you are logged in as a user.'
|
||||
};
|
||||
}
|
||||
|
||||
if (!workerNode) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Teacher information not found. Please ensure you are logged in as a teacher.'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate worker node has required fields
|
||||
const requiredWorkerFields = ['unique_id', 'teacher_code', 'teacher_name_formal', 'teacher_email', 'worker_db_name', 'path'];
|
||||
const requiredUserFields = ['unique_id', 'user_id', 'user_type', 'user_name', 'user_email', 'path', 'worker_node_data'];
|
||||
const missingWorkerFields = requiredWorkerFields.filter(field => !(field in workerNode));
|
||||
const missingUserFields = requiredUserFields.filter(field => !(field in userNode));
|
||||
|
||||
|
||||
if (missingWorkerFields.length > 0) {
|
||||
logger.error('timetable-service', '❌ Missing required teacher fields:', { missingWorkerFields });
|
||||
return {
|
||||
success: false,
|
||||
message: `Missing required teacher information: ${missingWorkerFields.join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
if (missingUserFields.length > 0) {
|
||||
logger.error('timetable-service', '❌ Missing required user fields:', { missingUserFields });
|
||||
return {
|
||||
success: false,
|
||||
message: `Missing required user information: ${missingUserFields.join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.uploadWorkerTimetable(file, userNode, workerNode);
|
||||
return {
|
||||
success: true,
|
||||
message: result.message
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to upload timetable';
|
||||
logger.error('timetable-service', '❌ Timetable upload failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
390
src/services/graph/userNeoDBService.ts
Normal file
390
src/services/graph/userNeoDBService.ts
Normal file
@ -0,0 +1,390 @@
|
||||
import axiosInstance from '../../axiosConfig';
|
||||
import { formatEmailForDatabase } from './neoDBService';
|
||||
import { CCUserNodeProps, CCTeacherNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||
import { NavigationNode, NodeContext } from '../../types/navigation';
|
||||
import { TLBinding, TLShapeId } from '@tldraw/tldraw';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { useNavigationStore } from '../../stores/navigationStore';
|
||||
import { DatabaseNameService } from './databaseNameService';
|
||||
|
||||
// Dev configuration - only hardcoded value we need
|
||||
const DEV_SCHOOL_UUID = 'kevlarai';
|
||||
|
||||
interface ShapeState {
|
||||
parentId: TLShapeId | null;
|
||||
isPageChild: boolean | null;
|
||||
hasChildren: boolean | null;
|
||||
bindings: TLBinding[] | null;
|
||||
}
|
||||
|
||||
interface NodeResponse {
|
||||
status: string;
|
||||
nodes: {
|
||||
userNode: CCUserNodeProps;
|
||||
calendarNode: CCCalendarNodeProps;
|
||||
teacherNode: CCTeacherNodeProps;
|
||||
timetableNode: CCUserTeacherTimetableNodeProps;
|
||||
};
|
||||
}
|
||||
|
||||
interface NodeDataResponse {
|
||||
__primarylabel__: string;
|
||||
unique_id: string;
|
||||
tldraw_snapshot: string;
|
||||
created: string;
|
||||
merged: string;
|
||||
state: ShapeState | null;
|
||||
defaultComponent: boolean | null;
|
||||
user_name?: string;
|
||||
user_email?: string;
|
||||
user_type?: string;
|
||||
user_id?: string;
|
||||
worker_node_data?: string;
|
||||
[key: string]: string | number | boolean | null | ShapeState | undefined;
|
||||
}
|
||||
|
||||
interface DefaultNodeResponse {
|
||||
status: string;
|
||||
node: {
|
||||
id: string;
|
||||
tldraw_snapshot: string;
|
||||
type: string;
|
||||
label: string;
|
||||
data: NodeDataResponse;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProcessedUserNodes {
|
||||
privateUserNode: CCUserNodeProps;
|
||||
connectedNodes: {
|
||||
calendar?: CCCalendarNodeProps;
|
||||
teacher?: CCTeacherNodeProps;
|
||||
timetable?: CCUserTeacherTimetableNodeProps;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CalendarStructureResponse {
|
||||
status: string;
|
||||
data: {
|
||||
currentDay: string;
|
||||
days: Record<string, {
|
||||
id: string;
|
||||
date: string;
|
||||
title: string;
|
||||
}>;
|
||||
weeks: Record<string, {
|
||||
id: string;
|
||||
title: string;
|
||||
days: { id: string }[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}>;
|
||||
months: Record<string, {
|
||||
id: string;
|
||||
title: string;
|
||||
days: { id: string }[];
|
||||
weeks: { id: string }[];
|
||||
year: string;
|
||||
month: string;
|
||||
}>;
|
||||
years: {
|
||||
id: string;
|
||||
title: string;
|
||||
months: { id: string }[];
|
||||
year: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkerStructureResponse {
|
||||
status: string;
|
||||
data: {
|
||||
timetables: Record<string, Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}>>;
|
||||
classes: Record<string, Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}>>;
|
||||
lessons: Record<string, Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}>>;
|
||||
journals: Record<string, Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
}>>;
|
||||
planners: Record<string, Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
}>>;
|
||||
};
|
||||
}
|
||||
|
||||
export class UserNeoDBService {
|
||||
static async fetchUserNodesData(
|
||||
email: string,
|
||||
userDbName?: string,
|
||||
workerDbName?: string
|
||||
): Promise<ProcessedUserNodes | null> {
|
||||
try {
|
||||
if (!userDbName) {
|
||||
logger.error('neo4j-service', '❌ Attempted to fetch nodes without database name');
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedEmail = formatEmailForDatabase(email);
|
||||
const uniqueId = `User_${formattedEmail}`;
|
||||
|
||||
logger.debug('neo4j-service', '🔄 Fetching user nodes data', {
|
||||
email,
|
||||
formattedEmail,
|
||||
userDbName,
|
||||
workerDbName,
|
||||
uniqueId
|
||||
});
|
||||
|
||||
// First get the user node from profile context
|
||||
const userNode = await this.getDefaultNode('profile', userDbName);
|
||||
if (!userNode || !userNode.data) {
|
||||
throw new Error('Failed to fetch user node or node data missing');
|
||||
}
|
||||
|
||||
logger.debug('neo4j-service', '✅ Found user node', {
|
||||
nodeId: userNode.id,
|
||||
type: userNode.type,
|
||||
hasData: !!userNode.data,
|
||||
userDbName,
|
||||
workerDbName
|
||||
});
|
||||
|
||||
// Initialize result structure
|
||||
const processedNodes: ProcessedUserNodes = {
|
||||
privateUserNode: {
|
||||
...userNode.data,
|
||||
__primarylabel__: 'User' as const,
|
||||
title: userNode.data.user_email || 'User',
|
||||
w: 200,
|
||||
h: 200,
|
||||
headerColor: '#3e6589',
|
||||
backgroundColor: '#f0f0f0',
|
||||
isLocked: false
|
||||
} as CCUserNodeProps,
|
||||
connectedNodes: {}
|
||||
};
|
||||
|
||||
try {
|
||||
// Get calendar node from calendar context
|
||||
const calendarNode = await this.getDefaultNode('calendar', userDbName);
|
||||
if (calendarNode?.data) {
|
||||
processedNodes.connectedNodes.calendar = {
|
||||
...calendarNode.data,
|
||||
__primarylabel__: 'Calendar' as const,
|
||||
title: calendarNode.data.calendar_name || 'Calendar',
|
||||
w: 200,
|
||||
h: 200,
|
||||
headerColor: '#3e6589',
|
||||
backgroundColor: '#f0f0f0',
|
||||
isLocked: false
|
||||
} as CCCalendarNodeProps;
|
||||
logger.debug('neo4j-service', '✅ Found calendar node', {
|
||||
nodeId: calendarNode.id,
|
||||
tldraw_snapshot: calendarNode.data.tldraw_snapshot
|
||||
});
|
||||
} else {
|
||||
logger.debug('neo4j-service', 'ℹ️ No calendar node found');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('neo4j-service', '⚠️ Failed to fetch calendar node:', error);
|
||||
// Continue without calendar node
|
||||
}
|
||||
|
||||
// Get teacher node from teaching context if worker database is available
|
||||
if (workerDbName) {
|
||||
try {
|
||||
const teacherNode = await this.getDefaultNode('teaching', userDbName);
|
||||
if (teacherNode?.data) {
|
||||
processedNodes.connectedNodes.teacher = {
|
||||
...teacherNode.data,
|
||||
__primarylabel__: 'Teacher' as const,
|
||||
title: teacherNode.data.teacher_name_formal || 'Teacher',
|
||||
w: 200,
|
||||
h: 200,
|
||||
headerColor: '#3e6589',
|
||||
backgroundColor: '#f0f0f0',
|
||||
isLocked: false,
|
||||
user_db_name: userDbName,
|
||||
school_db_name: workerDbName
|
||||
} as CCTeacherNodeProps;
|
||||
logger.debug('neo4j-service', '✅ Found teacher node', {
|
||||
nodeId: teacherNode.id,
|
||||
tldraw_snapshot: teacherNode.data.tldraw_snapshot,
|
||||
userDbName,
|
||||
workerDbName
|
||||
});
|
||||
} else {
|
||||
logger.debug('neo4j-service', 'ℹ️ No teacher node found');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('neo4j-service', '⚠️ Failed to fetch teacher node:', error);
|
||||
// Continue without teacher node
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('neo4j-service', '✅ Processed all user nodes', {
|
||||
hasUserNode: !!processedNodes.privateUserNode,
|
||||
hasCalendar: !!processedNodes.connectedNodes.calendar,
|
||||
hasTeacher: !!processedNodes.connectedNodes.teacher,
|
||||
teacherData: processedNodes.connectedNodes.teacher ? {
|
||||
unique_id: processedNodes.connectedNodes.teacher.unique_id,
|
||||
school_db_name: processedNodes.connectedNodes.teacher.school_db_name,
|
||||
tldraw_snapshot: processedNodes.connectedNodes.teacher.tldraw_snapshot
|
||||
} : null
|
||||
});
|
||||
|
||||
return processedNodes;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
logger.error('neo4j-service', '❌ Failed to fetch user nodes:', error.message);
|
||||
} else {
|
||||
logger.error('neo4j-service', '❌ Failed to fetch user nodes:', String(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static getUserDatabaseName(userType: string, username: string): string {
|
||||
return DatabaseNameService.getUserPrivateDB(userType, username);
|
||||
}
|
||||
|
||||
static getSchoolDatabaseName(schoolId: string): string {
|
||||
return DatabaseNameService.getSchoolPrivateDB(schoolId);
|
||||
}
|
||||
|
||||
static getDefaultSchoolDatabaseName(): string {
|
||||
return DatabaseNameService.getDevelopmentSchoolDB();
|
||||
}
|
||||
|
||||
static async fetchNodeData(nodeId: string, dbName: string): Promise<{ node_type: string; node_data: NodeResponse['nodes']['userNode'] } | null> {
|
||||
try {
|
||||
logger.debug('neo4j-service', '🔄 Fetching node data', { nodeId, dbName });
|
||||
|
||||
const response = await axiosInstance.get<{
|
||||
status: string;
|
||||
node: {
|
||||
node_type: string;
|
||||
node_data: NodeResponse['nodes']['userNode'];
|
||||
};
|
||||
}>('/database/tools/get-node', {
|
||||
params: {
|
||||
unique_id: nodeId,
|
||||
db_name: dbName
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data?.status === 'success' && response.data.node) {
|
||||
return response.data.node;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('neo4j-service', '❌ Failed to fetch node data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static getNodeDatabaseName(node: NavigationNode): string {
|
||||
// If the node path starts with /node_filesystem/users/, it's in a user database
|
||||
if (node.tldraw_snapshot.startsWith('/node_filesystem/users/')) {
|
||||
const parts = node.tldraw_snapshot.split('/');
|
||||
// parts[3] should be the database name (e.g., cc.users.surfacedashdev3atkevlaraidotcom)
|
||||
return parts[3];
|
||||
}
|
||||
// For school/worker nodes, extract from the path or use a default
|
||||
if (node.tldraw_snapshot.includes('/schools/')) {
|
||||
return `cc.institutes.${DEV_SCHOOL_UUID}`;
|
||||
}
|
||||
// Default to user database if we can't determine
|
||||
return node.tldraw_snapshot.split('/')[3];
|
||||
}
|
||||
|
||||
static async getDefaultNode(context: NodeContext, dbName: string): Promise<NavigationNode | null> {
|
||||
try {
|
||||
logger.debug('neo4j-service', '🔄 Fetching default node', { context, dbName });
|
||||
|
||||
// For overview context, we need to extract the base context from the current navigation state
|
||||
const params: Record<string, string> = { db_name: dbName };
|
||||
if (context === 'overview') {
|
||||
// Get the current base context from the navigation store
|
||||
const navigationStore = useNavigationStore.getState();
|
||||
params.base_context = navigationStore.context.base;
|
||||
}
|
||||
|
||||
const response = await axiosInstance.get<DefaultNodeResponse>(
|
||||
`/database/tools/get-default-node/${context}`,
|
||||
{ params }
|
||||
);
|
||||
|
||||
if (response.data?.status === 'success' && response.data.node) {
|
||||
return {
|
||||
id: response.data.node.id,
|
||||
tldraw_snapshot: response.data.node.tldraw_snapshot,
|
||||
type: response.data.node.type,
|
||||
label: response.data.node.label,
|
||||
data: response.data.node.data
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('neo4j-service', '❌ Failed to fetch default node:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async fetchCalendarStructure(dbName: string): Promise<CalendarStructureResponse['data']> {
|
||||
try {
|
||||
logger.debug('navigation', '🔄 Fetching calendar structure', { dbName });
|
||||
|
||||
const response = await axiosInstance.get<CalendarStructureResponse>(
|
||||
`/database/calendar-structure/get-calendar-structure?db_name=${dbName}`
|
||||
);
|
||||
|
||||
if (response.data.status === 'success') {
|
||||
logger.info('navigation', '✅ Calendar structure fetched successfully');
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
throw new Error('Failed to fetch calendar structure');
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to fetch calendar structure:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async fetchWorkerStructure(dbName: string): Promise<WorkerStructureResponse['data']> {
|
||||
try {
|
||||
logger.debug('navigation', '🔄 Fetching worker structure', { dbName });
|
||||
|
||||
const response = await axiosInstance.get<WorkerStructureResponse>(
|
||||
`/database/worker-structure/get-worker-structure?db_name=${dbName}`
|
||||
);
|
||||
|
||||
if (response.data.status === 'success') {
|
||||
logger.info('navigation', '✅ Worker structure fetched successfully');
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
throw new Error('Failed to fetch worker structure');
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to fetch worker structure:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/services/initService.ts
Normal file
25
src/services/initService.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { logger } from '../debugConfig';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
export const initializeApp = () => {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('app', '🚀 App initializing', {
|
||||
isDevMode: import.meta.env.VITE_DEV === 'true',
|
||||
environment: import.meta.env.MODE,
|
||||
appName: import.meta.env.VITE_APP_NAME
|
||||
});
|
||||
|
||||
// Set the app element for react-modal
|
||||
Modal.setAppElement('#root');
|
||||
|
||||
isInitialized = true;
|
||||
};
|
||||
|
||||
export const resetInitialization = () => {
|
||||
isInitialized = false;
|
||||
};
|
||||
11
src/services/llm/llmService.ts
Normal file
11
src/services/llm/llmService.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import axios from '../../axiosConfig';
|
||||
|
||||
export const sendPrompt = async (data: { model: string, prompt: string }) => {
|
||||
const response = await axios.post('/llm/ollama_text_prompt', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const sendVisionPrompt = async (data: { model: string, imagePath: string, prompt: string }) => {
|
||||
const response = await axios.post('/llm/ollama_vision_prompt', data);
|
||||
return response.data;
|
||||
};
|
||||
146
src/services/themeService.ts
Normal file
146
src/services/themeService.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { createTheme, ThemeOptions } from '@mui/material/styles';
|
||||
|
||||
// Define custom theme options
|
||||
const themeOptions: ThemeOptions = {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
light: '#42a5f5',
|
||||
dark: '#1565c0',
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
secondary: {
|
||||
main: '#dc004e',
|
||||
light: '#ff4081',
|
||||
dark: '#c51162',
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
error: {
|
||||
main: '#f44336',
|
||||
light: '#e57373',
|
||||
dark: '#d32f2f',
|
||||
},
|
||||
warning: {
|
||||
main: '#ff9800',
|
||||
light: '#ffb74d',
|
||||
dark: '#f57c00',
|
||||
},
|
||||
info: {
|
||||
main: '#2196f3',
|
||||
light: '#64b5f6',
|
||||
dark: '#1976d2',
|
||||
},
|
||||
success: {
|
||||
main: '#4caf50',
|
||||
light: '#81c784',
|
||||
dark: '#388e3c',
|
||||
},
|
||||
background: {
|
||||
default: '#f5f5f5',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
text: {
|
||||
primary: 'rgba(0, 0, 0, 0.87)',
|
||||
secondary: 'rgba(0, 0, 0, 0.6)',
|
||||
disabled: 'rgba(0, 0, 0, 0.38)',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
].join(','),
|
||||
h1: {
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h2: {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h6: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
body1: {
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
body2: {
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.43,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 4,
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '6px 16px',
|
||||
},
|
||||
contained: {
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
boxShadow: '0px 2px 4px -1px rgba(0,0,0,0.2)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0px 2px 4px -1px rgba(0,0,0,0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: '0px 1px 3px rgba(0,0,0,0.12)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
breakpoints: {
|
||||
values: {
|
||||
xs: 0,
|
||||
sm: 600,
|
||||
md: 960,
|
||||
lg: 1280,
|
||||
xl: 1920,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const theme = createTheme(themeOptions);
|
||||
82
src/services/tldraw/localStoreService.ts
Normal file
82
src/services/tldraw/localStoreService.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {
|
||||
TLStore,
|
||||
createTLStore,
|
||||
TLEditorSnapshot,
|
||||
loadSnapshot,
|
||||
TLAnyShapeUtilConstructor,
|
||||
TLAnyBindingUtilConstructor,
|
||||
TLSchema
|
||||
} from '@tldraw/tldraw';
|
||||
import { LoadingState } from './snapshotService';
|
||||
import { allShapeUtils } from '../../utils/tldraw/shapes';
|
||||
import { allBindingUtils } from '../../utils/tldraw/bindings';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { customSchema } from '../../utils/tldraw/schemas';
|
||||
|
||||
interface LocalStoreConfig {
|
||||
shapeUtils?: TLAnyShapeUtilConstructor[];
|
||||
bindingUtils?: TLAnyBindingUtilConstructor[];
|
||||
schema?: TLSchema;
|
||||
}
|
||||
|
||||
class LocalStoreService {
|
||||
private store: TLStore | null = null;
|
||||
private static instance: LocalStoreService;
|
||||
|
||||
public static getInstance(): LocalStoreService {
|
||||
if (!LocalStoreService.instance) {
|
||||
LocalStoreService.instance = new LocalStoreService();
|
||||
}
|
||||
return LocalStoreService.instance;
|
||||
}
|
||||
|
||||
public getStore(config?: LocalStoreConfig): TLStore {
|
||||
if (!this.store) {
|
||||
logger.debug('system', '🔄 Creating new TLStore');
|
||||
this.store = createTLStore({
|
||||
shapeUtils: config?.shapeUtils || allShapeUtils,
|
||||
bindingUtils: config?.bindingUtils || allBindingUtils,
|
||||
schema: config?.schema || customSchema,
|
||||
});
|
||||
}
|
||||
return this.store;
|
||||
}
|
||||
|
||||
public async loadSnapshot(
|
||||
snapshot: Partial<TLEditorSnapshot>,
|
||||
setLoadingState: (state: LoadingState) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!this.store) {
|
||||
throw new Error('Store not initialized');
|
||||
}
|
||||
|
||||
logger.debug('system', '📥 Loading snapshot into store');
|
||||
loadSnapshot(this.store, snapshot);
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
} catch (error) {
|
||||
logger.error('system', '❌ Failed to load snapshot:', error);
|
||||
if (this.store) {
|
||||
this.store.clear();
|
||||
}
|
||||
setLoadingState({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Failed to load snapshot'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public clearStore(): void {
|
||||
logger.debug('system', '🧹 Clearing store');
|
||||
if (this.store) {
|
||||
this.store.clear();
|
||||
}
|
||||
this.store = null;
|
||||
}
|
||||
|
||||
public isStoreReady(): boolean {
|
||||
return !!this.store;
|
||||
}
|
||||
}
|
||||
|
||||
export const localStoreService = LocalStoreService.getInstance();
|
||||
213
src/services/tldraw/nodeCanvasService.ts
Normal file
213
src/services/tldraw/nodeCanvasService.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { Editor, TLShape, createShapeId } from '@tldraw/tldraw';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { NavigationNode } from '../../types/navigation';
|
||||
import { NeoShapeService } from '../graph/neoShapeService';
|
||||
import { NodeData } from '../../types/graph-shape';
|
||||
|
||||
export class NodeCanvasService {
|
||||
private static readonly CANVAS_PADDING = 100;
|
||||
private static readonly ANIMATION_DURATION = 500;
|
||||
private static currentAnimation: number | null = null;
|
||||
|
||||
private static findAllNodeShapes(editor: Editor, nodeId: string): TLShape[] {
|
||||
const shapes = editor.getCurrentPageShapes();
|
||||
const exactShapeId = `shape:${nodeId}`;
|
||||
|
||||
// Filter shapes with exact ID match only
|
||||
return shapes.filter((shape: TLShape) => {
|
||||
const shapeId = shape.id.toString();
|
||||
return shapeId === exactShapeId || shapeId === nodeId;
|
||||
});
|
||||
}
|
||||
|
||||
private static handleMultipleNodeInstances(editor: Editor, nodeId: string, shapes: TLShape[]): TLShape | undefined {
|
||||
if (shapes.length > 1) {
|
||||
logger.warn('node-canvas', '⚠️ Multiple instances of node found', {
|
||||
nodeId,
|
||||
count: shapes.length,
|
||||
shapes: shapes.map(s => s.id)
|
||||
});
|
||||
// Return the first instance but log a warning for the user
|
||||
return shapes[0];
|
||||
}
|
||||
return shapes[0];
|
||||
}
|
||||
|
||||
private static cancelCurrentAnimation(): void {
|
||||
if (this.currentAnimation !== null) {
|
||||
cancelAnimationFrame(this.currentAnimation);
|
||||
this.currentAnimation = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static animateViewToShape(editor: Editor, shape: TLShape): void {
|
||||
// Cancel any existing animation
|
||||
this.cancelCurrentAnimation();
|
||||
|
||||
const bounds = editor.getShapePageBounds(shape);
|
||||
if (!bounds) {
|
||||
logger.warn('node-canvas', '⚠️ Could not get shape bounds', { shapeId: shape.id });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current viewport and camera state
|
||||
const viewportBounds = editor.getViewportPageBounds();
|
||||
const camera = editor.getCamera();
|
||||
const currentPage = editor.getCurrentPage();
|
||||
|
||||
// Calculate the center point of the shape in page coordinates
|
||||
const shapeCenterX = bounds.x + bounds.w / 2;
|
||||
const shapeCenterY = bounds.y + bounds.h / 2;
|
||||
|
||||
// Calculate where the shape currently appears in the viewport
|
||||
const currentViewportCenterX = viewportBounds.x + viewportBounds.w / 2;
|
||||
const currentViewportCenterY = viewportBounds.y + viewportBounds.h / 2;
|
||||
|
||||
// Check if the shape is already reasonably centered
|
||||
const tolerance = 50; // pixels
|
||||
const isAlreadyCentered =
|
||||
Math.abs(shapeCenterX - currentViewportCenterX) < tolerance &&
|
||||
Math.abs(shapeCenterY - currentViewportCenterY) < tolerance;
|
||||
|
||||
// Log the current state for debugging
|
||||
logger.debug('node-canvas', '📊 Current canvas state', {
|
||||
page: {
|
||||
id: currentPage.id,
|
||||
name: currentPage.name,
|
||||
shapes: editor.getCurrentPageShapes().length
|
||||
},
|
||||
camera: {
|
||||
current: camera,
|
||||
viewport: viewportBounds
|
||||
},
|
||||
shape: {
|
||||
id: shape.id,
|
||||
bounds,
|
||||
center: { x: shapeCenterX, y: shapeCenterY },
|
||||
currentViewportCenter: { x: currentViewportCenterX, y: currentViewportCenterY },
|
||||
isAlreadyCentered
|
||||
}
|
||||
});
|
||||
|
||||
// If the shape is already centered, don't animate
|
||||
if (isAlreadyCentered) {
|
||||
logger.debug('node-canvas', '✨ Shape is already centered, skipping animation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the target camera position to center the shape
|
||||
const targetX = camera.x + (currentViewportCenterX - shapeCenterX);
|
||||
const targetY = camera.y + (currentViewportCenterY - shapeCenterY);
|
||||
|
||||
const startX = camera.x;
|
||||
const startY = camera.y;
|
||||
|
||||
// Force the camera to maintain its current zoom level
|
||||
const currentZoom = camera.z;
|
||||
|
||||
// Animate the camera position
|
||||
const startTime = Date.now();
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / this.ANIMATION_DURATION, 1);
|
||||
|
||||
// Use easeInOutCubic for smooth animation
|
||||
const eased = progress < 0.5
|
||||
? 4 * progress * progress * progress
|
||||
: 1 - Math.pow(-2 * progress + 2, 3) / 2;
|
||||
|
||||
const x = startX + (targetX - startX) * eased;
|
||||
const y = startY + (targetY - startY) * eased;
|
||||
|
||||
editor.setCamera({
|
||||
...camera,
|
||||
x,
|
||||
y,
|
||||
z: currentZoom // Maintain zoom level
|
||||
});
|
||||
|
||||
if (progress < 1) {
|
||||
this.currentAnimation = requestAnimationFrame(animate);
|
||||
} else {
|
||||
this.currentAnimation = null;
|
||||
logger.debug('node-canvas', '✅ Shape centering animation complete', {
|
||||
finalPosition: { x, y, z: currentZoom },
|
||||
shapeCenterPoint: { x: shapeCenterX, y: shapeCenterY }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.currentAnimation = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
static async centerCurrentNode(editor: Editor, node: NavigationNode, nodeData: NodeData): Promise<void> {
|
||||
try {
|
||||
// Cancel any existing animation before starting
|
||||
this.cancelCurrentAnimation();
|
||||
|
||||
const shapes = this.findAllNodeShapes(editor, node.id);
|
||||
|
||||
if (shapes.length > 0) {
|
||||
const existingShape = this.handleMultipleNodeInstances(editor, node.id, shapes);
|
||||
if (existingShape) {
|
||||
// Ensure the shape is actually on the canvas
|
||||
const bounds = editor.getShapePageBounds(existingShape);
|
||||
if (!bounds) {
|
||||
logger.warn('node-canvas', '⚠️ Shape exists but has no bounds', {
|
||||
nodeId: node.id,
|
||||
shapeId: existingShape.id
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.animateViewToShape(editor, existingShape);
|
||||
logger.debug('node-canvas', '🎯 Centered view on existing shape', {
|
||||
nodeId: node.id,
|
||||
shapeBounds: bounds
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create new shape for the node
|
||||
const newShape = await this.createNodeShape(editor, node, nodeData);
|
||||
if (newShape) {
|
||||
this.animateViewToShape(editor, newShape);
|
||||
logger.debug('node-canvas', '✨ Created and centered new shape', { nodeId: node.id });
|
||||
} else {
|
||||
logger.warn('node-canvas', '⚠️ Could not create or center node shape', { nodeId: node.id });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.cancelCurrentAnimation();
|
||||
logger.error('node-canvas', '❌ Failed to center node', {
|
||||
nodeId: node.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async createNodeShape(editor: Editor, node: NavigationNode, nodeData: NodeData): Promise<TLShape | null> {
|
||||
try {
|
||||
const viewportBounds = editor.getViewportPageBounds();
|
||||
const centerX = viewportBounds.x + viewportBounds.w / 2;
|
||||
const centerY = viewportBounds.y + viewportBounds.h / 2;
|
||||
|
||||
// Get shape configuration from NeoShapeService
|
||||
const shapeConfig = NeoShapeService.getShapeConfig(node, nodeData, centerX, centerY);
|
||||
const shapeId = createShapeId(node.id);
|
||||
|
||||
// Create the shape with the configuration
|
||||
editor.createShape<TLShape>({
|
||||
id: shapeId,
|
||||
...shapeConfig
|
||||
});
|
||||
|
||||
return editor.getShape(shapeId) || null;
|
||||
} catch (error) {
|
||||
logger.error('node-canvas', '❌ Failed to create node shape', {
|
||||
nodeId: node.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/services/tldraw/optionsService.ts
Normal file
43
src/services/tldraw/optionsService.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { TldrawOptions } from "@tldraw/tldraw";
|
||||
|
||||
export const multiplayerOptions: Partial<TldrawOptions> = {
|
||||
actionShortcutsLocation: "swap",
|
||||
adjacentShapeMargin: 10,
|
||||
animationMediumMs: 320,
|
||||
cameraMovingTimeoutMs: 64,
|
||||
cameraSlideFriction: 0.09,
|
||||
coarseDragDistanceSquared: 36,
|
||||
coarseHandleRadius: 20,
|
||||
coarsePointerWidth: 12,
|
||||
collaboratorCheckIntervalMs: 1200,
|
||||
collaboratorIdleTimeoutMs: 3000,
|
||||
collaboratorInactiveTimeoutMs: 60000,
|
||||
defaultSvgPadding: 32,
|
||||
doubleClickDurationMs: 450,
|
||||
dragDistanceSquared: 16,
|
||||
edgeScrollDelay: 200,
|
||||
edgeScrollDistance: 8,
|
||||
edgeScrollEaseDuration: 200,
|
||||
edgeScrollSpeed: 25,
|
||||
flattenImageBoundsExpand: 64,
|
||||
flattenImageBoundsPadding: 16,
|
||||
followChaseViewportSnap: 2,
|
||||
gridSteps: [
|
||||
{ mid: 0.15, min: -1, step: 64 },
|
||||
{ mid: 0.375, min: 0.05, step: 16 },
|
||||
{ mid: 1, min: 0.15, step: 4 },
|
||||
{ mid: 2.5, min: 0.7, step: 1 }
|
||||
],
|
||||
handleRadius: 12,
|
||||
hitTestMargin: 8,
|
||||
laserDelayMs: 1200,
|
||||
longPressDurationMs: 500,
|
||||
maxExportDelayMs: 5000,
|
||||
maxFilesAtOnce: 100,
|
||||
maxPages: 1,
|
||||
maxPointsPerDrawShape: 500,
|
||||
maxShapesPerPage: 4000,
|
||||
multiClickDurationMs: 200,
|
||||
temporaryAssetPreviewLifetimeMs: 180000,
|
||||
textShadowLod: 0.35
|
||||
}
|
||||
234
src/services/tldraw/presentationService.ts
Normal file
234
src/services/tldraw/presentationService.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import { Editor, TLStoreEventInfo, createShapeId, TLShape } from '@tldraw/tldraw'
|
||||
import { logger } from '../../debugConfig'
|
||||
import { CCSlideShowShape } from '../../utils/tldraw/cc-base/cc-slideshow/CCSlideShowShapeUtil'
|
||||
import { CCSlideShape } from '../../utils/tldraw/cc-base/cc-slideshow/CCSlideShapeUtil'
|
||||
import { CCSlideLayoutBinding } from '../../utils/tldraw/cc-base/cc-slideshow/CCSlideLayoutBindingUtil'
|
||||
|
||||
export class PresentationService {
|
||||
private editor: Editor
|
||||
private initialSlideshow: CCSlideShowShape | null = null
|
||||
private cameraProxyId = createShapeId('camera-proxy')
|
||||
private lastUserInteractionTime = 0
|
||||
private readonly USER_INTERACTION_DEBOUNCE = 1000 // 1 second
|
||||
private zoomLevels = new Map<string, number>() // Track zoom levels by shape dimensions
|
||||
private isMoving = false
|
||||
|
||||
constructor(editor: Editor) {
|
||||
this.editor = editor
|
||||
logger.debug('system', '🎥 PresentationService initialized')
|
||||
|
||||
// Add style to hide camera proxy frame
|
||||
const style = document.createElement('style')
|
||||
style.setAttribute('data-camera-proxy', this.cameraProxyId)
|
||||
style.textContent = `
|
||||
[data-shape-id="${this.cameraProxyId}"] {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
private getShapeDimensionKey(width: number, height: number): string {
|
||||
return `${Math.round(width)}_${Math.round(height)}`
|
||||
}
|
||||
|
||||
private async moveToShape(shape: CCSlideShape | CCSlideShowShape): Promise<void> {
|
||||
if (this.isMoving) {
|
||||
logger.debug('presentation', '⏳ Movement in progress, queueing next movement')
|
||||
// Wait for current movement to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return this.moveToShape(shape)
|
||||
}
|
||||
|
||||
this.isMoving = true
|
||||
const bounds = this.editor.getShapePageBounds(shape.id)
|
||||
if (!bounds) {
|
||||
logger.warn('presentation', '⚠️ Could not get bounds for shape')
|
||||
this.isMoving = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Phase 1: Update proxy shape instantly
|
||||
this.editor.updateShape({
|
||||
id: this.cameraProxyId,
|
||||
type: 'frame',
|
||||
x: bounds.minX,
|
||||
y: bounds.minY,
|
||||
props: {
|
||||
w: bounds.width,
|
||||
h: bounds.height,
|
||||
name: 'camera-proxy'
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for a frame to ensure bounds are updated
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
|
||||
// Phase 2: Calculate and apply camera movement
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
const padding = 32
|
||||
const dimensionKey = this.getShapeDimensionKey(bounds.width, bounds.height)
|
||||
|
||||
// Get existing zoom level for this shape size or calculate new one
|
||||
let targetZoom = this.zoomLevels.get(dimensionKey)
|
||||
if (!targetZoom) {
|
||||
targetZoom = Math.min(
|
||||
(viewport.width - padding * 2) / bounds.width,
|
||||
(viewport.height - padding * 2) / bounds.height
|
||||
)
|
||||
this.zoomLevels.set(dimensionKey, targetZoom)
|
||||
logger.debug('presentation', '📏 New zoom level calculated', {
|
||||
dimensions: dimensionKey,
|
||||
zoom: targetZoom
|
||||
})
|
||||
}
|
||||
|
||||
// Stop any existing camera movement
|
||||
this.editor.stopCameraAnimation()
|
||||
|
||||
// Move camera to new position
|
||||
this.editor.zoomToBounds(bounds, {
|
||||
animation: {
|
||||
duration: 500,
|
||||
easing: (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
|
||||
},
|
||||
targetZoom,
|
||||
inset: padding
|
||||
})
|
||||
|
||||
// Wait for animation to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
} catch (error) {
|
||||
logger.error('presentation', '❌ Error during shape transition', { error })
|
||||
} finally {
|
||||
this.isMoving = false
|
||||
}
|
||||
}
|
||||
|
||||
startPresentationMode() {
|
||||
logger.info('presentation', '🎥 Starting presentation mode')
|
||||
|
||||
// Reset zoom levels on start
|
||||
this.zoomLevels.clear()
|
||||
|
||||
// Find initial slideshow to track
|
||||
const slideshows = this.editor.getSortedChildIdsForParent(this.editor.getCurrentPageId())
|
||||
.map(id => this.editor.getShape(id))
|
||||
.filter(shape => shape?.type === 'cc-slideshow')
|
||||
|
||||
if (slideshows.length === 0) {
|
||||
logger.warn('presentation', '⚠️ No slideshows found')
|
||||
return () => {}
|
||||
}
|
||||
|
||||
this.initialSlideshow = slideshows[0] as CCSlideShowShape
|
||||
|
||||
// Create camera proxy shape if it doesn't exist
|
||||
if (!this.editor.getShape(this.cameraProxyId)) {
|
||||
this.editor.createShape({
|
||||
id: this.cameraProxyId,
|
||||
type: 'frame',
|
||||
x: 0,
|
||||
y: 0,
|
||||
props: {
|
||||
w: 1,
|
||||
h: 1,
|
||||
name: 'camera-proxy'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleStoreChange = (event: TLStoreEventInfo) => {
|
||||
// Debounce user interaction logs
|
||||
if (event.source === 'user') {
|
||||
const now = Date.now()
|
||||
if (now - this.lastUserInteractionTime > this.USER_INTERACTION_DEBOUNCE) {
|
||||
logger.debug('presentation', '📝 User interaction received')
|
||||
this.lastUserInteractionTime = now
|
||||
}
|
||||
}
|
||||
|
||||
if (!event.changes.updated) return
|
||||
|
||||
// Only process shape updates
|
||||
const shapeUpdates = Object.entries(event.changes.updated)
|
||||
.filter(([, [from, to]]) =>
|
||||
from.typeName === 'shape' &&
|
||||
to.typeName === 'shape' &&
|
||||
(from as TLShape).type === 'cc-slideshow' &&
|
||||
(to as TLShape).type === 'cc-slideshow'
|
||||
)
|
||||
|
||||
if (shapeUpdates.length === 0) return
|
||||
|
||||
for (const [, [from, to]] of shapeUpdates) {
|
||||
const fromShape = from as TLShape
|
||||
const toShape = to as TLShape
|
||||
|
||||
if (!this.initialSlideshow || fromShape.id !== this.initialSlideshow.id) continue
|
||||
|
||||
const fromShow = fromShape as CCSlideShowShape
|
||||
const toShow = toShape as CCSlideShowShape
|
||||
|
||||
if (fromShow.props.currentSlideIndex === toShow.props.currentSlideIndex) continue
|
||||
|
||||
logger.info('presentation', '🔄 Moving to new slide', {
|
||||
from: fromShow.props.currentSlideIndex,
|
||||
to: toShow.props.currentSlideIndex
|
||||
})
|
||||
|
||||
// Get all bindings for this slideshow, sorted by index
|
||||
const bindings = this.editor
|
||||
.getBindingsFromShape(toShow, 'cc-slide-layout')
|
||||
.filter((b): b is CCSlideLayoutBinding => b.type === 'cc-slide-layout')
|
||||
.filter(b => !b.props.placeholder)
|
||||
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
|
||||
|
||||
const currentBinding = bindings[toShow.props.currentSlideIndex]
|
||||
if (!currentBinding) {
|
||||
logger.warn('presentation', '⚠️ Could not find binding for target slide')
|
||||
continue
|
||||
}
|
||||
|
||||
const currentSlide = this.editor.getShape(currentBinding.toId) as CCSlideShape
|
||||
if (!currentSlide) {
|
||||
logger.warn('presentation', '⚠️ Could not find target slide')
|
||||
continue
|
||||
}
|
||||
|
||||
void this.moveToShape(currentSlide)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up store listener and get cleanup function
|
||||
const storeCleanup = this.editor.store.listen(handleStoreChange)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
logger.info('presentation', '🧹 Running presentation mode cleanup')
|
||||
storeCleanup()
|
||||
this.stopPresentationMode()
|
||||
}
|
||||
}
|
||||
|
||||
stopPresentationMode() {
|
||||
this.zoomLevels.clear()
|
||||
this.isMoving = false
|
||||
if (this.editor.getShape(this.cameraProxyId)) {
|
||||
this.editor.deleteShape(this.cameraProxyId)
|
||||
}
|
||||
// Remove the style element
|
||||
const style = document.querySelector(`style[data-camera-proxy="${this.cameraProxyId}"]`)
|
||||
if (style) {
|
||||
style.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// Public method to move to any shape (slide or slideshow)
|
||||
zoomToShape(shape: CCSlideShape | CCSlideShowShape) {
|
||||
void this.moveToShape(shape)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user