Initial commit

This commit is contained in:
kcar 2025-07-11 13:21:49 +00:00
commit 8a7ab3ac24
262 changed files with 28219 additions and 0 deletions

14
.env Normal file
View 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
View 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
View File

15
.env.production Normal file
View 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
View 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
View 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
View 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

View 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"]

View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
'postcss-import': {},
},
};

12
public/audioWorklet.js Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

View 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
View 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

Binary file not shown.

34
src/App.tsx Normal file
View 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
View 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
View 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 });

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@ -0,0 +1,3 @@
export { CalendarNavigation } from './CalendarNavigation';
export { TeacherNavigation } from './TeacherNavigation';
export { UserNavigation } from './UserNavigation';

View 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'
}
]
}
};

View 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);

View 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);

View 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);

View 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);

View 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
View 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
View 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
View 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

Binary file not shown.

305
src/pages/Header.tsx Normal file
View 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
View 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;

View 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;

View 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>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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;

View 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;

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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
View 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;

View 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
View 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;

View 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
});
}
}
}

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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[];
}

View 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;

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
};
};

View 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;

View 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;

View 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

Binary file not shown.

View 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();

View 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();

View 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 || '',
},
};
}

View 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;
}
}

View 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();

View 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!');
}
}

View 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;
}
}

View 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
});
}
}

View 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];
}

View 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();

View 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;
}
}
}

View 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;
}
}
}

View 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
};
}
}
}

View 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;
}
}
}

View 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;
};

View 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;
};

View 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);

View 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();

View 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;
}
}
}

View 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
}

View 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